commit 0004726a36736e18580a103711392a7bf7ef102f Author: Brandon Thomas Date: Fri Sep 11 14:19:51 2026 -0400 ArtCraft Desktop (fresh start) This is a fresh start for the `artcraft` repo. The old monorepo, which included our web API gateway, job services, SRE tools, various frontends, and more has moved to `artcraft-services` You can find the full git history here: https://github.com/storytold/artcraft-services This change was motivated by the fact that the entire git history was hundreds of gigabytes in size, which is unwieldy for anyone not in the core team that doesn't care about support services outside of the core artcraft app. We wanted to keep this repo in its well-known place since we already have users and activity here. Please update your remotes to either `artcraft-services` or make a fresh clone of this repo. History can be found at the artcraft-services repo. diff --git a/.claude/rules/api-conventions.md b/.claude/rules/api-conventions.md new file mode 100644 index 0000000000..acd38325c5 --- /dev/null +++ b/.claude/rules/api-conventions.md @@ -0,0 +1,56 @@ +# API Conventions + +## HTTP Handlers (Actix-Web) + +Handlers in `storyteller_web` follow this structure: + +1. **Request/Response types** with `#[derive(Deserialize/Serialize, ToSchema)]` +2. **Error enum** implementing `ResponseError`, `Display`, `Serialize`, `ToSchema` +3. **Handler function** annotated with `#[utoipa::path(...)]` for OpenAPI docs +4. Handler returns `Result, ErrorType>`. Only use `Result` when you need to set headers/cookies. + +### Error Types + +- Use `CommonWebError` for simple handlers that only need standard HTTP error codes +- Use a custom error enum when you need endpoint-specific error variants (e.g. `LoginErrorType::InvalidCredentials`) +- Custom error enums MUST implement `ResponseError` (actix), `Display`, and `Serialize` +- When adding `From` or similar conversions, implement them on `CommonWebError` in its own module (orphan rule) +- Error response bodies include: `success: false`, `error_code`, `error_code_str` + +### Session/Auth + +Helpers live in `storyteller_web` under `http_server/web_utils/user_session/`. Each takes a +generic `mysql_executor: E` (any `sqlx::Executor`), so whether it reuses a connection or acquires +its own depends on the **argument you pass**: + +- `require_user_session(http_request, session_checker, mysql_executor)` — require a logged-in user. +- `require_user_session_extended(...)` — same, with extra session detail. +- `require_moderator(http_request, session_checker, mysql_executor)` — admin-only endpoints. + +PREFER passing an already-open connection (`&mut *conn`) so the helper reuses it. Passing +`&server_state.mysql_pool` makes it acquire a fresh connection — avoid that when the handler +already holds one (the double-acquire adds pool pressure; this crate has had pool-timeout +incidents). There is no `*_using_connection` variant — the reuse/acquire choice is the argument. + +### API Type Definitions + +- Shared request/response types go in `artcraft_api_defs` crate (under `crates/api_clients/artcraft/artcraft_api_defs/`) +- Types that need `ResponseError` impl must stay in `storyteller_web` (orphan rule) +- New types must be added to `api_doc.rs` schemas (in storyteller-web) for Swagger generation + +## Enums (Database-backed) + +- Located in `crates/schema/public/enums/src/by_table/{table_name}/` +- Require manual `to_str()` and `from_str()` implementations +- Use `impl_enum_display_and_debug_using_to_str!` and `impl_mysql_enum_coders!` macros +- Add comprehensive tests: serialization, round-trip, variant count +- YOU CAN ADD NEW VALUES but DO NOT CHANGE EXISTING VALUES without a migration strategy +- Note that adding new values can break older versions of the Tauri desktop app that read + responses over Rust - make sure it consumes variants with an "Unknown(String)" variant + catch-all to future proof the APIs. + +## Tokens (Primary Keys) + +- Located in `crates/schema/public/tokens/src/tokens/` +- Opaque `String` wrappers with Stripe-like prefixes (e.g. `user_`, `mf_`) +- Use `impl_string_token!`, `impl_mysql_token_from_row!`, `impl_crockford_generator!` macros diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000000..e7a72079d5 --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,92 @@ +# Code Style + +## Rust + +- Two spaces for indentation (not four) +- No minimum supported Rust version; use latest stable features freely +- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};` +- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead +- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result` instead +- SQLx: MySQL queries belong in the `mysql_queries` crate. Do NOT write `sqlx::query!` / + `sqlx::query()` in application/service crates (e.g. `storyteller-web`, which has zero direct + SQLx queries) — add or reuse a function in `mysql_queries` and call it. The compile-time + `query!` / `query_as!` macro conventions for that crate are documented in + `crates/schema/database/mysql_queries/CLAUDE.md`. +- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it) +- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token` +- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)` + +## File Layout (Top-to-Bottom Reading Order) + +Organize code so a human reading top-to-bottom encounters the most important things first and +can drill down into details only when curious. Think of it like a newspaper: headline first, +then the story, then the fine print. + +### Ordering within a file + +1. **Constants** at the top, right after imports. +2. **The primary public type** (the main struct/enum the file is about) comes next. + A reader opening this file should immediately see what it defines. +3. **Supporting/subordinate types** (sub-structs, helper enums, serialization DTOs) go + below the primary type, not above it. Ask: "Would a reader need to know this type + exists before seeing the main type?" — if no, push it down. +4. **Type ordering for API types**: Request, then Response (success), then Error. +5. **`impl` blocks** after their type definitions. + +### Ordering within an `impl` block + +1. **Constructors** (`new`, `from_*`, `builder`) at the top. +2. **Public methods** next — these are the main entry points a reader cares about. +3. **Private helper methods** below public methods. + - Among helpers: place "meaty" helpers (substantial logic, direct continuations of the + public entry point) above "leaf" helpers (small formatters, simple lookups). + - A method that calls another method in the same file should appear *above* the method + it calls. This lets readers follow the call chain downward. + +### Ordering within test modules + +Put test cases first, helpers last. A reader opening a test file should immediately see +*what* is being tested before *how* the test machinery works. + +1. **Imports** +2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference +3. **Test functions** or **sub-modules** grouped by category +4. **Helper functions** (builders, pipeline runners, assertions) + +Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+ +tests. Don't create a sub-module for a single test. Keep nesting to two levels max +(`mod tests { mod category { ... } }`). Shared helpers go in the parent module so +sub-modules can reach them via `use super::*`. + +## Naming + +- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`) +- Error enums: `{Feature}Error` (e.g. `FalWebhookError`, `HealthCheckError`) +- Request/response structs: `{Action}Request`, `{Action}Response`, `{Action}SuccessResponse` +- Path params: `{Action}PathInfo` +- Builder structs: `{Thing}Builder` with `set_` prefixed setters and a `build()` method + +## Imports + +- Group imports: std, external crates, internal workspace crates, `crate::` imports +- Prefer `use` imports over inline fully-qualified paths. Inline qualification is acceptable + only for genuine one-off references where adding an import would be noise, or for types + that collide with std prelude names (`Result`, `Option`, `Error`). If you reference a symbol + more than once, always import it. +- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules +- When two crates export a type with the same name, alias with a suffix describing the source crate: + `use enums::...::CommonResolution as CommonResolutionEnum;` + `use artcraft_router::...::CommonResolution as CommonResolutionRouter;` + This avoids long fully-qualified paths in function signatures and bodies. + +## MySQL + +- MySQL tables: primary key `id` columns must be `BIGINT(20) UNSIGNED`. Do not use signed IDs. +- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`. + +## TypeScript / Frontend + +- TypeScript with React, Vite, Zustand, Three.js +- Nx monorepo under `crates/frontend/` +- Two spaces for indentation + diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000000..b0cc943bde --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,73 @@ +# Testing + +## Database Fixture Tests + +- Live in-crate (e.g. `storyteller-web`'s omni_gen video pricing tests) and + use the `mysql_testing` crate (`crates/schema/database/mysql_testing`) for + guarded pools, schema setup, and account/session/wallet fixtures +- They RUN BY DEFAULT with `cargo test -p storyteller-web` and from the IDE + (requires a local MySQL with an `artcraft_test` database). Machines/CI + without MySQL skip them via the off switch: + `SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests` +- They connect ONLY via `ARTCRAFT_TEST_DATABASE_URL` (default + `mysql://root:@localhost:3306/artcraft_test`); the guard panics on any + database whose name lacks "test", on `storyteller`/`artcraft`, and on + cloud hosts — see `mysql_testing/README.md` +- Database tests run in PARALLEL: each test creates its own users, wallets, + and rows; schema setup single-flights via a MySQL named lock. Only tests + mutating shared/global state take + `mysql_testing::serial::acquire_serial_test_lock().await` +- External providers must never be called: the harness stubs Kinovi with an + in-process HTTP server via `KINOVI_CUSTOM_API_HOST`/`KINOVI_CUSTOM_CDN_HOST` + +## Running Tests + +- Most crates: `cargo test -p {crate_name}` +- Crates using SQLx offline mode: `SQLX_OFFLINE=true cargo test -p {crate_name}` +- The `storyteller-web` and `mysql_queries` crates require `SQLX_OFFLINE=true` for compilation +- Use `cargo check -p {crate_name}` for fast compilation checks without running tests + +## Test Patterns + +- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file +- Group related tests into sub-modules by category (e.g. `mod pricing_tests`, `mod resolution_tests`) +- Only create a sub-module when it has 2+ tests — don't wrap a single test in its own module +- Keep nesting shallow: `mod tests { mod category { ... } }` is fine; a third level is rarely needed +- Shared helpers used by multiple sub-modules belong in the parent `mod tests`, not duplicated in each sub-module +- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count +- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases + +## Test File Layout + +Put test cases first, helpers last. Engineers should see the actual tests before scrolling +to understand helper plumbing. + +Within a `mod tests { ... }` block: + +1. **Imports** at the top +2. **Constants** (test data IDs, URLs, etc.) — small context that tests reference +3. **Test functions** (`#[test]` / `#[tokio::test]`) grouped by category +4. **Helper functions** (builders, pipeline runners, etc.) at the very bottom + +If tests are grouped into sub-modules, the same rule applies within each sub-module: +constants first, then tests, then helpers at the end of that sub-module. + +## What to Test + +- New error types and their mappings +- Enum variant serialization stability (these are stored in the database) +- Hash/deduplication functions with known inputs +- Edge cases in parsers (empty input, missing fields, malformed data) + +## Imports in Tests + +- Import test fixtures and constants with `use`, not inline fully-qualified paths. + Write `use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;` at the top, + then reference `JUNO_AT_LAKE_IMAGE_URL` in test bodies. +- Same rule applies to enum variants used in match arms — import them, don't write + `crate::some::deep::module::MyEnum::Variant` inline. + +## What Not to Test + +- Don't add integration tests that require a running database unless explicitly asked +- Don't add tests for simple struct construction or trivial getters diff --git a/.config/hakari.toml b/.config/hakari.toml new file mode 100644 index 0000000000..a485efef2e --- /dev/null +++ b/.config/hakari.toml @@ -0,0 +1,14 @@ +hakari-package = "workspace-build-acceleration" + +resolver = "1" + +dep-format-version = "4" +workspace-hack-line-style = "workspace-dotted" + +platforms = [ + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", +] + +exact-versions = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..3b9631fee1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Ignore certain code for Github language statistics +# https://github.com/github/linguist/blob/master/docs/overrides.md +old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored +old_code/voder-vocodes-1.0/frontends/admin/node_modules/** linguist-vendored +test_data/html_scraping/** linguist-vendored +windows/sqlite/** linguist-vendored diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..e8d486ab3f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/artcraft-macos-publish.yml b/.github/workflows/artcraft-macos-publish.yml new file mode 100644 index 0000000000..b71111407e --- /dev/null +++ b/.github/workflows/artcraft-macos-publish.yml @@ -0,0 +1,237 @@ +name: "Artcraft MacOS Publish" + +on: + push: + branches: + - release + - artcraft-release + - artcraft-macos # Additional branch for MacOS testing + +# This workflow creates Universal macOS builds with signing certificate support. +# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release. + +jobs: + publish-artcraft: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + # Universal macOS builds (arm64 and x86_64) + - platform: 'macos-latest-xlarge' + args: '--target universal-apple-darwin' + # Uncomment below for additional platforms + # - platform: 'ubuntu-22.04' + # args: '' + # - platform: 'windows-latest' + # args: '' + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: install dependencies (ubuntu only) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: replace tauri config with mac-specific version + run: | + pushd ${{ github.workspace }}/crates/desktop/artcraft + # Use the mac-specific tauri conf for the build + # TODO(bt): Replace this with the platform-specific naming conventions + cp tauri-mac.conf.json tauri.conf.json + popd + + - name: install frontend dependencies + run: | + # Navigate to the frontend directory to install dependencies + cd ./frontend + + npm cache clean --force + + echo "Installing all workspace dependencies..." + npm ci + + echo "=== Version Information ===" + echo "Node version:" + node --version + echo "NPM version:" + npm --version + + echo "=== Nx versions from lockfile ===" + npm list --depth=0 | grep nx + + - name: import Apple Developer Certificate + if: matrix.platform == 'macos-latest-xlarge' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + echo "Starting certificate import process..." + + # Create certificate file + echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 + + # Debug: Check if certificate file exists and has content + echo "Certificate file info:" + ls -la certificate.p12 + file certificate.p12 + + # Debug: Check certificate contents without password (just structure) + echo "Certificate structure (may show error if password protected):" + openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure" + + # Debug: Try to list what's in the p12 file + echo "Attempting to list certificate contents:" + security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..." + openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents" + + # Debug: Extract certificate details to verify type and expiration + echo "Extracting certificate details..." + # Extract just the certificate portion and examine it + openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details" + + # Also try to see what type of certificate this is + echo "Certificate type check:" + openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type" + + # Debug: Check if private key exists + echo "Checking for private key..." + openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found" + + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + + # Import certificate - ensure both certificate and private key are imported + echo "Importing certificate into keychain..." + # Import the p12 directly - this is the most reliable method + security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild + + # Alternative: Try with additional flags if the first import didn't create an identity + echo "Verifying import..." + if ! security find-identity -v build.keychain | grep -q "1 valid identities found"; then + echo "First import didn't create identity, trying with -A flag..." + security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -A + fi + + # Set trust settings for the certificate + echo "Setting trust settings..." + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + # Unlock the keychain again to ensure it's accessible + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + + # Add the keychain to the search list + security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//') + + # List all certificates to debug + echo "Listing all keychains:" + security list-keychains + + echo "Listing all certificates in build keychain:" + security find-certificate -a -p build.keychain || echo "No certificates found" + + echo "Detailed certificate info:" + security find-certificate -c "Learning Machines" build.keychain || echo "Certificate 'Learning Machines' not found" + + # Check certificate trust settings + echo "Checking certificate trust settings:" + security dump-trust-settings || echo "No custom trust settings" + + # Try to find codesigning identities + echo "Finding all identities in keychain:" + security find-identity -v build.keychain || echo "No identities found" + + echo "Finding codesigning identities:" + security find-identity -v -p codesigning build.keychain || echo "No codesigning identities found" + + # Additional debug: Check if this is a valid Developer ID cert + echo "Checking for Developer ID certificates:" + security find-certificate -a build.keychain | grep -A 5 -B 5 "Developer ID" || echo "No Developer ID certificates found" + + - name: verify certificate + if: matrix.platform == 'macos-latest-xlarge' + run: | + echo "=== Certificate Verification ===" + + echo "1. Checking codesigning identities:" + security find-identity -v -p codesigning build.keychain || echo "No codesigning identities found" + + echo -e "\n2. Checking all identities:" + security find-identity -v build.keychain || echo "No identities found" + + echo -e "\n3. Checking certificates in keychain:" + security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate" + + # Set the correct signing identity + echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV + echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" + + # Also set the team ID + echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV + + - name: Pre-build frontend to avoid Tauri build issues + run: | + # Build the frontend app from the correct directory + cd ./frontend + + # Increase Node.js memory limit for the build + export NODE_OPTIONS="--max-old-space-size=8192" + + # Use npx to ensure we use the local nx version + echo "Building with npx nx..." + npx nx run artcraft:build + + echo "Checking build output..." + ls -la apps/artcraft/dist/ | head -10 + + # Update Tauri config to skip the build (since we already built) + cd ${{ github.workspace }}/crates/desktop/artcraft + cp tauri.conf.json tauri.conf.json.bak + # Set beforeBuildCommand to empty or a no-op command + if command -v jq &> /dev/null; then + jq '.build.beforeBuildCommand = ""' tauri.conf.json > tauri.conf.json.tmp + mv tauri.conf.json.tmp tauri.conf.json + else + node -e "const fs = require('fs'); const config = JSON.parse(fs.readFileSync('tauri.conf.json', 'utf8')); config.build.beforeBuildCommand = ''; fs.writeFileSync('tauri.conf.json', JSON.stringify(config, null, 2));" + fi + + + - name: build and publish + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} + APPLE_DEVELOPMENT_TEAM: ${{ env.APPLE_DEVELOPMENT_TEAM }} + # Use the SQLx query cache for SQLite + SQLX_OFFLINE: true + with: + projectPath: ./crates/desktop/artcraft + tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version. + releaseName: "ArtCraft v__VERSION__" + releaseBody: "See the assets below to download this version and install." + releaseDraft: true + prerelease: false + args: ${{ matrix.args }} diff --git a/.github/workflows/artcraft-windows-publish.yml b/.github/workflows/artcraft-windows-publish.yml new file mode 100644 index 0000000000..4be3d5c4e4 --- /dev/null +++ b/.github/workflows/artcraft-windows-publish.yml @@ -0,0 +1,104 @@ +name: "Artcraft Windows Publish" + +on: + push: + branches: + - release + - artcraft-release + - artcraft-windows # Additional branch for Windows testing + +# This workflow creates Windows builds. +# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release. + +jobs: + publish-artcraft-windows: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + # Windows builds + - platform: 'windows-latest' + args: '' + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: install wreq dependencies + run: | + choco install cmake -y + choco install strawberryperl -y + choco install pkgconfiglite -y + choco install llvm -y + choco install nasm -y + shell: cmd + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: install frontend dependencies + run: | + # Navigate to the frontend directory to install dependencies + cd ./frontend + + npm cache clean --force + + echo "Installing all workspace dependencies..." + npm ci + + echo "=== Version Information ===" + echo "Node version:" + node --version + echo "NPM version:" + npm --version + + echo "=== Nx versions from lockfile ===" + npm list --depth=0 | findstr nx + + - name: Pre-build frontend to avoid Tauri build issues + run: | + # Build the frontend app from the correct directory + cd ./frontend + + # Increase Node.js memory limit for the build + $env:NODE_OPTIONS="--max-old-space-size=8192" + + # Use npx to ensure we use the local nx version + echo "Building with npx nx..." + npx nx run artcraft:build + + echo "Checking build output..." + dir apps/artcraft/dist/ | Select-Object -First 10 + + # Update Tauri config to skip the build (since we already built) + cd ${{ github.workspace }}/crates/desktop/artcraft + Copy-Item tauri.conf.json tauri.conf.json.bak + + # Set beforeBuildCommand to empty using PowerShell + $config = Get-Content tauri.conf.json | ConvertFrom-Json + $config.build.beforeBuildCommand = "" + $config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json + + - name: build and publish + uses: tauri-apps/tauri-action@v0 + env: + # NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri + RUSTFLAGS: "-C target-feature=+crt-static" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Use the SQLx query cache for SQLite + SQLX_OFFLINE: true + with: + projectPath: ./crates/desktop/artcraft + tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version. + releaseName: "ArtCraft v__VERSION__" + releaseBody: "Production build of ArtCraft. See the assets below to download and install." + releaseDraft: true + prerelease: false + args: ${{ matrix.args }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..1336f92440 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Secrets are BAD! +*secret*sh +.env +.env-aichatbot-secrets +.env-analytics-job +.env-elasticsearch-secrets +.env-migration-tool-secrets +.env-secrets +.env-support-tool-secrets +_develop/tools/.secrets +_develop/tools/db_secrets.toml +crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env +db.secrets +db_secrets.toml +env-secrets +pubkeys.txt +secrets.toml +secrets.yaml +secrets/ +twitch_secrets.toml + +# Old env secrets +.*-secrets + +# New env secrets (per-app) +*-secrets.env + +# Utilities with secrets +production_database_secret_connect.sh +production_database_secret_query.sh +production_database_voice_clone_report_download.sh +script/artcraft/mac_signing_build.sh +script/production_database_secret_backup.sh +script/production_database_secret_connect.sh +script/socat_dev_database_connect.sh + +# Python virtual envs (named by convention) +venv/ + +# LLM Testing +/external/ +/prompts/ +/reports/ +/requests/ + +# Scratch data +scratch/ +test_data/temp/ +untracked/ +tmp/ + +# Rust build directory +target/ +crates/lib/database_queries/target/ +crates/lib/sqlite_queries/target/ + +# Javascript build directories +node_modules/ + +# Frontend build outputs +frontend/apps/artcraft/bundle-report.html + +# Vim noise +*.swo +*.swp +*~ + +# IntelliJ/CLion/RustRover noise +.idea/ + +# Python noise +__pycache__/ + +# We use diesel for DB migrations only. We don't care about its codgen. +_ignored_schema.rs + +# Diesel lock file +.diesel_lock + +# AI Chat Sidecar has its own schema managed by diesel, but we don't care for diesel codegen. +crates/programs/aichatbot-sidecar/_ignored_schema.rs + +# AI Chat sidecar's SQlite database shouldn't be checked in +crates/programs/aichatbot-sidecar/database.db + +# Runtime data for the AI chatbot +runtime_data/ + +# Old Python virtual environments +models/Wav2Lip/python/ +models/tacotron2/python/ + +# Stuff I want to keep around, but not commit +scraps/ + +# Mac nonsense +.DS_Store + +# Windows build files +# These should be built by the platform +windows/sqlite/sqlite3.exp +windows/sqlite/sqlite3.lib + +# ArtCraft Windows DLLs +crates/desktop/src-tauri/*.dll +windows_dlls/ + diff --git a/.sqlx/query-13d435d8f681b73e4080748dcef0579e246e41049af67e1bd79ffc06aa6ba974.json b/.sqlx/query-13d435d8f681b73e4080748dcef0579e246e41049af67e1bd79ffc06aa6ba974.json new file mode 100644 index 0000000000..129a5f59bd --- /dev/null +++ b/.sqlx/query-13d435d8f681b73e4080748dcef0579e246e41049af67e1bd79ffc06aa6ba974.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET is_dismissed_by_user = 1\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "13d435d8f681b73e4080748dcef0579e246e41049af67e1bd79ffc06aa6ba974" +} diff --git a/.sqlx/query-1e3758851fc8451670f86a9432009b73bdb81de4a08dcaa96a86afb5f9be7a68.json b/.sqlx/query-1e3758851fc8451670f86a9432009b73bdb81de4a08dcaa96a86afb5f9be7a68.json new file mode 100644 index 0000000000..5c1b1e3f1a --- /dev/null +++ b/.sqlx/query-1e3758851fc8451670f86a9432009b73bdb81de4a08dcaa96a86afb5f9be7a68.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET\n task_status = ?,\n on_complete_batch_token = ?,\n on_complete_primary_media_file_token = ?,\n on_complete_primary_media_file_class = ?,\n on_complete_primary_media_file_cdn_url = ?,\n on_complete_primary_media_file_thumbnail_url_template = ?\n WHERE\n provider = ?\n AND provider_job_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "1e3758851fc8451670f86a9432009b73bdb81de4a08dcaa96a86afb5f9be7a68" +} diff --git a/.sqlx/query-2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a.json b/.sqlx/query-2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a.json new file mode 100644 index 0000000000..923d83d541 --- /dev/null +++ b/.sqlx/query-2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a.json @@ -0,0 +1,128 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n on_failure_type,\n on_failure_message,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n completed_at as \"completed_at: DateTime\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "task_status", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "task_type", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "model_type", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "provider", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "provider_job_id", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "frontend_caller", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "frontend_subscriber_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "frontend_subscriber_payload", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "on_complete_primary_media_file_token", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "on_complete_primary_media_file_class", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "on_complete_batch_token", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "on_complete_primary_media_file_cdn_url", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "on_complete_primary_media_file_thumbnail_url_template", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "on_failure_type", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "on_failure_message", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 16, + "type_info": "Int64" + }, + { + "name": "updated_at: DateTime", + "ordinal": 17, + "type_info": "Int64" + }, + { + "name": "completed_at: DateTime", + "ordinal": 18, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true + ] + }, + "hash": "2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a" +} diff --git a/.sqlx/query-30272128d83543d9fbfca930b7cdd4af30adfb4d90f2de044463a111c6340495.json b/.sqlx/query-30272128d83543d9fbfca930b7cdd4af30adfb4d90f2de044463a111c6340495.json new file mode 100644 index 0000000000..4be4ed4894 --- /dev/null +++ b/.sqlx/query-30272128d83543d9fbfca930b7cdd4af30adfb4d90f2de044463a111c6340495.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET task_status = \"cancelled_by_user\"\n WHERE task_status NOT IN (\n \"complete_success\",\n \"complete_failure\",\n \"dead\",\n \"cancelled_by_user\",\n \"cancelled_by_provider\",\n \"cancelled_by_us\"\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "30272128d83543d9fbfca930b7cdd4af30adfb4d90f2de044463a111c6340495" +} diff --git a/.sqlx/query-35677894f7b02f32a1bf6396fa6d10caba69bb0f3f03ee7ffbfa1437689d42df.json b/.sqlx/query-35677894f7b02f32a1bf6396fa6d10caba69bb0f3f03ee7ffbfa1437689d42df.json new file mode 100644 index 0000000000..b63c200075 --- /dev/null +++ b/.sqlx/query-35677894f7b02f32a1bf6396fa6d10caba69bb0f3f03ee7ffbfa1437689d42df.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n queue_status_url,\n queue_response_url,\n prompt_token,\n frontend_subscriber_id,\n frontend_subscriber_payload\n FROM tasks\n WHERE\n provider = ?\n AND provider_job_id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "task_status", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "task_type", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "model_type", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "provider", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "provider_job_id", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "frontend_caller", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "queue_status_url", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "queue_response_url", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "prompt_token", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "frontend_subscriber_id", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "frontend_subscriber_payload", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "35677894f7b02f32a1bf6396fa6d10caba69bb0f3f03ee7ffbfa1437689d42df" +} diff --git a/.sqlx/query-4fc9674ae39abaadc36054c200290ec95d0e2c7765336f0fa97805a2265f5c4c.json b/.sqlx/query-4fc9674ae39abaadc36054c200290ec95d0e2c7765336f0fa97805a2265f5c4c.json new file mode 100644 index 0000000000..4c505058f4 --- /dev/null +++ b/.sqlx/query-4fc9674ae39abaadc36054c200290ec95d0e2c7765336f0fa97805a2265f5c4c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n queue_status_url,\n queue_response_url,\n prompt_token,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "4fc9674ae39abaadc36054c200290ec95d0e2c7765336f0fa97805a2265f5c4c" +} diff --git a/.sqlx/query-50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321.json b/.sqlx/query-50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321.json new file mode 100644 index 0000000000..8d5c10f103 --- /dev/null +++ b/.sqlx/query-50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET\n task_status = ?,\n on_complete_batch_token = ?,\n on_complete_primary_media_file_token = ?,\n on_complete_primary_media_file_class = ?,\n on_complete_primary_media_file_cdn_url = ?,\n on_complete_primary_media_file_thumbnail_url_template = ?\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321" +} diff --git a/.sqlx/query-9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516.json b/.sqlx/query-9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516.json new file mode 100644 index 0000000000..3f2887d4fe --- /dev/null +++ b/.sqlx/query-9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET task_status = ?,\n on_failure_type = ?,\n on_failure_message = ?\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516" +} diff --git a/.sqlx/query-dc0367e9e43c3d7dda3623e77337ab4529eac2192475be7d08589886703ba1cb.json b/.sqlx/query-dc0367e9e43c3d7dda3623e77337ab4529eac2192475be7d08589886703ba1cb.json new file mode 100644 index 0000000000..b4ed499639 --- /dev/null +++ b/.sqlx/query-dc0367e9e43c3d7dda3623e77337ab4529eac2192475be7d08589886703ba1cb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET task_status = ?\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "dc0367e9e43c3d7dda3623e77337ab4529eac2192475be7d08589886703ba1cb" +} diff --git a/.sqlx/query-f83018ec5493982659a56576296914f937bb4e75db3a522eb4bda486d5caa402.json b/.sqlx/query-f83018ec5493982659a56576296914f937bb4e75db3a522eb4bda486d5caa402.json new file mode 100644 index 0000000000..244607f5bd --- /dev/null +++ b/.sqlx/query-f83018ec5493982659a56576296914f937bb4e75db3a522eb4bda486d5caa402.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE tasks\n SET is_dismissed_by_user = 1\n WHERE is_dismissed_by_user != 1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "f83018ec5493982659a56576296914f937bb4e75db3a522eb4bda486d5caa402" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..e8e37516a4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "files.exclude": { + "**/*~": true, + ".sqlx/**": true, + "_database/**": true, + "_database/elasticsearch/**": true, + "_database/sql/**": true, + "frontend_old/**": true, + "includes/**": true, + "target/**": true, + "test_data/**": true, + "tmp/**": true, + "virtualenv/**": true, + "windows_dlls/**": true, + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..4a5a3ba24a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md + +This file provides guidance for coding agents when working with the Artcraft monorepo. + +## Project Overview + +Artcraft is a public-facing Tauri desktop app for generating AI images and video. +It uses Rust and TypeScript. Backend services, company tooling, and website builds +live in the separate `artcraft-services` repository. + +## Project Structure + +``` +artcraft/ +├── _database/sql/artcraft_migrations/ # Embedded SQLite task database migrations +├── .sqlx/ # SQLite query metadata for offline builds +├── crates/ +│ ├── desktop/artcraft/ # Tauri app, configuration, capabilities, icons +│ ├── api_clients/ # Desktop provider clients and shared API types +│ ├── lib/ # Desktop support libraries +│ ├── schema/database/sqlite_tasks/ # Desktop task persistence +│ ├── schema/public/ # Shared wire-format enums and identifiers +│ ├── testing/ # Shared test helpers +│ └── vendor/tauri-plugin-http/ # HTTP plugin fork with cookie access +├── frontend/ +│ ├── apps/artcraft/ # Tauri app frontend and public resources +│ └── libs/ # Shared frontend libraries +├── script/artcraft/ # Desktop development and build scripts +├── test_data/ # Fixtures used by retained crate tests +└── Cargo.toml # Desktop dependency workspace +``` + +Keep `external/` and `secrets/` untouched. Repository pruning must preserve +untracked and ignored local files; delete only Git-tracked paths unless the user +explicitly authorizes a cache cleanup. + +## Code Style + +- Rust with no minimum supported version +- SQLx for SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible +- A mix of wreq and reqwest for Rust HTTP clients +- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead +- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;` +- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions +- TypeScript with Nx, React, Vite, Zustand, and Three.js +- Use two spaces for indentation + +### File Layout + +Organize for top-to-bottom reading. Important things first, details later. + +- **Constants** at the top (after imports) +- **Structs/enums** next; outer structs above inner sub-structs +- **API types** in order: Request, Response, Error +- **In impl blocks**: constructors first, then public methods, then private helpers +- Private helpers go *below* the methods that call them +- Among helpers: meatier logic above leaf-level formatters +- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last + +## Markdown + +- **Tables must be space-padded so columns align in plain text.** Markdown + tables are read raw (terminals, diffs, editors) at least as often as + rendered, and condensed tables are unreadable there. Pad every cell to its + column width: + + ```markdown + | Model | Configuration | Credits | Speed | Score | + |--------------|---------------|--------------------|---------| + | Meshy 6 | text or image | 104 | 80.0 | +24 | + | Rodin 2.5 | text or image | 13 | 10.0 | +3 | + ``` + + Not: `| Model | Configuration | Credits | Speed | Score |` packed tight + with varying widths per row. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000..215ce04fc0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,9449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.14", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.1", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f39be698127218cca460cb624878c9aa4e2b47dba3b277963d2bf00bad263b" +dependencies = [ + "android_log-sys", + "env_filter 0.1.0", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "artcraft" +version = "0.0.1" +dependencies = [ + "anyhow", + "artcraft_api_defs", + "artcraft_client", + "artcraft_router", + "base64 0.22.1", + "build_metadata", + "bytes", + "chrono", + "cookie_store 0.0.1", + "directories", + "dirs", + "discord-rich-presence", + "enums", + "errors", + "fal_client", + "filesys", + "futures", + "grok_consumer_client", + "image", + "images", + "indexmap 2.13.0", + "log", + "memory_store", + "midjourney_client", + "mimetypes", + "once_cell", + "openai_sora_client", + "os_info", + "primitives", + "rand 0.9.2", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "serial_test", + "sqlite_tasks", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-http", + "tauri-plugin-log", + "tauri-plugin-opener", + "tauri-plugin-upload", + "tempdir", + "tempfile", + "tokens", + "tokio", + "tokio-util", + "url", + "url_utils", + "uuid_utils", + "web_base64", + "worldlabs_consumer_client", +] + +[[package]] +name = "artcraft_api_defs" +version = "0.0.1" +dependencies = [ + "anyhow", + "chrono", + "enums", + "serde", + "serde_derive", + "serde_json", + "tokens", + "url", + "utoipa", +] + +[[package]] +name = "artcraft_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "artcraft_api_defs", + "backoff", + "chrono", + "cloudflare_errors", + "cookie", + "enums", + "errors", + "log", + "mimetypes", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "tokens", + "tokio", + "url", + "url-builder", + "uuid 1.22.0", + "uuid_utils", +] + +[[package]] +name = "artcraft_router" +version = "0.0.1" +dependencies = [ + "anyhow", + "artcraft_api_defs", + "artcraft_client", + "enums", + "fal_client", + "gmicloud_client", + "grok_api_client", + "kinovi_web_client", + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "strum", + "test_data", + "test_utils", + "tokens", + "tokio", + "url_utils", + "uuid 1.22.0", + "worldlabs_api_client", + "wreq", +] + +[[package]] +name = "assertor" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b02f2af4042fbbed4d5732c517666629512c574c8e2edb5cfe2cf8a86564d52" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2776ead772134d55b62dd45e59a79e21612d85d0af729b8b7d3967d601a62a" +dependencies = [ + "concurrent-queue", + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "zstd", + "zstd-safe", +] + +[[package]] +name = "async-executor" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b10202063978b3351199d68f8b22c4e47e4b1b822f8d43fd862d5ea8c006b29a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 0.38.44", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix 0.38.44", + "tracing", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.44", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "av1-grain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.14", + "instant", + "pin-project-lite", + "rand 0.8.5", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "boring-sys2" +version = "5.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455d79965f5155dcc88a7abce112c3590883889131b799beda10bf9a813ed669" +dependencies = [ + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "boring2" +version = "5.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28" +dependencies = [ + "bitflags 2.11.0", + "boring-sys2", + "foreign-types 0.5.0", + "libc", + "openssl-macros", +] + +[[package]] +name = "borsh" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5430e3be710b68d984d1391c854eb431a9d548640711faa54eecb1df93db91cc" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b668d39970baad5356d7c83a86fee3a539e6f93bf6764c97368243e17a0487" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "build-info" +version = "0.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288657edd15bfa5a7f30ca3b123c1af2c503eaf218e517fa10bc9063dbc2ad99" +dependencies = [ + "bincode", + "build-info-common", + "build-info-proc", +] + +[[package]] +name = "build-info-build" +version = "0.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3146483d5bc5081ec26f9c4e60232e770980d750b2802d6cc2563cded665cc73" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bincode", + "build-info-common", + "cargo_metadata", + "chrono", + "git2", + "glob", + "pretty_assertions", + "rustc_version", + "serde_json", + "zstd", +] + +[[package]] +name = "build-info-common" +version = "0.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8524ad59f5958f37f95e66bf6c9a8fa8440c4f56c069247c44244434cfca3eb1" +dependencies = [ + "chrono", + "derive_more", + "semver", + "serde", +] + +[[package]] +name = "build-info-proc" +version = "0.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b58fb02636d968e8327d84a5a256df9704ac27a1eda98429c35dbe50a278f69" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bincode", + "build-info-common", + "chrono", + "num-bigint", + "num-traits", + "proc-macro-error2", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", + "zstd", +] + +[[package]] +name = "build_metadata" +version = "0.0.1" +dependencies = [ + "build-info", + "build-info-build", +] + +[[package]] +name = "built" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73848a43c5d63a1251d17adf6c2bf78aa94830e60a335a95eeea45d6ba9e1e4d" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byte-unit" +version = "5.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cd29c3c585209b0cbc7309bfe3ed7efd8c84c21b7af29c8bfae908f8777174" +dependencies = [ + "rust_decimal", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.63", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8769706aad5d996120af43197bf46ef6ad0fda35216b4505f926a365a232d924" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257" +dependencies = [ + "serde", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid 1.22.0", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.6", +] + +[[package]] +name = "cloudflare_errors" +version = "0.0.1" + +[[package]] +name = "cloudflare_mitigation" +version = "0.0.1" +dependencies = [ + "once_cell", + "wreq", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.0.1" +dependencies = [ + "errors", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.2", + "smallvec", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "destructure_traitobject" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.1", + "crypto-common 0.2.1", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "discord-rich-presence" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "uuid 0.8.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.1", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2646c2bb4993c86006961293208b69d86a2b254356889ddad826e9df3f684f9" +dependencies = [ + "bit-set", + "cssparser 0.35.0", + "foldhash 0.1.5", + "html5ever 0.35.0", + "precomputed-hash", + "selectors 0.30.0", + "tendril 0.4.3", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + +[[package]] +name = "dtoa-short" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbaceec3c6e4211c79e7b1800fb9680527106beb2f9c51904a3210c03a448c74" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" + +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + +[[package]] +name = "either" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" +dependencies = [ + "serde", +] + +[[package]] +name = "embed-resource" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.8.23", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enums" +version = "0.0.1" +dependencies = [ + "log", + "regex", + "serde", + "serde_derive", + "serde_json", + "sqlx", + "sqlx-core", + "sqlx-mysql", + "strum", + "toml 1.0.7+spec-1.1.0", + "utoipa", +] + +[[package]] +name = "env" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc95de49ad098572c02d3fbf368c9a020bfff5ae78483685b77f51d8a7e9486d" +dependencies = [ + "num_threads", +] + +[[package]] +name = "env_filter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter 1.0.0", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "erased-serde" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "errors" +version = "0.0.1" +dependencies = [ + "anyhow", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fal_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "errors", + "file_to_base64_url", + "futures", + "log", + "reqwest 0.12.23", + "serde", + "serde_json", + "strum", + "test_data", + "test_utils", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file_to_base64_url" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "errors", + "mimetypes", + "test_utils", +] + +[[package]] +name = "filesys" +version = "0.0.1" +dependencies = [ + "errors", + "log", + "test_utils", + "walkdir", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.63", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "git2" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.63", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "gmicloud_client" +version = "0.0.1" +dependencies = [ + "errors", + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "test_data", + "test_utils", + "tokio", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "grok_api_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "env_logger", + "errors", + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "test_data", + "tokio", +] + +[[package]] +name = "grok_consumer_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "byteorder", + "chrono", + "cloudflare_errors", + "cloudflare_mitigation", + "cookie_store 0.0.1", + "env", + "env_logger", + "errors", + "filesys", + "futures", + "log", + "log4rs", + "once_cell", + "regex", + "scraper", + "serde", + "serde_json", + "sha2 0.11.0-rc.5", + "test_data", + "test_utils", + "tokio", + "url", + "uuid 1.22.0", + "wreq", + "wreq-util", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever 0.35.0", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "http2" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faafa0f89e637a3524a9c081bcf825996e841afac53e691943e72049cea56f0a" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "parking_lot", + "slab", + "smallvec", + "tokio", + "tokio-util", +] + +[[package]] +name = "http_headers" +version = "0.0.1" +dependencies = [ + "errors", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hybrid-array" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 0.26.2", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core 0.51.1", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "num-traits", + "png 0.17.16", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "images" +version = "0.0.1" +dependencies = [ + "anyhow", + "errors", + "image", + "mimetypes", + "test_utils", +] + +[[package]] +name = "imgref" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.63", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.63", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jwt_light" +version = "0.0.1" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde_json", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kinovi_web_client" +version = "0.0.1" +dependencies = [ + "chrono", + "cloudflare_errors", + "env", + "env_logger", + "errors", + "log", + "rand 0.9.2", + "serde", + "serde_derive", + "serde_json", + "test_data", + "test_utils", + "tokio", + "url", + "wreq", + "wreq-util", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libgit2-sys" +version = "0.18.1+1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + +[[package]] +name = "log4rs" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e947bb896e702c711fccc2bf02ab2abb6072910693818d1d6b07ee2b9dfd86c" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "derive_more", + "fnv", + "humantime", + "libc", + "log", + "log-mdc", + "mock_instant", + "parking_lot", + "rand 0.9.2", + "serde", + "serde-value", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "thread-id", + "typemap-ors", + "unicode-segmentation", + "winapi", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril 0.4.3", + "web_atoms 0.1.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms 0.2.4", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memory_store" +version = "0.0.1" +dependencies = [ + "anyhow", + "errors", +] + +[[package]] +name = "midjourney_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "cloudflare_errors", + "cookie_store 0.0.1", + "dom_query 0.20.2", + "errors", + "filesys", + "futures", + "log", + "serde", + "serde_json", + "test_data", + "test_utils", + "tokio", + "url", + "wreq", + "wreq-util", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mimetypes" +version = "0.0.1" +dependencies = [ + "anyhow", + "infer", + "test_utils", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "mock_instant" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.63", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02339744ee7253741199f897151b38e72257d13802d4ee837285cc2990a90845" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b" +dependencies = [ + "proc-macro-crate 2.0.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.1", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.1", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.1", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openai_sora_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "cloudflare_errors", + "cookie", + "errors", + "log", + "once_cell", + "rand 0.9.2", + "serde", + "serde_derive", + "serde_json", + "sha3", + "test_utils", + "thiserror 2.0.18", + "tokio", + "toml 1.0.7+spec-1.1.0", + "url", + "uuid_utils", + "wreq", + "wreq-util", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.4.2+3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168ce4e058f975fe43e89d9ccf78ca668601887ae736090aacc23ae353c298e2" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +dependencies = [ + "log", + "plist", + "windows-sys 0.52.0", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.2", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros 0.11.2", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "plist" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.4.0", + "pin-project-lite", + "rustix 0.38.44", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primitives" +version = "0.0.1" +dependencies = [ + "assertor", + "test_utils", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.5+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.1", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285" +dependencies = [ + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.14", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.1", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.63", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "read-progress-stream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8" +dependencies = [ + "bytes", + "futures", + "pin-project-lite", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.14", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "async-compression", + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store 0.21.1", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.0", + "web-sys", + "webpki-roots 1.0.2", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.1", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.14", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid 1.22.0", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.14", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a" +dependencies = [ + "openssl-probe 0.1.5", + "rustls-pemfile 2.1.2", + "rustls-pki-types", + "schannel", + "security-framework 2.10.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scc" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea091f6cac2595aa38993f04f4ee692ed43757035c36e67c180b6828356385b1" +dependencies = [ + "sdd", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid 1.22.0", +] + +[[package]] +name = "schemars_derive" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "schnellru" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649" +dependencies = [ + "ahash 0.8.12", + "cfg-if", + "hashbrown 0.13.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9" +dependencies = [ + "cssparser 0.35.0", + "ego-tree", + "getopts", + "html5ever 0.35.0", + "precomputed-hash", + "selectors 0.31.0", + "tendril 0.4.3", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sdd" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df44ba8a7ca7a4d28c589e04f526266ed76b6cc556e33fe69fa25de31939a65" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.35.0", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.11.2", + "phf_codegen 0.11.3", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.35.0", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.11.2", + "phf_codegen 0.11.3", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2676ba99bd82f75cae5cbd2c8eda6fa0b8760f18978ea840e980dd5567b5c5b6" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.2", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall 0.5.2", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +dependencies = [ + "itertools 0.11.0", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlite_tasks" +version = "0.0.1" +dependencies = [ + "anyhow", + "chrono", + "enums", + "errors", + "log", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "sqlx", + "tempfile", + "tokens", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash 0.8.12", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener 2.5.3", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "sha2 0.10.8", + "smallvec", + "sqlformat", + "thiserror 1.0.63", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.25.3", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.8", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.11.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2 0.10.8", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.63", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.11.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2 0.10.8", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.63", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.1", + "core-foundation 0.10.0", + "core-graphics 0.25.0", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.1", + "glob", + "gtk", + "heck 0.5.0", + "http", + "http-range", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.8", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid 1.22.0", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation 0.3.1", + "percent-encoding", + "schemars", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.0.7+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-http" +version = "2.4.3" +dependencies = [ + "bytes", + "cookie_store 0.21.1", + "data-url", + "http", + "regex", + "reqwest 0.12.23", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "urlpattern", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.1", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-upload" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2844560c33b360506cea7289626267f43be253a01018b53bd556e86163b9fd" +dependencies = [ + "futures-util", + "log", + "read-progress-stream", + "reqwest 0.12.23", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.4", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query 0.27.0", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.0.7+spec-1.1.0", + "url", + "urlpattern", + "uuid 1.22.0", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56eaa45f707bedf34d19312c26d350bc0f3c59a47e58e8adbeecdc850d2c13a0" +dependencies = [ + "embed-resource", + "toml 0.8.23", +] + +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand 0.4.6", + "remove_dir_all", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "test_data" +version = "0.0.1" +dependencies = [ + "anyhow", + "errors", +] + +[[package]] +name = "test_utils" +version = "0.0.1" +dependencies = [ + "errors", +] + +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl 1.0.63", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread-id" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99043e46c5a15af379c06add30d9c93a6c0e8849de00d244c4a2c417da128d80" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "tiff" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d172b0f4d3fba17ba89811858b9d3d97f928aece846475bbda076ca46736211" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokens" +version = "0.0.1" +dependencies = [ + "once_cell", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sqlx", + "sqlx-core", + "sqlx-mysql", + "strum", + "utoipa", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-boring2" +version = "5.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f81df1210d791f31d72d840de8fbd80b9c3cb324956523048b1413e2bd55756" +dependencies = [ + "boring2", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.36", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.0", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.13", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typeid" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e" + +[[package]] +name = "typemap-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68c24b707f02dd18f1e4ccceb9d49f2058c2fb86384ef9972592904d7a28867" +dependencies = [ + "unsafe-any-ors", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unsafe-any-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a303d30665362d9680d7d91d78b23f5f899504d4f08b3c4cf08d055d87c0ad" +dependencies = [ + "destructure_traitobject", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "url-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c560d19163937c53680c515b82a9311c5a796691c67890b8ad579423123e194" + +[[package]] +name = "url_utils" +version = "0.0.1" +dependencies = [ + "url", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "url", +] + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.14", +] + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid_utils" +version = "0.0.1" +dependencies = [ + "uuid 1.22.0", +] + +[[package]] +name = "v_frame" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.2", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.2", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "web_base64" +version = "0.0.1" +dependencies = [ + "base64 0.22.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" + +[[package]] +name = "webpki-roots" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452ad30530b54a4d8e71952716a212b08efd0f3562baa66c29a618b07da7c3" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[package]] +name = "whoami" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +dependencies = [ + "redox_syscall 0.4.1", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12476c23a74725c539b24eae8bfc0dac4029c39cdb561d9f23616accd4ae26d" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worldlabs_api_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "chrono", + "env", + "env_logger", + "errors", + "filesys", + "log", + "serde", + "serde_json", + "strum", + "test_data", + "tokio", + "uuid 1.22.0", + "wreq", +] + +[[package]] +name = "worldlabs_consumer_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "byteorder", + "chrono", + "cloudflare_errors", + "cloudflare_mitigation", + "cookie_store 0.0.1", + "env", + "env_logger", + "errors", + "filesys", + "futures", + "http_headers", + "indexmap 2.13.0", + "jwt_light", + "log", + "log4rs", + "once_cell", + "regex", + "scraper", + "serde", + "serde_json", + "sha2 0.11.0-rc.5", + "test_data", + "test_utils", + "tokio", + "url", + "uuid 1.22.0", + "wreq", + "wreq-util", +] + +[[package]] +name = "wreq" +version = "6.0.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79937f6c4df65b3f6f78715b9de2977afe9ee3b3436483c7949a24511e25935" +dependencies = [ + "ahash 0.8.12", + "boring2", + "brotli", + "bytes", + "cookie", + "flate2", + "futures-channel", + "futures-util", + "http", + "http-body", + "http-body-util", + "http2", + "httparse", + "ipnet", + "libc", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "schnellru", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.2", + "sync_wrapper", + "tokio", + "tokio-boring2", + "tokio-tungstenite", + "tokio-util", + "tower", + "tower-http", + "url", + "want", + "webpki-root-certs", + "zstd", +] + +[[package]] +name = "wreq-util" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c6bbe24d28beb9ceb58b514bd6a613c759d3b706f768b9d2950d5d35b543c04" +dependencies = [ + "typed-builder", + "wreq", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query 0.27.0", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.8", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid 1.22.0", + "windows-sys 0.61.2", + "winnow 1.0.0", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.0", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd99b45c6bc03a018c8b8a86025678c87e55526064e38f9df301989dce7ec0a" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.10+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.0", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.0", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000..12fed20259 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,122 @@ +[workspace] +resolver = "2" + +# Desktop application and its local build/test dependencies. +members = [ + "crates/api_clients/artcraft/artcraft_api_defs", + "crates/api_clients/artcraft/artcraft_client", + "crates/api_clients/artcraft/artcraft_router", + "crates/api_clients/fal_client", + "crates/api_clients/gmicloud_client", + "crates/api_clients/grok_api_client", + "crates/api_clients/grok_consumer_client", + "crates/api_clients/kinovi_web_client", + "crates/api_clients/midjourney_client", + "crates/api_clients/openai_sora_client", + "crates/api_clients/worldlabs_api_client", + "crates/api_clients/worldlabs_consumer_client", + "crates/desktop/artcraft", + "crates/lib/build_metadata", + "crates/lib/caching/memory_store", + "crates/lib/cloudflare_errors", + "crates/lib/cloudflare_mitigation", + "crates/lib/cookie_store", + "crates/lib/data/primitives", + "crates/lib/data/web_base64", + "crates/lib/errors", + "crates/lib/files/file_to_base64_url", + "crates/lib/files/filesys", + "crates/lib/files/images", + "crates/lib/files/mimetypes", + "crates/lib/http_headers", + "crates/lib/jwt_light", + "crates/lib/url_utils", + "crates/lib/uuid_utils", + "crates/schema/database/sqlite_tasks", + "crates/schema/public/enums", + "crates/schema/public/tokens", + "crates/testing/test_data", + "crates/testing/test_utils", + "crates/vendor/tauri-plugin-http", +] +default-members = ["crates/desktop/artcraft"] + +[workspace.package] +publish = false + +[workspace.dependencies] +artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" } +artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" } +artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" } +build_metadata = { path = "crates/lib/build_metadata" } +cloudflare_errors = { path = "crates/lib/cloudflare_errors" } +cloudflare_mitigation = { path = "crates/lib/cloudflare_mitigation" } +cookie_store = { path = "crates/lib/cookie_store" } +enums = { path = "crates/schema/public/enums" } +errors = { path = "crates/lib/errors" } +fal_client = { path = "crates/api_clients/fal_client" } +gmicloud_client = { path = "crates/api_clients/gmicloud_client" } +file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" } +filesys = { path = "crates/lib/files/filesys" } +grok_api_client = { path = "crates/api_clients/grok_api_client" } +grok_consumer_client = { path = "crates/api_clients/grok_consumer_client" } +http_headers = { path = "crates/lib/http_headers" } +images = { path = "crates/lib/files/images" } +jwt_light = { path = "crates/lib/jwt_light" } +memory_store = { path = "crates/lib/caching/memory_store" } +midjourney_client = { path = "crates/api_clients/midjourney_client" } +mimetypes = { path = "crates/lib/files/mimetypes" } +openai_sora_client = { path = "crates/api_clients/openai_sora_client" } +primitives = { path = "crates/lib/data/primitives" } +kinovi_web_client = { path = "crates/api_clients/kinovi_web_client" } +sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" } +test_data = { path = "crates/testing/test_data" } +test_utils = { path = "crates/testing/test_utils" } +tokens = { path = "crates/schema/public/tokens" } +url_utils = { path = "crates/lib/url_utils" } +uuid_utils = { path = "crates/lib/uuid_utils" } +web_base64 = { path = "crates/lib/data/web_base64" } +worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" } +worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" } +anyhow = "1.0.102" +assertor = "0.0.3" +base64 = "0.22.1" +bstr = "1.12.1" +byteorder = "1.5.0" +chrono = { version = "0.4.44", features = ["serde"] } +cookie = "0.18.1" +env = "1.0.1" +env_logger = "0.11.9" +futures = { version = "0.3.32" } +indexmap = "2.13.0" +log = "0.4.29" +once_cell = "1.21.4" +rand = "0.9.1" +regex = "1.12.3" +reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] } +serde = { version = "1.0.228", features = ["derive"] } +serde_derive = "1.0.228" +serde_json = "1.0.149" +sha2 = "0.11.0-rc.5" +sha3 = "0.10.8" +strum = { version = "0.28.0", features = ["derive"] } +tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access +tauri-plugin-dialog = "2.7.1" +tauri-plugin-fs = "2.5.1" +tauri-plugin-log = "2.8.0" +tauri-plugin-upload = "2.4.0" +tempdir = "0.3.7" +tempfile = "3.27.0" +thiserror = "2.0.18" +tokio = { version = "1.50.0", features = ["macros"] } +tokio-util = "0.7.12" +toml = "1.0.7+spec-1.1.0" +url = { version = "2.5.8", features = ["serde"] } +utoipa = { version = "5.4.0", features = ["chrono", "actix_extras", "url"] } +uuid = "1.22.0" +walkdir = "2.4.0" +# wreq native prerequisites: +# macOS: brew install cmake pkg-config llvm +# Windows: choco install cmake strawberryperl pkgconfiglite llvm nasm +wreq-util = "3.0.0-rc.10" +wreq = { version = "6.0.0-rc.28", features = ["json", "query", "form", "multipart", "cookies", "ws", "stream", "gzip", "brotli", "zstd", "deflate"] } diff --git a/Cranky.toml b/Cranky.toml new file mode 100644 index 0000000000..e83222e89d --- /dev/null +++ b/Cranky.toml @@ -0,0 +1,70 @@ +# Persist clippy check configurations: +# https://github.com/ericseppanen/cargo-cranky +# Clippy doesn't allow this (yet): +# https://github.com/rust-lang/rust-clippy/issues/6625 +deny = [ + # Rust + "unsafe_code", # We may remove this if needed + + # Clippy - "serious" lints + "clippy::borrow_interior_mutable_const", # Seems dangerous to allow this. Eg. `const F = Lazy::new()`; + + # Clippy - bad math + #"clippy::cast_possible_truncation", # Seems dangerous, eg f64 to u64. + #"clippy::cast_possible_wrap", + + # Clippy - pointer safety / readability / correctness + "clippy::as_ptr_cast_mut", + "clippy::borrow_as_ptr", + "clippy::borrow_deref_ref", + + # Clippy async + "clippy::await_holding_lock", + + # Clippy - performance + "clippy::cmp_owned", + "clippy::useless_vec", + "clippy::format_in_format_args", + #"clippy::redundant_clone", + #"clippy::unnecessary_to_owned", + #"clippy::vec_init_then_push", + + # Clippy - "ergonomic" / "usage" lints + "clippy::clone_on_copy", # Don't use clone() on Copy types + "clippy::option_option", # Lint: Don't ever Option>! Enums instead. + "clippy::zero_sized_map_values", # Lint: use a HashSet instead of HashMap. + + # Clippy - "aesthetic" / "style" lints + "clippy::clone_double_ref", # Lint: feels safer/hygienic to disallow this, but I'm not strongly opposed. + "clippy::double_comparisons", # Lint: readability + "clippy::empty_structs_with_brackets", # Lint: {} feels less aesthetic than ; + "clippy::implicit_clone", # Lint: Recommendation here seems more idiomatic. + "clippy::needless_late_init", # Don't declare variables without initializing them + "clippy::needless_return", # Lint: not the worst offender, but let's try to keep unnecessary returns out. + "clippy::redundant_field_names", # Lint: Duplicate names is a burden when reading: you wonder why they're dupes. + "clippy::redundant_static_lifetimes", # Lint: feels more modern to elide static lifetimes + + # Rust + "non_fmt_panics", # panic!(format!(...)) will be unsupported soon + "unused_imports", # These are easy to automatically cull + "unused_mut", # Be strict about mutability + "unused_parens", # Unused parens are ugly + "unused_variables", # Don't leave cruft + + # Possible: + # "clippy::ptr_arg", + # "clippy::too_many_lines" (note: this is configurable) +] + +warn = [ +] + +allow = [ + # Rust + "deprecated", # Other systems can handle deprecated code instead of the linter + "missing_docs", # Not everything needs documentation. Too much documentation is bad. Literate code. + + # Clippy + "clippy::if_same_then_else", # There are cases this is clearer! Branches may have notes / considerations. + "clippy::single_char_pattern", # This is like punishing trailing commas. Chars are dumb. +] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000000..619a6ef3d9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,86 @@ +ArtCraft License (WIP) +====================== + +(**Warning**: This isn't a real lawyery license full of legitimate legalese yet, but we'll take care of that ASAP.) + +ArtCraft is a form of "fair source" software. It's like [free beer](https://en.wiktionary.org/wiki/free_as_in_beer) +in that we give you the code, but there are a few small limitations on what you can do with it. + +First, what you *can* do with ArtCraft: + +> 1. You do not have to pay us to use ArtCraft. It's free. +> +> 2. You can back up, copy, modify, compile, and use the code for your own private personal purposes. +> +> 3. You can do anything you want with the media you create with ArtCraft. Generated assets are yours. + +What you *can't do* with ArtCraft: + +> 1. You cannot sell the ArtCraft software commercially (eg. as your own software). +> +> 2. You cannot use the ArtCraft code to develop a competing business or product (eg. selling AI image and video tools). +> +> 3. You cannot fork ArtCraft and remove ArtCraft community, donation links, or paid model services. We intend to +> make money to improve ArtCraft for everyone. +> +> 4. You cannot use the ArtCraft name, logo, or mascot characters to promote your business without permission. + +This license means you can see what we're doing and not be at risk of having the rug pulled out from under +you (eg. if our founders die, if our business collapses, if zombies or space aliens invade). It also gives +you a really great way to customize the software or lend a hand in reporting (or even fixing) bugs. + +Our desire is to support artists, designers, and filmmakers. We want to help you build your own creative studios +and businesses. We want to give you creative software you can use forever. + +ArtCraft is free. We do offer models available through us where you can pay us and support our development, but we also +offer the ability for you to use other providers - and we'll keep adding more options so you have maximum flexibility. + +## Our Promise + +- All development and business plans will be communicated openly with the community. + +- Except for minor test scripts or a few small places where we might need to use licensed 3rd party code, all of + our engineering efforts will live in a single publicly available monorepo. You can see everything we do and even + benefit from using it for personal use. We plan on building a lot more than what you see today, and you'll get + all of it. + +- We will not "rug pull" our users. If our business shuts down, gets acquired, or otherwise changes, the tools + we create will always continue to function in the same way. + +- If the business ends and our founders quit working in this space, we'll make it so the software transitions + to an OSI-approved open source license. (We don't intend to quit!) + +## Why not OSI Open Source? + +Philosophical answer: The OSI is an industry body led by hyperscalers. They poach from open source all the time, yet +hyperscaler systems and code (AWS, GCP, YouTube, etc.) remain closed source. The tiniest nuggets are open, but the vast +machinery surrounding it - systems, user behaviors, funnels, at-scale moneymakers - is totally closed. + +Practical answer: OSI licenses make it exceptionally challenging to grow big things commercially. And many of +the licenses leave room for hyperscalers to reach in and cherry-pick pieces for themselves that they can better +commercialize than the original authors. + +Then there's completely open source software that doesn't even attempt to be a business. + +We love software like Blender, but (1) developing ArtCraft is our full-time job, (2) we want to build something at scale +that is much bigger than what you presently see, and (3) this will eventually become much more capital intensive than +just writing code - we want to train novel models that work directly in conjunction with the user interface. + +If and when we do get to scale, we intend to keep every bit of our work in the open, including the models. Available +for use, inspection, learning, fitting onto consumer GPU hardware, and more. + +Not having a way to make money is unfortunately what killed Stability.ai. Stability.ai walked so that others could +eventually run. (Black Forest Labs, et al.) We're not egotistical enough to count ourselves in that party yet, but we +believe in the original Stability.ai ethos and that their model can work with a few tweaks. + +We want to build big and incredible tools the world hasn't seen before. We want to keep them free and entirely source +available. You don't have to pay us, but if you do, all of your investment goes right back into the engineering +and the tools. We're writing code you can clone and keep. + +## Inspiration + +We took inspiration for this from [Fair Source](https://fair.io/), [Fair Code](https://faircode.io/), +the [Defold License](https://defold.com/license/), and Hacker News discussions. The folks at Obsidian +have some pretty good thoughts on this as well. + +~ Brandon Thomas diff --git a/README.md b/README.md new file mode 100644 index 0000000000..a55e9f8626 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +

+

+ +

The IDE for artists.

+

+ Discord + YouTube + X + LinkedIn +

+ +--- + +ArtCraft +======== +ArtCraft is the IDE for interactive AI image and video creation. +We turn prompting into *crafting*, so your ideas become a form of tangible expression and computing. +This is Adobe Photoshop for everyone, and we're giving away the source code! + +## Show, Don't Tell: Advanced Crafting Features + +Text-to-image is great, but artists *need control*. It's important to know what your image will look like before you generate it, and it's vitally important to achieve consistency and repeatability. + +| Feature | Demo + Explanation +|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Image to Location** | ![Video](https://github.com/user-attachments/assets/21f103e3-cc19-4882-a630-9caa1b76ae31) Placing virtual actors into physical environments establishes single-location consistency. You can film multiple shots within a room without having things disappear. | +| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. | +| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. | +| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. | +| **Character Posing** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". | +| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. | +| **Character Identity Transfer** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. | +| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. | +| **Mixed Asset Crafting** | ![Video](https://raw.githubusercontent.com/storytold/github-media/main/ship-editing.gif) You can use image cutouts, worlds, and simple 3D meshes all together to precisely and intentionally lay out your scenes. | +| **Scene Blocking** | (preview coming soon) | +| **Canvas Editing** | (preview coming soon) | +| **Scene Relighting** | (preview coming soon) | + +Note: all of the above videos were generated for free with Grok Video; the cost to build this README was negligible. + +## Quick and Easy Prompting +We haven't abandoned text-to-asset generation for quick prototyping and ideation. We support every popular workflow in a first class fashion. + +| Feature | Demo + Explanation +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Text to Image** | ![Video](https://github.com/user-attachments/assets/9cc289cd-faf4-4eaf-aed2-21134cce127c) Text prompt over a dozen different image models. | +| **Image Editing** | ![Video](https://github.com/user-attachments/assets/a06fa6ad-936c-42d0-8767-48fdbb8ff141) Edit with Nano Banana Pro and GPT Image 1.5. | +| **Image Editing** | ![Video](https://github.com/user-attachments/assets/f036e08a-f3a6-417a-98ee-ec7f04b2b5ff) Use inpainting, drawing, masking, etc. to edit images. | +| **Image to Video** | ![Video](https://github.com/user-attachments/assets/2bc6c592-511e-4fba-b40f-03c96699b7f7) Image to video with lots of different options and controls. | +| **Image Inpainting** | (preview coming soon) | +| **Image Ingredients** | (preview coming soon) | + +## Models and Providers Supported within Artcraft + +| Provider | Features | +|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 1.0 Lite / 1.5 Pro / 2.0, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Sora 2 / Pro, Hunyuan 3d 2 / 3 | +| Grok | Grok Imagine, Grok Video | +| Midjourney | Image Gen (all versions) | +| Sora | Sora 1, Sora 2, GPT-Image-1 | +| WorldLabs | Marble (Gaussian Splat World Generation) | + +We're going to be adding the following providers soon: Kling (via Kling website accounts), Google (via API keys), +Runway (via website account), Luma (via website account). + +We're potentially interested in adding other aggregators for those who already have subscriptions and credits at +those providers, for example: OpenArt, FreePik, etc. + +## Downloads + +- [Visit our website for the stable Windows and MacOS releases](https://getartcraft.com/) +- Or you can grab a [more recent Windows and MacOS build directly](https://github.com/storytold/artcraft/releases) +- Linux requires building from source for now + +## Documentation + +- [developer documentation](./_docs) +- [tools, scripts, misc](./script) +- [license](./LICENSE.md) +- [roadmap](./ROADMAP.md) + diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..dd7705251e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,47 @@ +ArtCraft Roadmap +================ + +## 2026 Goals (non-exhaustive) + +Product + +- Make ArtCraft better and more useful than model aggregation websites (our #1 goal). + We don't dislike those websites; we just set a high standard for ourselves. + +- Build the best "tangible" computing surfaces where you can mold scenes and videos like + clay and direct them with precision and clarity. + +- Add every model and provider into ArtCraft so you can build your own crafting IDE. + Nothing will be left out. + +- Build purpose-specific image models, eg. for relighting scenes and reposing characters + and props. + +Studio + +- Build our content studio. Make lots of narrative shorts and at least one + feature-length AI film by the year's end. Theatrical distribution, but also lots + of social media content. (We are creators, and we want to employ more creators!) + +- Host festivals and competitions, including in-person meetups and screenings. + +Architecture + +- Remove dependence on ArtCraft hosted services. + +- More Rust, more speed. + +- Code cleanup, more/better tests, CI, ... + +Community + +- Build a safe community free of negativity and anti-AI stigma. A community of filmmakers + and artists. + +## Loftier Goals + +- Build an *Open* OpenAI / Google / Anthropic. Open models, open platforms, open hardware. + Why? Because the future could be bleak without it. How, you might ask? Get in touch if + you're curious. + +- Don't Be Evil™ diff --git a/_database/sql/artcraft_migrations/20250708000000_create_tasks_table.sql b/_database/sql/artcraft_migrations/20250708000000_create_tasks_table.sql new file mode 100644 index 0000000000..c6fe4a2541 --- /dev/null +++ b/_database/sql/artcraft_migrations/20250708000000_create_tasks_table.sql @@ -0,0 +1,127 @@ +-- noinspection SqlDialectInspectionForFile +-- +-- NB: This version needs to be bumped even if just adding or changing comments! +-- Failure to do so will cause Windows to segfault at start. (This is horrible!) +-- +-- NB: `app_state_dir.rs` contains the file version number. +-- +-- Migration history: +-- tasks_v1.sqlite - initial version +-- tasks_v2.sqlite - added model_type (nullable) +-- tasks_v3.sqlite - added frontend_caller, comments +-- tasks_v4.sqlite - added is_dismissed_by_user +-- tasks_v5.sqlite - added five fields: +-- on_complete_batch_token, +-- on_complete_primary_media_file_token, +-- on_complete_primary_media_file_class, +-- on_complete_primary_media_file_cdn_url, +-- on_complete_primary_media_file_thumbnail_url_template, +-- tasks_v6.sqlite - added two fields: +-- on_failure_type +-- on_failure_message +-- tasks_v7.sqlite - added prompt token + two fields (for fal): +-- prompt_token +-- queue_status_url +-- queue_response_url + +CREATE TABLE tasks ( + -- Task auto-incrementing primary key. + -- We don't use this. + id TEXT NOT NULL PRIMARY KEY, + + -- TaskStatus enum + -- This is how the job system manages job states. + -- e.g. pending, started, complete_success, dead, etc. + task_status TEXT NOT NULL, + + -- TaskType enum + -- e.g. image_generation, video_generation, etc. + task_type TEXT NOT NULL, + + -- TaskModelType enum + -- e.g. flux_1_dev, veo_2, etc. + model_type TEXT, + + -- GenerationProvider enum + -- Together with `provider_job_id`, this creates a foreign key lookup to the job. + -- e.g. 'artcraft', 'fal', 'sora', etc. + provider TEXT, + + -- The primary key for the job in the provider's system. + provider_job_id TEXT, + + -- OPTIONAL. + -- For third party jobs, we need to record the prompt details upfront. + -- We create a prompt record and store the token to associate once the task completes. + prompt_token TEXT, + + -- OPTIONAL. + -- Tell the job system which caller initiated the task. + -- e.g. 'canvas' + frontend_caller TEXT, + + -- OPTIONAL. + -- An arbitrary opaque identifier that frontend can set. + frontend_subscriber_id TEXT, + + -- OPTIONAL. + -- An opaque JSON payload set by the frontend subscriber that + -- will be re-emitted back to the frontend. + frontend_subscriber_payload TEXT, + + -- Whether the user has dismissed the task from view. + is_dismissed_by_user INTEGER NOT NULL DEFAULT 0, + + -- OPTIONAL. + -- For some third party systems (eg. FAL), the URL to check the status of the + -- job in the queue. + queue_status_url TEXT, + + -- OPTIONAL. + -- For some third party systems (eg. FAL), the URL to check the + -- results (when the job completes) + queue_response_url TEXT, + + -- OPTIONAL. + -- When the generation completes, the batch token (if any). + -- This will point to a collection of multiple files. + on_complete_batch_token TEXT, + + -- OPTIONAL. + -- When the generation completes, the media token of the "primary" media file. + -- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch. + on_complete_primary_media_file_token TEXT, + + -- OPTIONAL. + -- When the generation completes, the file type of the primary media file. + -- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch. + on_complete_primary_media_file_class TEXT, + + -- OPTIONAL. + -- The URL to the media file. + on_complete_primary_media_file_cdn_url TEXT, + + -- OPTIONAL. + -- The URL template for the thumbnail of the primary media file. + on_complete_primary_media_file_thumbnail_url_template TEXT, + + -- OPTIONAL. + -- An enum-like string describing the failure reason (even for non-terminal failures). + -- The enum is `TaskFailureType`, which is a close analogue of the backend API's `FrontendFailureCategory`. + on_failure_type TEXT, + + -- OPTIONAL. + -- A human-readable message describing the failure reason (even for non-terminal failures). + on_failure_message TEXT, + + created_at INTEGER NOT NULL DEFAULT (unixepoch('now')), + updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')), + completed_at INTEGER DEFAULT NULL +); + +-- Unique Indices +CREATE UNIQUE INDEX idx_tasks_on_provider_job_id ON tasks(provider_job_id); + +-- Indices +CREATE INDEX idx_tasks_on_status ON tasks(task_status); +CREATE INDEX idx_tasks_on_provider ON tasks(provider); diff --git a/_docs/code_conventions.md b/_docs/code_conventions.md new file mode 100644 index 0000000000..721472d640 --- /dev/null +++ b/_docs/code_conventions.md @@ -0,0 +1,26 @@ +Code Conventions +================ + +We use three languages at Artcraft: Rust, TypeScript, and Python. + +## Rust style guide + +We deviate from the official Rust style guide in a few places: + +- We use two spaces for indentation instead of four. +- We typically keep one function or type definition per file, though this isn't a steadfast rule. +- We use a Cargo workspace to share code within our monorepo. + +### Rust Preferred Libraries + +We use the following Rust libraries, though we are open to migrating to alternatives: + +- `actix-web` for our HTTP web server +- `reqwest` for HTTP client requests +- `sqlx` for SQL database operations +- `tokio` for asynchronous programming + +## TypeScript style guide + +- We use two spaces for indentation instead of four. +- We use `nx` for monorepo management diff --git a/_docs/dev_setup.md b/_docs/dev_setup.md new file mode 100644 index 0000000000..0819ac0d64 --- /dev/null +++ b/_docs/dev_setup.md @@ -0,0 +1,40 @@ +Dev Setup +========= + +# ArtCraft + +ArtCraft is a Rust / Tauri app. + +To set up the ArtCraft development environment, install the following: + +1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html). +2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.) +3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.) +4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.) + +An easy way to get started with running the app in development is to run the two commands (in separate terminals): + +**Mac and Linux Development** + +```bash +# Run the frontend dev server +./script/artcraft/unix_frontend_dev.sh + +# Run the Tauri Rust application +./script/artcraft/unix_rust_dev.sh +``` + +**Windows Development** + +```powershell +# Run the frontend dev server +.\script\artcraft\windows_frontend_dev.ps1 + +# Run the Tauri Rust application +.\script\artcraft\windows_rust_dev.ps1 +``` + +Backend services and website builds live in the separate `artcraft-services` repository. +This repository retains the desktop task database in +`_database/sql/artcraft_migrations/` and its SQLite query cache in `.sqlx/`. +Use `SQLX_OFFLINE=true cargo check -p artcraft` to check Rust without a database server. diff --git a/_docs/dev_setup_server.md b/_docs/dev_setup_server.md new file mode 100644 index 0000000000..8664b3b7db --- /dev/null +++ b/_docs/dev_setup_server.md @@ -0,0 +1,105 @@ +# ArtCraft Server + +ArtCraft's server is a Rust / Actix app called `storyteller-web`. + +You don't need to run this to develop the ArtCraft application, but it can be useful to spin up +a development instance for adding new server functions or as your own private local copy. + +`storyteller-web` can function entirely within the development environment, and ArtCraft can be +pointed to a local instance of the server. + +**TODO**: Concisely describe setting up server components. Note: we have +previous docs in `old/` that may still be relevant, though they're perhaps +slightly out of date. + +## Server Setup (Mac) + +### Install and Migrate MySQL Database + +```bash +# For now, the tooling doesn't support MySQL 9.6 as it was just released in January 2026. +# This will probably change in the future to no longer need to specify the 8.* series. +brew install mysql@8.4 + +# Try to connect +mysql -uroot + +# If connection fails, start service, then try again. +brew services start mysql +mysql -uroot + +# If that fails, reboot and try connecting again. +``` + +Create the `storyteller` database table. Connect to the database: + +```bash +sudo mysql -u root + +# If it needs a password, use argument -p and when prompted use "root" as the typical default password +``` + +Then run this: + +```mysql +use mysql; +CREATE DATABASE storyteller; +CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password'; +GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost'; +``` + +Then verify access with `./script/dev_mysql_connect.sh` + +```bash +cargo install diesel_cli \ + --no-default-features \ + --features mysql,sqlite +``` + +If this fails, + +```bash +brew install zstd +brew install openssl +``` + +Possibly, + +```bash +export RUSTFLAGS="-L$(brew --prefix zstd)/lib -L$(brew --prefix openssl)/lib" +``` + +In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605) +that require a few extra dependencies to be installed: + +```bash +# If you're on Mac and the above command didn't work, run the following and then retry: +brew install libpq +``` + +You're ready to run the migrations: + +```bash +diesel migration run +``` + +You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this +error if you see it in isolation. It doesn't impact the migrations whatsoever. + +Finally, you'll need the sqlx CLI tool to run codegen. +You likely won't need this now, but if you change any queries, this will be necessary: + +```bash +cargo install sqlx-cli --features rustls,mysql,sqlite +``` + +### Install Redis (Mac) + +```bash +# Install Redis +brew install redis + +# Start the service: +brew services start redis +``` + diff --git a/_docs/old/local_development_setup_guide.md b/_docs/old/local_development_setup_guide.md new file mode 100644 index 0000000000..37ecef5cc9 --- /dev/null +++ b/_docs/old/local_development_setup_guide.md @@ -0,0 +1,395 @@ +Local Development Setup Guide (for bare metal) +============================================== + +This document describes how to run the FakeYou infrastructure on your development +machine's bare metal (no containers). + +Setup instructions and development were tested and known to work on the following +platforms: + +- Mac OS (Apple M2 silicon) +- Ubuntu 22.04 +- Windows Subsystem for Linux (Ubuntu 22.04 distro) aka "WSL" + +The applications (binary targets) you might be interested in running include, but +are not limited to: + +- `storyteller-web`, the HTTP API server +- `download-job`, which downloads models from the web and stores them in GCS +- `inference-job`, which downloads models from GCS and runs inference with them + +Setup Instructions +------------------ + +### (1) Install Rust + +Install Rust [using this guide](https://www.rust-lang.org/learn/get-started). If it asks, +you'll want "stable" Rust, not "nightly" Rust. If it doesn't ask, it defaults to "stable". + +For Windows Subsystem for Linux, you'll want the WSL version, not the `.exe` file! + +### (2) Install the necessary libraries + +#### Mac + +On an up-to-date M1/M2 Mac, there should be no libraries you need to install! Any exceptions +to this assumption should be documented here. + +#### Ubuntu 22.04 and WSL: + +If you're on Ubuntu or WSL, you'll need to install some libraries to build the Rust code: + +```bash +# If you haven't run apt before, you'll need to fetch the package list: +sudo apt update + +# Install the dependencies +sudo apt install jq \ + libmysqlclient-dev \ + libsqlite3-dev \ + pkgconf +``` + +(Note: jq is for combining mysql codegen outputs; pkgconf is if using openssl instead of rustls.) + +### (3) Install MySQL server + +#### Mac + +```bash +# Install MySQL +brew install mysql + +# Try to connect +mysql -uroot +``` + +In the event of failure to connect, try the following steps: + +```bash +# If connection fails, start service +brew services start mysql + +# Try to connect again +# If this fails, reboot the machine, eg. using `sudo reboot now` +mysql -uroot +``` + +#### Ubuntu 22.04 and WSL + +```bash +# Install MySQL +sudo apt install mysql-server + +# Try to connect +# The default password is typically "root". +sudo mysql -u root -p +```` + +In the event of failure to connect, try the following steps: + +```bash +# If it failed, you may need to start MySQL (typically only on WSL, not on native Ubuntu) +sudo service mysql start + +# You may also have to change some socket file permissions (again, typically only on WSL) +sudo chmod g+rx /var/run/mysqld +sudo usermod -aG mysql $USER +newgrp mysql + +# Try to connect again +# The default password is typically "root". +# If this fails, try rebooting. If it still fails, read some of the suggestions at the bottom +# of this README. +sudo mysql -u root -p +``` + +### (4) Install a `storyteller` user and table in MySQL + +First, connect to MySQL: + +```bash +# If the following command asks for a password, the password is typically "root" +sudo mysql -u root -p +``` + +Once in MySQL, run the following to set up the database and user accounts: + +```mysql +use mysql; +CREATE DATABASE storyteller; +CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password'; +GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost'; +``` + +Then verify access with `./dev_mysql_connect.sh` + +### (5) Install Diesel CLI (for MySQL migrations): + +In order to run MySQL database migrations, you'll need to install the migration tool we use: + +```bash +cargo install diesel_cli \ + --no-default-features \ + --features mysql,sqlite +``` + +In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605) +that require a few extra dependencies to be installed: + +```bash +# If you're on Mac and the above command didn't work, run the following and then retry: +brew install libpq +``` + +### (6) Run the pending database migrations: + +You're ready to run the migrations: + +```bash +diesel migration run +``` + +You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this +error if you see it in isolation. It doesn't impact the migrations whatsoever. (See notes at the bottom of +the README for details.) + +### (7) (optional) Install the SQLx CLI (if doing database development) + +(You can probably skip this step unless you're a backend engineer.) + +If (and only if) you're doing Rust service development that changes the MySQL queries, then you should install +SQLx utilities. This tool is used to cache the column types and query plans for CI, as SQLx statically verifies +raw MySQL queries against the schema and Rust types. + +We'll be using diesel to manage the migrations, but sqlx within the app to actually perform queries. +Diesel is an immature ORM, which isn't a good tech bet, so we use sqlx as at-compile-time +typesafe SQL. + +(TODO: Reevaluate this assessment for 2023-2024 and beyond. Maybe we do want Diesel now.) + +```bash +# NB(bt,2023-10-04): "--no-default-features" not working +# cargo install sqlx-cli --no-default-features --features rustls,mysql,sqlite +cargo install sqlx-cli --features rustls,mysql,sqlite +``` + +### (8) Install hosts file: + +(This is the same for Linux, Mac, and WSL.) + +If you're developing against the frontend, it'll target development domains (eg. `dev.fakeyou.com`) instead +of `127.0.0.1` or `localhost`. You can make your machine route domains to localhost by editing your hosts +file (located at `/etc/hosts`) to include the following configuration lines: + +``` +# 1) Edit hosts file with your editor of choice +# +# - sudo vi /etc/hosts +# - sudo nano /etc/hosts +# +# 2) Then paste: +# + +127.0.0.1 dev.fakeyou.com +127.0.0.1 api.dev.fakeyou.com +127.0.0.1 devproxy.fakeyou.com + +127.0.0.1 dev.storyteller.ai +127.0.0.1 api.dev.storyteller.ai +127.0.0.1 devproxy.storyteller.ai +``` + +### (9) Install Redis + +#### Mac: + +```bash +# Install Redis +brew install redis + +# Start the service: +brew services start redis +``` + +#### Ubuntu 22.04 and WSL + +``` +# Install Redis +sudo apt install redis + +# Start the service (typically only needed on WSL): +sudo service redis-server start +``` + +### (10) Install Elasticsearch (optional) + +(You can most likely skip this step.) + +Mac: (TODO - haven't installed yet) + +WSL: (TODO - haven't installed yet) + +Ubuntu 22.04: + +Install: + +```bash +curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /usr/share/keyrings/elastic.gpg + +echo "deb [signed-by=/usr/share/keyrings/elastic.gpg] https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-7.x.list + +sudo apt update +sudo apt install elasticsearch +``` + +Configure: + +```text +# Edit /etc/elasticsearch/elasticsearch.yml +# Set the following: +network.host: localhost +``` + +Run: + +```bash +sudo systemctl start elasticsearch +sudo systemctl enable elasticsearch +``` + +Verify: + +```bash +curl -X GET 'http://localhost:9200' +``` + +Install Kibana (prototyping UI): + +```bash +# Install +sudo apt-get install kibana + +# Run +sudo /bin/systemctl daemon-reload +sudo /bin/systemctl enable kibana.service +sudo systemctl start kibana.service + +# Access the Kibana Status Page: +# http://localhost:5601/status + +# Access the query console UI: +# Click Sidebar > Management > Dev Tools > Console +# http://localhost:5601/ +``` + +Extra reading on setting up Elasticsearch: + +- [Guide 1](https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-elasticsearch-on-ubuntu-22-04) +- [Guide 2](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html) +- [Kibana](https://www.elastic.co/guide/en/kibana/current/deb.html) + +### (11) Run one or more applications: + +To start the HTTP API server, + +```bash +cargo run --bin storyteller-web +``` + +Note that this compiles and runs the "development" binary. It's faster and easier to debug than the +optimized "production" build. To build a fully optimized production release, +run `cargo build --release --bin storyteller-web` . Note that this will take much longer. + +You may notice that this might not work right off the bat. You may need to specify some environment variables +and supply some development secrets. + +Secrets: + +- Global secrets (ask Brandon for these): + - Put `.env-secrets` in the root `storyteller-rust` directory. + +- Per app secrets (ask Brandon for these): + - Put `storteller-web.development-secrets.env` in `crates/service/web/storyteller_web/config` + - Put `inference-job.development-secrets.env` in `crates/service/job/inference_job/config` + - Put `donwload-job.development-secrets.env` in `crates/service/job/download_job/config` + +**DO NOT CHECK THESE SECRET FILES INTO THE REPO!** + +Environment config: + +You may need to set local development paths for your environment. You can set these in `.env-secrets` too, +so that they won't be updated for other engineers. + +``` +# this should be the directory containing all of the "storyteller" projects: +STORYTELLER_ROOT = "/home/tensor/code/storyteller" # change this + +# The following are entirely optional, but if you want to fine tune where each project lives, you may do so: +STORYTELLER_FRONTEND = "/somewhere/else/storyteller-frontend" +STORYTELLER_ML = "storyteller-ml" +STORYTELLER_RUST = "/Users/bt/Development/storyteller-rust" +``` + +To download some ML models, run: + +```bash +cargo run --bin download-job +cargo run --bin tts-download-job +``` + +If you want to run both the HTTP API and the jobs, you'll need to run both processes in different +terminals or tmux sessions. + +Note also that the configurations pointing to the ML monorepo must be set up for each application. +Additionally, some development secrets may be needed (ask the team to share). + +To execute ML inference, run: + +```bash +cargo run --bin inference-job +cargo run --bin tts-inference-job +``` + +Again, note that the configurations pointing to the ML monorepo must be set up for each application. +Additionally, some development secrets may be needed (ask the team to share). + +Seeding the Database with Some Values +------------------------------------- +For voice designer you might want some pre-seeded audio that will hit the google cloud bucket, it will require env-secrets ask Brandon. +cargo run --bin dev-database-seed -- --bucket + +Solutions to Common Setup Problems +----------------------------------- + +### [Fix] Sqlx Error when Performing Schema Migrations + +You might get this error message during migration, + +``` +Encountered unknown type for Mysql: enum +thread 'main' panicked at 'internal error: entered unreachable code: Mysql only supports a closed set of types. + If you ever see this error message please open an issue at https://github.com/diesel-rs/diesel + containing a dump of your schema definition.', diesel_cli/src/print_schema.rs:310:17 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +``` + +I haven't found the cause (it doesn't happen on newer installs), but the migrations appear to work regardless +of this error message. You can essentially ignore it. + +### [Fix] Can't connect to local MySQL after install on Ubuntu + +- If MySql in local dev can't be connected to, [reset the accounts](https://linuxconfig.org/how-to-reset-root-mysql-mariadb-password-on-ubuntu-20-04-focal-fossa-linux). + +### API Server +``` +cargo run --bin docs +``` +This setups an api server with swagger docs to show you responses and requests with each associated endpoint +It is run at http://127.0.0.1:8989/ +To obtain the json for the postman collection go to this url http://127.0.0.1:8989/api-docs/openapi.json +We use this: `https://github.com/juhaku/utoipa` +The documentation example is in: +`crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/inference/enqueue_tts_request.rs` +Write into the api doc to: +`crates/service/web/storyteller_web/src/api_doc.rs` diff --git a/_docs/old/old_docs/README.md b/_docs/old/old_docs/README.md new file mode 100644 index 0000000000..8b55ea0f13 --- /dev/null +++ b/_docs/old/old_docs/README.md @@ -0,0 +1,54 @@ +README +====== + +This directory contains a collection of (hopefully organized and up to date) documentation on +running the various backend pieces, development setup, production maintenance, etc. Check out +all the markdown documents in this directory tree for information organized by topic. + +Development / Setup +------------------- + +- [Most of the general documents for cross-cutting setup of FakeYou.com live here.](./local_development) +- Some per-application documents (such as for `aichatbot-sidecar`) live as READMEs under that particular + application's code directory, eg. `/crates/{appCategory}/{appName}/README.md` (These are irrelevant + for most folks.) + +Web API Overview +---------------- + +### FakeYou.com + +* [FakeYou.com](https://fakeyou.com) is powered by the following applications: + * HTTP APIs: + * `storyteller-web` - HTTP API + database monolith for FakeYou + * Asynchronous Jobs: + * `download-job` - Handle async download of ML models (RVC, SVC, etc.) + * `inference-job` - Run async ML model inference (RVC, SVC, SadTalker, etc.) + This will handle most model inference going forward. + * `tts-download-job` - (*deprecated*) Async download of Tacotron2 models only + * `tts-inference-job` - (*deprecated*) Async TTS inference of Tacotron2 models only + * `w2l-download-job` - (*deprecated*) Async download of videos and images + * `w2l-inference-job` - (*deprecated*) Async W2L inference + * Documentation Server: + * `docs` - this will run a local swagger server with the endpoints that you have access to, http://127.0.0.1:8989/api-docs/openapi.json use this to port everything over to the post man. + +### PowerStream + +* [PowerStream](https://power.stream) (deprecated) is powered by the following applications: + * HTTP APIs: + * `storyteller-web` - HTTP + database monolith for FakeYou ***and*** PowerStream. + * Twitch API integration: + * `twitch-pubsub-subscriber` - Dynamically subscribes to Twitch PubSub for "active" users + * Websocket events: + * `websocket-gateway` - Websocket for users' browsers to pull down enriched Twitch events. + +TODO +---- + +Notes / TODOs: + +* Examples for good Actix+Sqlx Tests: + https://stackoverflow.com/questions/65370752/how-do-i-create-an-actix-web-server-that-accepts-both-sqlx-database-pools-and-tr + +* Actix/sqlx runtime compat: + https://github.com/launchbadge/sqlx/issues/1117#issuecomment-801237734 diff --git a/_docs/old/old_docs/cheat_sheet/actix.md b/_docs/old/old_docs/cheat_sheet/actix.md new file mode 100644 index 0000000000..145effc265 --- /dev/null +++ b/_docs/old/old_docs/cheat_sheet/actix.md @@ -0,0 +1,21 @@ +development notes +================= + +Actix notes +----------- + +json request +``` +async fn handler(request: web::Json) -> impl Responder { "whatever" } +``` + +form-multipart request +``` +async fn handler(request: web::Form) -> impl Responder { "whatever" } +``` + +route parameters +``` +#[get("/{name}")] +async fn hello(name: web::Path) -> impl Responder { format!("Hi {}", name) } +``` diff --git a/_docs/old/old_docs/cheat_sheet/migrations.md b/_docs/old/old_docs/cheat_sheet/migrations.md new file mode 100644 index 0000000000..323795531f --- /dev/null +++ b/_docs/old/old_docs/cheat_sheet/migrations.md @@ -0,0 +1,43 @@ +Migrations guide +================ + +## Diesel Migrations + +To run pending migrations, execute the following: + +```bash +diesel migration run +``` + +To revert the last 3 migrations: + +```bash +diesel migration revert -n 3 +``` + +To reset the entire database (drop, migrate), run: + +```bash +diesel database reset +``` + +## Server Query Codegen + +We use SQLx instead of Diesel in the production server. It's typesafe +SQL instead of an ORM like Diesel. + +SQLx connects to a database to derive type information, but obviously +cannot do this for builds in CI. In order to cache the types, we build +and check in a cache file (necessary for builds): + +```bash +SQLX_OFFLINE=true cargo sqlx prepare +``` + +Now that we have multiple binaries, it's required to include all the queries in the main +binary so we can generate the cached queries as a single target. That's then executed +with: + +```bash +SQLX_OFFLINE=true cargo sqlx prepare -- --bin storyteller-web +``` diff --git a/_docs/old/old_docs/local_development/local_development_k8s.md b/_docs/old/old_docs/local_development/local_development_k8s.md new file mode 100644 index 0000000000..f15c997ad5 --- /dev/null +++ b/_docs/old/old_docs/local_development/local_development_k8s.md @@ -0,0 +1,22 @@ +# Local Kubernetes environment for standing up the APIs and their dependencies + +(These are Paul's notes.) + +- Install minikube, kubectl, docker, and musl from your OS package manager +- `rustup target add x86_64-unknown-linux-musl` +- `cargo build --release --bin storyteller-web --target=x86_64-unknown-linux-musl` +- `minikube start --extra-config=apiserver.service-node-port-range=1-65535` +- `eval $(minikube docker-env)` +- `minikube mount /path/to/storyteller-rust/:/storyteller-rust &> /dev/null &` +- `docker build -t storyteller-web -f localdev/Dockerfile .` +- `kubectl apply -f localdev/kubernetes.yml` +- Get the ip address of minikube with `minikube ip`, this is where all the services will be running on their +regular ports (12345 for the api, 6379 for redis, 3306 for mysql) +- That's it, we just spun up mysql, applied database migrations, spun up redis, and spun up our api + +# Development tips +- Rebuild and "deploy" (locally) with `cargo build --release --bin storyteller-web --target=x86_64-unknown-linux-musl && kubectl rollout restart storyteller-web` +- View logs from the api with `kubectl logs storyteller-web` +- Delete everything minikube (if you start running out of disk space or what-have-you) with `minikube delete` +- See pod status with `kubectl get pods` +- If there are errors you can get more info with `kubectl describe pods` diff --git a/_docs/old/old_docs/local_development/local_development_nginx_proxy.md b/_docs/old/old_docs/local_development/local_development_nginx_proxy.md new file mode 100644 index 0000000000..43853f9895 --- /dev/null +++ b/_docs/old/old_docs/local_development/local_development_nginx_proxy.md @@ -0,0 +1,56 @@ +Local Development nginx proxy +============================= + +It isn't necessary to use nginx for development, but it can be nice. Nginx allows for a more +sophisticated testing setup that makes cookie and CORS issues less of a pain. + +You can use nginx to serve (locally-signed) SSL, redirect traffic to different ports, or run +multiple copies of the app side by side. + +### Hosts file + +Set the following configs in your `/etc/hosts` file (as you would probably do even without nginx), + +``` +127.0.0.1 dev.fakeyou.com +127.0.0.1 api.dev.fakeyou.com +127.0.0.1 devproxy.fakeyou.com + +127.0.0.1 dev.storyteller.ai +127.0.0.1 api.dev.storyteller.ai +127.0.0.1 devproxy.storyteller.ai + +# Deprecated development hostnames : +127.0.0.1 jungle.horse +127.0.0.1 api.jungle.horse +``` + +### Nginx installation and administration + +Configure Nginx per the checked in Nginx configs (and instructions) in +the directory `/_develop/localdev/nginx-http-config`. + +This should be straightforward, but if it isn't, contribute docs here. + +### [Fix] Python 3.6 on Apple M1 Mac + +Python3.6 isn't supported on Apple silicon, and it's not in homebrew. It can be installed with +[nix using Rosetta](https://stackoverflow.com/a/65980989): + +Download: https://nixos.org/download.html#nix-quick-install + +``` +nix run nixpkgs.python36 -c python +``` + +Install venv: + +``` +nix run nixpkgs.python36 -c python -m venv python +``` + +Install other packages on Mac that aren't used in venv: + +``` +python3 -m pip install --user requests gdown youtube_dl +``` diff --git a/_docs/old/old_docs/production/production_builds_and_deployment.md b/_docs/old/old_docs/production/production_builds_and_deployment.md new file mode 100644 index 0000000000..855832be1f --- /dev/null +++ b/_docs/old/old_docs/production/production_builds_and_deployment.md @@ -0,0 +1,36 @@ +production builds and deployment +================================ + +### Github Docker builds + +The repository needs to be given read access to the base docker image: + +https://github.com/orgs/storytold/packages/container/docker-base-images-rust-ssl/settings + +These instructions assume running on GCP. + +### Database migrations + +1. Set `DATABASE_URL` in `.env` to the production secrets (DO NOT COMMIT!) +2. Run `diesel migration run` + +### Setting up public buckets without list permission + +Public buckets that deny the `list` action should use the following Role: + +`roles/storage.legacyObjectReader` + +See: + +* https://stackoverflow.com/a/56354633 +* https://cloud.google.com/storage/docs/access-control/making-data-public#buckets + +### Generating Bucket Access Key and Secret Key + +https://cloud.telestream.net/tutorials/how-to-setting-up-google-cloud-storage/ + +1. Go to the GCS page +2. Click "settings" +3. Click "interoperability" tab +4. (enable interoperable access if not already set) +5. Click "create new key" diff --git a/_docs/old/old_docs/production/production_database.md b/_docs/old/old_docs/production/production_database.md new file mode 100644 index 0000000000..29c26e7cf9 --- /dev/null +++ b/_docs/old/old_docs/production/production_database.md @@ -0,0 +1,17 @@ +Production Database +=================== + +Secrets can be subbed in for `.env` and the standard diesel migration tooling will work. + +Dump a MySQL report +------------------- + +```bash +mysql -u storyteller \ + -pPasswordHere \ + -h IP_HERE \ + -P 3306 \ + -D storyteller \ + -B \ + -e "select * from voice_clone_requests;" | sed "s/'/\'/;s/\t/\",\"/g;s/^/\"/;s/$/\"/;s/\n//g" > voice_clone_report.csv +``` diff --git a/_docs/old/old_docs/schema/database_schema.md b/_docs/old/old_docs/schema/database_schema.md new file mode 100644 index 0000000000..4dcb3e5d8b --- /dev/null +++ b/_docs/old/old_docs/schema/database_schema.md @@ -0,0 +1,21 @@ +database schema +================ + +(This is woefully incomplete and unmaintained.) + +Twitch Model +------------ + +- `twitch_user_id` + Though numeric, this should always be a string per + [https://discuss.dev.twitch.tv/t/bug-v5-api-returns--id-as-string-for-featured-channels/10310] + and + [https://discuss.dev.twitch.tv/t/type-of-user-id-in-api-responses/10205] + +- `twitch_channel_id` + This is the same as `twitch_user_id`. They're exactly 1:1. Prefer to + use 'user_id' terminology. + +- `twitch_username` + Between 4 and 25 characters. + diff --git a/_docs/old/old_docs/schema/redis_schema.md b/_docs/old/old_docs/schema/redis_schema.md new file mode 100644 index 0000000000..4b3ed5420f --- /dev/null +++ b/_docs/old/old_docs/schema/redis_schema.md @@ -0,0 +1,27 @@ +redis schema +============ + +Database 0: TTS / RateLimiter +----------------------------- + +TODO: Document rate limiter + +Public statistics on usage displayed on the website: + +* `ttsUseCount:{model_token}`: INCR, GET +* `w2lUseCount:{template_token}`: INCR, GET + +Temporary job progress reports to the user: + +These are written as a single string, such as "downloading model", "uploading model", +"generating preview", "executing", etc. + +* `ttsDownloadExtraStatus:{inference_job_token}`: SETEX, GET +* `ttsInferenceExtraStatus:{inference_job_token}`: SETEX, GET +* `w2lDownloadExtraStatus:{inference_job_token}`: SETEX, GET +* `w2lInferenceExtraStatus:{inference_job_token}`: SETEX, GET + +Database 1: Obs / Twitch +------------------------ + +* WIP diff --git a/_docs/omni_api/artcraft_omni_api.md b/_docs/omni_api/artcraft_omni_api.md new file mode 100644 index 0000000000..18697b2b1b --- /dev/null +++ b/_docs/omni_api/artcraft_omni_api.md @@ -0,0 +1,625 @@ +# ArtCraft Omni API + +> # 📚 Full API reference: **** +> +> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video +> endpoints — for everything else, start at the link above. + +The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the +in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key +in the `Authorization` header instead of a browser session cookie. + +This guide covers **video generation from URL inputs** — passing image, video, and audio reference +URLs directly. The server downloads each URL, stores it as a media file on your account, and then +runs generation against it. You never have to upload files yourself. + +> All examples send **no cookies**. The Omni API is authenticated purely by your API key. If you +> send session cookies they are ignored. + +--- + +## Base URLs + +| Environment | Base URL | +| ------------- | ------------------------------ | +| Development | `http://localhost:12345` | +| Production | `https://api.storyteller.ai` | + +All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/video`). + +The examples in this guide use the **development** base URL. To run against production, swap +`http://localhost:12345` for `https://api.storyteller.ai`. + +Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a +typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`. + +--- + +## Getting an API key + +Create and manage API keys from the ArtCraft web app: + +1. Go to **** +2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff) +3. Create a key and copy it. The secret is shown **once** — store it somewhere safe. + +> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team +> to enable it. + +### What an API key looks like + +- Prefix: `artcraft_api_` +- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`) +- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6` + +Treat the key like a password: never commit it to source control or paste it into logs. + +--- + +## Authentication + +Send the secret key in the `Authorization` header. Three forms are accepted: + +``` +Authorization: Bearer artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +Authorization: Key artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +Authorization: artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +`Bearer` is recommended. **Do not send cookies.** + +--- + +## Video generation — `POST /v1/omni_api/generate/video` + +Generates a video and returns an inference job token you can poll for results. + +### Request body + +| Field | Type | Notes | +| ---------------------------- | --------------- | ----- | +| `model` | string | Generation model, e.g. `seedance_2p0`. **Required.** | +| `prompt` | string | Text prompt. | +| `duration_seconds` | integer | Length of the output video, e.g. `6`. | +| `aspect_ratio` | string | e.g. `wide_sixteen_by_nine`, `tall_nine_by_sixteen`, `square`. | +| `idempotency_token` | string (UUID) | **Required.** A fresh UUID per request; prevents accidental duplicates. | +| `negative_prompt` | string | Optional. | +| `resolution` | string | Optional, e.g. `seven_twenty_p`, `ten_eighty_p`. | +| `generate_audio` | boolean | Optional. | +| **URL inputs** (this guide) | | | +| `start_frame_image_url` | string (URL) | Starting keyframe image. | +| `end_frame_image_url` | string (URL) | Ending keyframe image. | +| `reference_image_urls` | array of URL | Reference images. | +| `reference_video_urls` | array of URL | Reference videos (`mp4`). | +| `reference_audio_urls` | array of URL | Reference audio (`wav`, `mp3`, `ogg`, …). | +| **Media-token inputs** | | | +| `start_frame_image_media_token`, `end_frame_image_media_token`, `reference_image_media_tokens`, `reference_video_media_tokens`, `reference_audio_media_tokens` | token(s) | Use these instead if you've already uploaded media. | + +#### Rules for URL inputs + +- Each URL field and its media-token counterpart are **mutually exclusive**. Sending both + `reference_image_urls` and `reference_image_media_tokens`, for example, returns: + `Either reference_image_media_tokens or reference_image_urls must be set, not both`. +- Every URL must start with `http://` or `https://`, otherwise: + `URL must start with http:// or https://, bad URL: `. +- Redirects are followed (up to 10), so CDN/redirecting URLs work. +- The file type is detected from the downloaded bytes. Images must be a real image + (`jpeg`/`png`/`gif`/`webp`), reference videos must be `mp4`, reference audio must be a + supported audio type (`wav`, `mp3`, `aac`, `ogg`, `flac`, …). + +### Response + +```json +{ + "success": true, + "inference_job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx", + "all_job_tokens": ["jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"] +} +``` + +Use `inference_job_token` to poll for the finished video. `all_job_tokens` lists every job created +(batch requests can create more than one). + +--- + +## Examples + +The examples reuse these public sample URLs: + +- Images (Wikimedia Commons): an Atlantic puffin, the Matterhorn, a toco toucan +- Video: a short sample `mp4` +- Audio: a short sample `wav` + +Replace `YOUR_API_KEY` with your `artcraft_api_…` secret, and generate a fresh `idempotency_token` +(UUID) for every request. + +--- + +### Example 1 — Starting-frame Seedance (`start_frame_image_url`) + +Animate outward from a single starting image. + +#### bash + +```bash +curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": "'"$(uuidgen)"'", + "start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900" + }' +``` + +#### python + +```python +import uuid +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" + +resp = requests.post( + f"{BASE_URL}/v1/omni_api/generate/video", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": str(uuid.uuid4()), + "start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + }, + timeout=240, +) +resp.raise_for_status() +print(resp.json()) +``` + +#### javascript + +```javascript +import { randomUUID } from "node:crypto"; + +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; + +const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "seedance_2p0", + prompt: + "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.", + duration_seconds: 6, + aspect_ratio: "wide_sixteen_by_nine", + idempotency_token: randomUUID(), + start_frame_image_url: + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + }), +}); + +console.log(resp.status, await resp.json()); +``` + +--- + +### Example 2 — Image-reference Seedance (`reference_image_urls`) + +Guide the generation with one or more reference images. + +#### bash + +```bash +curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "seedance_2p0", + "prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": "'"$(uuidgen)"'", + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900" + ] + }' +``` + +#### python + +```python +import uuid +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" + +resp = requests.post( + f"{BASE_URL}/v1/omni_api/generate/video", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "seedance_2p0", + "prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": str(uuid.uuid4()), + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + }, + timeout=240, +) +resp.raise_for_status() +print(resp.json()) +``` + +#### javascript + +```javascript +import { randomUUID } from "node:crypto"; + +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; + +const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "seedance_2p0", + prompt: + "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.", + duration_seconds: 6, + aspect_ratio: "wide_sixteen_by_nine", + idempotency_token: randomUUID(), + reference_image_urls: [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + }), +}); + +console.log(resp.status, await resp.json()); +``` + +--- + +### Example 3 — Image + video reference Seedance (`reference_image_urls` + `reference_video_urls`) + +Combine reference images with a reference video (and, optionally, reference audio). This is the +fullest URL-driven request. + +#### bash + +```bash +curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": "'"$(uuidgen)"'", + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900" + ], + "reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"], + "reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"] + }' +``` + +#### python + +```python +import uuid +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" + +resp = requests.post( + f"{BASE_URL}/v1/omni_api/generate/video", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "seedance_2p0", + "prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.", + "duration_seconds": 6, + "aspect_ratio": "wide_sixteen_by_nine", + "idempotency_token": str(uuid.uuid4()), + "reference_image_urls": [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + "reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"], + "reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"], + }, + timeout=240, +) +resp.raise_for_status() +print(resp.json()) +``` + +#### javascript + +```javascript +import { randomUUID } from "node:crypto"; + +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; + +const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "seedance_2p0", + prompt: + "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.", + duration_seconds: 6, + aspect_ratio: "wide_sixteen_by_nine", + idempotency_token: randomUUID(), + reference_image_urls: [ + "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900", + "https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900", + ], + reference_video_urls: ["https://download.samplelib.com/mp4/sample-5s.mp4"], + reference_audio_urls: ["https://download.samplelib.com/wav/sample-6s.wav"], + }), +}); + +console.log(resp.status, await resp.json()); +``` + +--- + +## Checking job status + +Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status +endpoint until the job reaches a terminal state, then read the result URL. + +### Endpoints + +- **Single job:** `GET /v1/omni_api/job_status/job/{token}` +- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb` + +Both require the same `Authorization: Bearer …` header (no cookies). + +### Response shape + +```json +{ + "success": true, + "state": { + "job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx", + "request": { + "inference_category": "video_generation", + "maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx", + "maybe_model_type": "seedance_2p0", + "maybe_model_token": null + }, + "status": { + "status": "pending", + "maybe_first_started_at": null, + "maybe_failure_category": null, + "progress_percentage": 0 + }, + "maybe_result": null, + "created_at": "2026-06-24T04:06:14Z", + "updated_at": "2026-06-24T04:06:14Z" + } +} +``` + +- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or + `attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`, + `cancelled_by_user`, and `cancelled_by_system`. +- `state.status.progress_percentage` — an integer 0–100. +- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the + finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**. + For video, `media_links.maybe_video_previews` also carries still/animated preview URLs. + +The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same +`state` payload as above. + +### Where the result video / image lives + +When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`: + +- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the + finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job + it's the image file (`.jpg` / `.png` / etc.). +- `state.maybe_result.entity_token` — the `m_…` media-file token for the result. +- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still` + (poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This + is `null` for image jobs. +- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template; + replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs. + +**Example — completed video job:** + +```json +{ + "success": true, + "state": { + "job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v", + "request": { + "inference_category": "video_generation", + "maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt", + "maybe_model_type": "seedance_2p0", + "maybe_model_token": null + }, + "status": { + "status": "complete_success", + "maybe_first_started_at": "2026-06-24T04:07:11Z", + "maybe_failure_category": null, + "progress_percentage": 100 + }, + "maybe_result": { + "entity_type": "media_file", + "entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6", + "media_links": { + "cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4", + "maybe_thumbnail_template": null, + "maybe_video_previews": { + "still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg", + "animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif", + "still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg", + "animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif" + } + }, + "maybe_successfully_completed_at": "2026-06-24T04:17:30Z" + }, + "created_at": "2026-06-24T04:06:14Z", + "updated_at": "2026-06-24T04:17:30Z" + } +} +``` + +→ The video is at +`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4` +(example, not a live URL). + +**Example — completed image job** (from `POST /v1/omni_api/generate/image`): + +```json +{ + "maybe_result": { + "entity_type": "media_file", + "entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6", + "media_links": { + "cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg", + "maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg", + "maybe_video_previews": null + }, + "maybe_successfully_completed_at": "2026-06-24T04:09:02Z" + } +} +``` + +→ The image is at +`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg` +(example, not a live URL). + +### Poll until complete + +#### bash + +```bash +JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx" + +while :; do + resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \ + -H "Authorization: Bearer YOUR_API_KEY") + status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])") + echo "status=$status" + case "$status" in + complete_success) + echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])" + break ;; + complete_failure|dead|cancelled_by_user|cancelled_by_system) + echo "job ended without a result: $status"; break ;; + esac + sleep 5 +done +``` + +#### python + +```python +import time +import requests + +BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai +API_KEY = "YOUR_API_KEY" +JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx" + +TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"} + +while True: + resp = requests.get( + f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}", + headers={"Authorization": f"Bearer {API_KEY}"}, + timeout=30, + ) + resp.raise_for_status() + state = resp.json()["state"] + status = state["status"]["status"] + print("status:", status, state["status"]["progress_percentage"], "%") + + if status == "complete_success": + print("result URL:", state["maybe_result"]["media_links"]["cdn_url"]) + break + if status in TERMINAL_FAILURES: + print("job ended without a result:", status) + break + time.sleep(5) +``` + +#### javascript + +```javascript +const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai +const API_KEY = "YOUR_API_KEY"; +const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"; + +const TERMINAL_FAILURES = new Set([ + "complete_failure", "dead", "cancelled_by_user", "cancelled_by_system", +]); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +while (true) { + const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, { + headers: { Authorization: `Bearer ${API_KEY}` }, + }); + const { state } = await resp.json(); + const status = state.status.status; + console.log("status:", status, state.status.progress_percentage, "%"); + + if (status === "complete_success") { + console.log("result URL:", state.maybe_result.media_links.cdn_url); + break; + } + if (TERMINAL_FAILURES.has(status)) { + console.log("job ended without a result:", status); + break; + } + await sleep(5000); +} +``` + +--- + +## Notes & troubleshooting + +- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate. +- **No cookies.** The endpoint authenticates only via the `Authorization` header. +- **URL must be `http(s)`** and reachable by the server; redirects are followed. +- **Type mismatches** (e.g. a `.webm` passed as a reference video, which requires `mp4`) are + rejected with a `400` describing the unpermitted type. +- **`401 Unauthorized`** — missing/invalid API key, or the key's owner is banned. +- **`402 Payment required`** — insufficient credits/balance. +- There is a companion image endpoint, `POST /v1/omni_api/generate/image`, which accepts + `image_urls` (mutually exclusive with `image_media_tokens`) following the same rules. diff --git a/_docs/pagedraw_design_review.md b/_docs/pagedraw_design_review.md new file mode 100644 index 0000000000..cdcd0cb93b --- /dev/null +++ b/_docs/pagedraw_design_review.md @@ -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: ; /* 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. diff --git a/_docs/pagescene_animations_handoff.md b/_docs/pagescene_animations_handoff.md new file mode 100644 index 0000000000..55ea881b1f --- /dev/null +++ b/_docs/pagescene_animations_handoff.md @@ -0,0 +1,227 @@ +# PageScene Animations — Branch Handoff + +Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04). +Everything below is TypeScript-typechecked per-lib (`tsc -p /tsconfig.lib.json`); runtime +verification status is tracked in the checklist at the bottom. Drag-and-drop has had one manual +pass (confirmed working); most other surfaces have not been launched. + +## What this branch does + +Makes skeletal animations a first-class asset in the 3D editor (pagescene): browse them in the +asset drawer, drag them onto characters or the timeline with live rig-compatibility feedback, +play clips baked into uploaded models, preview animations everywhere models are previewed, and +upload Mixamo FBX files (with or without mesh) end to end. + +## Commit map + +| Commit | What | +|--------------|-------------------------------------------------------------------------| +| `7af3fb9856` | Animations tab in AssetModal; AnimationsDrawer retired | +| `1fcbc55780` | Pointer drag-and-drop with live rig-compatibility badge | +| `79d7ade8bc` | Baked-in clips on the timeline (+ clip-row eligibility widened) | +| `142279414e` | Animation dropdown in Viewer3D + upload preview | +| `913905308e` | Clip fixes: vanishing character, 1 s strips, select-to-delete, undo | +| `60251104e3` | FBX accepted at the picker, converted to GLB in-browser | +| `0942ec4804` | Nx tsconfig references for the new ui-select dependency | +| `d0c7b65a6e` | FBX conversion moved into a Web Worker (main-thread fallback) | +| `8e51b6c524` | Explicit "Upload as Animation" toggle + required duration_millis | +| `00ebeae10c` | User's uploaded animations merged into the Animations tab | +| `e1c7a0c7b0` | Persistent skeleton-helper toggle in the outliner | +| `30f40c0ddb` | Previewer bone toggles + mesh-less framing/mixer-binding fixes | +| `5f7ba7bbc5` | AnimationsModal (All/Presets/Uploaded) + timeline quality-of-life | +| `d55735620a` | Timeline ruler tick marks + strip button spacing | +| `79875d8c76` | Upload animations from the AnimationsModal (initialCategory) | +| *(this)* | NodeHierarchyHelper: bone toggle works for converted mesh-less GLBs | + +## Features + +### 1. Animations tab in the asset drawer (`AssetModal`) + +- New left-rail section fed by `GET /v1/media_files/list_featured?filter_engine_categories=animation` + (GLB/GLTF only — the clip loader is GLTF-based). Featured results REPLACE the 37 hardcoded + `demoAnimationItems`; demos are only the empty-API fallback. +- The session user's own uploaded animations (`list/user`, same filter) render AHEAD of the + featured set. Fetched on every modal open and after each upload; fails silently for anonymous + users (`suppressErrorToast` on the user-media fetch path). +- Card click appends the clip to the selected character's row at the earliest free slot (toast + when no character is selected or the row is full). The old right-docked `AnimationsDrawer` is + deleted; the old HTML5-DnD path (`ANIMATION_CLIP_MIME`) is gone with it. + +### 2. Drag-and-drop with rig-compatibility badge (`DndAsset`) + +- Animation cards ride the same pointer-drag pipeline as every other asset. On drag start the + clip's track node names are preloaded (`loadRawGlb`, cached per media id); hovering a + character (or any skinned object) checks those names against its nodes — the exact direct-bind + rule `CharacterAnimationManager` plays by. No retargeting (explicitly out of scope). +- The shared `DragGhost` shows the verdict: green check (binds) / red slash (no target or rig + mismatch); nothing while the clip is still loading. Incompatible or slot-less drops are + rejected with a toast. +- Drop targets: a character in the 3D scene (earliest free slot) or a timeline row + (`data-clip-drop-uuid`; pointer-x → time via the `data-timeline-ruler` rect). + +### 3. Timeline clips: behavior + UX + +- **Strips default to 1 s** and the strip width is the authoritative play window + (`LaneRuntime.stripDuration`): trimming genuinely cuts playback; loop repeats inside the + strip; a non-loop strip wider than its clip freezes on the last frame. On load a strip only + shrinks (clip shorter than default) — never grows. +- **Rest-pose in gaps**: `Skeleton.pose()` was replaced with a rest-pose snapshot captured at + mixer creation (all nodes BELOW the object root; root excluded so gizmo/keyframes never snap + back). `pose()` rebuilds bone locals from inverse bind matrices and mis-scales cm-rigs — that + was the "character vanishes when moving a strip" bug. +- **Selection UX**: strips are click-to-select; × renders only on the selected strip; + Del/Backspace deletes; removal is individually undoable (`RemoveClipLaneAction` — the one + exception to clip edits riding the timeline Save/Cancel session). +- **Full rows reject adds**: `resolveFreeStart` returns null instead of accepting an + overlapping clamp. + +### 4. Baked-in clips + +- `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own `animations[]` + (resolved synchronously — no media fetch). Objects with baked clips get a timeline clip row + with a "+" picker listing them. +- Clip-row eligibility is wider than characters: any skinned object (creatures, rigged uploads) + accepts animation drags (bind check gates); baked-only objects get a row for the picker. +- INVARIANT: baked clips are never removed from the THREE model. Removing a baked strip only + unschedules it; the clip stays on `object.animations` and in the picker. Library strips stay + fully removable. +- Outliner metadata (`hasSkeleton`, `bakedClips`) is computed in `convert_object` and refreshed + after every GLB load (`SceneDeps.onGlbLoaded` → `refreshOutliner`). + +### 5. Previewers (Viewer3D + upload preview) + +- Both play baked clips: autoplay the first, dropdown with every clip + "T-pose (none)", hidden + entirely for animation-less models. +- Both have a bone icon toggling a skeleton overlay — default ON when the model has no mesh + (Mixamo "without skin" exports), so animation-only files are visible. The upload thumbnail + snapshot then captures the skeleton render. +- The overlay adapts to the rig kind: real `THREE.Bone` joints get `THREE.SkeletonHelper`; + mesh-less models whose joints re-imported as plain nodes get `NodeHierarchyHelper` + (exported from `viewer-3d`) — GLTF only round-trips bone-ness through a *skin*, and a + mesh-less export has no skin, so converted FBX animations lose `Bone`-ness entirely. The + toggle shows for `hasBones || !hasMesh`. +- Latent bugs fixed: upload preview discarded `gltf.animations`; its child re-parenting loop + skipped every other child (`scene.add` mutates the array mid-forEach); its mixer bound before + the model entered the scene (silently played nothing); camera framing NaN'd on empty + bounding boxes (now falls back to bone world positions); camera now fits once, not per-child. + +### 6. FBX upload support + +- The 3D uploader accepts `.fbx` and normalizes it at the door: `FBXLoader` → `GLTFExporter` + → binary GLB with clips preserved. Runs in a dedicated Web Worker (transferred buffers, jobs + naturally serialized); a broken worker environment falls back to the main thread. The raw FBX + can never reach the backend — retry of a failed conversion re-converts. +- Caveats: embedded textures may not survive; units are exported as parsed (cm-scaled Mixamo + rigs are not rescaled). + +### 7. Upload as Animation + +- Mutually exclusive checkbox next to "Upload as Character" → `engine_category=animation`. + Mesh-less files preselect it (preview loader reports `hasMesh:false`); a user click always + wins. Rig-type dropdown defaults to Mixamo (characters keep Mixamo ArKit first). +- The backend-required `maybe_duration_millis` is read from the longest clip at submit time; + clip-less files are flagged and excluded client-side. + +### 8. Outliner skeleton toggle (`SkeletonHelperController`) + +- Bone icon on skinned rows toggles a persistent `SkeletonHelper` on LAYER 1 (editor-only — + the render camera renders layer 0, so skeletons never appear in captures/recordings). +- Persists as `userData.skeletonVisible` through scene save/load (explicit restore in the proxy + load path). Reconciliation rides `OutlinerRefreshedEvent` — add/delete/load/new-scene and the + post-GLB-load refresh all funnel there. +- FK/pose-mode compatible: `PoseModeChangedEvent("pose")` suppresses the persistent helper for + the posed character (FKHelper draws its own rig); exit restores it. The toggle state itself + is never touched. + +## Manual test checklist (outstanding) + +- [ ] Prod data: does `list_featured?filter_engine_categories=animation` return rows? If yes + they replace the demo clips in the tab; if empty, the 37 demos show. +- [ ] Clip→rig bind smoke test on shipped characters (runtime-unverified `mixamorig:*` + assumption; a "bound 0 tracks" console warning means retargeting becomes the real task). +- [ ] Creature experiment: drag a clip onto a creature — badge should reflect bind result. +- [ ] Baked clips: "+" picker on an animated object's row; add/remove/re-add; save/reload + persistence (the `object.animations` stash on load is gated on `auto_add` — statically + unconfirmed for every load path). +- [ ] Clip UX round: strips at 1 s; trim semantics; select → ×/Del; undo/redo of removal; + character stays visible when a strip moves off the playhead. +- [ ] FBX round trip: pick `.fbx` → converts (worker; check console for worker load failures — + falls back to main thread) → skeleton visible + clip dropdown → Upload as Animation + preselected → appears at front of Animations tab → drag onto character → plays. +- [ ] Converted-FBX scale: if placed objects come in ~100× too big, the converter needs a + normalize-scale step. +- [ ] Outliner bone toggle: on/off, FK-mode enter/exit restore, save/reload persistence, and + absence from Capture/Record output. +- [ ] Previewer dropdown + bone toggle across lightbox, media page, lightbox-modal, + ImageTo3DExperience, upload modal. + +## Debug pass + hardening round (post-review) + +A four-agent review of the combined branch produced 30 findings (5 high / 12 medium / 13 low); +ALL have been fixed or explicitly closed, one commit per finding (range `72cb218e..b21732d1`). +Highlights, roughly in commit order: + +- Highs: baked clips cloned per lane (shared-AnimationAction playback/removal corruption); + runtime revalidation for objects recreated under the same uuid (delete→undo, reloads) — later + hardened into LOOP-FREE terminal lane states (bound / missing / unbindable, reassessed only on + root-instance change; the pre-fetch rig gate is sound because every load path stamps the target + uuid only post-load); skeleton-helper sync self-heals across in-session reloads; upload modal + can no longer report success for empty/partial submissions; ref-guarded double-submit. +- Mediums: strip removal mirrored into the Save/Cancel `saved` snapshot (Cancel can't resurrect, + no phantom undo); stale strip/keyframe selections pruned in the bridge + live-checked before + swallowing Del/Backspace; one winning lane per character at flush boundaries (blend-glitch + frame was being ENCODED into recordings); orphaned tracks/lanes filtered from scene JSON at + save; per-clip queued-drop tokens; animation raycasts gated to the canvas element + honest + front-most-surface occlusion; virtualized-grid thumbnail state keyed by item; one shared + lazily-fetched animation-library store (was double-fetch + double-toast + divergent lists); + previewer rest-pose snapshots replace `Skeleton.pose()` everywhere; all-node camera-framing + fallback for plain-node rigs; upload-preview races (superseded-load cancellation, snapshot + after helper creation, File-identity row statuses, retry re-entry). +- Lows: duration-shrink re-fits strips; baked-clip picker portals out of the scrolling row list; + timeline resets on scene switch/new scene instead of leaking across; click-to-add accepts every + drag-eligible target; queued drops bail on a torn-down editor (`Editor.isLive`); top-level root + lookups unified; flick-click race killed via `wasDragGesture`; converting-overlay control reset; + conversion completions reload only the previewed entry; worker `onmessageerror` recovery + + corrupt-vs-clip-less error messages + explicit `initialCategory` wins over mesh-less + auto-detect; Uploaded tab pages past the 100-item cap. +- Closed without code: the "compat cache stales on streaming characters" finding (invalidated — + uuid-resolvable roots are always fully loaded) and the Tauri `promptSignup` gap (desktop is + always signed in by adapter contract). + +## Landed since the original handoff + +- **AnimationsModal** (was deferred — now built, `5f7ba7bbc5`/`79875d8c76`): standalone modal + with All / Presets / Uploaded tabs, opened from an "Add Animation" button next to Enter Pose + Mode and from the "+" add-asset menu. Adding a clip expands the timeline and scrolls to the + character (`timelineRevealObjectUuid`); the modal closes after an add unless "Reopen after + adding" is on. Its "Upload animation" button opens `UploadModal3D` with + `initialCategory="animation"` (anonymous users get the signup prompt), landing on the + Uploaded tab afterwards. `AssetModal`'s animations tab was folded into this. +- **Timeline QoL**: ruler tick marks (major/second/sub-tick), selected-object-only track + filter, directional expand/collapse chevrons, thumbnail-less animation cards get an icon + placeholder. + +## Deferred (explicitly out of scope for this branch) + +- **Split-screen preview window** (based on the `?output=` `DemoOutputOverlay`) — parked until + explicit approval. +- **Retargeting** (`SkeletonUtils.retargetClip`) — too complex for now; mismatched rigs are + simply rejected by the bind check. +- **Clip blending/layering** — one non-overlapping row per object, by design. + +## Key files + +| Area | Files | +|---------------------|-------------------------------------------------------------------------------------------| +| Drawer tab | `pagescene/src/lib/comps/AssetMenu/AssetModal.tsx`, `shared/ItemElement.tsx` | +| Drag-and-drop | `pagescene/src/lib/DragAndDrop/DndAsset.ts`, `gallery-modal/src/lib/DragGhost.tsx` | +| Timeline data/engine| `pagescene/src/lib/engine/timeline/types.ts`, `engine/editor/TimelineController.ts`, | +| | `engine/animation/CharacterAnimationManager.ts`, `engine/editor/actions/RemoveClipLaneAction.ts` | +| Timeline UI | `pagescene/src/lib/comps/Timeline/TimelineEditor.tsx`, `TimelineClipRow.tsx` | +| Skeleton toggle | `pagescene/src/lib/engine/editor/SkeletonHelperController.ts`, `comps/Outliner/Outliner.tsx` | +| Previewers | `viewer-3d/src/lib/viewer-3d.tsx`, `upload-modal/.../utilities/loadPreviewOnCanvas.ts` | +| FBX + upload | `upload-modal/.../utilities/convertFbxToGlb.ts` (+ `.worker.ts`), `UploadModal3D.tsx`, | +| | `UploadFiles3D.tsx`, `utilities/readGlbAnimationDuration.ts` | + +The pagescene `CLAUDE.md` "Mixamo animation clips" section has been kept current with these +changes and carries the same invariants in engine-doc form. diff --git a/crates/AGENTS.md b/crates/AGENTS.md new file mode 100644 index 0000000000..40b4cb1af8 --- /dev/null +++ b/crates/AGENTS.md @@ -0,0 +1,61 @@ +# Rust Workspace + +All Rust code lives under `crates/`. The workspace is defined in `Cargo.toml` at the repo root. + +## Code Style + +- Two spaces for indentation (not four) +- No minimum supported Rust version; use latest stable features freely +- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead +- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code +- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token` +- When two crates export the same type name, alias with a suffix: `use foo::Bar as BarFoo;` +- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std prelude collisions (`Result`, `Option`, `Error`) + +## File Layout + +Organize for top-to-bottom reading. Important things first, details later. + +1. **Constants** at the top (after imports) +2. **Primary public type** (the main struct/enum the file defines) +3. **Supporting types** below the primary type +4. **API types** in order: Request, Response, Error +5. **`impl` blocks** after their type definitions + - Constructors first, then public methods, then private helpers + - Callers above callees — follow the call chain downward + +## Test Layout + +Within `#[cfg(test)] mod tests { ... }`: + +1. **Imports** +2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference +3. **Test functions or sub-modules** grouped by category +4. **Helper functions** (builders, pipeline runners, assertions) + +Constants provide small context up front. Test cases come next so engineers see *what* is +tested before *how* the helpers work. + +Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+ +tests. Don't wrap a single test in its own module. Keep nesting to two levels max. Shared +helpers go in the parent `mod tests` so sub-modules can `use super::*`. + +## Building + +- Most crates: `cargo check -p {crate_name}` or `cargo test -p {crate_name}` +- Crates using SQLx: `SQLX_OFFLINE=true cargo check -p {crate_name}` +- The desktop SQLite query cache and migrations must remain in `.sqlx/` and `_database/sql/artcraft_migrations/` + +## Key Crates + +- `artcraft` (desktop) — Tauri desktop app +- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, KinoviWeb, Grok, GMICloud, WorldLabs) +- `artcraft_api_defs` — shared HTTP API type definitions +- `kinovi_web_client` — HTTP client for the Kinovi/KinoviWeb video generation service +- `enums` — database-backed enums stored as VARCHAR fields +- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`) + +- `sqlite_tasks` — SQLite persistence for desktop generation tasks + +The workspace contains only desktop dependencies and their shared test helpers. +There is no Hakari workspace helper; keep new dependencies local to their consumers. diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/crates/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/AGENTS.md b/crates/api_clients/artcraft/artcraft_api_defs/AGENTS.md new file mode 100644 index 0000000000..4b551c0a9a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/AGENTS.md @@ -0,0 +1,22 @@ +# artcraft_api_defs + +Shared HTTP API type definitions used by both the server (`storyteller_web`) and clients. + +## Structure + +Organized by feature: `users/`, `media_file/`, `jobs/`, `moderation/`, etc. + +## Conventions + +- Request types: `#[derive(Deserialize, ToSchema)]` +- Response types: `#[derive(Serialize, ToSchema)]` +- Path params: `#[derive(Deserialize, ToSchema)]` with `PathInfo` suffix +- Query params: `#[derive(Deserialize, IntoParams)]` + +## Orphan Rule + +Types that need `ResponseError` impl (actix-web) CANNOT live here because of Rust's orphan rule. Keep error response types in `storyteller_web` and only put data types here. + +## Dependencies + +Only `serde`, `serde_derive`, `utoipa`, `chrono`, `url`, and internal schema crates (`enums`, `tokens`). No actix-web dependency. diff --git a/crates/api_clients/artcraft/artcraft_api_defs/CLAUDE.md b/crates/api_clients/artcraft/artcraft_api_defs/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/Cargo.toml b/crates/api_clients/artcraft/artcraft_api_defs/Cargo.toml new file mode 100644 index 0000000000..69be631fdf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "artcraft_api_defs" +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "artcraft_api_defs" +path = "src/lib.rs" + +[dependencies] + +# Internal +enums.workspace = true +tokens.workspace = true + +# External +chrono.workspace = true +url.workspace = true + +# Serialization +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +utoipa.workspace = true + +[dev-dependencies] +anyhow.workspace = true \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/analytics/log_active_user.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/analytics/log_active_user.rs new file mode 100644 index 0000000000..0c55cbbc73 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/analytics/log_active_user.rs @@ -0,0 +1,117 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::app_session::AppSessionToken; +use utoipa::ToSchema; + +/// This is used in Artcraft versions 0.0.1 (several) through 0.3.0 +/// Clients to this version do not send generation count data (even if the endpoint supports it). +pub const LOG_ACTIVE_USER_V1_PATH: &str = "/v1/analytics/active_user"; + +/// This is used in Artcraft versions 0.4.0 and later. +pub const LOG_ACTIVE_USER_V2_PATH: &str = "/v1/analytics/active_user_v2"; + +#[derive(Serialize, Deserialize, ToSchema, Debug, Default)] +pub struct LogAppActiveUserRequest { + /// Clientside-generated session token. + /// If present, it'll be validated against an expected format. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_app_session_token: Option, + + /// An override for the application platform/OS (windows, mac, linux). + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_os_platform: Option, + + /// An override for the version of the OS (e.g. 10.15.7, 11, 22.04). + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_os_version: Option, + + /// An override for the application name. + /// If set together with `maybe_app_version`, the two will be + /// concatenated as `{maybe_app_name}/{maybe_app_version}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_app_name: Option, + + /// An override for the application version. + /// If set together with `maybe_app_name`, the two will be + /// concatenated as `{maybe_app_name}/{maybe_app_version}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_app_version: Option, + + /// How long the user has been active in the app, in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_session_duration_seconds: Option, + + // ========== GENERATION COUNTS ========== + + /// Number of items generated across all types. + #[serde(skip_serializing_if = "Option::is_none")] + pub total_generation_count: Option, + + /// Number of images generated. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_generation_count: Option, + + /// Number of videos generated. + #[serde(skip_serializing_if = "Option::is_none")] + pub video_generation_count: Option, + + /// Number of objects generated (meshes, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub object_generation_count: Option, + + /// Number of images generated with text to image. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_to_image_count: Option, + + /// Number of images generated with image to image (image edits). + #[serde(skip_serializing_if = "Option::is_none")] + pub image_to_image_count: Option, + + /// Number of videos generated with text to video. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_to_video_count: Option, + + /// Number of videos generated with image to video. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_to_video_count: Option, + + /// Number of objects generated with text to object. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_to_object_count: Option, + + /// Number of objects generated with image to object. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_to_object_count: Option, + + /// Number of prompts from the image page. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_page_prompt_count: Option, + + /// Number of prompts from the video page. + #[serde(skip_serializing_if = "Option::is_none")] + pub video_page_prompt_count: Option, + + /// Number of prompts from the edit page. + #[serde(skip_serializing_if = "Option::is_none")] + pub edit_page_prompt_count: Option, + + /// Number of prompts from the stage page. + #[serde(skip_serializing_if = "Option::is_none")] + pub stage_page_prompt_count: Option, + + /// Number of prompts from the object page. + #[serde(skip_serializing_if = "Option::is_none")] + pub object_page_prompt_count: Option, + + /// Number of prompts from other pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub other_page_prompt_count: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct LogAppActiveUserResponse { + pub success: bool, + + /// How long to wait until the next analytics event, in milliseconds. + /// The client should honor this and is free to add jitter. + pub wait_for_retry_millis: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/analytics/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/analytics/mod.rs new file mode 100644 index 0000000000..8cb3740b95 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/analytics/mod.rs @@ -0,0 +1 @@ +pub mod log_active_user; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs new file mode 100644 index 0000000000..6769167106 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +/// Canonical wire shape for an API key, used by the list rows and the +/// single-key GET. +/// +/// NB: the full secret `api_key` is NEVER included here — only +/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys +/// in a UI. The full value is returned exactly once, at creation time. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ApiKeyInfo { + pub token: ApiKeyToken, + + /// First 20 characters of the API key. The full secret is never returned + /// after creation. + pub truncated_api_key: String, + + pub name: String, + pub maybe_description: Option, + + pub owner_user_token: UserToken, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// Soft-delete timestamp. `None` for live keys. + pub maybe_deleted_at: Option>, +} + +/// Path info for endpoints addressed by the API key's `token` (NOT the secret +/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`. +#[derive(Deserialize, ToSchema)] +pub struct ApiKeyPathInfo { + pub api_key_token: ApiKeyToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs new file mode 100644 index 0000000000..6455bb5d3c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs @@ -0,0 +1,28 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::api_keys::ApiKeyToken; + +// ── POST /v1/api_keys/create ── + +#[derive(Deserialize, ToSchema)] +pub struct CreateApiKeyRequest { + /// Display name (title) for the key. + pub name: String, + + /// Optional description. + pub maybe_description: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateApiKeySuccessResponse { + pub success: bool, + + /// The key's `token` — used for all subsequent management + /// (get / update / delete). Safe to store and display. + pub api_key_token: ApiKeyToken, + + /// The full secret API key value. This is the ONLY time it is ever + /// returned — the caller must store it now, as it cannot be retrieved again. + pub api_key: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs new file mode 100644 index 0000000000..361ed4ac09 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs @@ -0,0 +1,9 @@ +use serde_derive::Serialize; +use utoipa::ToSchema; + +// ── DELETE /v1/api_keys/{api_key_token} ── + +#[derive(Serialize, ToSchema)] +pub struct DeleteApiKeySuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs new file mode 100644 index 0000000000..7f131fcfbb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs @@ -0,0 +1,12 @@ +use serde_derive::Serialize; +use utoipa::ToSchema; + +use crate::api_keys::common::ApiKeyInfo; + +// ── GET /v1/api_keys/{api_key_token} ── + +#[derive(Serialize, ToSchema)] +pub struct GetApiKeySuccessResponse { + pub success: bool, + pub api_key: ApiKeyInfo, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs new file mode 100644 index 0000000000..9e3750f903 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs @@ -0,0 +1,24 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::api_keys::common::ApiKeyInfo; + +// ── GET /v1/api_keys/list ── + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListApiKeysQueryParams { + /// Page size. Defaults server-side if omitted. + pub limit: Option, + + /// Number of rows to skip. Defaults to 0. + pub offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListApiKeysSuccessResponse { + pub success: bool, + + /// The user's API keys (including soft-deleted ones), newest first. Each + /// entry's key value is truncated — the full secret is never listed. + pub api_keys: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs new file mode 100644 index 0000000000..e4a9b484f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs @@ -0,0 +1,6 @@ +pub mod common; +pub mod create_api_key; +pub mod delete_api_key; +pub mod get_api_key; +pub mod list_api_keys; +pub mod update_api_key; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs new file mode 100644 index 0000000000..c51f7193d4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs @@ -0,0 +1,15 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// ── PUT /v1/api_keys/{api_key_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct UpdateApiKeyRequest { + /// New description for the key. `None` clears it. + pub maybe_description: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct UpdateApiKeySuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/characters/create_character.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/create_character.rs new file mode 100644 index 0000000000..bfec60e703 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/create_character.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_model_type::CommonModelType; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Request body for creating a character. +#[derive(Deserialize, ToSchema)] +pub struct CreateCharacterRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// The model to create the character for. + pub model: CommonModelType, + + /// The reference image media file token. + pub image_media_token: MediaFileToken, + + /// Name of the character. + pub character_name: String, + + /// Description of the character. + pub character_description: Option, +} + +/// Response body for creating a character. +#[derive(Serialize, ToSchema)] +pub struct CreateCharacterResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/characters/delete_character.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/delete_character.rs new file mode 100644 index 0000000000..a752672db1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/delete_character.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::characters::CharacterToken; + +/// Path parameters for deleting a character. +#[derive(Deserialize, ToSchema)] +pub struct DeleteCharacterPathInfo { + pub character_token: CharacterToken, +} + +/// Response body for deleting a character. +#[derive(Serialize, ToSchema)] +pub struct DeleteCharacterResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/characters/edit_character.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/edit_character.rs new file mode 100644 index 0000000000..fa2c4a483c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/edit_character.rs @@ -0,0 +1,23 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::characters::CharacterToken; + +/// Request body for editing a character. +#[derive(Deserialize, ToSchema)] +pub struct EditCharacterRequest { + /// The character to edit. + pub token: CharacterToken, + + /// Updated name (if changing). + pub updated_name: Option, + + /// Updated description (if changing). + pub updated_description: Option, +} + +/// Response body for editing a character. +#[derive(Serialize, ToSchema)] +pub struct EditCharacterResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/characters/get_character.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/get_character.rs new file mode 100644 index 0000000000..3a8a3cf7e3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/get_character.rs @@ -0,0 +1,36 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::common::responses::media_links::MediaLinks; +use enums::common::generation::common_model_type::CommonModelType; +use tokens::tokens::characters::CharacterToken; + +/// Path parameters for getting a character. +#[derive(Deserialize, ToSchema)] +pub struct GetCharacterPathInfo { + pub character_token: CharacterToken, +} + +/// Response body for getting a character. +#[derive(Serialize, ToSchema)] +pub struct GetCharacterResponse { + pub success: bool, + pub character: GetCharacterDetails, +} + +/// Full character details returned by the get endpoint. +#[derive(Serialize, ToSchema)] +pub struct GetCharacterDetails { + pub token: CharacterToken, + + /// Which models this character can be used with. + pub models: Vec, + + pub name: String, + + pub maybe_description: Option, + + pub maybe_avatar: Option, + + pub maybe_full_image: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/characters/list_characters.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/list_characters.rs new file mode 100644 index 0000000000..348ad2e646 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/list_characters.rs @@ -0,0 +1,38 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::common::responses::media_links::MediaLinks; +use enums::common::generation::common_model_type::CommonModelType; +use tokens::tokens::characters::CharacterToken; + +/// Query string parameters for listing characters. +#[derive(Deserialize, IntoParams)] +pub struct ListCharactersQuery { + /// Optional cursor for pagination. + pub cursor: Option, +} + +/// Response body for listing characters in the current session. +#[derive(Serialize, ToSchema)] +pub struct ListCharactersResponse { + pub success: bool, + pub characters: Vec, + pub next_cursor: Option, +} + +/// A character entry in the list response. +#[derive(Serialize, ToSchema)] +pub struct ListCharactersEntry { + pub token: CharacterToken, + + /// Which models this character can be used with. + pub models: Vec, + + pub name: String, + + pub maybe_description: Option, + + pub maybe_avatar: Option, + + pub maybe_full_image: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/characters/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/mod.rs new file mode 100644 index 0000000000..bb9070c8bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/characters/mod.rs @@ -0,0 +1,5 @@ +pub mod create_character; +pub mod delete_character; +pub mod edit_character; +pub mod get_character; +pub mod list_characters; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/mod.rs new file mode 100644 index 0000000000..6885733dcc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/mod.rs @@ -0,0 +1 @@ +pub mod responses; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/cover_image_links.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/cover_image_links.rs new file mode 100644 index 0000000000..452c8179e0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/cover_image_links.rs @@ -0,0 +1,19 @@ +use serde_derive::{Deserialize, Serialize}; +use url::Url; +use utoipa::ToSchema; + +// TODO(bt,2025-08-20): Replace the storyteller-web version of this. +// All it needs is the impl ported into a builder. + +/// Cover image links can only be jpg, png, etc. No videos. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct CoverImageLinks { + /// Primary link to the cover image via the CDN. + pub cdn_url: Url, + + /// Template to construct thumbnail URLs. + /// Replace the string `{WIDTH}` with the desired width. + /// Only relevant for image media files. (Video media files instead have + /// video previews, which, in turn, have their own thumbnail templates.) + pub thumbnail_template: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/job_details.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/job_details.rs new file mode 100644 index 0000000000..1ff8f3229d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/job_details.rs @@ -0,0 +1,33 @@ +use serde::Deserialize; +use serde::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +/// Details about submitted live portrait jobs (request arguments only) +#[derive(Serialize, Deserialize, ToSchema)] +pub struct JobDetailsLivePortraitRequest { + /// Source media token + /// This can be an image or a video media file token + /// This is what constitutes the "portrait" or the overall final video. + /// This video or image must contain a face. + pub source_media_file_token: MediaFileToken, + + /// Driving media token + /// This must be a video media file token. + /// This drives the animation of the face, but the actor will disappear + /// and their facial expressions will be transferred to the source. + /// This video must contain a face. + pub face_driver_media_file_token: MediaFileToken, +} + +/// Details about submitted lipsync jobs (request arguments only) +#[derive(Serialize, Deserialize, ToSchema)] +pub struct JobDetailsLipsyncRequest { + /// Media file token for the source audio. + /// This is probably an audio file, but in the future we might pull audio from video. + pub audio_source_token: MediaFileToken, + + /// Media file token for the source visuals. + /// This is either an image or video. + pub image_or_video_source_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/media_file_cover_image_details.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/media_file_cover_image_details.rs new file mode 100644 index 0000000000..38f1765b4e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/media_file_cover_image_details.rs @@ -0,0 +1,44 @@ +use crate::common::responses::cover_image_links::CoverImageLinks; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// TODO(bt,2025-08-20): Replace the storyteller-web version of this. +// We can't drop the old version just yet due to the `impl` (easy) +// and a few differing fields. + +/// Everything we need to create a cover image. +/// Cover images are small descriptive images that can be set for any media file. +/// If a cover image is set, this is the path to the asset. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct MediaFileCoverImageDetails { + // /// (DEPRECATED) URL path to the media file + // #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")] + // pub maybe_cover_image_public_bucket_path: Option, + + // /// (DEPRECATED) Full URL to the media file + // #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")] + // pub maybe_cover_image_public_bucket_url: Option, + + // NB(bt,2024-09-19): I accidentally rolled this field out to production. + // I don't think this field is in use, but maybe ... + // /// (DEPRECATED) Use maybe_links instead. + // #[deprecated(note="Use `maybe_links` instead.")] + // pub maybe_media_links: Option, + + /// Links to the cover image (CDN direct link, thumbnail template) + /// If a cover image is set, this is the path to the asset. + /// If a cover image is not set, use the information in `default_cover` instead. + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub maybe_links: Option, + + /// For items without a cover image, we can use one of our own. + pub default_cover: MediaFileDefaultCover, +} + +/// The default cover is composed of an image and color pair that are +/// predefined by the frontend. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct MediaFileDefaultCover { + pub image_index: u8, + pub color_index: u8, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/media_links.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/media_links.rs new file mode 100644 index 0000000000..367dae629d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/media_links.rs @@ -0,0 +1,39 @@ +use serde::Deserialize; +use serde::Serialize; +use url::Url; +use utoipa::ToSchema; + +/// Links to media file locations (bucket, CDN, etc.) +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Eq, PartialEq)] +pub struct MediaLinks { + /// Primary link to the asset via the CDN. + pub cdn_url: Url, + + /// Template to construct thumbnail URLs. + /// Replace the string `{WIDTH}` with the desired width. + /// Only relevant for image media files. (Video media files instead have + /// video previews, which, in turn, have their own thumbnail templates.) + pub maybe_thumbnail_template: Option, + + /// Video preview images (still and animated gif) for mp4 video files. + /// These are only set for video media files. + /// These are not set for "cover images" that are gif or webms, but rather video files. + pub maybe_video_previews: Option, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Eq, PartialEq)] +pub struct VideoPreviews { + /// A static single frame preview image of the video. + pub still: Url, + + /// An animated gif preview of the video. + pub animated: Url, + + /// A template used to construct the still thumbnail URL. + /// Replace the string `{WIDTH}` with the desired width. + pub still_thumbnail_template: String, + + /// A template used to construct the animated thumbnail URL. + /// Replace the string `{WIDTH}` with the desired width. + pub animated_thumbnail_template: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/mod.rs new file mode 100644 index 0000000000..0a600adc9b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/mod.rs @@ -0,0 +1,8 @@ +pub mod cover_image_links; +pub mod job_details; +pub mod media_file_cover_image_details; +pub mod media_links; +pub mod pagination_cursors; +pub mod simple_entity_stats; +pub mod simple_generic_json_success; +pub mod user_details_light; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/pagination_cursors.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/pagination_cursors.rs new file mode 100644 index 0000000000..27bf0bb103 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/pagination_cursors.rs @@ -0,0 +1,22 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// TODO(bt,2026-07-09): Replace the storyteller-web version of this. + +/// Pagination by cursors +/// These types of pagination are for "infinite scrolling", which do not reveal a number of pages. +/// This is good so that investors and competitors cannot reveal how many database records we have. +/// This is typically used for "discovery" type pages, not user profiles. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct PaginationCursors { + /// The "next" cursor. + /// This is an opaque (typically even encrypted) handle. + pub maybe_next: Option, + + /// The "previous" cursor. + /// This is an opaque (typically even encrypted) handle. + pub maybe_previous: Option, + + /// Details whether we're walking forward or backward. + pub cursor_is_reversed: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/simple_entity_stats.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/simple_entity_stats.rs new file mode 100644 index 0000000000..0ba424cb59 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/simple_entity_stats.rs @@ -0,0 +1,12 @@ +use serde_derive::{Deserialize, Serialize}; + +// TODO(bt,2025-08-20): Replace the storyteller-web version of this. + +/// Simple stats that can be attached to any entity +#[derive(Serialize, Deserialize, Debug)] +pub struct SimpleEntityStats { + /// Number of positive ratings (or "likes") for this item + pub positive_rating_count: u32, + /// Number of bookmarks for this item + pub bookmark_count: u32, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/simple_generic_json_success.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/simple_generic_json_success.rs new file mode 100644 index 0000000000..297ad544d4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/simple_generic_json_success.rs @@ -0,0 +1,7 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SimpleGenericJsonSuccess { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/user_details_light.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/user_details_light.rs new file mode 100644 index 0000000000..dc40ec346b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/common/responses/user_details_light.rs @@ -0,0 +1,34 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +/// Everything we need to refer to a user on the public web interface. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct UserDetailsLight { + /// The token for the user + pub user_token: UserToken, + + /// The unique username someone logs in with + /// As of 2023-08-23, this is always lowercase + pub username: String, + + /// As of 2023-08-23, this is the username with capitalization + /// (In the future, a display name can be customized by the user.) + pub display_name: String, + + /// Email hash for Gravatar + /// Always set for now since login is email/username+password. + /// In the future this will need to become an optional *OR* be filled with a bogus hash. + pub gravatar_hash: String, + + /// For users without a gravatar, we show one of our own avatars. + pub default_avatar: UserDefaultAvatarInfo, + + // In the future, we'll also support user-uploaded avatars that we store on our servers. +} + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct UserDefaultAvatarInfo { + pub image_index: u8, + pub color_index: u8, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/credits/get_session_credits.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/credits/get_session_credits.rs new file mode 100644 index 0000000000..a24f00685f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/credits/get_session_credits.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GetSessionCreditsResponse { + pub success: bool, + + /// Any free credits the user might have + /// We might support daily free credits. + pub free_credits: u64, + + /// Any monthly refilling credits the user might have + pub monthly_credits: u64, + + /// Any banked credits the user might have. + pub banked_credits: u64, + + /// All the credit amounts added together. + pub sum_total_credits: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/credits/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/credits/mod.rs new file mode 100644 index 0000000000..c6a4be341d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/credits/mod.rs @@ -0,0 +1 @@ +pub mod get_session_credits; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs new file mode 100644 index 0000000000..e403afe3ad --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs @@ -0,0 +1,94 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use url::Url; +use utoipa::ToSchema; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +// NB: The "folder media file" list-item shape lives in storyteller_web +// (see `endpoints/folders/media_files/list_folder_media_files_handler.rs`) +// because it embeds `MediaFileCoverImageDetails` and other domain types +// that depend on the request's `MediaDomain` + `ServerEnvironment`. + +/// Canonical wire shape for a folder. Used by single-folder GETs, create +/// responses, list rows, and subfolder list rows. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct FolderInfo { + pub token: FolderToken, + pub name: String, + pub owner_user_token: UserToken, + pub maybe_parent_folder_token: Option, + + /// The four most-recent media files added to the folder, in slot order + /// (the schema stores them as `maybe_last_media_file_token_1..4`). + /// Slots whose media file has been deleted are skipped, so the vec + /// may contain 0-4 entries. + pub last_media_thumbnails: Vec, + + /// User-chosen cover image. When present, the frontend should use this + /// as the folder's primary thumbnail instead of the + /// `last_media_thumbnails` grid. + pub maybe_custom_cover_thumbnail: Option, + + pub maybe_color_code: Option, + pub has_star: bool, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// True when `maybe_parent_folder_token` is set but the referenced + /// parent row is missing or soft-deleted. + pub is_orphaned: bool, +} + +/// A compact thumbnail descriptor for a media file referenced by a +/// folder (either one of the `last_media_*` slots or the custom cover). +/// +/// Intentionally narrower than `MediaLinks` / `CoverImageLinks` — folders +/// only need the bare minimum to render a thumbnail card. The CDN URL is +/// the direct asset link; `maybe_thumbnail_template` carries the resizing +/// template URL (with `{{width}}` / `{{height}}` placeholders) when the +/// media class supports it. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct FolderThumbnail { + pub token: MediaFileToken, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + + /// Direct CDN URL to the underlying media asset. + pub cdn_url: Url, + + /// Resizing template URL (with `{{width}}` / `{{height}}` placeholders) + /// when the media class supports it. `None` for media classes without + /// a thumbnail variant. (Video media files instead have video previews, + /// which, in turn, have their own thumbnail templates.) + pub maybe_thumbnail_template: Option, + + /// Video preview images (still and animated gif) for mp4 video files. + /// These are only set for video media files. + pub maybe_video_previews: Option, +} + +/// Video preview images for a video media file referenced by a folder. +/// Mirrors the shape of `MediaLinks`' video previews, but is its own type +/// so the folder wire shape can evolve independently. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct FolderThumbnailVideoPreviews { + /// A static single frame preview image of the video. + pub still: Url, + + /// An animated gif preview of the video. + pub animated: Url, + + /// A template used to construct the still thumbnail URL. + /// Replace the string `{WIDTH}` with the desired width. + pub still_thumbnail_template: String, + + /// A template used to construct the animated thumbnail URL. + /// Replace the string `{WIDTH}` with the desired width. + pub animated_thumbnail_template: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/folder.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/folder.rs new file mode 100644 index 0000000000..25a1d5a27e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/folder.rs @@ -0,0 +1,116 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; + +use crate::folders::common::FolderInfo; + +// ── POST /v1/folders/create ── + +#[derive(Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, + pub maybe_parent_folder_token: Option, + pub maybe_color_code: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateFolderSuccessResponse { + pub success: bool, + pub folder: FolderInfo, +} + +// ── GET /v1/folders/list_all ── + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListFoldersQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListFoldersSuccessResponse { + pub success: bool, + pub folders: Vec, + pub maybe_cursor: Option, +} + +// ── GET /v1/folders/folder/{folder_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct FolderPathInfo { + pub folder_token: FolderToken, +} + +#[derive(Serialize, ToSchema)] +pub struct GetFolderSuccessResponse { + pub success: bool, + pub folder: FolderInfo, +} + +// ── PUT /v1/folders/folder/{folder_token}/rename ── + +#[derive(Deserialize, ToSchema)] +pub struct RenameFolderRequest { + pub new_name: String, +} + +#[derive(Serialize, ToSchema)] +pub struct RenameFolderSuccessResponse { + pub success: bool, +} + +// ── PUT /v1/folders/folder/{folder_token}/star ── + +#[derive(Deserialize, ToSchema)] +pub struct SetFolderStarRequest { + pub has_star: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct SetFolderStarSuccessResponse { + pub success: bool, +} + +// ── PUT /v1/folders/folder/{folder_token}/color_code ── + +#[derive(Deserialize, ToSchema)] +pub struct SetFolderColorCodeRequest { + /// `None` clears the color tag. + pub maybe_color_code: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct SetFolderColorCodeSuccessResponse { + pub success: bool, +} + +// ── PUT /v1/folders/folder/{folder_token}/cover_image ── + +#[derive(Deserialize, ToSchema)] +pub struct SetFolderCoverImageRequest { + /// `None` clears the cover image. If set, the server resolves it to a + /// usable cover-image media file token: image/video media files are + /// used directly; other types fall back to the file's own + /// `maybe_cover_image_media_file_token` (and fail if neither path + /// applies). + pub maybe_media_file_token: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct SetFolderCoverImageSuccessResponse { + pub success: bool, + + /// The media file token that ended up being stored as the cover image + /// (may differ from the requested token if the server resolved through + /// the file's own cover image). `None` when the cover was cleared. + pub maybe_resolved_cover_media_file_token: Option, +} + +// ── DELETE /v1/folders/folder/{folder_token} ── + +#[derive(Serialize, ToSchema)] +pub struct DeleteFolderSuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs new file mode 100644 index 0000000000..ce0725562c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs @@ -0,0 +1,111 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; + +// ── GET /v1/folders/media_files/{folder_token} ── +// +// NB: `ListFolderMediaFilesSuccessResponse` and `FolderMediaFileListItem` +// live in `storyteller_web`'s handler because the wire shape embeds +// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend +// on the request's `MediaDomain` + `ServerEnvironment`. + +#[derive(Deserialize, ToSchema)] +pub struct FolderMediaFilesPathInfo { + pub folder_token: FolderToken, +} + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListFolderMediaFilesQueryParams { + pub cursor: Option, + pub limit: Option, +} + +// ── GET /v1/folders/media_files_without_folder ── +// +// NB: `ListMediaFilesWithoutFolderSuccessResponse` and its list item live in +// `storyteller_web`'s handler because the wire shape embeds +// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend +// on the request's `MediaDomain` + `ServerEnvironment`. + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListMediaFilesWithoutFolderQueryParams { + pub cursor: Option, + pub limit: Option, + + /// Optional filter on the coarse media class. + /// + /// Usage: + /// - `?filter_media_class=image` + /// - `?filter_media_class=video` + /// - `?filter_media_class=mesh` + /// - `?filter_media_class=splat` + pub filter_media_class: Option, +} + +// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkAddFolderMediaFilesRequest { + /// Media files to add to the folder. Tokens that don't exist or are + /// soft-deleted are silently skipped. Already-added rows are no-ops + /// (idempotent via INSERT IGNORE on the composite primary key). + pub media_file_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkAddFolderMediaFilesSuccessResponse { + pub success: bool, + /// Tokens that pre-existed (existed + not deleted). Already-added rows + /// are included since the operation is idempotent. + pub accepted_media_file_tokens: Vec, +} + +// ── PUT /v1/folders/media_files/{folder_token}/bulk_remove ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkRemoveFolderMediaFilesRequest { + pub media_file_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkRemoveFolderMediaFilesSuccessResponse { + pub success: bool, + /// How many membership rows were actually hard-deleted. + pub removed_count: u64, +} + +// ── POST /v1/folders/media_files/{folder_token}/bulk_move ── + +/// Atomically move the listed media files from `source_folder` to the +/// URL `folder_token` (destination). Idempotent on both sides: tokens +/// that aren't currently in the source are silently skipped, and tokens +/// that are already in the destination stay there. +#[derive(Deserialize, ToSchema)] +pub struct BulkMoveFolderMediaFilesRequest { + pub source_folder: FolderToken, + pub media_file_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkMoveFolderMediaFilesSuccessResponse { + pub success: bool, + + /// Number of membership rows actually deleted from the source folder. + pub removed_from_source_count: u64, + + /// Number of membership rows actually inserted into the destination + /// folder. May be lower than the input length because (a) tokens that + /// were already present in the destination don't insert, and (b) + /// tokens whose media file doesn't exist / is soft-deleted are filtered + /// out before the insert. + pub added_to_destination_count: u64, + + /// The media file tokens that ended up resident in the destination + /// folder after the move — i.e. the subset of the input that actually + /// referred to live media files. Includes both newly-inserted rows and + /// rows that were already there. + pub accepted_media_file_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/mod.rs new file mode 100644 index 0000000000..46416cdc02 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/mod.rs @@ -0,0 +1,4 @@ +pub mod common; +pub mod folder; +pub mod media_files; +pub mod subfolder; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/subfolder.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/subfolder.rs new file mode 100644 index 0000000000..346039af34 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/subfolder.rs @@ -0,0 +1,61 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::folders::FolderToken; + +use crate::folders::common::FolderInfo; + +// ── GET /v1/folders/subfolders/{folder_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct SubfolderPathInfo { + pub folder_token: FolderToken, +} + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListSubfoldersQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListSubfoldersSuccessResponse { + pub success: bool, + pub subfolders: Vec, + pub maybe_cursor: Option, +} + +// ── PUT /v1/folders/subfolders/{folder_token}/bulk_add ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkAddSubfoldersRequest { + /// The folders to reparent under the URL `folder_token`. Tokens that + /// don't exist, aren't owned by the caller, are soft-deleted, or are + /// the parent itself are silently skipped (idempotent). + pub subfolder_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkAddSubfoldersSuccessResponse { + pub success: bool, + /// Tokens that were actually accepted (existed + owned + not deleted + + /// not the parent itself). Already-parented tokens are included here + /// since the operation is idempotent. + pub accepted_subfolder_tokens: Vec, +} + +// ── PUT /v1/folders/subfolders/{folder_token}/bulk_remove ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkRemoveSubfoldersRequest { + /// Folders to unparent. Only rows currently parented to the URL + /// `folder_token` are affected; others are silently skipped. + pub subfolder_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkRemoveSubfoldersSuccessResponse { + pub success: bool, + /// How many rows were actually unparented. + pub removed_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs new file mode 100644 index 0000000000..0cf0b57423 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_image_cost.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_image_model::CommonImageModel; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation_provider::GenerationProvider; + +pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image"; + +/// Request body for the image cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct EstimateImageCostRequest { + /// The image model to estimate costs for. + pub model: CommonImageModel, + + /// The provider to route the generation through. + pub provider: GenerationProvider, + + /// The type of generation (determines whether input images are involved). + /// This is a tagged enum, so it looks like: + /// "generation_mode": {"type": "text_to_image"} + /// "generation_mode": {"type": "image_edit", "count": 2} + pub generation_mode: GenerationMode, + + /// Optional aspect ratio. + pub aspect_ratio: Option, + + /// Optional resolution. + pub resolution: Option, + + // Optional quality. + pub quality: Option, + + /// Number of images to generate in parallel. + pub image_batch_count: Option, +} + +/// Describes the type of image generation being requested. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GenerationMode { + /// No input images — generate from prompt only. + TextToImage, + /// One or more input images are provided for editing/compositing. + ImageEdit { count: u32 }, +} + +/// Response body for the image cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateImageCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, +} + +/// Error response for the image cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateImageCostError { + pub success: bool, + pub error_type: EstimateImageCostErrorType, + pub error_message: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum EstimateImageCostErrorType { + InvalidProviderForModel, + InvalidInput, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serialization_text_to_image() { + let request = EstimateImageCostRequest { + model: CommonImageModel::NanoBananaPro, + provider: GenerationProvider::Artcraft, + generation_mode: GenerationMode::TextToImage, + aspect_ratio: None, + resolution: None, + quality: None, + image_batch_count: None, + }; + let serialized = serde_json::to_string(&request).unwrap(); + assert!(serialized.contains("\"generation_mode\":{\"type\":\"text_to_image\"}")); + } + + #[test] + fn test_serialization_image_edit() { + let request = EstimateImageCostRequest { + model: CommonImageModel::NanoBananaPro, + provider: GenerationProvider::Artcraft, + generation_mode: GenerationMode::ImageEdit { count: 2 }, + aspect_ratio: None, + resolution: None, + quality: None, + image_batch_count: None, + }; + let serialized = serde_json::to_string(&request).unwrap(); + assert!(serialized.contains("\"generation_mode\":{\"type\":\"image_edit\",\"count\":2}")); + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_splat_cost.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_splat_cost.rs new file mode 100644 index 0000000000..a42355bfe8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_splat_cost.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_splat_model::CommonSplatModel; +use enums::common::generation_provider::GenerationProvider; + +pub const ESTIMATE_SPLAT_COST_PATH: &str = "/v1/generate/cost_estimate/splat"; + +/// Request body for the splat cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateSplatCostRequest { + /// The splat model to estimate costs for. + pub model: CommonSplatModel, + + /// The provider to route the generation through. + pub provider: GenerationProvider, + + /// Whether the request includes a reference image. + pub has_reference_image: Option, +} + +/// Response body for the splat cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateSplatCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, +} + +/// Error response for the splat cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateSplatCostError { + pub success: bool, + pub error_type: EstimateSplatCostErrorType, + pub error_message: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum EstimateSplatCostErrorType { + InvalidProviderForModel, + InvalidInput, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_video_cost.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_video_cost.rs new file mode 100644 index 0000000000..060e5df8da --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/estimate_video_cost.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_video_model::CommonVideoModel; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation_provider::GenerationProvider; + +pub const ESTIMATE_VIDEO_COST_PATH: &str = "/v1/generate/cost_estimate/video"; + +/// Request body for the video cost estimate endpoint. +/// +/// Every model cares about its own set of parameters and not all +/// parameters may be relevant for every model. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct EstimateVideoCostRequest { + /// The video model to estimate costs for. + pub model: CommonVideoModel, + + /// The provider to route the generation through. + pub provider: GenerationProvider, + + /// The type of generation (determines how many input images are involved). + /// This is a tagged enum, so it looks like: + /// "generation_mode": {"type": "text_to_video"} + /// "generation_mode": {"type": "reference_image_to_video", "count": 1} + pub generation_mode: GenerationMode, + + /// Optional aspect ratio. + pub aspect_ratio: Option, + + /// Optional resolution. + pub resolution: Option, + + /// Duration in seconds. + pub duration_seconds: Option, + + /// Number of videos to generate in parallel. + pub video_batch_count: Option, + + /// Whether to generate audio for the video. + pub generate_audio: Option, +} + +/// Describes the type of video generation being requested. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GenerationMode { + TextToVideo, + StartFrameToVideo, + StartAndEndFrameToVideo, + ReferenceImageToVideo { count: u32 }, +} + +/// Response body for the video cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateVideoCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, +} + +/// Error response for the video cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EstimateVideoCostError { + pub success: bool, + pub error_type: EstimateVideoCostErrorType, + pub error_message: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum EstimateVideoCostErrorType { + InvalidProviderForModel, + InvalidInput, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serialization_tagging1() { + let request = EstimateVideoCostRequest { + model: CommonVideoModel::GrokVideo, + provider: GenerationProvider::Artcraft, + generation_mode: GenerationMode::TextToVideo, + aspect_ratio: None, + resolution: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + }; + let serialized = serde_json::to_string(&request).unwrap(); + assert!(serialized.contains("\"generation_mode\":{\"type\":\"text_to_video\"}")); + //assert_eq!(serialized, "{}"); + } + + #[test] + fn test_serialization_tagging2() { + let request = EstimateVideoCostRequest { + model: CommonVideoModel::GrokVideo, + provider: GenerationProvider::Artcraft, + generation_mode: GenerationMode::ReferenceImageToVideo { count: 1 }, + aspect_ratio: None, + resolution: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + }; + let serialized = serde_json::to_string(&request).unwrap(); + assert!(serialized.contains("\"generation_mode\":{\"type\":\"reference_image_to_video\",\"count\":1}")); + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/mod.rs new file mode 100644 index 0000000000..bcae15ceae --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/cost_estimate/mod.rs @@ -0,0 +1,3 @@ +pub mod estimate_image_cost; +pub mod estimate_splat_cost; +pub mod estimate_video_cost; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs new file mode 100644 index 0000000000..baf1e3c1fe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/flux_2_lora_edit_image_angle.rs @@ -0,0 +1,56 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH: &str = "/v1/generate/image/angle/flux_2_lora"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct Flux2LoraEditImageAngleRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// The image to edit with angle adjustment. + pub image_media_token: MediaFileToken, + + /// Horizontal rotation angle in degrees. + pub horizontal_angle: Option, + + /// Vertical rotation angle in degrees. + pub vertical_angle: Option, + + /// Zoom level. + pub zoom: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Output image size. Default is square_hd. + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Flux2LoraEditImageAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Flux2LoraEditImageAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Flux2LoraEditImageAngleResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs new file mode 100644 index 0000000000..fa3fe51645 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_2_lora_edit_image_angle; +pub mod qwen_edit_2511_edit_image_angle; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs new file mode 100644 index 0000000000..5efc396dc9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/angle/qwen_edit_2511_edit_image_angle.rs @@ -0,0 +1,59 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH: &str = "/v1/generate/image/angle/qwen_edit_2511"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct QwenEdit2511EditImageAngleRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// The image to edit with angle adjustment. + pub image_media_token: MediaFileToken, + + /// Horizontal rotation angle in degrees. + pub horizontal_angle: Option, + + /// Vertical rotation angle in degrees. + pub vertical_angle: Option, + + /// Zoom level. + pub zoom: Option, + + /// Additional text prompt to guide the generation. + pub additional_prompt: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Output image size. Default is square_hd. + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum QwenEdit2511EditImageAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum QwenEdit2511EditImageAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct QwenEdit2511EditImageAngleResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/bg_removal/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/bg_removal/mod.rs new file mode 100644 index 0000000000..243c3076ef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/bg_removal/mod.rs @@ -0,0 +1 @@ +pub mod remove_image_background; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/bg_removal/remove_image_background.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/bg_removal/remove_image_background.rs new file mode 100644 index 0000000000..fac3188186 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/bg_removal/remove_image_background.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const REMOVE_IMAGE_BACKGROUND_PATH: &str = "/v1/generate/image/remove_background"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct RemoveImageBackgroundRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the file to remove the background from. + /// It must be an image. + pub media_file_token: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct RemoveImageBackgroundResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/flux_pro_kontext_max_edit_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/flux_pro_kontext_max_edit_image.rs new file mode 100644 index 0000000000..3fc97aee21 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/flux_pro_kontext_max_edit_image.rs @@ -0,0 +1,36 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const FLUX_PRO_KONTEXT_MAX_EDIT_IMAGE_PATH: &str = "/v1/generate/image/edit/flux_pro_kontext_max"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FluxProKontextMaxEditImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// The image we're editing. + pub image_media_token: MediaFileToken, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FluxProKontextMaxEditImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FluxProKontextMaxEditImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/gemini_25_flash_edit_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/gemini_25_flash_edit_image.rs new file mode 100644 index 0000000000..5fceacff3b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/gemini_25_flash_edit_image.rs @@ -0,0 +1,50 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GEMINI_25_FLASH_EDIT_IMAGE_PATH: &str = "/v1/generate/image/edit/gemini_25_flash"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Gemini25FlashEditImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + // TODO: OneOf for image_urls or image_media_tokens + /// Image media tokens to include in the editing context. + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Quality of the image to generate. Default is High. + pub image_quality: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Gemini25FlashEditImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Gemini25FlashEditImageImageQuality { + Auto, + Low, + Medium, + High, // Default +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Gemini25FlashEditImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/gpt_image_1_edit_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/gpt_image_1_edit_image.rs new file mode 100644 index 0000000000..74e6bd90f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/gpt_image_1_edit_image.rs @@ -0,0 +1,62 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GPT_IMAGE_1_EDIT_IMAGE_PATH: &str = "/v1/generate/image/edit/gpt_image_1"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GptImage1EditImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + // TODO: OneOf for image_urls or image_media_tokens + /// Image media tokens to include in the editing context. + pub image_media_tokens: Option>, + + /// Size of the image to generate. Default is Square. + pub image_size: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Quality of the image to generate. Default is High. + pub image_quality: Option, +} + + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1EditImageImageSize { + Square, // 1:1, Default + Horizontal, + Vertical, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1EditImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1EditImageImageQuality { + Auto, + Low, + Medium, + High, // Default +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GptImage1EditImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/mod.rs new file mode 100644 index 0000000000..d883c78a56 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/mod.rs @@ -0,0 +1,6 @@ +pub mod flux_pro_kontext_max_edit_image; +pub mod gemini_25_flash_edit_image; +pub mod gpt_image_1_edit_image; +pub mod qwen_edit_image; +pub mod seededit_3_edit_image; + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/qwen_edit_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/qwen_edit_image.rs new file mode 100644 index 0000000000..b65669591d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/qwen_edit_image.rs @@ -0,0 +1,51 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const QWEN_EDIT_IMAGE_PATH: &str = "/v1/generate/image/edit/qwen"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct QwenEditImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// The image we're editing. + pub image_media_token: MediaFileToken, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Text prompt to generate the image from. + pub negative_prompt: Option, + + /// Acceleration level for image generation. + /// Options: 'none', 'regular'. Higher acceleration increases speed. + /// 'regular' balances speed and quality. Default value: "none" + pub acceleration: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub enum QwenEditImageAcceleration { + None, + Regular, + High, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum QwenEditImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct QwenEditImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/seededit_3_edit_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/seededit_3_edit_image.rs new file mode 100644 index 0000000000..0551f34e1d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/edit/seededit_3_edit_image.rs @@ -0,0 +1,24 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const SEEDEDIT_3_EDIT_IMAGE_PATH: &str = "/v1/generate/image/edit/seededit_3"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SeedEdit3EditImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// The image we're editing. + pub image_media_token: MediaFileToken, + + /// Text prompt to generate the image from. + pub prompt: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SeedEdit3EditImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/flux_dev_juggernaut_inpaint_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/flux_dev_juggernaut_inpaint_image.rs new file mode 100644 index 0000000000..8f06022e38 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/flux_dev_juggernaut_inpaint_image.rs @@ -0,0 +1,39 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const FLUX_DEV_JUGGERNAUT_INPAINT_PATH: &str = "/v1/generate/image/inpaint/flux_dev_juggernaut"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FluxDevJuggernautInpaintImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// The image we're editing. + pub image_media_token: MediaFileToken, + + /// The mask to use against the image. + pub mask_media_token: MediaFileToken, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FluxDevJuggernautInpaintImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FluxDevJuggernautInpaintImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/flux_pro_1_inpaint_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/flux_pro_1_inpaint_image.rs new file mode 100644 index 0000000000..7a6fda8ae9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/flux_pro_1_inpaint_image.rs @@ -0,0 +1,39 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const FLUX_PRO_1_INPAINT_PATH: &str = "/v1/generate/image/inpaint/flux_pro_1"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FluxPro1InpaintImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// The image we're editing. + pub image_media_token: MediaFileToken, + + /// The mask to use against the image. + pub mask_media_token: MediaFileToken, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FluxPro1InpaintImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FluxPro1InpaintImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/mod.rs new file mode 100644 index 0000000000..639ce489c8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/inpaint/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_dev_juggernaut_inpaint_image; +pub mod flux_pro_1_inpaint_image; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs new file mode 100644 index 0000000000..068f2ef5f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/mod.rs @@ -0,0 +1,6 @@ +pub mod angle; +pub mod bg_removal; +pub mod edit; +pub mod inpaint; +pub mod multi_function; +pub mod text; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen.rs new file mode 100644 index 0000000000..3d21989419 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen.rs @@ -0,0 +1,68 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const BYTEDANCE_SEEDREAM_5_LITE_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/bytedance_seedream_5_lite"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BytedanceSeedream5LiteMultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context. + /// If present, we're doing image editing (image-to-image / image-editing) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + pub max_images: Option, + + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedream5LiteMultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedream5LiteMultiFunctionImageGenMaxImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedream5LiteMultiFunctionImageGenImageSize { + // Square + Square, + SquareHd, + // Tall + PortraitFourThree, + PortraitSixteenNine, + // Wide + LandscapeFourThree, + LandscapeSixteenNine, + // Auto (NB: 5 Lite max is auto_3K, not auto_4K like V4.5) + Auto2k, + Auto3k, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BytedanceSeedream5LiteMultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen.rs new file mode 100644 index 0000000000..b6212141b6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen.rs @@ -0,0 +1,69 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const BYTEDANCE_SEEDREAM_V4_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/bytedance_seedream_4"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BytedanceSeedreamV4MultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context. + /// If present, we're doing image editing (image-to-image / image-editing) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + pub max_images: Option, + + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedreamV4MultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedreamV4MultiFunctionImageGenMaxImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedreamV4MultiFunctionImageGenImageSize { + // Square + Square, + SquareHd, + // Tall + PortraitFourThree, + PortraitSixteenNine, + // Wide + LandscapeFourThree, + LandscapeSixteenNine, + // Auto + Auto, + Auto2k, + Auto4k, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BytedanceSeedreamV4MultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen.rs new file mode 100644 index 0000000000..090c0786fb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen.rs @@ -0,0 +1,68 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const BYTEDANCE_SEEDREAM_V4P5_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/bytedance_seedream_4p5"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BytedanceSeedreamV4p5MultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context. + /// If present, we're doing image editing (image-to-image / image-editing) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + pub max_images: Option, + + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedreamV4p5MultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedreamV4p5MultiFunctionImageGenMaxImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BytedanceSeedreamV4p5MultiFunctionImageGenImageSize { + // Square + Square, + SquareHd, + // Tall + PortraitFourThree, + PortraitSixteenNine, + // Wide + LandscapeFourThree, + LandscapeSixteenNine, + // Auto + Auto2k, + Auto4k, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BytedanceSeedreamV4p5MultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen.rs new file mode 100644 index 0000000000..e95901f746 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen.rs @@ -0,0 +1,95 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GPT_IMAGE_1P5_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/gpt_image_1p5"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GptImage1p5MultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context (**OR INPAINTING CONTEXT!**). + /// If present, we're doing image editing (image-to-image / image-editing / image-inpainting) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Only for image editing, which turns this into an inpainting task. + /// Text to image should not set this. + pub mask_image_token: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + pub image_size: Option, + + pub background: Option, + + pub quality: Option, + + /// Only for image editing. + pub input_fidelity: Option, + + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1p5MultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1p5MultiFunctionImageGenSize { + /// 1024x1024 + Square, + /// 1536x1024 + Wide, + /// 1024x1536 + Tall, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1p5MultiFunctionImageGenBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1p5MultiFunctionImageGenQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1p5MultiFunctionImageGenInputFidelity { + Low, + High, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GptImage1p5MultiFunctionImageGenOutputFormat { + Jpeg, + Png, + Webp, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GptImage1p5MultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/mod.rs new file mode 100644 index 0000000000..9df8351867 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/mod.rs @@ -0,0 +1,7 @@ +pub mod bytedance_seedream_v4_multi_function_image_gen; +pub mod bytedance_seedream_v4p5_multi_function_image_gen; +pub mod bytedance_seedream_5_lite_multi_function_image_gen; +pub mod gpt_image_1p5_multi_function_image_gen; +pub mod nano_banana_2_multi_function_image_gen; +pub mod nano_banana_multi_function_image_gen; +pub mod nano_banana_pro_multi_function_image_gen; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_2_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_2_multi_function_image_gen.rs new file mode 100644 index 0000000000..4e22dd4ae1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_2_multi_function_image_gen.rs @@ -0,0 +1,74 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const NANO_BANANA_2_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/nano_banana_2"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct NanaBanana2MultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context. + /// If present, we're doing image editing (image-to-image / image-editing) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Resolution of the image to generate. Default is OneK (1K). + pub resolution: Option, + + /// Aspect ratio of the images to generate. Default is "1:1" + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanaBanana2MultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanaBanana2MultiFunctionImageGenImageResolution { + HalfK, + OneK, + TwoK, + FourK, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanaBanana2MultiFunctionImageGenAspectRatio { + // Auto (only for image editing) + Auto, // Default for image editing. + // Square + OneByOne, // Default for text-to-image + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct NanaBanana2MultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_multi_function_image_gen.rs new file mode 100644 index 0000000000..bdd69110f7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_multi_function_image_gen.rs @@ -0,0 +1,62 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const NANO_BANANA_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/nano_banana"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct NanoBananaMultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context. + /// If present, we're doing image editing (image-to-image / image-editing) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Aspect ratio of the images to generate. Default is "1:1" + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanoBananaMultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanoBananaMultiFunctionImageGenAspectRatio { + // Auto (only for image editing) + Auto, // Default for image editing. + // Square + OneByOne, // Default for text-to-image + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, // NB: No NineByTwentyOne ? +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct NanoBananaMultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_pro_multi_function_image_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_pro_multi_function_image_gen.rs new file mode 100644 index 0000000000..ce60ef5632 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/multi_function/nano_banana_pro_multi_function_image_gen.rs @@ -0,0 +1,73 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const NANO_BANANA_PRO_MULTI_FUNCTION_IMAGE_GEN_PATH: &str = "/v1/generate/image/multi_function/nano_banana_pro"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct NanoBananaProMultiFunctionImageGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Image media tokens to include in the editing context. + /// If present, we're doing image editing (image-to-image / image-editing) + /// If absent, we're doing image generation (text-to-image) + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Resolution of the image to generate. Default is OneK (1K). + pub resolution: Option, + + /// Aspect ratio of the images to generate. Default is "1:1" + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanoBananaProMultiFunctionImageGenNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanoBananaProMultiFunctionImageGenImageResolution { + OneK, + TwoK, + FourK, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NanoBananaProMultiFunctionImageGenAspectRatio { + // Auto (only for image editing) + Auto, // Default for image editing. + // Square + OneByOne, // Default for text-to-image + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, // NB: No NineByTwentyOne ? +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct NanoBananaProMultiFunctionImageGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/gemini_25_flash_text_to_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/gemini_25_flash_text_to_image.rs new file mode 100644 index 0000000000..a343ef3081 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/gemini_25_flash_text_to_image.rs @@ -0,0 +1,50 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GEMINI_25_FLASH_EDIT_IMAGE_PATH: &str = "/v1/generate/image/gemini_25_flash"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Gemini25FlashTextToImageImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + // TODO: OneOf for image_urls or image_media_tokens + /// Image media tokens to include in the editing context. + pub image_media_tokens: Option>, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Quality of the image to generate. Default is High. + pub image_quality: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Gemini25FlashTextToImageImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Gemini25FlashTextToImageImageImageQuality { + Auto, + Low, + Medium, + High, // Default +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Gemini25FlashTextToImageImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_1_dev_text_to_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_1_dev_text_to_image.rs new file mode 100644 index 0000000000..421bd5e657 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_1_dev_text_to_image.rs @@ -0,0 +1,49 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +pub const GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH: &str = "/v1/generate/image/flux_1_dev_text_to_image"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFlux1DevTextToImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Aspect ratio of the output images. + pub aspect_ratio: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFlux1DevTextToImageAspectRatio { + Square, // 1:1 + SquareHd, // 1:1 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFlux1DevTextToImageNumImages { + One, // Default + Two, + Three, + Four, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFlux1DevTextToImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_1_schnell_text_to_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_1_schnell_text_to_image.rs new file mode 100644 index 0000000000..a2d636fdf0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_1_schnell_text_to_image.rs @@ -0,0 +1,49 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +pub const GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH: &str = "/v1/generate/image/flux_1_schnell_text_to_image"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFlux1SchnellTextToImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Aspect ratio of the output images. + pub aspect_ratio: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFlux1SchnellTextToImageAspectRatio { + Square, // 1:1 + SquareHd, // 1:1 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFlux1SchnellTextToImageNumImages { + One, // Default + Two, + Three, + Four, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFlux1SchnellTextToImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_pro_11_text_to_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_pro_11_text_to_image.rs new file mode 100644 index 0000000000..bc302d3a09 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_pro_11_text_to_image.rs @@ -0,0 +1,47 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +pub const GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH: &str = "/v1/generate/image/flux_pro_1.1_text_to_image"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFluxPro11TextToImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Aspect ratio of the output images. + pub aspect_ratio: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + +// TODO(btw,2026-01-01): Fal.ai and Fal.rs client libraries seem to disagree about the options. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFluxPro11TextToImageAspectRatio { + Square, // 1:1 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFluxPro11TextToImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFluxPro11TextToImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_pro_11_ultra_text_to_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_pro_11_ultra_text_to_image.rs new file mode 100644 index 0000000000..1eec1d82e0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_flux_pro_11_ultra_text_to_image.rs @@ -0,0 +1,50 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +pub const GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH: &str = "/v1/generate/image/flux_pro_1.1_ultra_text_to_image"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFluxPro11UltraTextToImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Aspect ratio of the output images. + pub aspect_ratio: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFluxPro11UltraTextToImageAspectRatio { + Square, // 1:1 + LandscapeThreeByTwo, // 3:2 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + LandscapeTwentyOneByNine, // 21:9 + PortraitTwoByThree, // 2:3 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + PortraitNineByTwentyOne, // 9:21 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateFluxPro11UltraTextToImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateFluxPro11UltraTextToImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_gpt_image_1_text_to_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_gpt_image_1_text_to_image.rs new file mode 100644 index 0000000000..6425b5760a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/generate_gpt_image_1_text_to_image.rs @@ -0,0 +1,58 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GPT_IMAGE_1_TEXT_TO_IMAGE_PATH: &str = "/v1/generate/image/gpt_image_1_text_to_image"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateGptImage1TextToImageRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt to generate the image from. + pub prompt: Option, + + /// Size of the image to generate. Default is Square. + pub image_size: Option, + + /// Number of images to generate. Default is one. + pub num_images: Option, + + /// Quality of the image to generate. Default is High. + pub image_quality: Option, +} + + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateGptImage1TextToImageImageSize { + Square, // 1:1, Default + Horizontal, + Vertical, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateGptImage1TextToImageNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateGptImage1TextToImageImageQuality { + Auto, + Low, + Medium, + High, // Default +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateGptImage1TextToImageResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/mod.rs new file mode 100644 index 0000000000..c3c7477352 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/image/text/mod.rs @@ -0,0 +1,6 @@ +pub mod gemini_25_flash_text_to_image; +pub mod generate_flux_1_dev_text_to_image; +pub mod generate_flux_1_schnell_text_to_image; +pub mod generate_flux_pro_11_text_to_image; +pub mod generate_flux_pro_11_ultra_text_to_image; +pub mod generate_gpt_image_1_text_to_image; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/mod.rs new file mode 100644 index 0000000000..414181138d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/mod.rs @@ -0,0 +1,5 @@ +pub mod cost_estimate; +pub mod image; +pub mod object; +pub mod splat; +pub mod video; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/generate_hunyuan_2_0_image_to_3d.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/generate_hunyuan_2_0_image_to_3d.rs new file mode 100644 index 0000000000..8584efa006 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/generate_hunyuan_2_0_image_to_3d.rs @@ -0,0 +1,22 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_HUNYUAN_2_0_IMAGE_TO_3D_URL_PATH: &str = "/v1/generate/object/hunyuan_2.0_image_to_3d"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateHunyuan20ImageTo3dRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the file to convert to 3D + /// It must be an image. + pub media_file_token: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateHunyuan20ImageTo3dResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/generate_hunyuan_2_1_image_to_3d.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/generate_hunyuan_2_1_image_to_3d.rs new file mode 100644 index 0000000000..7983a54422 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/generate_hunyuan_2_1_image_to_3d.rs @@ -0,0 +1,22 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_HUNYUAN_2_1_IMAGE_TO_3D_URL_PATH: &str = "/v1/generate/object/hunyuan_2.1_image_to_3d"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateHunyuan21ImageTo3dRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the file to convert to 3D + /// It must be an image. + pub media_file_token: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateHunyuan21ImageTo3dResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/mod.rs new file mode 100644 index 0000000000..582e9c131f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/mod.rs @@ -0,0 +1,3 @@ +pub mod generate_hunyuan_2_0_image_to_3d; +pub mod generate_hunyuan_2_1_image_to_3d; +pub mod multi_function; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/multi_function/hunyuan3d_v3_multi_function_object_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/multi_function/hunyuan3d_v3_multi_function_object_gen.rs new file mode 100644 index 0000000000..70d0c62f64 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/multi_function/hunyuan3d_v3_multi_function_object_gen.rs @@ -0,0 +1,84 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const HUNYUAN3D_V3_MULTI_FUNCTION_OBJECT_GEN_PATH: &str = "/v1/generate/object/multi_function/hunyuan3d_v3"; + +/// Hunyuan 3D v3 Multi-Function Request +/// +/// This endpoint combines three modes: +/// - Text-to-3D: Provide only a prompt (no image_media_token) +/// - Image-to-3D: Provide an image_media_token (no prompt or empty prompt) +/// - Sketch-to-3D: Provide both a prompt AND an image_media_token +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct Hunyuan3dV3MultiFunctionObjectGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt for generation. + /// - Required for text-to-3d mode + /// - Required for sketch-to-3d mode + /// - Ignored for image-to-3d mode + pub prompt: Option, + + /// Primary image media token. + /// - Required for image-to-3d mode + /// - Required for sketch-to-3d mode + /// - Not used for text-to-3d mode + pub image_media_token: Option, + + /// Back view image for multi-view image-to-3d mode. + /// Only applicable when image_media_token is provided without a prompt. + pub back_image_media_token: Option, + + /// Left view image for multi-view image-to-3d mode. + /// Only applicable when image_media_token is provided without a prompt. + pub left_image_media_token: Option, + + /// Right view image for multi-view image-to-3d mode. + /// Only applicable when image_media_token is provided without a prompt. + pub right_image_media_token: Option, + + /// Target face count for the generated mesh. + /// Optional - if not provided, the model will use its default. + pub face_count: Option, + + /// Type of generation to perform. + /// Default is Normal. + pub generate_type: Option, + + /// Type of polygon to use in the generated mesh. + /// Default is Triangle. + pub polygon_type: Option, + + /// Whether to enable PBR (Physically Based Rendering) materials. + /// Default is false. + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Hunyuan3dV3GenerateType { + /// Standard generation quality (default) + Normal, + /// Low polygon count for game assets + LowPoly, + /// Geometry only, no textures + Geometry, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Hunyuan3dV3PolygonType { + /// Triangle polygons (default) + Triangle, + /// Quadrilateral polygons + Quadrilateral, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Hunyuan3dV3MultiFunctionObjectGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/multi_function/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/multi_function/mod.rs new file mode 100644 index 0000000000..22285b9e3a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/object/multi_function/mod.rs @@ -0,0 +1 @@ +pub mod hunyuan3d_v3_multi_function_object_gen; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/generate_worldlabs_marble_0p1_mini_splat.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/generate_worldlabs_marble_0p1_mini_splat.rs new file mode 100644 index 0000000000..0048640caf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/generate_worldlabs_marble_0p1_mini_splat.rs @@ -0,0 +1,25 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_WORLDLABS_MARBLE_0P1_MINI_SPLAT_URL_PATH: &str = "/v1/generate/splat/worldlabs_marble_0p1_mini"; + +/// World Labs Marble 0.1-mini Splat Generation +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateWorldlabsMarble0p1MiniSplatRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Optional image to use as input for world generation. + pub image_media_file_token: Option, + + /// Optional text prompt for world generation. + pub prompt: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateWorldlabsMarble0p1MiniSplatResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/generate_worldlabs_marble_0p1_plus_splat.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/generate_worldlabs_marble_0p1_plus_splat.rs new file mode 100644 index 0000000000..bed7ea606e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/generate_worldlabs_marble_0p1_plus_splat.rs @@ -0,0 +1,25 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_WORLDLABS_MARBLE_0P1_PLUS_SPLAT_URL_PATH: &str = "/v1/generate/splat/worldlabs_marble_0p1_plus"; + +/// World Labs Marble 0.1-plus Splat Generation +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateWorldlabsMarble0p1PlusSplatRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Optional image to use as input for world generation. + pub image_media_file_token: Option, + + /// Optional text prompt for world generation. + pub prompt: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateWorldlabsMarble0p1PlusSplatResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/mod.rs new file mode 100644 index 0000000000..68557f5370 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/splat/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_worldlabs_marble_0p1_mini_splat; +pub mod generate_worldlabs_marble_0p1_plus_splat; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/edit/beeble_switchx_edit_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/edit/beeble_switchx_edit_video.rs new file mode 100644 index 0000000000..63c81fe119 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/edit/beeble_switchx_edit_video.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const BEEBLE_SWITCHX_EDIT_VIDEO_PATH: &str = "/v1/generate/video/edit/beeble_switchx"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct BeebleSwitchXEditVideoRequest { + pub uuid_idempotency_token: String, + pub source_video_media_token: Option, + pub reference_image_media_token: Option, + pub prompt: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BeebleSwitchXEditVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/edit/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/edit/mod.rs new file mode 100644 index 0000000000..ad8f1b29f4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/edit/mod.rs @@ -0,0 +1 @@ +pub mod beeble_switchx_edit_video; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_1_6_pro_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_1_6_pro_image_to_video.rs new file mode 100644 index 0000000000..767f225eb5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_1_6_pro_image_to_video.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_KLING_1_6_PRO_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/kling_1.6_pro_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateKling16ProImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional. + /// Image media file; the image to remove the background from. + pub end_frame_image_media_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, + + /// Optional: aspect ratio of the generated video. + pub duration: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateKling16ProAspectRatio { + /// 16:9 aspect ratio + WideSixteenNine, + /// 9:16 aspect ratio + TallNineSixteen, + /// 1:1 aspect ratio + Square, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateKling16ProDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateKling16ProImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_2_1_master_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_2_1_master_image_to_video.rs new file mode 100644 index 0000000000..db39fec73a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_2_1_master_image_to_video.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_KLING_2_1_MASTER_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/kling_2.1_master_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateKling21MasterImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, + + /// Optional: aspect ratio of the generated video. + pub duration: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateKling21MasterAspectRatio { + /// 16:9 aspect ratio + WideSixteenNine, + /// 9:16 aspect ratio + TallNineSixteen, + /// 1:1 aspect ratio + Square, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateKling21MasterDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateKling21MasterImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_2_1_pro_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_2_1_pro_image_to_video.rs new file mode 100644 index 0000000000..b5ef89677f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_kling_2_1_pro_image_to_video.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_KLING_2_1_PRO_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/kling_2.1_pro_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateKling21ProImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional. + /// Image media file; the image to remove the background from. + pub end_frame_image_media_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, + + /// Optional: aspect ratio of the generated video. + pub duration: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateKling21ProAspectRatio { + /// 16:9 aspect ratio + WideSixteenNine, + /// 9:16 aspect ratio + TallNineSixteen, + /// 1:1 aspect ratio + Square, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateKling21ProDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateKling21ProImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_seedance_1_0_lite_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_seedance_1_0_lite_image_to_video.rs new file mode 100644 index 0000000000..b1112b30bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_seedance_1_0_lite_image_to_video.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_SEEDANCE_1_0_LITE_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/seedance_1.0_lite_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateSeedance10LiteImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional. + /// Image media file; the image to remove the background from. + pub end_frame_image_media_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: resolution of the generated video. + pub resolution: Option, + + /// Optional: duration of the generated video. + pub duration: Option, + + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateSeedance10LiteResolution { + FourEightyP, + SevenTwentyP, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateSeedance10LiteDuration { + FiveSeconds, + TenSeconds, +} + + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateSeedance10LiteAspectRatio { + Auto, + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateSeedance10LiteImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_seedance_1_0_pro_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_seedance_1_0_pro_image_to_video.rs new file mode 100644 index 0000000000..f14680f28b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_seedance_1_0_pro_image_to_video.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_SEEDANCE_1_0_PRO_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/seedance_1.0_pro_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateSeedance10ProImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: aspect ratio of the generated video. + pub resolution: Option, + + /// Optional: aspect ratio of the generated video. + pub duration: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateSeedance10ProResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateSeedance10ProDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateSeedance10ProImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_2_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_2_image_to_video.rs new file mode 100644 index 0000000000..319a48e834 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_2_image_to_video.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_VEO_2_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/veo_2_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateVeo2ImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// **NB(bt): ASPECT RATIO IS ACTUALLY NOT USED IN VEO 2 IMAGE-TO-VIDEO** + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, + + /// Optional: aspect ratio of the generated video. + pub duration: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo2AspectRatio { + Auto, + AutoPreferPortrait, + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo2Duration { + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateVeo2ImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_fast_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_fast_image_to_video.rs new file mode 100644 index 0000000000..aa6e8517bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_fast_image_to_video.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/veo_3_fast_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateVeo3FastImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: resolution of the generated video. + pub resolution: Option, + + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, + + /// Optional: duration of the generated video. + pub duration: Option, + + /// Optional: Generate an audio track along with the video + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo3FastResolution { + SevenTwentyP, // 720p + TenEightyP, // 1080p +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo3FastAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo3FastDuration { + EightSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateVeo3FastImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs new file mode 100644 index 0000000000..7b462da839 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH: &str = "/v1/generate/video/veo_3_image_to_video"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateVeo3ImageToVideoRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Source of the image file to convert to video. + /// It must be an image. + pub media_file_token: Option, + + /// Optional text prompt. + pub prompt: Option, + + /// Optional: aspect ratio of the generated video. + pub aspect_ratio: Option, + + /// Optional: resolution of the generated video. + pub resolution: Option, + + /// Optional: aspect ratio of the generated video. + pub duration: Option, + + /// Optional: Generate an audio track along with the video + pub generate_audio: Option, + + // /// Optional. + // pub enhance_prompt: Option, + // /// Optional. Whether to automatically attempt to fix prompts that fail + // /// content policy or other validation checks by rewriting them + // /// Default value: true + // pub auto_fix: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo3AspectRatio { + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 + + /// NB: DO NOT REMOVE. This is an unsupported resolution for Veo 3, but we can't + /// impact existing callers by removing it. The handler will convert this to a + /// supported resolution or 400 the request instead. + Square, // 1:1 +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo3Resolution { + SevenTwentyP, // 720p + TenEightyP, // 1080p +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerateVeo3Duration { + EightSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GenerateVeo3ImageToVideoResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/mod.rs new file mode 100644 index 0000000000..a1a46f0e8b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/mod.rs @@ -0,0 +1,10 @@ +pub mod edit; +pub mod generate_kling_1_6_pro_image_to_video; +pub mod generate_kling_2_1_master_image_to_video; +pub mod generate_kling_2_1_pro_image_to_video; +pub mod generate_seedance_1_0_lite_image_to_video; +pub mod generate_seedance_1_0_pro_image_to_video; +pub mod generate_veo_2_image_to_video; +pub mod generate_veo_3_fast_image_to_video; +pub mod generate_veo_3_image_to_video; +pub mod multi_function; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_2_5_turbo_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_2_5_turbo_multi_function_video_gen.rs new file mode 100644 index 0000000000..ca33079410 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_2_5_turbo_multi_function_video_gen.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const KLING_2P5_TURBO_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH: &str = "/v1/generate/video/multi_function/kling_2p5_turbo_pro"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling2p5TurboProMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for text-to-video. + pub negative_prompt: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the starting frame + /// If present, we're doing image-to-video + /// If absent, we're doing text-to-video + pub start_frame_image_media_token: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the ending frame (if desired) + pub end_frame_image_media_token: Option, + + /// Optional + /// Duration of the video + pub duration: Option, + + /// Optional. + /// Only for text-to-video + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling2p5TurboProMultiFunctionVideoGenDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling2p5TurboProMultiFunctionVideoGenAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling2p5TurboProMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_2_6_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_2_6_multi_function_video_gen.rs new file mode 100644 index 0000000000..113edaca28 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_2_6_multi_function_video_gen.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const KLING_2P6_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH: &str = "/v1/generate/video/multi_function/kling_2p6_pro"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling2p6ProMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for text-to-video. + pub negative_prompt: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the starting frame + /// If present, we're doing image-to-video + /// If absent, we're doing text-to-video + pub start_frame_image_media_token: Option, + + // /// Optional. + // /// Only for image-to-video + // /// Source of the ending frame (if desired) + // pub end_frame_image_media_token: Option, + + /// Optional + /// Duration of the video + pub duration: Option, + + /// Optional + /// Whether to generate audio. + pub generate_audio: Option, + + /// Optional. + /// Only for text-to-video + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling2p6ProMultiFunctionVideoGenDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling2p6ProMultiFunctionVideoGenAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling2p6ProMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..7707c367e7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const KLING_3P0_PRO_MULTI_FUNCTION_VIDEO_PATH: &str = "/v1/generate/video/multi_function/kling_3p0_pro"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling3p0ProMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt. + pub prompt: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// If present, we're doing image-to-video. If absent, text-to-video. + pub image_media_token: Option, + + /// Optional end frame image. + pub end_image_media_token: Option, + + /// Duration of the video (3-15 seconds). + pub duration: Option, + + /// Whether to generate audio. + pub generate_audio: Option, + + /// Aspect ratio (only for text-to-video). + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling3p0ProMultiFunctionVideoGenDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling3p0ProMultiFunctionVideoGenAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling3p0ProMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen.rs new file mode 100644 index 0000000000..e5b0484fff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const KLING_3P0_STANDARD_MULTI_FUNCTION_VIDEO_PATH: &str = "/v1/generate/video/multi_function/kling_3p0_standard"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling3p0StandardMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt. + pub prompt: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// If present, we're doing image-to-video. If absent, text-to-video. + pub image_media_token: Option, + + /// Optional end frame image. + pub end_image_media_token: Option, + + /// Duration of the video (3-15 seconds). + pub duration: Option, + + /// Whether to generate audio. + pub generate_audio: Option, + + /// Aspect ratio (only for text-to-video). + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling3p0StandardMultiFunctionVideoGenDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Kling3p0StandardMultiFunctionVideoGenAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Kling3p0StandardMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/mod.rs new file mode 100644 index 0000000000..e29945ab86 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/mod.rs @@ -0,0 +1,10 @@ +pub mod kling_2_5_turbo_multi_function_video_gen; +pub mod kling_2_6_multi_function_video_gen; +pub mod kling_3p0_pro_multi_function_video_gen; +pub mod kling_3p0_standard_multi_function_video_gen; +pub mod seedance_1p5_pro_multi_function_video_gen; +pub mod seedance_2p0_multi_function_video_gen; +pub mod sora_2_multi_function_video_gen; +pub mod sora_2_pro_multi_function_video_gen; +pub mod veo_3p1_fast_multi_function_video_gen; +pub mod veo_3p1_multi_function_video_gen; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..75272c89a6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const SEEDANCE_1P5_PRO_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_1p5_pro"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct Seedance1p5ProMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for image-to-video. + /// Source of the starting frame. + /// If present, we're doing image-to-video. + /// If absent, we're doing text-to-video. + pub start_frame_image_media_token: Option, + + /// Optional. + /// Only for image-to-video. + /// Source of the ending frame. + pub end_frame_image_media_token: Option, + + /// Optional. + pub resolution: Option, + + /// Optional. + /// Duration of the video. + /// (this is uniform against all modes) + pub duration: Option, + + /// Optional. + pub aspect_ratio: Option, + + /// Whether to generate audio. + pub generate_audio: Option, +} + +#[derive(Serialize, Deserialize, ToSchema, Copy, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Seedance1p5ProMultiFunctionVideoGenResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Serialize, Deserialize, ToSchema, Copy, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Seedance1p5ProMultiFunctionVideoGenDuration { + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, +} + +#[derive(Serialize, Deserialize, ToSchema, Copy, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Seedance1p5ProMultiFunctionVideoGenAspectRatio { + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, + Auto, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Seedance1p5ProMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs new file mode 100644 index 0000000000..f9279c3c63 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0"; + +/// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based). +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct Seedance2p0MultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// Text prompt. + pub prompt: Option, + + /// Optional start frame image. + pub start_frame_media_token: Option, + + /// Optional end frame image. + pub end_frame_media_token: Option, + + /// Optional reference images (reference mode). When present, takes priority over start/end frames. + pub reference_image_media_tokens: Option>, + + /// Optional reference videos. + pub reference_video_media_tokens: Option>, + + /// Optional reference audio files. + pub reference_audio_media_tokens: Option>, + + /// Optional character tokens to reference in the prompt. + /// Characters are referenced in prompts as @CharacterName. + pub reference_character_tokens: Option>, + + /// Video aspect ratio / resolution. + pub aspect_ratio: Option, + + /// Output resolution quality tier (480p, 720p, 1080p). + pub output_resolution: Option, + + /// Duration in seconds (4–15). + pub duration_seconds: Option, + + /// Number of videos to generate. + pub batch_count: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Seedance2p0AspectRatio { + /// 16:9 landscape (1280x720) + Landscape16x9, + /// 9:16 portrait (720x1280) + Portrait9x16, + /// 1:1 square (720x720) + Square1x1, + /// 4:3 standard (960x720) + Standard4x3, + /// 3:4 portrait (720x960) + Portrait3x4, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Seedance2p0BatchCount { + One, + Two, + Four, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Seedance2p0OutputResolution { + /// 480p + FourEightyP, + /// 720p (default) + SevenTwentyP, + /// 1080p + TenEightyP, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Seedance2p0MultiFunctionVideoGenResponse { + pub success: bool, + + /// The first inference job token (always present). + pub inference_job_token: InferenceJobToken, + + /// All inference job tokens for the batch (includes `inference_job_token` as the first entry). + pub all_inference_job_tokens: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn aspect_to_string(input: Seedance2p0AspectRatio) -> anyhow::Result { + let val = serde_json::to_string(&input)?; + Ok(val.replace("\"", "")) + } + + #[test] + fn just_testing_snake_case() -> anyhow::Result<()> { + // Ugh, not sure what these are... + assert_eq!(aspect_to_string(Seedance2p0AspectRatio::Landscape16x9)?, "landscape16x9"); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/sora_2_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/sora_2_multi_function_video_gen.rs new file mode 100644 index 0000000000..0f194a49be --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/sora_2_multi_function_video_gen.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const SORA_2_MULTI_FUNCTION_VIDEO_VIDEO_PATH: &str = "/v1/generate/video/multi_function/sora_2"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Sora2MultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the starting frame + /// If present, we're doing image-to-video + /// If absent, we're doing text-to-video + pub image_media_token: Option, + + /// Optional. + pub resolution: Option, + + /// Optional + /// Duration of the video + /// (this is uniform against all modes) + pub duration: Option, + + /// Optional. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Sora2MultiFunctionVideoGenResolution { + Auto, + SevenTwentyP, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Sora2MultiFunctionVideoGenDuration { + FourSeconds, + EightSeconds, + TwelveSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Sora2MultiFunctionVideoGenAspectRatio { + Auto, + NineBySixteen, + SixteenByNine, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Sora2MultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/sora_2_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/sora_2_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..3b1db90e69 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/sora_2_pro_multi_function_video_gen.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const SORA_2_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH: &str = "/v1/generate/video/multi_function/sora_2_pro"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Sora2ProMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the starting frame + /// If present, we're doing image-to-video + /// If absent, we're doing text-to-video + pub image_media_token: Option, + + /// Optional. + pub resolution: Option, + + /// Optional + /// Duration of the video + /// (this is uniform against all modes) + pub duration: Option, + + /// Optional. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Sora2ProMultiFunctionVideoGenResolution { + Auto, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Sora2ProMultiFunctionVideoGenDuration { + FourSeconds, + EightSeconds, + TwelveSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Sora2ProMultiFunctionVideoGenAspectRatio { + Auto, + NineBySixteen, + SixteenByNine, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Sora2ProMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen.rs new file mode 100644 index 0000000000..74aadcdc4b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const VEO_3P1_FAST_MULTI_FUNCTION_VIDEO_VIDEO_PATH: &str = "/v1/generate/video/multi_function/veo_3p1_fast"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Veo3p1FastMultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for text-to-video. + pub negative_prompt: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the starting frame + /// If present, we're doing image-to-video + /// If absent, we're doing text-to-video + pub start_frame_image_media_token: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the ending frame (if desired) + pub end_frame_image_media_token: Option, + + /// Optional + /// Duration of the video + /// (this is uniform against all modes) + pub duration: Option, + + /// Optional. + pub aspect_ratio: Option, + + /// Optional. + pub resolution: Option, + + /// Optional + /// Whether to generate audio. + pub generate_audio: Option, + + /// Optional + /// Only for text-to-video + pub enhance_prompt: Option, + + /// Optional + /// Only for text-to-video + pub seed: Option, + + /// Optional + /// Only for text-to-video + pub auto_fix: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Veo3p1FastMultiFunctionVideoGenDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Veo3p1FastMultiFunctionVideoGenAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Veo3p1FastMultiFunctionVideoGenResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Veo3p1FastMultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/veo_3p1_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/veo_3p1_multi_function_video_gen.rs new file mode 100644 index 0000000000..1598401c49 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/veo_3p1_multi_function_video_gen.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use utoipa::ToSchema; + +pub const VEO_3P1_MULTI_FUNCTION_VIDEO_VIDEO_PATH: &str = "/v1/generate/video/multi_function/veo_3p1"; + +/// Both text-to-video and image-to-video in one request. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Veo3p1MultiFunctionVideoGenRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// "Required". + /// Required downstream, but we'll coerce null to empty string. + /// Text prompt. + pub prompt: Option, + + /// Optional. + /// Only for text-to-video. + pub negative_prompt: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the starting frame + /// If present, we're doing image-to-video + /// If absent, we're doing text-to-video + pub start_frame_image_media_token: Option, + + /// Optional. + /// Only for image-to-video + /// Source of the ending frame (if desired) + pub end_frame_image_media_token: Option, + + /// Optional + /// Duration of the video + /// (this is uniform against all modes) + pub duration: Option, + + /// Optional. + pub aspect_ratio: Option, + + /// Optional. + pub resolution: Option, + + /// Optional + /// Whether to generate audio. + pub generate_audio: Option, + + /// Optional + /// Only for text-to-video + pub enhance_prompt: Option, + + /// Optional + /// Only for text-to-video + pub seed: Option, + + /// Optional + /// Only for text-to-video + pub auto_fix: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Veo3p1MultiFunctionVideoGenDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Veo3p1MultiFunctionVideoGenAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Veo3p1MultiFunctionVideoGenResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Veo3p1MultiFunctionVideoGenResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mark_minimax_job_failure.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mark_minimax_job_failure.rs new file mode 100644 index 0000000000..b912f9e30f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mark_minimax_job_failure.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; + +/// Request body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MarkMinimaxJobFailureRequest { + /// Optional failure class the frontend can localize and present. + pub maybe_frontend_failure_category: Option, + + /// Optional failure reason we can show the user (512 characters max). + pub maybe_user_failure_reason: Option, + + /// Optional internal-only stack trace or error (512 characters max). + pub maybe_internal_debugging_failure_reason: Option, + + /// Optional total wall-clock runtime of the failed attempt, in milliseconds. + pub execution_duration_millis: Option, + + /// Optional inference-only runtime of the failed attempt, in milliseconds. + pub inference_duration_millis: Option, +} + +/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MarkMinimaxJobFailureResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mark_minimax_job_success.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mark_minimax_job_success.rs new file mode 100644 index 0000000000..8d586bc0b4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mark_minimax_job_success.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +// NB: The success request is form-multipart (video bytes + optional +// `duration_millis` / `width` / `height` metadata fields), so its form struct +// lives with the actix handler; only the response shape is declared here. + +/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/success`. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MarkMinimaxJobSuccessResponse { + pub success: bool, + + /// The media file created from the uploaded video. + pub media_file_token: MediaFileToken, + + /// Video duration stored on the media file record, in milliseconds + /// (worker-supplied, or probed from the file when not supplied). + pub maybe_duration_millis: Option, + + /// Frame width stored on the media file record, in pixels + /// (worker-supplied, or probed from the file when not supplied). + pub maybe_width: Option, + + /// Frame height stored on the media file record, in pixels + /// (worker-supplied, or probed from the file when not supplied). + pub maybe_height: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/minimax_worker_model.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/minimax_worker_model.rs new file mode 100644 index 0000000000..4b6da0f442 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/minimax_worker_model.rs @@ -0,0 +1,33 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// The first-party Minimax models a worker can request jobs for. +/// +/// This is deliberately its own enum (not `CommonVideoModel`) so the internal +/// worker API surface stays small and stable. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +pub enum MinimaxWorkerModel { + #[serde(rename = "minimax_h3_turbo")] + MinimaxH3Turbo, + + #[serde(rename = "minimax_h3_ultra")] + MinimaxH3Ultra, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_round_trip() { + let cases = [ + (MinimaxWorkerModel::MinimaxH3Turbo, "\"minimax_h3_turbo\""), + (MinimaxWorkerModel::MinimaxH3Ultra, "\"minimax_h3_ultra\""), + ]; + for (variant, json) in cases { + assert_eq!(serde_json::to_string(&variant).unwrap(), json); + let parsed: MinimaxWorkerModel = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mod.rs new file mode 100644 index 0000000000..89f4f9c4b3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/mod.rs @@ -0,0 +1,4 @@ +pub mod mark_minimax_job_failure; +pub mod mark_minimax_job_success; +pub mod minimax_worker_model; +pub mod obtain_minimax_job; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/obtain_minimax_job.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/obtain_minimax_job.rs new file mode 100644 index 0000000000..26ad77cc58 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/minimax_jobs/obtain_minimax_job.rs @@ -0,0 +1,85 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_resolution::CommonResolution; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::common::responses::media_links::MediaLinks; +use crate::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel; + +/// Request body for `POST /v1/internal/minimax_jobs/obtain_job`. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ObtainMinimaxJobRequest { + /// Which model the worker wants a job for. + pub model: MinimaxWorkerModel, + + /// The worker's hostname (linux hostname, k8s pod name), if reported. + pub worker_hostname: Option, + + /// The cluster the worker runs in (e.g. "runpod", "lambdalabs"), if reported. + pub cluster_name: Option, +} + +/// Response body for `POST /v1/internal/minimax_jobs/obtain_job`. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ObtainMinimaxJobResponse { + pub success: bool, + + /// The obtained job, or `None` when no pending job is available. + pub maybe_job: Option, +} + +/// A job the worker now holds a lock on (status was moved to `started`). +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ObtainedMinimaxJob { + pub job_token: InferenceJobToken, + + /// Echo of the requested model. + pub model: MinimaxWorkerModel, + + /// The prompt and inputs the worker needs to run inference. Only `None` + /// when the job was somehow enqueued without a prompt record (or the + /// prompt lookup failed) — workers should fail such jobs via the failure + /// endpoint. + pub maybe_prompt: Option, +} + +/// The prompt (text + generation settings) and its attached media references. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MinimaxJobPromptDetails { + pub prompt_token: PromptToken, + + pub maybe_positive_prompt: Option, + pub maybe_negative_prompt: Option, + + pub maybe_generation_mode: Option, + pub maybe_aspect_ratio: Option, + pub maybe_resolution: Option, + pub maybe_bitrate: Option, + pub maybe_generate_audio: Option, + pub maybe_duration_seconds: Option, + + /// Image / video / audio references attached to the prompt, with full CDN + /// links, in their original order. + pub media_references: Vec, +} + +/// One media input attached to the prompt (start frame, end frame, or an +/// image / video / audio reference), with full CDN links. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MinimaxJobMediaReference { + pub media_file_token: MediaFileToken, + + /// The role the media plays (start frame, end frame, image / video / audio + /// reference, etc.). + pub semantic_type: PromptContextSemanticType, + + /// Full CDN links to the media file. + pub media_links: MediaLinks, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/internal/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/mod.rs new file mode 100644 index 0000000000..41132bb386 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/internal/mod.rs @@ -0,0 +1,4 @@ +//! Internal-facing API surfaces (worker fleets, etc.). These are +//! authenticated with internal API keys, never consumer credentials. + +pub mod minimax_jobs; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/jobs/list_session_jobs.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/jobs/list_session_jobs.rs new file mode 100644 index 0000000000..3341832439 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/jobs/list_session_jobs.rs @@ -0,0 +1,142 @@ +use crate::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest}; +use crate::common::responses::media_links::MediaLinks; +use chrono::{DateTime, Utc}; +use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients; +use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients; +use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; +use enums::common::job_status_plus::JobStatusPlus; +use enums::no_table::style_transfer::style_transfer_name::StyleTransferName; +use serde::Deserialize; +use serde::Serialize; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::prompts::PromptToken; +use utoipa::{IntoParams, ToSchema}; + +pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session"; + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListSessionJobsQueryParams { + //pub sort_ascending: Option, + //pub page_size: Option, // TODO(bt,2024-04-23): One page for now. + //pub page_index: Option, // TODO(bt,2024-04-23): One page for now. + + /// NB: This can be one (or more comma-separated values) from `JobStatusPlus`. + /// ?include_states=pending or ?include_states=pending,started,complete_success (etc.) + pub include_states: Option, + + /// The opposite of include_states, this filters out states from view. + pub exclude_states: Option, + + // TODO(bt,2024-04-23): Add the ability for users to dismiss completed/dead jobs from view. +} +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionJobsSuccessResponse { + pub success: bool, + + /// This is not paginated and is limited to showing 100 jobs. + pub jobs: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionJobsItem { + pub job_token: InferenceJobToken, + + pub request: ListSessionRequestDetailsResponse, + pub status: ListSessionStatusDetailsResponse, + pub maybe_result: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Details about what the user requested for generation +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionRequestDetailsResponse { + pub inference_category: InferenceCategory, + + pub maybe_prompt_token: Option, + + pub maybe_model_type: Option, + pub maybe_model_token: Option, + + /// OPTIONAL. Title of the model, if it has one + pub maybe_model_title: Option, + + /// OPTIONAL. If the result was TTS, this is the raw inference text. + pub maybe_raw_inference_text: Option, + + /// OPTIONAL. For Comfy / Video Style Transfer jobs, this might include + /// the name of the selected style. + pub maybe_style_name: Option, + + /// OPTIONAL. For Live Portrait jobs, this is additional information on the request. + pub maybe_live_portrait_details: Option, + + /// OPTIONAL. For lipsync jobs (face fusion and sad talker), this is additional + /// information on the request. + pub maybe_lipsync_details: Option, +} + +/// Details about the ongoing job status +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionStatusDetailsResponse { + /// Primary status from the database (a state machine). + pub status: JobStatusPlus, + + /// Extra, temporary status from Redis. + /// This can denote inference progress, and the Python code can write to it. + pub maybe_extra_status_description: Option, + + pub maybe_assigned_worker: Option, + pub maybe_assigned_cluster: Option, + + pub maybe_first_started_at: Option>, + + /// If the job is currently running, this is how long it has been running in seconds. + /// This is heuristic estimate since we don't record the precise start time across runs. + pub maybe_current_execution_duration_seconds: Option, + + pub attempt_count: u8, + + /// Whether the frontend needs to maintain a keepalive check. + /// This is typically only for non-premium users. + pub requires_keepalive: bool, + + /// NB: DO NOT CHANGE: Old clients can't read the new enum payload values as they are introduced. + /// + /// An enum the frontend can use to display localized/I18N error + /// messages. These pertain to both transient and permanent failures. + #[deprecated(note="use maybe_failure_category_updated")] + pub maybe_failure_category: Option, + + /// An enum the frontend can use to display localized/I18N error + /// messages. These pertain to both transient and permanent failures. + pub maybe_failure_category_updated: Option, + + /// User-facing failure messages + pub maybe_failure_message: Option, + + /// This is an integer number between 0 and 100 (both inclusive) that + /// reports the completeness. + pub progress_percentage: u8, +} + +/// Details about the completed result (if any) +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionResultDetailsResponse { + pub entity_type: String, + pub entity_token: String, + + /// If generated as part of a batch, the batch token. + pub maybe_batch_token: Option, + + /// (DEPRECATED) URL path to the media file + #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")] + pub maybe_public_bucket_media_path: Option, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + pub maybe_successfully_completed_at: Option>, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/jobs/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/jobs/mod.rs new file mode 100644 index 0000000000..164271aad7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/jobs/mod.rs @@ -0,0 +1 @@ +pub mod list_session_jobs; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs new file mode 100644 index 0000000000..1e284b44d9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -0,0 +1,23 @@ +pub mod analytics; +pub mod api_keys; +pub mod characters; +pub mod common; +pub mod credits; +pub mod folders; +pub mod generate; +pub mod internal; +pub mod jobs; +pub mod mcp_sessions; +pub mod media_file; +pub mod moderation; +pub mod omni_api; +pub mod omni_gen; +pub mod prompts; +pub mod stripe_artcraft; +pub mod subscriptions; +pub mod tags; +pub mod user_referral_codes; +pub mod users; +pub mod utils; +pub mod video_info; +pub mod web_referrals; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/common.rs new file mode 100644 index 0000000000..050633617f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/common.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::mcp_sessions::McpSessionToken; + +/// Canonical wire shape for an MCP session, used by the dashboard list. +/// +/// NB: the secret `private_session_token` is NEVER included here — it is returned exactly once, +/// at creation time. The `token` is the non-private management handle. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct McpSessionInfo { + pub token: McpSessionToken, + + /// Trimmed name of the MCP client that created the session, if any. + pub maybe_mcp_client_name: Option, + + /// Trimmed version of the MCP client, if any. + pub maybe_mcp_client_version: Option, + + /// Trimmed vendor of the MCP client, if any. + pub maybe_mcp_client_vendor: Option, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// When the session expires, if not refreshed. + pub expires_at: DateTime, + + /// Termination timestamp. `None` for live (though possibly expired) sessions. + pub maybe_deleted_at: Option>, +} + +/// Path info for endpoints addressed by the session's management `token` (NOT the secret +/// `private_session_token`): `POST /v1/mcp/session/{token}/delete`. +#[derive(Deserialize, ToSchema)] +pub struct McpSessionPathInfo { + pub token: McpSessionToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs new file mode 100644 index 0000000000..ae5b236f9c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/create_mcp_session.rs @@ -0,0 +1,34 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::mcp_session_private::McpSessionPrivateToken; +use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; + +// ── POST /v1/mcp/session/create ── + +#[derive(Deserialize, ToSchema)] +pub struct CreateMcpSessionRequest { + /// Name of the MCP client creating the session, if any. + pub maybe_mcp_client_name: Option, + + /// Version of the MCP client, if any. + pub maybe_mcp_client_version: Option, + + /// Vendor of the MCP client, if any. + pub maybe_mcp_client_vendor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateMcpSessionSuccessResponse { + pub success: bool, + + /// The secret credential the MCP client presents in the `Authorization` header. This is the + /// ONLY time it is ever returned — the caller must store it now, as it cannot be retrieved + /// again. + pub private_session_token: McpSessionPrivateToken, + + /// The secret credential the MCP client presents on `/v1/mcp/session/refresh` to extend the + /// session. Each successful refresh returns a NEW refresh token and invalidates this one — + /// store whichever value is newest. + pub private_refresh_token: McpSessionRefreshToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/delete_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/delete_mcp_session.rs new file mode 100644 index 0000000000..03553c8562 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/delete_mcp_session.rs @@ -0,0 +1,9 @@ +use serde_derive::Serialize; +use utoipa::ToSchema; + +// ── POST /v1/mcp/session/{token}/delete ── + +#[derive(Serialize, ToSchema)] +pub struct DeleteMcpSessionSuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/list_mcp_sessions.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/list_mcp_sessions.rs new file mode 100644 index 0000000000..341f0f9b82 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/list_mcp_sessions.rs @@ -0,0 +1,24 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::mcp_sessions::common::McpSessionInfo; + +// ── GET /v1/mcp/session/list ── + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListMcpSessionsQueryParams { + /// Page size. Defaults server-side if omitted. + pub limit: Option, + + /// Number of rows to skip. Defaults to 0. + pub offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListMcpSessionsSuccessResponse { + pub success: bool, + + /// The user's MCP sessions (including terminated ones), newest first. The secret + /// `private_session_token` is never listed. + pub mcp_sessions: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/mod.rs new file mode 100644 index 0000000000..10ba221d9e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/mod.rs @@ -0,0 +1,6 @@ +pub mod common; +pub mod create_mcp_session; +pub mod delete_mcp_session; +pub mod list_mcp_sessions; +pub mod refresh_mcp_session; +pub mod revoke_mcp_session; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs new file mode 100644 index 0000000000..56d2582935 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/refresh_mcp_session.rs @@ -0,0 +1,22 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken; + +// ── POST /v1/mcp/session/refresh ── + +#[derive(Deserialize, ToSchema)] +pub struct RefreshMcpSessionRequest { + /// The session's CURRENT refresh token (from creation, or from the most recent refresh). + /// A stale or wrong value is an error — refresh tokens rotate and are never replayable. + pub private_refresh_token: McpSessionRefreshToken, +} + +#[derive(Serialize, ToSchema)] +pub struct RefreshMcpSessionSuccessResponse { + pub success: bool, + + /// The NEW refresh token to present on the next refresh. The one just used is now invalid — + /// the caller must store this replacement. + pub private_refresh_token: McpSessionRefreshToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/revoke_mcp_session.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/revoke_mcp_session.rs new file mode 100644 index 0000000000..bccd00d702 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/mcp_sessions/revoke_mcp_session.rs @@ -0,0 +1,9 @@ +use serde_derive::Serialize; +use utoipa::ToSchema; + +// ── POST /v1/mcp/session/revoke ── + +#[derive(Serialize, ToSchema)] +pub struct RevokeMcpSessionSuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/delete_media_file.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/delete_media_file.rs new file mode 100644 index 0000000000..70ca5e02c1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/delete_media_file.rs @@ -0,0 +1,19 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct DeleteMediaFileRequest { + pub set_delete: bool, + + /// NB: this is only to disambiguate when a user is both a mod and an author. + pub as_mod: Option, +} + +/// For the URL PathInfo +#[derive(Serialize, Deserialize, ToSchema)] +pub struct DeleteMediaFilePathInfo { + pub token: MediaFileToken, +} + + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/list_media_files_by_job.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/list_media_files_by_job.rs new file mode 100644 index 0000000000..327d47213d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/list_media_files_by_job.rs @@ -0,0 +1,61 @@ +//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files +//! produced by an inference job. + +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::common::responses::media_links::MediaLinks; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListMediaFilesByJobSuccessResponse { + pub success: bool, + + /// The job's output files, oldest first. + pub media_files: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct JobMediaFileInfo { + pub token: MediaFileToken, + + /// The coarse-grained class of media file: image, video, etc. + pub media_class: MediaFileClass, + + /// Type of media will dictate which fields are populated and what + /// the frontend should display (eg. video player vs audio player). + /// This is closer in meaning to a "mime type". + pub media_type: MediaFileType, + + /// If the file was generated as part of a batch, this is the token for the batch. + pub maybe_batch_token: Option, + + /// The foreign key to the prompt used to generate the media, if applicable. + pub maybe_prompt_token: Option, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// Information about the cover image. Many media files do not require a cover image, + /// e.g. image files, video files with thumbnails, audio files, etc. + /// 3D files require them. + pub cover_image: MediaFileCoverImageDetails, + + /// The original filename for uploaded files, if they were provided. + /// In the future we'll provide our own internal optional filenames. + pub maybe_original_filename: Option, + + /// Duration for audio and video files, if available. + /// Measured in milliseconds. + pub maybe_duration_millis: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/mod.rs new file mode 100644 index 0000000000..f026a6a007 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/mod.rs @@ -0,0 +1 @@ +pub mod list_media_files_by_job; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_common.rs new file mode 100644 index 0000000000..0f2be464b5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_common.rs @@ -0,0 +1,64 @@ +//! Shared types for the by-class session media file list endpoints +//! (`/v1/media_files/mesh/list`, `/v1/media_files/splat/list`). + +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +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 tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::common::responses::media_links::MediaLinks; +use crate::common::responses::user_details_light::UserDetailsLight; + +/// Query string parameters for the by-class session media file lists. +/// Pagination only — the media class is fixed by the endpoint. +#[derive(Deserialize, IntoParams)] +pub struct ListSessionMediaFilesByTypeQueryParams { + pub sort_ascending: Option, + pub page_size: Option, + pub cursor: Option, + pub cursor_is_reversed: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SessionMediaFileInfo { + /// Primary key identifier + pub token: MediaFileToken, + + /// The coarse-grained class of media file. + /// Fixed per endpoint (`mesh` or `splat`). + pub media_class: MediaFileClass, + + /// Type of media will dictate which fields are populated and what + /// the frontend should display (eg. video player vs audio player). + /// This is closer in meaning to a "mime type". + pub media_type: MediaFileType, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// Information about the cover image. Many media files do not require a cover image, + /// e.g. image files, video files with thumbnails, audio files, etc. + /// 3D files require them. + pub cover_image: MediaFileCoverImageDetails, + + /// User info + pub maybe_creator_user: Option, + + pub creator_set_visibility: Visibility, + + /// The generation's prompt record, when there is one. Clients resolve it + /// to display the original prompt and model. + pub maybe_prompt_token: Option, + + /// The name or title of the media file (optional) + pub maybe_title: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_mesh_media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_mesh_media_files.rs new file mode 100644 index 0000000000..6b72d66a8e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_mesh_media_files.rs @@ -0,0 +1,14 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::common::responses::pagination_cursors::PaginationCursors; +use crate::media_file::list::by_type::list_session_common::SessionMediaFileInfo; + +pub const LIST_SESSION_MESH_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/mesh/list"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionMeshMediaFilesSuccessResponse { + pub success: bool, + pub results: Vec, + pub pagination: PaginationCursors, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_splat_media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_splat_media_files.rs new file mode 100644 index 0000000000..5f3fd0935a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/list_session_splat_media_files.rs @@ -0,0 +1,14 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::common::responses::pagination_cursors::PaginationCursors; +use crate::media_file::list::by_type::list_session_common::SessionMediaFileInfo; + +pub const LIST_SESSION_SPLAT_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/splat/list"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionSplatMediaFilesSuccessResponse { + pub success: bool, + pub results: Vec, + pub pagination: PaginationCursors, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/mod.rs new file mode 100644 index 0000000000..458c52acef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/by_type/mod.rs @@ -0,0 +1,3 @@ +pub mod list_session_common; +pub mod list_session_mesh_media_files; +pub mod list_session_splat_media_files; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/mod.rs new file mode 100644 index 0000000000..d7d55e6638 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list/mod.rs @@ -0,0 +1 @@ +pub mod by_type; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list_batch_generated_media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list_batch_generated_media_files.rs new file mode 100644 index 0000000000..e49833fde7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list_batch_generated_media_files.rs @@ -0,0 +1,77 @@ +use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::common::responses::media_links::MediaLinks; +use crate::common::responses::user_details_light::UserDetailsLight; +use chrono::{DateTime, Utc}; +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 serde_derive::{Deserialize, Serialize}; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +use utoipa::ToSchema; + +pub const LIST_BATCH_GENERATED_REDUX_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/batch_gen_redux"; + +/// For the URL PathInfo +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListBatchGeneratedReduxMediaFilesPathInfo { + pub token: BatchGenerationToken, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListBatchGeneratedReduxMediaFilesSuccessResponse { + pub success: bool, + pub media_files: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct BatchGeneratedReduxMediaFileInfo { + /// Primary key identifier + pub token: MediaFileToken, + + /// The coarse-grained class of media file: image, video, etc. + pub media_class: MediaFileClass, + + /// Type of media will dictate which fields are populated and what + /// the frontend should display (e.g. video player vs audio player). + /// This is closer in meaning to a "mime type". + pub media_type: MediaFileType, + + /// If the file was generated as part of a batch, this is the token for the batch. + pub maybe_batch_token: Option, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// Information about the cover image. Many media files do not require a cover image, + /// e.g. image files, video files with thumbnails, audio files, etc. + /// 3D files require them. + pub cover_image: MediaFileCoverImageDetails, + + /// User info. + pub maybe_creator_user: Option, + + /// File visibility. + pub creator_set_visibility: Visibility, + + /// The foreign key to the prompt used to generate the media, if applicable. + pub maybe_prompt_token: Option, + + /// The name or title of the media file (optional) + pub maybe_title: Option, + + /// The original filename for uploaded files, if they were provided. + /// In the future we'll provide our own internal optional filenames. + pub maybe_original_filename: Option, + + /// Duration for audio and video files, if available. + /// Measured in milliseconds. + pub maybe_duration_millis: Option, + + /// Created timestamp + pub created_at: DateTime, + + /// Updated timestamp + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list_session_project_media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list_session_project_media_files.rs new file mode 100644 index 0000000000..b2f846a1f0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/list_session_project_media_files.rs @@ -0,0 +1,78 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_project_type::MediaFileProjectType; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use tokens::tokens::media_files::MediaFileToken; + +use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::common::responses::media_links::MediaLinks; +use crate::common::responses::pagination_cursors::PaginationCursors; +use crate::common::responses::user_details_light::UserDetailsLight; + +pub const LIST_SESSION_PROJECT_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/project/list"; + +/// Query string parameters for listing the session user's project files. +#[derive(Deserialize, IntoParams)] +pub struct ListSessionProjectMediaFilesQueryParams { + pub sort_ascending: Option, + pub page_size: Option, + pub cursor: Option, + pub cursor_is_reversed: Option, + + /// Optional filter on the specific project document type. + /// + /// Usage: + /// - `?filter_project_type=scene_3d` + /// - `?filter_project_type=mood_board` + /// - `?filter_project_type=workflow` + /// - `?filter_project_type=video_timeline` + pub filter_project_type: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListSessionProjectMediaFilesSuccessResponse { + pub success: bool, + pub results: Vec, + pub pagination: PaginationCursors, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ProjectMediaFileInfo { + /// Primary key identifier + pub token: MediaFileToken, + + /// The coarse-grained class of media file. + /// Always `project` for this endpoint. + pub media_class: MediaFileClass, + + /// The specific kind of project document: 3D scene, mood board, etc. + pub project_type: MediaFileProjectType, + + /// Type of media will dictate which fields are populated and what + /// the frontend should display (eg. video player vs audio player). + /// This is closer in meaning to a "mime type". + pub media_type: MediaFileType, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// Information about the cover image. Many media files do not require a cover image, + /// e.g. image files, video files with thumbnails, audio files, etc. + /// 3D files require them. + pub cover_image: MediaFileCoverImageDetails, + + /// User info + pub maybe_creator_user: Option, + + pub creator_set_visibility: Visibility, + + /// The name or title of the media file (optional) + pub maybe_title: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs new file mode 100644 index 0000000000..1cac0362c9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs @@ -0,0 +1,6 @@ +pub mod delete_media_file; +pub mod job; +pub mod list; +pub mod list_batch_generated_media_files; +pub mod list_session_project_media_files; +pub mod project; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/mod.rs new file mode 100644 index 0000000000..0ebb463e2d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/mod.rs @@ -0,0 +1,8 @@ +pub mod upload_new_editor_2d_project; +pub mod upload_new_mood_board_project; +pub mod upload_new_scene_3d_project; +pub mod upload_new_video_timeline_project; +pub mod upload_updated_editor_2d_project; +pub mod upload_updated_mood_board_project; +pub mod upload_updated_scene_3d_project; +pub mod upload_updated_video_timeline_project; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_editor_2d_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_editor_2d_project.rs new file mode 100644 index 0000000000..9a5703f5c4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_editor_2d_project.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_NEW_EDITOR_2D_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/editor_2d/new"; + +/// Response for saving a new 2D editor document project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadNewEditor2dProjectSuccessResponse { + pub success: bool, + + /// Token for the newly created project media file. Use it to query the + /// project and to save subsequent updates via the update endpoint. + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_mood_board_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_mood_board_project.rs new file mode 100644 index 0000000000..1191cfcee1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_mood_board_project.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_NEW_MOOD_BOARD_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/mood_board/new"; + +/// Response for saving a new mood board project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadNewMoodBoardProjectSuccessResponse { + pub success: bool, + + /// Token for the newly created project media file. Use it to query the + /// project and to save subsequent updates via the update endpoint. + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_scene_3d_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_scene_3d_project.rs new file mode 100644 index 0000000000..fde4d873ac --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_scene_3d_project.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_NEW_SCENE_3D_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/scene_3d/new"; + +/// Response for saving a new 3D scene project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadNewScene3dProjectSuccessResponse { + pub success: bool, + + /// Token for the newly created project media file. Use it to query the + /// project and to save subsequent updates via the update endpoint. + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_video_timeline_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_video_timeline_project.rs new file mode 100644 index 0000000000..2eb2baf3c6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_new_video_timeline_project.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_NEW_VIDEO_TIMELINE_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/video_timeline/new"; + +/// Response for saving a new video editor timeline project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadNewVideoTimelineProjectSuccessResponse { + pub success: bool, + + /// Token for the newly created project media file. Use it to query the + /// project and to save subsequent updates via the update endpoint. + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_editor_2d_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_editor_2d_project.rs new file mode 100644 index 0000000000..ef3a355e8d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_editor_2d_project.rs @@ -0,0 +1,21 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_UPDATED_EDITOR_2D_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/editor_2d/update/{token}"; + +/// For the URL PathInfo +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedEditor2dProjectPathInfo { + pub token: MediaFileToken, +} + +/// Response for overwriting an existing 2D editor project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedEditor2dProjectSuccessResponse { + pub success: bool, + + /// Token of the updated project media file (unchanged by the update). + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_mood_board_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_mood_board_project.rs new file mode 100644 index 0000000000..923f3677ad --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_mood_board_project.rs @@ -0,0 +1,21 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_UPDATED_MOOD_BOARD_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/mood_board/update/{token}"; + +/// For the URL PathInfo +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedMoodBoardProjectPathInfo { + pub token: MediaFileToken, +} + +/// Response for overwriting an existing mood board project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedMoodBoardProjectSuccessResponse { + pub success: bool, + + /// Token of the updated project media file (unchanged by the update). + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_scene_3d_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_scene_3d_project.rs new file mode 100644 index 0000000000..ba897da804 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_scene_3d_project.rs @@ -0,0 +1,21 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_UPDATED_SCENE_3D_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/scene_3d/update/{token}"; + +/// For the URL PathInfo +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedScene3dProjectPathInfo { + pub token: MediaFileToken, +} + +/// Response for overwriting an existing 3D scene project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedScene3dProjectSuccessResponse { + pub success: bool, + + /// Token of the updated project media file (unchanged by the update). + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_video_timeline_project.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_video_timeline_project.rs new file mode 100644 index 0000000000..9916664c80 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/project/upload_updated_video_timeline_project.rs @@ -0,0 +1,21 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +pub const UPLOAD_UPDATED_VIDEO_TIMELINE_PROJECT_URL_PATH: &str = "/v1/media_files/upload/project/video_timeline/update/{token}"; + +/// For the URL PathInfo +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedVideoTimelineProjectPathInfo { + pub token: MediaFileToken, +} + +/// Response for overwriting an existing video editor timeline project. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UploadUpdatedVideoTimelineProjectSuccessResponse { + pub success: bool, + + /// Token of the updated project media file (unchanged by the update). + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/alerts/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/alerts/mod.rs new file mode 100644 index 0000000000..4970ea6299 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/alerts/mod.rs @@ -0,0 +1 @@ +pub mod moderation_send_alert; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/alerts/moderation_send_alert.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/alerts/moderation_send_alert.rs new file mode 100644 index 0000000000..ca5255878a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/alerts/moderation_send_alert.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const MODERATION_SEND_ALERT_PATH: &str = "/v1/moderation/alerts/send"; + +#[derive(Deserialize, ToSchema)] +pub struct ModerationSendAlertRequest { + /// Optional title for the alert. Defaults to "Test Moderation Alert". + pub title: Option, + + /// Optional description for the alert. Defaults to "This is a test moderation alert." + pub description: Option, + + /// Optional urgency of the page. + pub urgency: Option, +} + +#[derive(Deserialize, ToSchema, Copy, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ModerationSendAlertUrgency { + High, + Medium, + Low, +} + +#[derive(Serialize, ToSchema)] +pub struct ModerationSendAlertResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/debug_log_entry.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/debug_log_entry.rs new file mode 100644 index 0000000000..37148adbe9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/debug_log_entry.rs @@ -0,0 +1,36 @@ +use chrono::{DateTime, Utc}; +use serde_derive::Serialize; +use utoipa::ToSchema; + +use enums::by_table::debug_logs::debug_log_level::DebugLogLevel; +use enums::by_table::debug_logs::debug_log_type::DebugLogType; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; +use tokens::tokens::users::UserToken; + +/// A single debug log row. +/// Shared by the moderation debug-log list endpoints. +#[derive(Serialize, ToSchema)] +pub struct ModerationDebugLogEntry { + pub id: u64, + pub event_token: DebugLogEventToken, + pub debug_log_type: DebugLogType, + pub maybe_log_level: Option, + pub maybe_creator_user_token: Option, + /// The client IP address of the request (if recorded). + pub maybe_ip_address: Option, + /// The request URL (if recorded; truncated to 255 chars). + pub maybe_url: Option, + pub message: String, + pub created_at: DateTime, + /// The creator user, joined in when the row has a user token. + pub maybe_user: Option, +} + +/// The creator user of a debug log row. +#[derive(Serialize, ToSchema)] +pub struct ModerationDebugLogUser { + pub user_token: UserToken, + pub display_name: String, + pub username: String, + pub gravatar_hash: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/mod.rs new file mode 100644 index 0000000000..e8e6926c67 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/mod.rs @@ -0,0 +1,4 @@ +pub mod debug_log_entry; +pub mod moderation_list_all_debug_logs; +pub mod moderation_list_debug_logs_for_token; +pub mod moderation_list_debug_logs_for_user; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_all_debug_logs.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_all_debug_logs.rs new file mode 100644 index 0000000000..4bbd5185a0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_all_debug_logs.rs @@ -0,0 +1,76 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use enums::by_table::debug_logs::debug_log_level::DebugLogLevel; +use enums::by_table::debug_logs::debug_log_type::DebugLogType; +use enums::error::enum_error::EnumError; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; +use tokens::tokens::users::UserToken; + +use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogUser; + +pub const MODERATION_LIST_ALL_DEBUG_LOGS_PATH: &str = "/v1/moderation/debug_logs/list_all"; + +// ── Query params ── + +#[derive(Deserialize, IntoParams, ToSchema)] +pub struct ModerationListAllDebugLogsQueryParams { + /// Optional comma-separated list of severity levels to include, + /// eg. `severities=error,warn`. Omit to include all rows. + pub severities: Option, + + /// Cursor for pagination. Pass the `next_cursor` from a previous response. + pub cursor: Option, + + /// Max rows per page (default 50, max 200). + pub limit: Option, +} + +impl ModerationListAllDebugLogsQueryParams { + /// Parse the comma-separated `severities` parameter. + /// Returns `None` when the parameter is absent or empty. + pub fn parsed_severities(&self) -> Result>, EnumError> { + let raw = match self.severities.as_deref() { + Some(value) if !value.trim().is_empty() => value, + _ => return Ok(None), + }; + + let levels = raw + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(DebugLogLevel::from_str) + .collect::, EnumError>>()?; + + Ok(if levels.is_empty() { None } else { Some(levels) }) + } +} + +// ── Response ── + +#[derive(Serialize, ToSchema)] +pub struct ModerationListAllDebugLogsSuccessResponse { + pub success: bool, + /// Most recent first. + pub debug_logs: Vec, + /// Cursor for the next page. `None` if there are no more results. + pub next_cursor: Option, +} + +/// A debug log row with the creator user joined in (when one exists). +#[derive(Serialize, ToSchema)] +pub struct ModerationListAllDebugLogsEntry { + pub id: u64, + pub event_token: DebugLogEventToken, + pub debug_log_type: DebugLogType, + pub maybe_log_level: Option, + pub maybe_creator_user_token: Option, + /// The client IP address of the request (if recorded). + pub maybe_ip_address: Option, + /// The request URL (if recorded; truncated to 255 chars). + pub maybe_url: Option, + pub message: String, + pub created_at: DateTime, + pub maybe_user: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_debug_logs_for_token.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_debug_logs_for_token.rs new file mode 100644 index 0000000000..b3b50845e9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_debug_logs_for_token.rs @@ -0,0 +1,30 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; + +use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry; + +pub const MODERATION_LIST_DEBUG_LOGS_FOR_TOKEN_PATH: &str = "/v1/moderation/debug_logs/list/{token}"; + +// ── Path params ── + +#[derive(Deserialize, ToSchema)] +pub struct ModerationListDebugLogsForTokenPathInfo { + pub token: DebugLogEventToken, +} + +// ── Query params ── + +#[derive(Deserialize, IntoParams, ToSchema)] +pub struct ModerationListDebugLogsForTokenQueryParams { + pub limit: Option, +} + +// ── Response ── + +#[derive(Serialize, ToSchema)] +pub struct ModerationListDebugLogsForTokenSuccessResponse { + pub success: bool, + pub debug_logs: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_debug_logs_for_user.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_debug_logs_for_user.rs new file mode 100644 index 0000000000..cc700e324a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/debug_logs/moderation_list_debug_logs_for_user.rs @@ -0,0 +1,37 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::users::UserToken; + +use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry; + +pub const MODERATION_LIST_DEBUG_LOGS_FOR_USER_PATH: &str = "/v1/moderation/debug_logs/user_list/{user_token}"; + +// ── Path params ── + +#[derive(Deserialize, ToSchema)] +pub struct ModerationListDebugLogsForUserPathInfo { + pub user_token: UserToken, +} + +// ── Query params ── + +#[derive(Deserialize, IntoParams, ToSchema)] +pub struct ModerationListDebugLogsForUserQueryParams { + /// Cursor for pagination. Pass the `next_cursor` from a previous response. + pub cursor: Option, + + /// Max rows per page (default 50, max 200). + pub limit: Option, +} + +// ── Response ── + +#[derive(Serialize, ToSchema)] +pub struct ModerationListDebugLogsForUserSuccessResponse { + pub success: bool, + /// Most recent first. + pub debug_logs: Vec, + /// Cursor for the next page. `None` if there are no more results. + pub next_cursor: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/mod.rs new file mode 100644 index 0000000000..22d12a3827 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/user/list_user_jobs.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/user/list_user_jobs.rs new file mode 100644 index 0000000000..9e78ff46bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/user/list_user_jobs.rs @@ -0,0 +1,39 @@ +use chrono::{DateTime, Utc}; +use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty; +use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType; +use enums::common::job_status_plus::JobStatusPlus; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; +use utoipa::ToSchema; + +pub const LIST_USER_JOBS_PATH: &str = "/v1/moderation/jobs/user/{user_token}/list"; + +#[derive(Deserialize, ToSchema)] +pub struct ListUserJobsPathInfo { + pub user_token: UserToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ListUserJobsResponse { + pub success: bool, + pub jobs: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ListUserJobsEntry { + pub job_status: JobStatusPlus, + pub job_failure_reason: Option, + pub credits_delta: Option, + pub maybe_linked_refund_ledger_token: Option, + pub on_success_result_media_token: Option, + pub job_token: InferenceJobToken, + pub wallet_ledger_entry_token: Option, + pub wallet_ledger_entry_type: Option, + pub maybe_external_third_party: Option, + pub maybe_external_third_party_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/user/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/user/mod.rs new file mode 100644 index 0000000000..084446e8ef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/jobs/user/mod.rs @@ -0,0 +1 @@ +pub mod list_user_jobs; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs new file mode 100644 index 0000000000..154098c1fa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs @@ -0,0 +1,12 @@ +pub mod alerts; +pub mod debug_logs; +pub mod top_spenders; +pub mod jobs; +pub mod user; +pub mod user_referrals; +pub mod user_stripe_data; +pub mod wallet_ledger_entries; +pub mod wallets; +pub mod user_daily_spends; +pub mod user_spend_summaries; +pub mod user_spend_events; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs new file mode 100644 index 0000000000..8977e9eb74 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs @@ -0,0 +1,82 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_LIST_TOP_SPENDERS_PATH: &str = + "/v1/moderation/top_spenders/list"; + +/// Rolling aggregation window for the top spenders list. +#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)] +pub enum TopSpendersWindow { + #[serde(rename = "24h")] + TwentyFourHours, + #[serde(rename = "7d")] + SevenDays, + #[serde(rename = "14d")] + FourteenDays, + #[serde(rename = "30d")] + ThirtyDays, + #[serde(rename = "60d")] + SixtyDays, + #[serde(rename = "90d")] + NinetyDays, + #[serde(rename = "180d")] + OneHundredEightyDays, + #[serde(rename = "365d")] + ThreeHundredSixtyFiveDays, +} + +impl TopSpendersWindow { + /// Window length in hours. + pub fn as_hours(self) -> i64 { + match self { + TopSpendersWindow::TwentyFourHours => 24, + TopSpendersWindow::SevenDays => 7 * 24, + TopSpendersWindow::FourteenDays => 14 * 24, + TopSpendersWindow::ThirtyDays => 30 * 24, + TopSpendersWindow::SixtyDays => 60 * 24, + TopSpendersWindow::NinetyDays => 90 * 24, + TopSpendersWindow::OneHundredEightyDays => 180 * 24, + TopSpendersWindow::ThreeHundredSixtyFiveDays => 365 * 24, + } + } +} + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorListTopSpendersQueryParams { + /// Aggregation window: `24h`, `7d`, `14d`, `30d` (default), `60d`, `90d`, + /// `180d`, or `365d`. + pub window: Option, + /// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page. + pub offset: Option, + /// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListTopSpendersResponse { + pub success: bool, + /// The window the results were aggregated over (echoed back, post-default). + pub window: TopSpendersWindow, + pub spenders: Vec, + /// Offset for the next page, or `None` if this was the last page. + pub maybe_next_offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct TopSpenderEntry { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + /// Sum of positive amounts in the window. + pub gross_spend_usd_cents: u64, + /// Refund/chargeback magnitude in the window (positive number). + pub refund_usd_cents: u64, + /// Gross minus refunds. Can be negative if refunds outweigh purchases. + pub net_spend_usd_cents: i64, + /// Number of positive money events in the window. + pub payment_count: u64, + /// Credits granted in the window. + pub credits_granted: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs new file mode 100644 index 0000000000..d17e233fbf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/list_subscribing_users_by_signup_date.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/list_subscribing_users_by_signup_date.rs new file mode 100644 index 0000000000..85ad6682bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/list_subscribing_users_by_signup_date.rs @@ -0,0 +1,55 @@ +use chrono::{DateTime, Utc}; +use enums::by_table::users::user_signup_method::UserSignupMethod; +use enums::by_table::users::user_signup_source::UserSignupSource; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +pub const MODERATOR_LIST_SUBSCRIBING_USERS_BY_SIGNUP_DATE_PATH: &str = "/v1/moderation/users/list_subscribers_by_signup_date"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorListSubscribingUsersBySignupDateRequest { + /// Optional cursor for pagination. Pass the `next_cursor` from a previous response. + pub id_cursor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListSubscribingUsersBySignupDateResponse { + pub success: bool, + pub users: Vec, + /// The cursor for the next page. `None` if there are no more results. + pub next_cursor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListSubscribingUsersBySignupDateEntry { + // -- User fields -- + pub id: u64, + pub token: UserToken, + pub username: String, + pub display_name: String, + pub username_is_not_customized: bool, + pub email_address: String, + pub email_confirmed: bool, + pub is_without_password: bool, + pub ip_address_creation: String, + pub maybe_source: Option, + pub maybe_signup_method: Option, + pub created_at: DateTime, + pub maybe_referral_url: Option, + pub is_temporary: bool, + + // TODO: Use enum types/strong types - + // -- Subscription fields -- + pub subscription_namespace: String, + pub subscription_product_slug: String, + pub maybe_stripe_subscription_id: Option, + pub maybe_stripe_customer_id: Option, + pub maybe_stripe_product_id: Option, + pub maybe_stripe_price_id: Option, + pub maybe_stripe_subscription_status: Option, + pub maybe_stripe_recurring_interval: Option, + pub maybe_stripe_invoice_is_paid: Option, + pub maybe_cancel_at: Option>, + pub maybe_canceled_at: Option>, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/list_users_by_signup_date.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/list_users_by_signup_date.rs new file mode 100644 index 0000000000..4ae0d92345 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/list_users_by_signup_date.rs @@ -0,0 +1,40 @@ +use chrono::{DateTime, Utc}; +use enums::by_table::users::user_signup_method::UserSignupMethod; +use enums::by_table::users::user_signup_source::UserSignupSource; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +pub const MODERATOR_LIST_USERS_BY_SIGNUP_DATE_PATH: &str = "/v1/moderation/users/list_all_by_signup_date"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorListUsersBySignupDateRequest { + /// Optional cursor for pagination. Pass the `next_cursor` from a previous response. + pub id_cursor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListUsersBySignupDateResponse { + pub success: bool, + pub users: Vec, + /// The cursor for the next page. `None` if there are no more results. + pub next_cursor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListUsersBySignupDateEntry { + pub id: u64, + pub token: UserToken, + pub username: String, + pub display_name: String, + pub username_is_not_customized: bool, + pub email_address: String, + pub email_confirmed: bool, + pub is_without_password: bool, + pub ip_address_creation: String, + pub maybe_source: Option, + pub maybe_signup_method: Option, + pub created_at: DateTime, + pub maybe_referral_url: Option, + pub is_temporary: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/mod.rs new file mode 100644 index 0000000000..56e6c75710 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/mod.rs @@ -0,0 +1,4 @@ +pub mod list_subscribing_users_by_signup_date; +pub mod list_users_by_signup_date; +pub mod user_lookup; +pub mod user_lookup_by_stripe_customer_id; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/user_lookup.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/user_lookup.rs new file mode 100644 index 0000000000..f06766b118 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/user_lookup.rs @@ -0,0 +1,39 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +pub const MODERATOR_USER_LOOKUP_PATH: &str = "/v1/moderation/users/lookup"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorUserLookupRequest { + pub search: String, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserLookupSuccessResponse { + pub success: bool, + pub maybe_user: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserLookupUserDetails { + pub token: UserToken, + pub username: String, + pub display_name: String, + pub username_is_generated: bool, + pub is_temporary: bool, + pub username_is_not_customized: bool, + pub email_address: String, + pub email_confirmed: bool, + pub email_is_synthetic: bool, + pub is_without_password: bool, + pub ip_address_creation: String, + pub ip_address_last_login: String, + pub maybe_avatar_media_file_token: Option, + pub email_gravatar_hash: String, + pub is_banned: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/user_lookup_by_stripe_customer_id.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/user_lookup_by_stripe_customer_id.rs new file mode 100644 index 0000000000..46636aa2a1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user/user_lookup_by_stripe_customer_id.rs @@ -0,0 +1,27 @@ +use enums::common::payments_namespace::PaymentsNamespace; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +pub const MODERATOR_USER_LOOKUP_BY_STRIPE_CUSTOMER_ID_PATH: &str = "/v1/moderation/users/lookup_by_stripe_customer_id"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorUserLookupByStripeCustomerIdRequest { + pub stripe_customer_id: String, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserLookupByStripeCustomerIdResponse { + pub success: bool, + pub users: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserLookupByStripeCustomerIdEntry { + pub subscription_namespace: PaymentsNamespace, + pub maybe_stripe_subscription_id: Option, + pub token: UserToken, + pub email_address: String, + pub username: String, + pub display_name: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_daily_spends/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_daily_spends/mod.rs new file mode 100644 index 0000000000..612b34b487 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_daily_spends/mod.rs @@ -0,0 +1 @@ +pub mod user_daily_spends_list; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_daily_spends/user_daily_spends_list.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_daily_spends/user_daily_spends_list.rs new file mode 100644 index 0000000000..2f3b62d5e3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_daily_spends/user_daily_spends_list.rs @@ -0,0 +1,41 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_USER_DAILY_SPENDS_PATH: &str = + "/v1/moderation/user_daily_spends/user/{user_token}"; + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorUserDailySpendsQueryParams { + /// Page size (default 200, max 5000). + pub limit: Option, + /// Row offset for pagination. Pass `next_offset` from the previous page. + pub offset: Option, + /// Payments namespace. Defaults to `artcraft`. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserDailySpendsResponse { + pub success: bool, + pub user_token: UserToken, + pub records: Vec, + /// Offset for the next page, or `None` if this was the last page. + pub maybe_next_offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct UserDailySpendEntry { + pub payments_namespace: String, + pub spend_date: NaiveDate, + pub subscription_spend_usd_cents: u64, + pub credits_spend_usd_cents: u64, + pub gross_spend_usd_cents: u64, + pub refund_usd_cents: u64, + pub net_spend_usd_cents: i64, + pub payment_count: u32, + pub credits_granted: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/list_global_user_referrals.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/list_global_user_referrals.rs new file mode 100644 index 0000000000..10887eae8f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/list_global_user_referrals.rs @@ -0,0 +1,47 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::user_referral_codes::UserReferralCodeToken; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +// --- Request --- + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListGlobalUserReferralsQueryParams { + pub cursor: Option, + pub limit: Option, +} + +// --- Response --- + +#[derive(Serialize, ToSchema)] +pub struct ListGlobalUserReferralsSuccessResponse { + pub success: bool, + pub referrals: Vec, + pub maybe_cursor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct UserReferralResponse { + pub invited_user: InvitedUserDetails, + pub referrer_user: ReferrerUserDetails, + pub maybe_referral_code_token: Option, + pub maybe_referral_url: Option, + pub maybe_landing_url: Option, + pub created_at: DateTime, +} + +#[derive(Serialize, ToSchema)] +pub struct InvitedUserDetails { + pub token: UserToken, + pub username: String, + pub display_name: String, + pub email_address: String, +} + +#[derive(Serialize, ToSchema)] +pub struct ReferrerUserDetails { + pub token: UserToken, + pub username: String, + pub display_name: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/list_user_referrals_for_user.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/list_user_referrals_for_user.rs new file mode 100644 index 0000000000..d47a598584 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/list_user_referrals_for_user.rs @@ -0,0 +1,26 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::moderation::user_referrals::list_global_user_referrals::UserReferralResponse; + +// --- Request --- + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListUserReferralsForUserQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct ListUserReferralsForUserPathInfo { + pub username: String, +} + +// --- Response --- + +#[derive(Serialize, ToSchema)] +pub struct ListUserReferralsForUserSuccessResponse { + pub success: bool, + pub referrals: Vec, + pub maybe_cursor: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/mod.rs new file mode 100644 index 0000000000..3401f0ba8b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_referrals/mod.rs @@ -0,0 +1,2 @@ +pub mod list_global_user_referrals; +pub mod list_user_referrals_for_user; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_events/list.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_events/list.rs new file mode 100644 index 0000000000..5904cea10e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_events/list.rs @@ -0,0 +1,46 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::user_spend_events::UserSpendEventToken; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_LIST_USER_SPEND_EVENTS_PATH: &str = + "/v1/moderation/user_spend_events/list"; + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorListUserSpendEventsQueryParams { + /// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page. + pub offset: Option, + /// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListUserSpendEventsResponse { + pub success: bool, + pub events: Vec, + /// Offset for the next page, or `None` if this was the last page. + pub maybe_next_offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct UserSpendEventListEntry { + pub token: UserSpendEventToken, + pub payments_namespace: String, + pub maybe_user_token: Option, + pub maybe_username: Option, + pub maybe_display_name: Option, + pub maybe_email_gravatar_hash: Option, + pub event_type: String, + pub amount_usd_cents: i64, + pub maybe_credits_granted: Option, + pub payment_source: String, + pub maybe_source_object_id: Option, + pub maybe_stripe_invoice_id: Option, + pub maybe_stripe_payment_intent_id: Option, + pub maybe_stripe_charge_id: Option, + pub maybe_stripe_customer_id: Option, + pub is_production: bool, + pub payment_occurred_at: DateTime, + pub created_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_events/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_events/mod.rs new file mode 100644 index 0000000000..d17e233fbf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_events/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/get_summary.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/get_summary.rs new file mode 100644 index 0000000000..8e8b98ad3a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/get_summary.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_GET_USER_SPEND_SUMMARY_PATH: &str = + "/v1/moderation/user_spend_summaries/summary/{user_token}"; + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorGetUserSpendSummaryQueryParams { + /// Payments namespace. Defaults to `artcraft`. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorGetUserSpendSummaryResponse { + pub success: bool, + /// `None` if the user has no spend summary row. + pub maybe_summary: Option, +} + +/// The full `user_spend_summaries` record for a user. +#[derive(Serialize, ToSchema)] +pub struct UserSpendSummaryView { + pub payments_namespace: String, + pub user_token: UserToken, + + pub lifetime_gross_spend_usd_cents: u64, + pub lifetime_subscription_spend_usd_cents: u64, + pub lifetime_credits_spend_usd_cents: u64, + pub lifetime_refund_usd_cents: u64, + pub lifetime_net_spend_usd_cents: u64, + pub lifetime_payment_count: u32, + pub lifetime_refund_count: u32, + pub maybe_first_payment_at: Option>, + pub first_spend_usd_cents: u64, + pub maybe_last_payment_at: Option>, + pub last_spend_usd_cents: u64, + pub maybe_days_since_first_payment: Option, + pub maybe_days_since_last_payment: Option, + + pub net_spend_7d_usd_cents: i64, + pub net_spend_prev_7d_usd_cents: i64, + pub net_spend_14d_usd_cents: i64, + pub net_spend_prev_14d_usd_cents: i64, + pub net_spend_30d_usd_cents: i64, + pub net_spend_prev_30d_usd_cents: i64, + pub net_spend_60d_usd_cents: i64, + pub net_spend_90d_usd_cents: i64, + pub net_spend_this_year_usd_cents: i64, + + pub avg_weekly_net_spend_4w_usd_cents: i64, + pub avg_weekly_net_spend_12w_usd_cents: i64, + pub active_weeks_in_last_4: u8, + pub active_weeks_in_last_8: u8, + pub active_weeks_in_last_12: u8, + pub active_weeks_in_last_24: u8, + pub active_weeks_in_last_52: u8, + pub consecutive_active_weeks: u32, + pub consecutive_inactive_weeks: u32, + pub maybe_weeks_since_last_spend: Option, + + pub is_active_subscriber: bool, + pub maybe_subscription_interval: Option, + pub maybe_reengagement_score: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/mod.rs new file mode 100644 index 0000000000..1a1be21060 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/mod.rs @@ -0,0 +1,3 @@ +pub mod get_summary; +pub mod reengagement_list; +pub mod top_users_list; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/reengagement_list.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/reengagement_list.rs new file mode 100644 index 0000000000..f354eaf420 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/reengagement_list.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_REENGAGEMENT_LIST_PATH: &str = + "/v1/moderation/user_spend_summaries/reengagement_list"; + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorReengagementListQueryParams { + /// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page. + pub offset: Option, + /// Payments namespace. Defaults to `artcraft`. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorReengagementListResponse { + pub success: bool, + pub candidates: Vec, + /// Offset for the next page, or `None` if this was the last page. + pub maybe_next_offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ReengagementCandidateEntry { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + pub reengagement_score: u32, + pub lifetime_net_spend_usd_cents: u64, + pub maybe_last_payment_at: Option>, + pub maybe_days_since_last_payment: Option, + pub maybe_weeks_since_last_spend: Option, + pub is_active_subscriber: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/top_users_list.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/top_users_list.rs new file mode 100644 index 0000000000..60f8d4e2f7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_spend_summaries/top_users_list.rs @@ -0,0 +1,77 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_TOP_USERS_LIST_PATH: &str = + "/v1/moderation/user_spend_summaries/top_users_list"; + +/// Net-spend window that selects which column `top_users_list` sorts by. +#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)] +pub enum TopUsersWindow { + #[serde(rename = "7d")] + SevenDays, + #[serde(rename = "14d")] + FourteenDays, + #[serde(rename = "30d")] + ThirtyDays, + #[serde(rename = "60d")] + SixtyDays, + #[serde(rename = "90d")] + NinetyDays, + #[serde(rename = "1y")] + OneYear, + #[serde(rename = "lifetime")] + Lifetime, +} + +impl TopUsersWindow { + /// The `window` key the `list_top_users_by_net_spend` query matches on. + pub fn as_query_key(self) -> &'static str { + match self { + TopUsersWindow::SevenDays => "7d", + TopUsersWindow::FourteenDays => "14d", + TopUsersWindow::ThirtyDays => "30d", + TopUsersWindow::SixtyDays => "60d", + TopUsersWindow::NinetyDays => "90d", + TopUsersWindow::OneYear => "1y", + TopUsersWindow::Lifetime => "lifetime", + } + } +} + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorTopUsersListQueryParams { + /// Sort window: `7d`, `14d`, `30d`, `60d`, `90d`, `1y`, or `lifetime` (default). + pub window: Option, + /// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page. + pub offset: Option, + /// Payments namespace. Defaults to `artcraft`. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorTopUsersListResponse { + pub success: bool, + /// The window that was sorted by (echo of the request). + pub window: String, + pub users: Vec, + /// Offset for the next page, or `None` if this was the last page. + pub maybe_next_offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct TopUserEntry { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + pub lifetime_net_spend_usd_cents: u64, + pub net_spend_7d_usd_cents: i64, + pub net_spend_14d_usd_cents: i64, + pub net_spend_30d_usd_cents: i64, + pub net_spend_60d_usd_cents: i64, + pub net_spend_90d_usd_cents: i64, + pub net_spend_this_year_usd_cents: i64, + pub is_active_subscriber: bool, + pub maybe_reengagement_score: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_stripe_data/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_stripe_data/mod.rs new file mode 100644 index 0000000000..bfb5246904 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_stripe_data/mod.rs @@ -0,0 +1 @@ +pub mod moderator_get_user_stripe_customer_ids; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids.rs new file mode 100644 index 0000000000..ab97dafd09 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/user_stripe_data/moderator_get_user_stripe_customer_ids.rs @@ -0,0 +1,43 @@ +use enums::common::payments_namespace::PaymentsNamespace; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +pub const MODERATOR_GET_USER_STRIPE_CUSTOMER_IDS_PATH: &str = + "/v1/moderation/user_stripe_data/{user_token}/customer_ids"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorGetUserStripeCustomerIdsPathInfo { + pub user_token: UserToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorGetUserStripeCustomerIdsResponse { + pub success: bool, + + /// All stripe customer ids we have on file for the user, across payments + /// namespaces. A user may have zero or several: a customer link and/or + /// subscriptions per namespace (eg. artcraft), plus legacy fakeyou + /// subscriptions. The same customer id can appear once per source. + pub customer_ids: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserStripeCustomerIdEntry { + pub stripe_customer_id: String, + pub payments_namespace: PaymentsNamespace, + pub source: ModeratorStripeCustomerIdSource, + + /// Link to the customer in the Stripe dashboard for the namespace's account. + pub stripe_dashboard_url: String, +} + +/// Which table the customer id was found in. +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ModeratorStripeCustomerIdSource { + /// From the `user_stripe_customer_links` table (1:1 per namespace). + CustomerLink, + /// From the user's rows in the `user_subscriptions` table. + Subscription, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet.rs new file mode 100644 index 0000000000..2b0d33612e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/list_wallet_ledger_entries_by_wallet.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; +use tokens::tokens::wallets::WalletToken; +use utoipa::ToSchema; + +pub const LIST_WALLET_LEDGER_ENTRIES_BY_WALLET_PATH: &str = "/v1/moderation/wallet_ledger_entries/wallet/{wallet_token}/list"; + +#[derive(Deserialize, ToSchema)] +pub struct ListWalletLedgerEntriesByWalletPathInfo { + pub wallet_token: WalletToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ListWalletLedgerEntriesByWalletResponse { + pub success: bool, + pub entries: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ListWalletLedgerEntriesByWalletEntry { + pub token: WalletLedgerEntryToken, + pub wallet_token: WalletToken, + pub entry_type: WalletLedgerEntryType, + pub maybe_entity_ref: Option, + pub credits_delta: i32, + pub banked_credits_before: u32, + pub banked_credits_after: u32, + pub monthly_credits_before: u32, + pub monthly_credits_after: u32, + pub created_at: DateTime, + pub is_refunded: bool, + pub maybe_linked_refund_ledger_token: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/mod.rs new file mode 100644 index 0000000000..6103b92f0b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/mod.rs @@ -0,0 +1,2 @@ +pub mod list_wallet_ledger_entries_by_wallet; +pub mod moderator_get_wallet_ledger_entry; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry.rs new file mode 100644 index 0000000000..49343351e3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallet_ledger_entries/moderator_get_wallet_ledger_entry.rs @@ -0,0 +1,33 @@ +use chrono::{DateTime, Utc}; +use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; +use utoipa::ToSchema; + +pub const MODERATOR_GET_WALLET_LEDGER_ENTRY_PATH: &str = "/v1/moderation/wallet_ledger_entry/{wallet_ledger_entry_token}"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorGetWalletLedgerEntryPathInfo { + pub wallet_ledger_entry_token: WalletLedgerEntryToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorGetWalletLedgerEntryResponse { + pub success: bool, + pub maybe_entry: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorGetWalletLedgerEntryDetails { + pub token: WalletLedgerEntryToken, + pub entry_type: WalletLedgerEntryType, + pub maybe_entity_ref: Option, + pub credits_delta: i32, + pub banked_credits_before: u32, + pub banked_credits_after: u32, + pub monthly_credits_before: u32, + pub monthly_credits_after: u32, + pub is_refunded: bool, + pub maybe_linked_refund_ledger_token: Option, + pub created_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/list_user_wallets.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/list_user_wallets.rs new file mode 100644 index 0000000000..fa7ffeda6a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/list_user_wallets.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use enums::common::payments_namespace::PaymentsNamespace; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallets::WalletToken; +use utoipa::ToSchema; + +pub const LIST_USER_WALLETS_PATH: &str = "/v1/moderation/wallets/user/{user_token}/list"; + +#[derive(Deserialize, ToSchema)] +pub struct ListUserWalletsPathInfo { + pub user_token: UserToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ListUserWalletsResponse { + pub success: bool, + pub wallets: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ListUserWalletsEntry { + pub token: WalletToken, + pub wallet_namespace: PaymentsNamespace, + pub banked_credits: u32, + pub monthly_credits: u32, + pub version: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/mod.rs new file mode 100644 index 0000000000..23e8bc033e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/mod.rs @@ -0,0 +1,4 @@ +pub mod list_user_wallets; +pub mod moderator_add_banked_balance_to_wallet; +pub mod moderator_create_wallet_for_user; +pub mod moderator_get_wallet; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_add_banked_balance_to_wallet.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_add_banked_balance_to_wallet.rs new file mode 100644 index 0000000000..04faf6278c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_add_banked_balance_to_wallet.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::wallets::WalletToken; +use utoipa::ToSchema; + +pub const MODERATOR_ADD_BANKED_BALANCE_TO_WALLET_PATH: &str = "/v1/moderation/wallet/{wallet_token}/add_banked_balance"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorAddBankedBalanceToWalletPathInfo { + pub wallet_token: WalletToken, +} + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorAddBankedBalanceToWalletRequest { + pub credits: u16, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorAddBankedBalanceToWalletResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_create_wallet_for_user.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_create_wallet_for_user.rs new file mode 100644 index 0000000000..6661ae5828 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_create_wallet_for_user.rs @@ -0,0 +1,19 @@ +use enums::common::payments_namespace::PaymentsNamespace; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallets::WalletToken; +use utoipa::ToSchema; + +pub const MODERATOR_CREATE_WALLET_FOR_USER_PATH: &str = "/v1/moderation/wallet/create_for_user"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorCreateWalletForUserRequest { + pub user_token: Option, + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorCreateWalletForUserResponse { + pub success: bool, + pub wallet_token: WalletToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_get_wallet.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_get_wallet.rs new file mode 100644 index 0000000000..f88ad68fe6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/wallets/moderator_get_wallet.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use enums::common::payments_namespace::PaymentsNamespace; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallets::WalletToken; +use utoipa::ToSchema; + +pub const MODERATOR_GET_WALLET_PATH: &str = "/v1/moderation/wallet/{wallet_token}"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorGetWalletPathInfo { + pub wallet_token: WalletToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorGetWalletResponse { + pub success: bool, + pub maybe_wallet: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorGetWalletDetails { + pub token: WalletToken, + pub wallet_namespace: PaymentsNamespace, + pub owner_user_token: UserToken, + pub banked_credits: u32, + pub monthly_credits: u32, + pub version: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/mod.rs new file mode 100644 index 0000000000..4dcdc2a47d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/mod.rs @@ -0,0 +1,2 @@ +pub mod omni_api_image_generate_request; +pub mod omni_api_video_generate_request; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_image_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_image_generate_request.rs new file mode 100644 index 0000000000..b0126c833b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_image_generate_request.rs @@ -0,0 +1,58 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_image_model::CommonImageModel; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use tokens::tokens::media_files::MediaFileToken; + +/// Request body for the Omni API image generation endpoint. +/// +/// Forked from `OmniGenImageCostAndGenerateRequest` so the Omni API's request +/// shape can evolve independently without affecting the product-critical Omni +/// Gen endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniApiImageGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The prompt for the image generation. + pub prompt: Option, + + /// Input images for image editing. + /// If present, we're doing image editing (image-to-image). + /// If absent, we're doing text-to-image. + pub image_media_tokens: Option>, + + /// URL alternative to `image_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub image_urls: Option>, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The quality to use. + pub quality: Option, + + /// How many images to generate. + pub image_batch_count: Option, + + /// Only for angle manipulation models. + pub adjust_horizontal_angle: Option, + + /// Only for angle manipulation models. + pub adjust_vertical_angle: Option, + + /// Only for angle manipulation models. + pub adjust_zoom: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_video_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_video_generate_request.rs new file mode 100644 index 0000000000..5d41a3941a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/generate_requests/omni_api_video_generate_request.rs @@ -0,0 +1,96 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::common_video_model::CommonVideoModel; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Request body for the Omni API video generation endpoint. +/// +/// Forked from `OmniGenVideoCostAndGenerateRequest` so the Omni API's request +/// shape can evolve independently without affecting the product-critical Omni +/// Gen endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniApiVideoGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the video generation. + pub prompt: Option, + + /// Some models support negative text prompts. + pub negative_prompt: Option, + + /// Starting keyframe (optional). + pub start_frame_image_media_token: Option, + + /// URL alternative to `start_frame_image_media_token`. Mutually exclusive with it. + /// Downloaded and stored as a media file before generation. + pub start_frame_image_url: Option, + + /// Ending keyframe (optional). + pub end_frame_image_media_token: Option, + + /// URL alternative to `end_frame_image_media_token`. Mutually exclusive with it. + /// Downloaded and stored as a media file before generation. + pub end_frame_image_url: Option, + + /// Reference images (optional). + pub reference_image_media_tokens: Option>, + + /// URL alternative to `reference_image_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub reference_image_urls: Option>, + + /// Reference videos (optional). + pub reference_video_media_tokens: Option>, + + /// URL alternative to `reference_video_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub reference_video_urls: Option>, + + /// Reference audio (optional). + pub reference_audio_media_tokens: Option>, + + /// URL alternative to `reference_audio_media_tokens`. Mutually exclusive with it. + /// Each URL is downloaded and stored as a media file before generation. + pub reference_audio_urls: Option>, + + /// Optional character tokens to reference in the prompt. + /// Characters are referenced in prompts as @CharacterName. + pub reference_character_tokens: Option>, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The output bitrate to use. + /// Not all models support this; models that don't simply ignore it. + pub bitrate: Option, + + /// The quality to use. + pub quality: Option, + + /// How many seconds to generate. + pub duration_seconds: Option, + + /// How many videos to generate. + pub video_batch_count: Option, + + /// Whether to turn on/off audio. + /// Not all models support audio, not all models have a choice. + /// Some models will default this to true, others will default it to false, + /// so it's best to be explicit. + pub generate_audio: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/mod.rs new file mode 100644 index 0000000000..818a9def51 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/mod.rs @@ -0,0 +1,3 @@ +pub mod omni_api_batch_get_job_status; +pub mod omni_api_get_job_status; +pub mod omni_api_job_status_payload; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_batch_get_job_status.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_batch_get_job_status.rs new file mode 100644 index 0000000000..ee3092d4d2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_batch_get_job_status.rs @@ -0,0 +1,25 @@ +use std::collections::HashSet; + +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload; + +/// Query params for `GET /v1/omni_api/job_status/batch`. +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct OmniApiBatchGetJobStatusQueryParams { + /// A grab bag of job tokens. Each token family generally has its own prefix, + /// so a flat set is sufficient. + /// + /// NB: handlers parse this with `actix_web_lab`'s `Query` because the + /// default actix-web `Query` doesn't decode repeated/sequence params yet. + /// See https://github.com/actix/actix-web/issues/1301 + pub tokens: HashSet, +} + +/// Success body for `GET /v1/omni_api/job_status/batch`. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniApiBatchGetJobStatusSuccessResponse { + pub success: bool, + pub job_states: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_get_job_status.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_get_job_status.rs new file mode 100644 index 0000000000..e1425e43fd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_get_job_status.rs @@ -0,0 +1,19 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload; + +/// Path params for `GET /v1/omni_api/job_status/job/{token}`. +#[derive(Deserialize, ToSchema)] +pub struct OmniApiGetJobStatusPathInfo { + pub token: InferenceJobToken, +} + +/// Success body for `GET /v1/omni_api/job_status/job/{token}`. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniApiGetJobStatusSuccessResponse { + pub success: bool, + pub state: OmniApiJobStatusPayload, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_job_status_payload.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_job_status_payload.rs new file mode 100644 index 0000000000..c888ee7492 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/job_status/omni_api_job_status_payload.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; +use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; +use enums::common::job_status_plus::JobStatusPlus; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::prompts::PromptToken; + +use crate::common::responses::media_links::MediaLinks; + +/// Full status payload for a single inference job. +/// +/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch +/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the +/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can +/// evolve independently. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniApiJobStatusPayload { + pub job_token: InferenceJobToken, + + pub request: OmniApiJobRequestDetails, + pub status: OmniApiJobStatusDetails, + pub maybe_result: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Details about what the user requested for generation. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniApiJobRequestDetails { + pub inference_category: InferenceCategory, + + pub maybe_prompt_token: Option, + + pub maybe_model_type: Option, + pub maybe_model_token: Option, +} + +/// Details about the ongoing job status. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniApiJobStatusDetails { + /// Primary status from the database (a state machine). + pub status: JobStatusPlus, + + pub maybe_first_started_at: Option>, + + /// An enum the frontend can use to display localized/I18N error + /// messages. These pertain to both transient and permanent failures. + pub maybe_failure_category: Option, + + /// This is an integer number between 0 and 100 (both inclusive) that + /// reports the completeness. + pub progress_percentage: u8, +} + +/// Details about the completed result (if any). +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniApiJobResultDetails { + pub entity_type: String, + pub entity_token: String, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + pub maybe_successfully_completed_at: Option>, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/mod.rs new file mode 100644 index 0000000000..60b8059d7f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/mod.rs @@ -0,0 +1,5 @@ +pub mod generate_requests; +pub mod job_status; +pub mod omni_upload_audio; +pub mod omni_upload_image; +pub mod omni_upload_video; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_audio.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_audio.rs new file mode 100644 index 0000000000..8c4bb2588f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_audio.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_image.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_image.rs new file mode 100644 index 0000000000..b8e968d81f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_image.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_video.rs new file mode 100644 index 0000000000..da2c606ee5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_api/omni_upload_video.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs new file mode 100644 index 0000000000..35501ad1e2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs @@ -0,0 +1,5 @@ +pub mod omni_gen_audio_cost_and_generate_request; +pub mod omni_gen_image_cost_and_generate_request; +pub mod omni_gen_mesh_cost_and_generate_request; +pub mod omni_gen_splat_cost_and_generate_request; +pub mod omni_gen_video_cost_and_generate_request; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_audio_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_audio_cost_and_generate_request.rs new file mode 100644 index 0000000000..241cc5d12e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_audio_cost_and_generate_request.rs @@ -0,0 +1,59 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_audio_model::CommonAudioModel; +use enums::common::generation::common_musical_key::CommonMusicalKey; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the audio cost estimate and audio generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenAudioCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the audio generation. + pub prompt: Option, + + /// Style/genre direction (Suno's "tags"). + pub style_prompt: Option, + + /// Reference audio (optional). + /// The remix/sample source, or Seed Audio @Audio references (up to 3). + pub audio_media_tokens: Option>, + + /// Reference image (optional). + /// Seed Audio supports a single reference image; cannot be combined with audio references. + pub image_media_tokens: Option>, + + /// Whether to keep the original lyrics (Suno Remix). + pub keep_lyrics: Option, + + /// Whether to generate instrumental-only audio (Suno Music / Sample). + pub is_instrumental: Option, + + /// Whether the sound should loop vs a single hit (Suno Sounds). + pub is_loopable: Option, + + /// Beats per minute (Suno Sounds). + pub bpm: Option, + + /// The musical key to use (Suno Sounds). + pub musical_key: Option, + + /// Output sample rate in Hz (Seed Audio: 8000/16000/24000/32000/44100/48000). + pub sample_rate_hz: Option, + + /// Playback speed multiplier (Seed Audio: 0.5–2.0). + pub speed: Option, + + /// Volume multiplier (Seed Audio: 0.5–2.0). + pub volume: Option, + + /// Pitch shift in semitones (Seed Audio: -12..=12). + pub pitch: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_image_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_image_cost_and_generate_request.rs new file mode 100644 index 0000000000..38399e2b6f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_image_cost_and_generate_request.rs @@ -0,0 +1,50 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_image_model::CommonImageModel; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the image cost estimate and image generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenImageCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The prompt for the image generation. + pub prompt: Option, + + /// Input images for image editing. + /// If present, we're doing image editing (image-to-image). + /// If absent, we're doing text-to-image. + pub image_media_tokens: Option>, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The quality to use. + pub quality: Option, + + /// How many images to generate. + pub image_batch_count: Option, + + /// Only for angle manipulation models. + pub adjust_horizontal_angle: Option, + + /// Only for angle manipulation models. + pub adjust_vertical_angle: Option, + + /// Only for angle manipulation models. + pub adjust_zoom: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_mesh_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_mesh_cost_and_generate_request.rs new file mode 100644 index 0000000000..f005d057d3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_mesh_cost_and_generate_request.rs @@ -0,0 +1,73 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_mesh_model::CommonMeshModel; +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_mesh_quality::CommonMeshQuality; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the mesh cost estimate and mesh generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenMeshCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the mesh generation (text-to-3D). + pub prompt: Option, + + /// Reference images (optional). + /// The primary/front input image, or the sketch image for sketch-to-3D. + pub reference_image_media_tokens: Option>, + + /// Front view image (optional; multi-view input). + /// Can be used instead of `reference_image_media_tokens[0]` when the other + /// side views are supplied. + pub front_image_media_token: Option, + + /// Back view image (optional; multi-view input). + pub back_image_media_token: Option, + + /// Left view image (optional; multi-view input). + pub left_image_media_token: Option, + + /// Right view image (optional; multi-view input). + pub right_image_media_token: Option, + + /// Input mesh file for mesh-to-mesh models (part splitting, retopology). + /// Required by `hunyuan_3d_3p1_part` (FBX) and + /// `hunyuan_3d_3p1_topology` (GLB/OBJ). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_mesh_media_token: Option, + + /// The mesh output type to generate (normal, low-poly, or geometry-only). + pub mesh_output_type: Option, + + /// The polygon type to use for the generated mesh (triangle or quad). + pub polygon_type: Option, + + /// Target face count for the generated mesh. + pub face_count: Option, + + /// Whether to generate PBR (physically based rendering) materials. + pub enable_pbr: Option, + + /// Whether to generate textures. Some models (Tripo3D) can skip texturing + /// for a cheaper, faster generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_texture: Option, + + /// Texture quality. `Detailed` costs extra on some models (Tripo3D's HD + /// texture tier). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub texture_quality: Option, + + /// Geometry quality. `Detailed` costs extra on some models (Tripo3D). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub geometry_quality: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_splat_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_splat_cost_and_generate_request.rs new file mode 100644 index 0000000000..5434903698 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_splat_cost_and_generate_request.rs @@ -0,0 +1,34 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_splat_model::CommonSplatModel; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the splat cost estimate and splat generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenSplatCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the splat generation. + pub prompt: Option, + + /// Reference images (optional). + /// NOTE: Spherical/panorama coordinates are not yet supported per-image; + /// multiple images are treated as multi-view input of the same world. + pub reference_image_media_tokens: Option>, + + /// Reference video (optional). + pub reference_video_media_token: Option, + + /// Whether the single reference image is a 360-degree panorama. + pub is_panoramic: Option, + + /// Whether to disable prompt recaptioning. + pub disable_recaption: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs new file mode 100644 index 0000000000..56822ac8e8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs @@ -0,0 +1,91 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::common_video_model::CommonVideoModel; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the video cost estimate and video generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenVideoCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the video generation. + pub prompt: Option, + + /// Some models support negative text prompts. + pub negative_prompt: Option, + + /// Starting keyframe (optional). + pub start_frame_image_media_token: Option, + + /// Ending keyframe (optional). + pub end_frame_image_media_token: Option, + + /// Reference images (optional). + pub reference_image_media_tokens: Option>, + + /// Reference videos (optional). + pub reference_video_media_tokens: Option>, + + /// Reference audio (optional). + pub reference_audio_media_tokens: Option>, + + /// Optional character tokens to reference in the prompt. + /// Characters are referenced in prompts as @CharacterName. + pub reference_character_tokens: Option>, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The output bitrate to use. + /// Not all models support this; models that don't simply ignore it. + pub bitrate: Option, + + /// The quality to use. + pub quality: Option, + + /// How many seconds to generate. + pub duration_seconds: Option, + + /// How many videos to generate. + pub video_batch_count: Option, + + /// Whether to turn on/off audio. + /// Not all models support audio, not all models have a choice. + /// Some models will default this to true, others will default it to false, + /// so it's best to be explicit. + pub generate_audio: Option, + + /// Frontend-supplied hints for cost estimation only. IGNORED by + /// generation — the generate endpoints measure inputs themselves (e.g. + /// ffprobing reference videos) and bill from those measurements. + pub estimate_only: Option, +} + +/// These fields are ignored by generation. They're only used by the frontend +/// to aid in cost estimation (e.g. Seedance 2.5 bills reference-video input +/// seconds, so the cost quote needs the combined input duration without the +/// server downloading and probing the files on every poll). +#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, Debug, Default)] +pub struct EstimateFields { + /// Combined duration of all reference video inputs, in milliseconds. + pub total_input_video_duration_millis: Option, + + /// Combined duration of all reference audio inputs, in milliseconds. + /// (No model bills audio input seconds yet; accepted for future models.) + pub total_input_audio_duration_millis: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs new file mode 100644 index 0000000000..f360c72ce4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs @@ -0,0 +1,5 @@ +pub mod omni_gen_audio_cost_response; +pub mod omni_gen_image_cost_response; +pub mod omni_gen_mesh_cost_response; +pub mod omni_gen_splat_cost_response; +pub mod omni_gen_video_cost_response; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_audio_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_audio_cost_response.rs new file mode 100644 index 0000000000..d6e89ba00d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_audio_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen audio cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenAudioCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate-limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_image_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_image_cost_response.rs new file mode 100644 index 0000000000..33d3337d5d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_image_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen image cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenImageCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_mesh_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_mesh_cost_response.rs new file mode 100644 index 0000000000..a3308aa497 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_mesh_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen mesh cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenMeshCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate-limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_splat_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_splat_cost_response.rs new file mode 100644 index 0000000000..823a169c3c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_splat_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen splat cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenSplatCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate-limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_video_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_video_cost_response.rs new file mode 100644 index 0000000000..2bd98df28e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_video_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen video cost estimate endpoint. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct OmniGenVideoCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate-limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs new file mode 100644 index 0000000000..97c385973c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs @@ -0,0 +1,5 @@ +pub mod omni_gen_audio_generate_response; +pub mod omni_gen_image_generate_response; +pub mod omni_gen_mesh_generate_response; +pub mod omni_gen_splat_generate_response; +pub mod omni_gen_video_generate_response; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_audio_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_audio_generate_response.rs new file mode 100644 index 0000000000..6eb10dddcc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_audio_generate_response.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen audio generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenAudioGenerateResponse { + pub success: bool, + + pub inference_job_token: InferenceJobToken, + + /// All job tokens created by this request (including the primary). + /// For single-job requests this will contain just one element matching + /// `inference_job_token`. For batch requests this will contain all of them. + pub all_job_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_image_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_image_generate_response.rs new file mode 100644 index 0000000000..9038747048 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_image_generate_response.rs @@ -0,0 +1,10 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen image generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenImageGenerateResponse { + pub success: bool, + pub inference_job_token: InferenceJobToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_mesh_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_mesh_generate_response.rs new file mode 100644 index 0000000000..5a6d1e2b3b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_mesh_generate_response.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen mesh generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenMeshGenerateResponse { + pub success: bool, + + pub inference_job_token: InferenceJobToken, + + /// All job tokens created by this request (including the primary). + /// For single-job requests this will contain just one element matching + /// `inference_job_token`. For batch requests this will contain all of them. + pub all_job_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_splat_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_splat_generate_response.rs new file mode 100644 index 0000000000..9e1f51b138 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_splat_generate_response.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen splat generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenSplatGenerateResponse { + pub success: bool, + + pub inference_job_token: InferenceJobToken, + + /// All job tokens created by this request (including the primary). + /// For single-job requests this will contain just one element matching + /// `inference_job_token`. For batch requests this will contain all of them. + pub all_job_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_video_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_video_generate_response.rs new file mode 100644 index 0000000000..b7ceb720f4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_video_generate_response.rs @@ -0,0 +1,17 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen video generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenVideoGenerateResponse { + pub success: bool, + + pub inference_job_token: InferenceJobToken, + + /// All job tokens created by this request (including the primary). + /// For single-job requests this will contain just one element matching + /// `inference_job_token`. For batch requests (eg. KinoviWeb with + /// multiple order IDs) this will contain all of them. + pub all_job_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/mod.rs new file mode 100644 index 0000000000..1544ead36a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/mod.rs @@ -0,0 +1,4 @@ +pub mod cost_and_generate_requests; +pub mod cost_response; +pub mod generate_response; +pub mod models; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs new file mode 100644 index 0000000000..1aa2d95888 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs @@ -0,0 +1,5 @@ +pub mod omni_gen_audio_models; +pub mod omni_gen_image_models; +pub mod omni_gen_mesh_models; +pub mod omni_gen_splat_models; +pub mod omni_gen_video_models; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_audio_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_audio_models.rs new file mode 100644 index 0000000000..8480813053 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_audio_models.rs @@ -0,0 +1,139 @@ +use enums::common::generation::common_audio_model::CommonAudioModel; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::Serialize; +use utoipa::ToSchema; + +/// Audio model to default to if none is specified +const DEFAULT_AUDIO_MODEL : CommonAudioModel = CommonAudioModel::SunoMusic; + +/// Response body for the audio models endpoint. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenAudioModelsResponse { + pub success: bool, + + /// A list of all models: details, features, and capabilities + pub models: Vec, + + /// Provider-by-provider model offering and capability list, + /// with possible capability overrides (future) + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenAudioModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenAudioProviderModelDetails { + pub model: CommonAudioModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenAudioModelDetails { + + pub model: CommonAudioModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + /// Additional details about the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + + /// Additional details about the model. (Brief; only a few words.) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + /// Whether a style/genre prompt (Suno's "tags") is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub style_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_max: Option, + + /// Whether the "keep lyrics" toggle is supported (Suno Remix). + #[serde(skip_serializing_if = "Option::is_none")] + pub keep_lyrics_supported: Option, + + /// Whether the instrumental-only toggle is supported (Suno Music / Sample). + #[serde(skip_serializing_if = "Option::is_none")] + pub instrumental_toggle_supported: Option, + + /// Whether the loop vs single-hit toggle is supported (Suno Sounds). + #[serde(skip_serializing_if = "Option::is_none")] + pub loopable_toggle_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bpm_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub musical_key_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_rate_hz_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_rate_hz_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub speed_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub pitch_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenAudioModelDetails { + fn default() -> Self { + Self { + model: DEFAULT_AUDIO_MODEL, + model_creator: None, + full_name: None, + extra_info: None, + extra_info_short: None, + text_prompt_supported: None, + style_prompt_supported: None, + audio_references_supported: None, + audio_references_max: None, + image_references_supported: None, + image_references_max: None, + keep_lyrics_supported: None, + instrumental_toggle_supported: None, + loopable_toggle_supported: None, + bpm_supported: None, + musical_key_supported: None, + sample_rate_hz_options: None, + sample_rate_hz_default: None, + speed_supported: None, + volume_supported: None, + pitch_supported: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs new file mode 100644 index 0000000000..119d2fb38b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_image_models.rs @@ -0,0 +1,159 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_image_model::CommonImageModel; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +/// Query string parameters for the image models endpoint. +#[derive(Deserialize, IntoParams, ToSchema)] +pub struct OmniGenImageModelsQuery { + /// Which provider's models to list. Defaults to "artcraft" if absent. + pub provider: Option, +} + +/// The provider filter for the models endpoint. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum OmniGenImageModelsProvider { + /// Only models available through ArtCraft. + Artcraft, + /// All known models across all providers. + All, +} + +impl Default for OmniGenImageModelsProvider { + fn default() -> Self { + Self::Artcraft + } +} + +/// Response body for the image models endpoint. +/// TBD — fields will be added later. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenImageModelsResponse { + pub success: bool, + pub models: Vec, + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenImageModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenImageProviderModelDetails { + pub model: CommonImageModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenImageModelDetails { + pub model: CommonImageModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + // TODO: model_creator: ModelCreator, + + // TODO: Types of UI to show up in (list or bools) + // eg. can use in image editing UI, 3d editor, etc. + + // TODO: sub-features like inpainting masking, + // TODO: angle edit models + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_max_length: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_max_length: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_refs_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_refs_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub has_fixed_editing_aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_default_when_editing: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub default_quality: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_min: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenImageModelDetails { + fn default() -> Self { + Self { + model: CommonImageModel::NanoBananaPro, + model_creator: None, + full_name: None, + text_prompt_supported: None, + text_prompt_max_length: None, + negative_text_prompt_supported: None, + negative_text_prompt_max_length: None, + image_refs_supported: None, + image_refs_max: None, + has_fixed_editing_aspect_ratio: None, + aspect_ratio_options: None, + aspect_ratio_default: None, + aspect_ratio_default_when_editing: None, + resolution_options: None, + resolution_default: None, + quality_options: None, + default_quality: None, + batch_size_min: None, + batch_size_max: None, + batch_size_options: None, + batch_size_default: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_mesh_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_mesh_models.rs new file mode 100644 index 0000000000..0707e59de3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_mesh_models.rs @@ -0,0 +1,134 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel; +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::Serialize; +use utoipa::ToSchema; + +/// Mesh model to default to if none is specified +const DEFAULT_MESH_MODEL : CommonMeshModel = CommonMeshModel::Hunyuan3d3; + +/// Response body for the mesh models endpoint. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshModelsResponse { + pub success: bool, + + /// A list of all models: details, features, and capabilities + pub models: Vec, + + /// Provider-by-provider model offering and capability list, + /// with possible capability overrides (future) + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshProviderModelDetails { + pub model: CommonMeshModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshModelDetails { + + pub model: CommonMeshModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + /// Additional details about the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + + /// Additional details about the model. (Brief; only a few words.) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + + /// Whether text-to-3D prompting is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + /// Whether image-to-3D input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_input_supported: Option, + + /// Whether sketch-to-3D input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub sketch_input_supported: Option, + + /// Whether multi-view (front/back/left/right) image input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_view_supported: Option, + + /// Whether the model takes an existing mesh file as input + /// (mesh-to-mesh models like part splitting and retopology). + #[serde(skip_serializing_if = "Option::is_none")] + pub mesh_input_supported: Option, + + /// The mesh output types the model supports. + #[serde(skip_serializing_if = "Option::is_none")] + pub mesh_output_types: Option>, + + /// The polygon types the model supports. + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_types: Option>, + + /// Whether a target face count can be specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count_supported: Option, + + /// Whether PBR (physically based rendering) material generation is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub pbr_supported: Option, + + /// Whether texture generation can be toggled off (untextured output). + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_toggle_supported: Option, + + /// Whether a texture quality level (standard/detailed) can be selected. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_quality_supported: Option, + + /// Whether a geometry quality level (standard/detailed) can be selected. + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_quality_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenMeshModelDetails { + fn default() -> Self { + Self { + model: DEFAULT_MESH_MODEL, + model_creator: None, + full_name: None, + extra_info: None, + extra_info_short: None, + text_prompt_supported: None, + image_input_supported: None, + sketch_input_supported: None, + multi_view_supported: None, + mesh_input_supported: None, + mesh_output_types: None, + polygon_types: None, + face_count_supported: None, + pbr_supported: None, + texture_toggle_supported: None, + texture_quality_supported: None, + geometry_quality_supported: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_splat_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_splat_models.rs new file mode 100644 index 0000000000..ed9212103f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_splat_models.rs @@ -0,0 +1,98 @@ +use enums::common::generation::common_splat_model::CommonSplatModel; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::Serialize; +use utoipa::ToSchema; + +/// Splat model to default to if none is specified +const DEFAULT_SPLAT_MODEL : CommonSplatModel = CommonSplatModel::Marble1p1; + +/// Response body for the splat models endpoint. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatModelsResponse { + pub success: bool, + + /// A list of all models: details, features, and capabilities + pub models: Vec, + + /// Provider-by-provider model offering and capability list, + /// with possible capability overrides (future) + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatProviderModelDetails { + pub model: CommonSplatModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatModelDetails { + + pub model: CommonSplatModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + /// Additional details about the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + + /// Additional details about the model. (Brief; only a few words.) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_max: Option, + + /// Whether a reference video input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub video_reference_supported: Option, + + /// Whether a 360-degree panorama reference image is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub panorama_supported: Option, + + /// Whether the "disable recaption" toggle is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_recaption_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenSplatModelDetails { + fn default() -> Self { + Self { + model: DEFAULT_SPLAT_MODEL, + model_creator: None, + full_name: None, + extra_info: None, + extra_info_short: None, + text_prompt_supported: None, + image_references_supported: None, + image_references_max: None, + video_reference_supported: None, + panorama_supported: None, + disable_recaption_supported: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs new file mode 100644 index 0000000000..8cbea2f5e0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs @@ -0,0 +1,246 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::common_video_model::CommonVideoModel; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use enums::common::generation::common_bitrate::CommonBitrate; + +/// Video model to default to if none is specified +const DEFAULT_VIDEO_MODEL : CommonVideoModel = CommonVideoModel::Seedance2p0; + +/// Query string parameters for the video models endpoint. +#[derive(Deserialize, IntoParams, ToSchema)] +pub struct OmniGenVideoModelsQuery { + /// Which provider's models to list. Defaults to "artcraft" if absent. + pub provider: Option, +} + +/// The provider filter for the models endpoint. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum OmniGenVideoModelsProvider { + /// Only models available through ArtCraft. + Artcraft, + /// All known models across all providers. + All, +} + +impl Default for OmniGenVideoModelsProvider { + fn default() -> Self { + Self::Artcraft + } +} + +/// Response body for the video models endpoint. +/// TBD — fields will be added later. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenVideoModelsResponse { + pub success: bool, + + /// A list of all models: details, features, and capabilities + pub models: Vec, + + /// Provider-by-provider model offering and capability list, + /// with possible capability overrides (future) + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenVideoModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenVideoProviderModelDetails { + pub model: CommonVideoModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenVideoModelDetails { + + pub model: CommonVideoModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + /// Additional details about the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + + /// Additional details about the model. (Brief; only a few words.) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + + /// Whether a pure text-to-video modality is supported + #[serde(skip_serializing_if = "Option::is_none")] + pub text_to_video_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_max_length: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_max_length: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_keyframe_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_keyframe_required: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ending_keyframe_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub video_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub video_references_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub video_references_max_total_duration_seconds: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_max_total_duration_seconds: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub character_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub character_references_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub show_generate_with_sound_toggle: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub default_quality: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_min: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_max: Option, + + /// Specific maximum when image references are used, + /// eg. Grok limits to 10 seconds in this mode. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_max_with_image_references: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_min: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_max: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenVideoModelDetails { + fn default() -> Self { + Self { + model: DEFAULT_VIDEO_MODEL, + model_creator: None, + full_name: None, + extra_info: None, + extra_info_short: None, + text_to_video_supported: None, + text_prompt_supported: None, + text_prompt_max_length: None, + negative_text_prompt_supported: None, + negative_text_prompt_max_length: None, + starting_keyframe_supported: None, + starting_keyframe_required: None, + ending_keyframe_supported: None, + image_references_supported: None, + image_references_max: None, + video_references_supported: None, + video_references_max: None, + video_references_max_total_duration_seconds: None, + audio_references_supported: None, + audio_references_max: None, + audio_references_max_total_duration_seconds: None, + character_references_supported: None, + character_references_max: None, + show_generate_with_sound_toggle: None, + aspect_ratio_options: None, + aspect_ratio_default: None, + resolution_options: None, + resolution_default: None, + bitrate_options: None, + bitrate_default: None, + quality_options: None, + default_quality: None, + duration_seconds_min: None, + duration_seconds_max: None, + duration_seconds_max_with_image_references: None, + duration_seconds_options: None, + duration_seconds_default: None, + batch_size_min: None, + batch_size_max: None, + batch_size_options: None, + batch_size_default: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs new file mode 100644 index 0000000000..ba409f5a7d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/batch_get_prompts.rs @@ -0,0 +1,75 @@ +use crate::prompts::get_prompt::GetPromptImageContextItem; +use chrono::{DateTime, Utc}; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_class::CommonModelClass; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation_provider::GenerationProvider; +use std::collections::HashSet; + +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::prompts::PromptToken; +use utoipa::{IntoParams, ToSchema}; + +pub const BATCH_GET_PROMPTS_PATH: &str = "/v1/prompt/batch"; + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct BatchGetPromptsQuery { + /// Prompt tokens to look up. + pub tokens: HashSet, +} + +#[derive(Serialize, ToSchema)] +pub struct BatchGetPromptsResponse { + pub success: bool, + pub prompts: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BatchPromptInfo { + pub token: PromptToken, + + /// The type of model used + pub maybe_model_type: Option, + + /// The class of model used + pub maybe_model_class: Option, + + /// The service provider used + pub maybe_generation_provider: Option, + + /// Positive prompt (technically optional, but usually present) + pub maybe_positive_prompt: Option, + + /// Negative prompt (optional) + pub maybe_negative_prompt: Option, + + /// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.) + pub maybe_generation_mode: Option, + + /// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.) + pub maybe_aspect_ratio: Option, + + /// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.) + pub maybe_resolution: Option, + + /// OPTIONAL. The output bitrate (eg. normal, high). + pub maybe_bitrate: Option, + + /// OPTIONAL. The number of outputs to generate (0-255). + pub maybe_batch_count: Option, + + /// OPTIONAL. Whether to generate audio. + pub maybe_generate_audio: Option, + + /// OPTIONAL. The duration in seconds. + pub maybe_duration_seconds: Option, + + /// Context images (optional) + pub maybe_context_images: Option>, + + pub created_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/create_prompt.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/create_prompt.rs new file mode 100644 index 0000000000..7e9d3298b5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/create_prompt.rs @@ -0,0 +1,52 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::prompts::PromptToken; +use utoipa::ToSchema; + +pub const CREATE_PROMPT_PATH: &str = "/v1/prompts/create"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct CreatePromptRequest { + /// Idempotency token to prevent duplicate requests. + pub uuid_idempotency_token: String, + + /// OPTIONAL. The positive prompt. + pub positive_prompt: Option, + + /// OPTIONAL. The negative prompt. + pub negative_prompt: Option, + + /// OPTIONAL. The model type. + pub model_type: Option, + + /// OPTIONAL. The service used. + pub generation_provider: Option, + + /// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.) + pub maybe_generation_mode: Option, + + /// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.) + pub maybe_aspect_ratio: Option, + + /// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.) + pub maybe_resolution: Option, + + /// OPTIONAL. The number of outputs to generate (0-255). + pub maybe_batch_count: Option, + + /// OPTIONAL. Whether to generate audio. + pub maybe_generate_audio: Option, + + /// OPTIONAL. The duration in seconds. + pub maybe_duration_seconds: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct CreatePromptResponse { + pub success: bool, + pub prompt_token: PromptToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs new file mode 100644 index 0000000000..ec096842c6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/get_prompt.rs @@ -0,0 +1,153 @@ +use crate::common::responses::media_links::MediaLinks; +use chrono::{DateTime, Utc}; +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_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_bitrate::CommonBitrate; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation_provider::GenerationProvider; +use enums::no_table::style_transfer::style_transfer_name::StyleTransferName; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +use utoipa::ToSchema; +use enums::common::generation::common_model_class::CommonModelClass; + +pub const GET_PROMPT_PATH: &str = "/v1/prompts/{token}"; + +#[derive(Deserialize, ToSchema)] +pub struct GetPromptPathInfo { + pub token: PromptToken, +} + +#[derive(Serialize, ToSchema)] +pub struct GetPromptSuccessResponse { + pub success: bool, + pub prompt: PromptInfo, +} + +#[derive(Serialize, ToSchema)] +pub struct PromptInfo { + pub token: PromptToken, + + /// The type of prompt. + /// Note: Prompts may or may not be compatible across systems. + pub prompt_type: PromptType, + + /// The type of model used + pub maybe_model_type: Option, + + /// The class of model used + pub maybe_model_class: Option, + + /// The service provider used + pub maybe_generation_provider: Option, + + /// Positive prompt (technically optional, but usually present) + pub maybe_positive_prompt: Option, + + /// Negative prompt (optional) + pub maybe_negative_prompt: Option, + + /// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.) + pub maybe_generation_mode: Option, + + /// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.) + pub maybe_aspect_ratio: Option, + + /// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.) + pub maybe_resolution: Option, + + /// OPTIONAL. The output bitrate (eg. normal, high). + pub maybe_bitrate: Option, + + /// OPTIONAL. The number of outputs to generate (0-255). + pub maybe_batch_count: Option, + + /// OPTIONAL. Whether to generate audio. + pub maybe_generate_audio: Option, + + /// OPTIONAL. The duration in seconds. + pub maybe_duration_seconds: Option, + + /// Context images (optional) + pub maybe_context_images: Option>, + + /// Scheduled / travel prompt (optional) + pub maybe_travel_prompt: Option, + + /// If a "style" was used, this is the name of it. + /// This might not be present for all types of inference + /// and typically only applies to video style transfer. + pub maybe_style_name: Option, + + /// How many milliseconds it took to run generation. + pub maybe_inference_duration_millis: Option, + + /// If a "strength" was used. + /// Typically only for video style transfer. + pub maybe_strength: Option, + + /// If a frame skip setting was used. + pub maybe_frame_skip: Option, + + /// If a face detailer was used. + /// This might not be present for all types of inference + /// and typically only applies to video style transfer. + pub used_face_detailer: bool, + + /// If an upscaling pass was used. + /// This might not be present for all types of inference + /// and typically only applies to video style transfer. + pub used_upscaler: bool, + + /// If lipsync was enabled. + /// This might not be present for all types of inference + /// and typically only applies to video style transfer. + pub lipsync_enabled: bool, + + /// If LCM was disabled. + /// Only staff can do this for now. + pub lcm_disabled: bool, + + /// If the cinematic workflow was used. + /// Only staff can do this for now. + pub use_cinematic: bool, + + /// If a global IP Adapter Image was used, this is the details. + /// NB: We can't easily query for this without another DB round trip, + /// so the frontend should query for it instead. + pub maybe_global_ipa_image_token: Option, + + /// Fields that only moderators should see. + pub maybe_moderator_fields: Option, + + pub created_at: DateTime, +} + +#[derive(Serialize, ToSchema)] +pub struct GetPromptImageContextItem { + pub media_token: MediaFileToken, + pub semantic: PromptContextSemanticType, + pub media_links: MediaLinks, +} + +#[derive(Serialize, ToSchema)] +pub struct PromptInfoModeratorFields { + /// How many milliseconds it took to run generation. + pub maybe_inference_duration_millis: Option, + + /// Version of Yae's workflow + /// Used for logging and debugging by the art team. + pub main_ipa_workflow: Option, + + /// Version of Yae's face detailer workflow + /// Used for logging and debugging by the art team. + pub face_detailer_workflow: Option, + + /// Version of Yae's upscaler workflow + /// Used for logging and debugging by the art team. + pub upscaler_workflow: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/mod.rs new file mode 100644 index 0000000000..90ff797ace --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/prompts/mod.rs @@ -0,0 +1,3 @@ +pub mod batch_get_prompts; +pub mod create_prompt; +pub mod get_prompt; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs new file mode 100644 index 0000000000..8e4fbbdeae --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs @@ -0,0 +1,70 @@ +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CREATE_NEW_USER_ACCOUNT_AND_SUBSCRIPTION_CHECKOUT_URL_PATH: &str = "/v1/stripe_artcraft/user_signup/subscription_checkout"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest { + /// The (non-Stripe) internal identifier for the product or subscription. + /// This will be translated into a Stripe identifier. + pub plan: Option, + + pub cadence: Option, + + /// Optional: The referral URL the user arrived from when first hitting the site, prior to navigation and signing up. + /// The browser can send `document.referrer` to the backend so we know how people are finding us. + /// If the browser doesn't send this parameter, we'll try the `referer` header. + pub maybe_referral_url: Option, + + /// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up. + /// The browser can send `window.location.href` to the backend so we know how people are finding us. + pub maybe_landing_url: Option, + + /// Optional: A referral username or code from a referring user. + pub maybe_referral_username: Option, + + /// Optional: A referral code created by another user. If present, takes priority over + /// `maybe_referral_username` for resolving the referring user. + pub maybe_referral_code: Option, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)] +pub enum PlanBillingCadence { + #[serde(rename = "monthly")] + Monthly, + + #[serde(rename = "yearly")] + Yearly, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupResponse { + pub success: bool, + + /// The checkout session URL. + pub stripe_checkout_redirect_url: String, + + /// If a user account was created, these are the details. + pub generated_user: Option, + + /// If a session was created, these are the details. + pub session: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UserDetails { + /// The generated username. + pub username: String, + + /// The generated display name. + pub display_name: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SessionDetails { + /// A signed session that can be sent as a header, bypassing cookies. + /// This is useful for API clients that don't support cookies or Google + /// browsers killing cross-domain cookies. + pub signed_session: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_credits_pack_checkout.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_credits_pack_checkout.rs new file mode 100644 index 0000000000..4aed91b671 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_credits_pack_checkout.rs @@ -0,0 +1,17 @@ +use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CREATE_CREDITS_PACK_CHECKOUT_URL_PATH: &str = "/v1/stripe_artcraft/checkout/credits_pack"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateCreditsPackCheckoutRequest { + pub credits_pack: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateCreditsPackCheckoutResponse { + pub success: bool, + pub stripe_checkout_redirect_url: String, +} + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_subscription_checkout.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_subscription_checkout.rs new file mode 100644 index 0000000000..2d30193d87 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_subscription_checkout.rs @@ -0,0 +1,30 @@ +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CREATE_SUBSCRIPTION_CHECKOUT_URL_PATH: &str = "/v1/stripe_artcraft/checkout/subscription"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateSubscriptionCheckoutRequest { + /// The (non-Stripe) internal identifier for the product or subscription. + /// This will be translated into a Stripe identifier. + pub plan: Option, + + pub cadence: Option, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)] +pub enum PlanBillingCadence { + #[serde(rename = "monthly")] + Monthly, + + #[serde(rename = "yearly")] + Yearly, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateSubscriptionCheckoutResponse { + pub success: bool, + pub stripe_checkout_redirect_url: String, +} + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_cancel_plan.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_cancel_plan.rs new file mode 100644 index 0000000000..59bae5b9eb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_cancel_plan.rs @@ -0,0 +1,17 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CUSTOMER_PORTAL_CANCEL_PLAN_URL_PATH: &str = "/v1/stripe_artcraft/portal/cancel_plan"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalCancelPlanRequest { + // TODO: Not sure if this is needed + pub portal_config_id: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalCancelPlanResponse { + pub success: bool, + pub stripe_portal_url: String, +} + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_manage_plan.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_manage_plan.rs new file mode 100644 index 0000000000..31f4c5278d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_manage_plan.rs @@ -0,0 +1,17 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CUSTOMER_PORTAL_MANAGE_PLAN_URL_PATH: &str = "/v1/stripe_artcraft/portal/manage_plan"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalManagePlanRequest { + // TODO: Not sure if this is needed + pub portal_config_id: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalManagePlanResponse { + pub success: bool, + pub stripe_portal_url: String, +} + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_switch_plan.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_switch_plan.rs new file mode 100644 index 0000000000..f04285f2bd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_switch_plan.rs @@ -0,0 +1,37 @@ +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CUSTOMER_PORTAL_SWITCH_PLAN_URL_PATH: &str = "/v1/stripe_artcraft/portal/switch_plan"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalSwitchPlanRequest { + // TODO: Not sure if this is needed + pub portal_config_id: Option, + + /// REQUIRED + /// The plan we're switching to. + pub plan: Option, + + /// REQUIRED + /// The cadence for the plan billing. + pub cadence: Option, +} + + +#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)] +pub enum PlanBillingCadenceConfirmation { + #[serde(rename = "monthly")] + Monthly, + + #[serde(rename = "yearly")] + Yearly, +} + + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalSwitchPlanResponse { + pub success: bool, + pub stripe_portal_url: String, +} + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_update_payment_method.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_update_payment_method.rs new file mode 100644 index 0000000000..4ea9f423e3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/customer_portal_update_payment_method.rs @@ -0,0 +1,17 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CUSTOMER_PORTAL_UPDATE_PAYMENT_METHOD_URL_PATH: &str = "/v1/stripe_artcraft/portal/update_payment_method"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalUpdatePaymentMethodRequest { + // TODO: Not sure if this is needed + pub portal_config_id: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCustomerPortalUpdatePaymentMethodResponse { + pub success: bool, + pub stripe_portal_url: String, +} + diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs new file mode 100644 index 0000000000..0083af57ca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs @@ -0,0 +1,7 @@ +pub mod create_create_new_user_account_and_subscription_checkout; +pub mod create_credits_pack_checkout; +pub mod create_subscription_checkout; +pub mod customer_portal_cancel_plan; +pub mod customer_portal_manage_plan; +pub mod customer_portal_switch_plan; +pub mod customer_portal_update_payment_method; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/subscriptions/get_session_subscription.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/subscriptions/get_session_subscription.rs new file mode 100644 index 0000000000..aa7a0088dd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/subscriptions/get_session_subscription.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use enums::common::payments_namespace::PaymentsNamespace; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::user_subscriptions::UserSubscriptionToken; +use utoipa::ToSchema; + +// TODO: Maybe make "artcraft_subscription" or "fakeyou_subscription" with strongly typed slugs? + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct GetSessionSubscriptionResponse { + pub success: bool, + + /// Information about the user's subscription, if any. + pub active_subscription: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SubscriptionInfo { + /// Unique token for the subscription. + pub subscription_token: UserSubscriptionToken, + + /// Namespace for the subscription. + pub namespace: PaymentsNamespace, + + /// Local key for the subscription. + pub product_slug: String, + + /// If the subscription is currently active, this is the next billing date. + pub next_bill_at: Option>, + + /// If the subscription is set to cancel, this is the end date of the subscription. + pub subscription_end_at: Option>, + + ///// When the current billing period ends (either + ///// auto-renew/rebill date or auto-cancel/cancellation date). + //pub current_billing_period_end_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/subscriptions/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/subscriptions/mod.rs new file mode 100644 index 0000000000..1cc61e22e6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/subscriptions/mod.rs @@ -0,0 +1 @@ +pub mod get_session_subscription; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/add_media_file_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/add_media_file_tags.rs new file mode 100644 index 0000000000..9928ed0692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/add_media_file_tags.rs @@ -0,0 +1,35 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::tags::common::TagDetails; + +// ── POST /v1/tags/media_file/add/{media_file_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct AddMediaFileTagsPathInfo { + pub media_file_token: MediaFileToken, +} + +/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if +/// both or neither). Tag text is trimmed and deduped case-insensitively; +/// empty entries are dropped. Must sanitize to at least one tag (400 +/// otherwise). +#[derive(Deserialize, ToSchema)] +pub struct AddMediaFileTagsRequest { + /// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`. + pub maybe_tags: Option, + + /// Tags as a list. Entries are still trimmed. + pub maybe_tags_list: Option>, +} + +#[derive(Serialize, ToSchema)] +pub struct AddMediaFileTagsSuccessResponse { + pub success: bool, + + /// The tags from this request after upsert, with canonical tokens and + /// fresh use counts. Tags already on the file are absorbed (no error). + pub tags: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_add_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_add_tags.rs new file mode 100644 index 0000000000..7c9adb6e5e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_add_tags.rs @@ -0,0 +1,38 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::tags::common::TagDetails; + +// ── POST /v1/tags/bulk_add ── + +/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if +/// both or neither). Tag text is trimmed and deduped case-insensitively; +/// empty entries are dropped. Must sanitize to at least one tag (400 +/// otherwise). Media file tokens are deduped; tokens the user doesn't +/// own (or that are deleted) are silently skipped. +#[derive(Deserialize, ToSchema)] +pub struct BulkAddTagsRequest { + /// Media files to tag. + pub media_file_tokens: Vec, + + /// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`. + pub maybe_tags: Option, + + /// Tags as a list. Entries are still trimmed. + pub maybe_tags_list: Option>, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkAddTagsSuccessResponse { + pub success: bool, + + /// The subset of the input tokens that were actually tagged: files + /// that exist, aren't deleted, and are owned by the caller. + pub accepted_media_file_tokens: Vec, + + /// The tags from this request after upsert, with canonical tokens and + /// fresh use counts. + pub tags: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_list_media_file_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_list_media_file_tags.rs new file mode 100644 index 0000000000..b9d9f78fea --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_list_media_file_tags.rs @@ -0,0 +1,33 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::tags::common::TagDetails; + +// ── POST /v1/tags/media_files/bulk_list_tags ── +// +// POST rather than GET: the token list belongs in a request body, and +// GET request bodies are non-standard. The operation is still a pure +// read. + +#[derive(Deserialize, ToSchema)] +pub struct BulkListMediaFileTagsRequest { + /// Media files to look up. Tokens with no tags come back with an + /// empty `tags` list. + pub media_file_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct MediaFileTagsEntry { + pub media_file_token: MediaFileToken, + pub tags: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkListMediaFileTagsSuccessResponse { + pub success: bool, + + /// One entry per requested media file token (deduped), in request order. + pub media_files: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_set_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_set_tags.rs new file mode 100644 index 0000000000..91bac22fd6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/bulk_set_tags.rs @@ -0,0 +1,44 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::tags::common::TagDetails; + +// ── POST /v1/tags/bulk_set ── + +/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if +/// both or neither). Tag text is trimmed and deduped case-insensitively; +/// empty entries are dropped. Unlike `bulk_add`, sanitizing down to zero +/// tags is allowed — it clears all tags from the listed media files. +/// Media file tokens are deduped; tokens the user doesn't own (or that +/// are deleted) are silently skipped. +#[derive(Deserialize, ToSchema)] +pub struct BulkSetTagsRequest { + /// Media files whose tag sets will be replaced. + pub media_file_tokens: Vec, + + /// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`. + pub maybe_tags: Option, + + /// Tags as a list. Entries are still trimmed. + pub maybe_tags_list: Option>, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkSetTagsSuccessResponse { + pub success: bool, + + /// The subset of the input tokens that were actually updated: files + /// that exist, aren't deleted, and are owned by the caller. + pub accepted_media_file_tokens: Vec, + + /// The full tag set now on every accepted media file, with canonical + /// tokens and fresh use counts. + pub tags: Vec, + + /// How many previously-attached tag links were removed across all the + /// accepted media files because they weren't mentioned in the request. + /// (Orphaned tags are not deleted.) + pub removed_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/clear_media_file_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/clear_media_file_tags.rs new file mode 100644 index 0000000000..a7dac894ed --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/clear_media_file_tags.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +// ── POST /v1/tags/media_file/clear/{media_file_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct ClearMediaFileTagsPathInfo { + pub media_file_token: MediaFileToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ClearMediaFileTagsSuccessResponse { + pub success: bool, + + /// How many tag links were removed from the media file. (Orphaned + /// tags are not deleted.) + pub removed_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/common.rs new file mode 100644 index 0000000000..bff534cadd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/common.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::tags::TagToken; + +/// One tag as returned by every tags endpoint. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct TagDetails { + pub tag_token: TagToken, + + /// The display value of the tag, as entered by its creator. + pub tag_value: String, + + /// Lowercased form of `tag_value`. This is the tag's unique key + /// within the creator's account. + pub tag_value_lowercase: String, + + /// Rollup statistic: how many media files currently carry this tag. + pub use_count: u32, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/delete_tag.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/delete_tag.rs new file mode 100644 index 0000000000..033eeb5a64 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/delete_tag.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::tags::TagToken; + +// ── DELETE /v1/tags/{tag_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct DeleteTagPathInfo { + pub tag_token: TagToken, +} + +#[derive(Serialize, ToSchema)] +pub struct DeleteTagSuccessResponse { + pub success: bool, + + /// How many media-file links were hard-deleted along with the tag. + /// (The tag record itself is soft-deleted.) + pub removed_link_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_media_file_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_media_file_tags.rs new file mode 100644 index 0000000000..d9d8fdd7d4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_media_file_tags.rs @@ -0,0 +1,21 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::tags::common::TagDetails; + +// ── GET /v1/tags/media_file/list/{media_file_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct ListMediaFileTagsPathInfo { + pub media_file_token: MediaFileToken, +} + +#[derive(Serialize, ToSchema)] +pub struct ListMediaFileTagsSuccessResponse { + pub success: bool, + + /// All (live) tags on the media file, sorted by tag value. + pub tags: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_media_files_with_tag.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_media_files_with_tag.rs new file mode 100644 index 0000000000..64d7673baa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_media_files_with_tag.rs @@ -0,0 +1,22 @@ +use serde_derive::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::tags::TagToken; + +// ── GET /v1/tags/media_files/with_tag/{tag_token} ── +// +// NB: The success response lives in `storyteller_web`'s handler because +// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails` +// constructors that depend on the request's `MediaDomain` + +// `ServerEnvironment`. + +#[derive(Deserialize, ToSchema)] +pub struct ListMediaFilesWithTagPathInfo { + pub tag_token: TagToken, +} + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListMediaFilesWithTagQueryParams { + pub cursor: Option, + pub limit: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_tagged_media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_tagged_media_files.rs new file mode 100644 index 0000000000..fc4d3c50f3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_tagged_media_files.rs @@ -0,0 +1,15 @@ +use serde_derive::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +// ── GET /v1/tags/media_files/list_tagged ── +// +// NB: The success response lives in `storyteller_web`'s handler because +// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails` +// constructors that depend on the request's `MediaDomain` + +// `ServerEnvironment`. + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListTaggedMediaFilesQueryParams { + pub cursor: Option, + pub limit: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_tags.rs new file mode 100644 index 0000000000..ec11ad06c9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_tags.rs @@ -0,0 +1,23 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::tags::common::TagDetails; + +// ── GET /v1/tags/list ── + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListTagsQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListTagsSuccessResponse { + pub success: bool, + + /// The logged-in user's (live) tags, newest first. + pub tags: Vec, + + /// Present when there may be more results; pass back as `cursor`. + pub maybe_cursor: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_untagged_media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_untagged_media_files.rs new file mode 100644 index 0000000000..11be21d4be --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/list_untagged_media_files.rs @@ -0,0 +1,15 @@ +use serde_derive::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +// ── GET /v1/tags/media_files/list_untagged ── +// +// NB: The success response lives in `storyteller_web`'s handler because +// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails` +// constructors that depend on the request's `MediaDomain` + +// `ServerEnvironment`. + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListUntaggedMediaFilesQueryParams { + pub cursor: Option, + pub limit: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/mod.rs new file mode 100644 index 0000000000..cdade59b8a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/mod.rs @@ -0,0 +1,14 @@ +pub mod add_media_file_tags; +pub mod bulk_add_tags; +pub mod bulk_list_media_file_tags; +pub mod bulk_set_tags; +pub mod clear_media_file_tags; +pub mod common; +pub mod delete_tag; +pub mod list_media_file_tags; +pub mod list_media_files_with_tag; +pub mod list_tagged_media_files; +pub mod list_tags; +pub mod list_untagged_media_files; +pub mod rename_tag; +pub mod set_media_file_tags; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/rename_tag.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/rename_tag.rs new file mode 100644 index 0000000000..743c2d9917 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/rename_tag.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::tags::TagToken; + +use crate::tags::common::TagDetails; + +// ── PUT /v1/tags/rename/{tag_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct RenameTagPathInfo { + pub tag_token: TagToken, +} + +#[derive(Deserialize, ToSchema)] +pub struct RenameTagRequest { + /// The new tag text (trimmed server-side). Both case-only changes and + /// wholesale renames are allowed. Fails with 400 if the caller already + /// has a different tag with the same lowercased value. + pub new_tag_value: String, +} + +#[derive(Serialize, ToSchema)] +pub struct RenameTagSuccessResponse { + pub success: bool, + + /// The tag after the rename. + pub tag: TagDetails, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/tags/set_media_file_tags.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/set_media_file_tags.rs new file mode 100644 index 0000000000..367ecf8317 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/tags/set_media_file_tags.rs @@ -0,0 +1,39 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::tags::common::TagDetails; + +// ── POST /v1/tags/media_file/set/{media_file_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct SetMediaFileTagsPathInfo { + pub media_file_token: MediaFileToken, +} + +/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if +/// both or neither). Tag text is trimmed and deduped case-insensitively; +/// empty entries are dropped. Unlike `add`, sanitizing down to zero tags +/// is allowed — it clears all tags from the media file. +#[derive(Deserialize, ToSchema)] +pub struct SetMediaFileTagsRequest { + /// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`. + pub maybe_tags: Option, + + /// Tags as a list. Entries are still trimmed. + pub maybe_tags_list: Option>, +} + +#[derive(Serialize, ToSchema)] +pub struct SetMediaFileTagsSuccessResponse { + pub success: bool, + + /// The media file's full tag set after the operation, with canonical + /// tokens and fresh use counts. + pub tags: Vec, + + /// How many previously-attached tag links were removed because they + /// weren't mentioned in the request. (Orphaned tags are not deleted.) + pub removed_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/create_referral_code.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/create_referral_code.rs new file mode 100644 index 0000000000..d41ed315b7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/create_referral_code.rs @@ -0,0 +1,17 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::user_referral_codes::UserReferralCodeToken; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +pub struct CreateReferralCodeRequest { + /// The referral code string. Alphanumeric plus underscore, period, dash. + pub code: String, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateReferralCodeResponse { + pub success: bool, + pub token: UserReferralCodeToken, + pub code: String, + pub code_lowercase: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/delete_referral_code.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/delete_referral_code.rs new file mode 100644 index 0000000000..cebe7009cb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/delete_referral_code.rs @@ -0,0 +1,13 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::user_referral_codes::UserReferralCodeToken; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +pub struct DeleteReferralCodePathInfo { + pub token: UserReferralCodeToken, +} + +#[derive(Serialize, ToSchema)] +pub struct DeleteReferralCodeResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/list_referral_codes.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/list_referral_codes.rs new file mode 100644 index 0000000000..fb1321ad68 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/list_referral_codes.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::user_referral_codes::UserReferralCodeToken; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct ListReferralCodesResponse { + pub success: bool, + pub referral_codes: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ReferralCodeEntry { + pub token: UserReferralCodeToken, + pub code: String, + pub code_lowercase: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/mod.rs new file mode 100644 index 0000000000..97274226d9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/mod.rs @@ -0,0 +1,3 @@ +pub mod create_referral_code; +pub mod delete_referral_code; +pub mod list_referral_codes; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/change_password.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/change_password.rs new file mode 100644 index 0000000000..0c24c08eb3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/change_password.rs @@ -0,0 +1,13 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ChangePasswordRequest { + pub password: String, + pub password_confirmation: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ChangePasswordResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs new file mode 100644 index 0000000000..ea91726cf5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs @@ -0,0 +1,12 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EditEmailRequest { + pub email_address: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EditEmailResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_username.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_username.rs new file mode 100644 index 0000000000..427aa14ddf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_username.rs @@ -0,0 +1,12 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EditUsernameRequest { + pub display_name: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EditUsernameResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/login.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/login.rs new file mode 100644 index 0000000000..0a0f9570d8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/login.rs @@ -0,0 +1,27 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +pub struct LoginRequest { + pub username_or_email: String, + pub password: String, +} + +#[derive(Serialize, ToSchema)] +pub struct LoginSuccessResponse { + pub success: bool, + + /// A signed session that can be sent as a header, bypassing cookies. + /// This is useful for API clients that don't support cookies or Google + /// browsers killing cross-domain cookies. + pub signed_session: String, +} + +#[derive(Copy, Clone, Debug, Serialize, ToSchema)] +pub enum LoginErrorType { + /// Account was created without a password and the user needs to create one + AccountNeedsPassword, + /// Invalid login credentials were supplied + InvalidCredentials, + ServerError, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs new file mode 100644 index 0000000000..a2a81c8136 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs @@ -0,0 +1,5 @@ +pub mod change_password; +pub mod edit_email; +pub mod edit_username; +pub mod login; +pub mod session_info; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs new file mode 100644 index 0000000000..a4baac7c97 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs @@ -0,0 +1,99 @@ +use crate::common::responses::user_details_light::UserDetailsLight; +use enums::by_table::users::user_feature_flag::UserFeatureFlag; +use serde_derive::Serialize; +use std::collections::BTreeSet; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +#[derive(Serialize, Copy, Clone, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FakeYouPlan { + Free, + Basic, + Standard, + Pro, +} + +#[derive(Serialize, Copy, Clone, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StorytellerStreamPlan { + Free, + Basic, + Standard, + Pro, +} + +#[derive(Serialize, ToSchema)] +pub struct SessionUserInfo { + pub core_info: UserDetailsLight, + + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + + pub onboarding: SessionOnboardingState, + + // TODO(bt,2024-03-30): Remove legacy feature flag + #[deprecated(note = "DO NOT USE. Use `maybe_feature_flags` instead! The flag you're looking for is `studio`.")] + pub can_access_studio: bool, + + /// Collection of feature / rollout flags + /// This is the proper place to detect if a user has access to some rollout (non-paywall) feature. + /// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes + pub maybe_feature_flags: BTreeSet, + + // Premium plans: + #[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")] + pub fakeyou_plan: FakeYouPlan, + + #[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")] + pub storyteller_stream_plan: StorytellerStreamPlan, + + // Usage permissions: + pub can_use_tts: bool, + pub can_use_w2l: bool, + pub can_delete_own_tts_results: bool, + pub can_delete_own_w2l_results: bool, + pub can_delete_own_account: bool, + + // Contribution permissions: + pub can_upload_tts_models: bool, + pub can_upload_w2l_templates: bool, + pub can_delete_own_tts_models: bool, + pub can_delete_own_w2l_templates: bool, + + // Moderation permissions: + pub can_approve_w2l_templates: bool, + pub can_edit_other_users_profiles: bool, + pub can_edit_other_users_tts_models: bool, + pub can_edit_other_users_w2l_templates: bool, + pub can_delete_other_users_tts_models: bool, + pub can_delete_other_users_tts_results: bool, + pub can_delete_other_users_w2l_templates: bool, + pub can_delete_other_users_w2l_results: bool, + pub can_ban_users: bool, + pub can_delete_users: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct SessionOnboardingState { + /// If true, the user hasn't set their email. + pub email_not_set: bool, + + /// If true, the user hasn't confirmed their email. + pub email_not_confirmed: bool, + + /// If true, the user hasn't set their password. + pub password_not_set: bool, + + /// If true, the user hasn't set their username (the username they have is random). + pub username_not_customized: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct SessionInfoSuccessResponse { + pub success: bool, + pub logged_in: bool, + pub user: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/utils/media_links_to_thumbnail_template.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/utils/media_links_to_thumbnail_template.rs new file mode 100644 index 0000000000..04d3715864 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/utils/media_links_to_thumbnail_template.rs @@ -0,0 +1,12 @@ +use crate::common::responses::media_links::MediaLinks; + +/// Get the thumbnail template for either images or videos. +pub fn media_links_to_thumbnail_template(links: &MediaLinks) -> Option<&str> { + // NB: We only populate video previews for video tasks. + if let Some(video) = links.maybe_video_previews.as_ref() { + return Some(&video.animated_thumbnail_template); + } + + // Image case - use the image thumbnail template if it exists. + links.maybe_thumbnail_template.as_deref() +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/utils/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/utils/mod.rs new file mode 100644 index 0000000000..a9155d7e9c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod media_links_to_thumbnail_template; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/mod.rs new file mode 100644 index 0000000000..af59a00150 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/mod.rs @@ -0,0 +1,3 @@ +pub mod notes; +pub mod read_only; +pub mod upload; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/notes.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/notes.rs new file mode 100644 index 0000000000..e3f3b2e151 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/notes.rs @@ -0,0 +1,60 @@ +//! Request/response types for `POST /v1/video_info/notes`. +//! +//! A note is user-submitted context about an uploaded video (what model they +//! believe it is, where it came from, free-form comments). Always tied to an +//! [`UploadedVideoToken`]; pass a [`UploadVideoNoteToken`] to update an existing +//! note instead of creating a new one. + +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_model_type::UploadedVideoNoteReportedModelType; +use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_website::UploadedVideoNoteReportedWebsite; +use tokens::tokens::uploaded_video_notes::UploadVideoNoteToken; +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +/// Request body for `POST /v1/video_info/notes`. +#[derive(Deserialize, ToSchema, Debug, Clone)] +pub struct VideoInfoNoteRequest { + /// The uploaded video this note is about. Required. + pub uploaded_video_token: UploadedVideoToken, + + /// If present, update this existing note instead of creating a new one. + pub maybe_uploaded_video_note_token: Option, + + /// Original filename of the submitted clip. + pub maybe_filename: Option, + + /// The model type the submitter claims this video is (dropdown / enum). + pub maybe_reported_model_type: Option, + + /// Free-form custom model name (trimmed; empty becomes absent). + pub maybe_reported_model_name: Option, + + /// The website / platform the video came from (dropdown / enum). + pub maybe_website: Option, + + /// Free-form website (trimmed; empty becomes absent). + pub maybe_other_website: Option, + + /// Free-form comments (trimmed; empty becomes absent). + pub maybe_comments: Option, + + /// Optional contact email (trimmed; empty becomes absent). + pub maybe_email_address: Option, + + /// Whether the submitter consents to us sharing the report. Defaults to false. + pub can_share_report: Option, + + /// Whether the submitter believes they were scammed. Defaults to false. + pub was_scammed: Option, +} + +/// Response body for `POST /v1/video_info/notes`. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct VideoInfoNoteResponse { + pub success: bool, + + /// The created or updated note's token (e.g. `uvnote_…`). + pub uploaded_video_note_token: UploadVideoNoteToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/read_only.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/read_only.rs new file mode 100644 index 0000000000..6c6c9dffd5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/read_only.rs @@ -0,0 +1,167 @@ +//! Response types for `POST /v1/video_info/read_only`. +//! +//! The endpoint accepts a `multipart/form-data` video upload, inspects its +//! embedded provenance (C2PA manifests, AIGC labels, in-stream watermarks), and +//! returns what it found. These are the public, wire-stable API types — they +//! deliberately mirror, but do not re-export, the internal `video_info` crate +//! types so the parsing library can evolve independently of the HTTP contract. + +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response for `POST /v1/video_info/read_only`. +/// +/// [`kind`](Self::kind) names the detected provenance family; the matching +/// `maybe_*` field carries its details (all others are `null`). `unrecognized` +/// means no supported provenance was found — [`maybe_encoder`](Self::maybe_encoder) +/// still reports the container encoder tag when present (an `Lavf…` value means +/// the file was re-encoded through ffmpeg, which strips provenance). +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct VideoInfoReadOnlyResponse { + pub success: bool, + + /// Which provenance family was detected (if any). + pub kind: VideoProvenanceKind, + + /// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`. + pub maybe_encoder: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`]. + pub maybe_seedance: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`]. + pub maybe_veo: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`]. + pub maybe_sora: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`]. + pub maybe_dreamina: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`]. + pub maybe_kling: Option, +} + +/// The recognized AI-video provenance family. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VideoProvenanceKind { + /// ByteDance Seedance API (Volcengine / BytePlus C2PA). + Seedance, + /// Google Veo (Google Generative AI video C2PA). + Veo, + /// OpenAI Sora (OpenAI Generative AI video C2PA). + Sora, + /// Dreamina (ByteDance/CapCut consumer app `ilst` metadata). + Dreamina, + /// Kling (Kuaishou AIGC-label / in-stream watermark). + Kling, + /// No supported provenance was found (often a re-encoded / stripped file). + Unrecognized, +} + +/// Seedance (ByteDance) provenance from an embedded C2PA manifest. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct SeedanceVideoInfo { + /// `"volcengine"` or `"byteplus"`. + pub platform: String, + /// C2PA `softwareAgent`, e.g. `"Volcengine_Ark_CN"`. + pub software_agent: String, + pub maybe_software_agent_version: Option, + /// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`. + pub model_name: String, + pub maybe_model_brand: Option, + /// Parsed version, e.g. `"2.0"` / `"1.0"`. + pub maybe_model_version: Option, + pub is_fast: bool, + pub is_lite: bool, + /// Raw generation timestamp string (RFC 3339). + pub generated_at: String, + /// Normalized RFC 3339 timestamp, when parseable. + pub maybe_generated_at_utc: Option, + pub maybe_log_id: Option, + pub maybe_log_id_decoded_hex: Option, + pub maybe_digital_source_type: Option, + pub maybe_claim_generator: Option, + pub maybe_claim_generator_version: Option, + pub maybe_manifest_id: Option, + pub maybe_instance_id: Option, + pub maybe_signer_email: Option, + pub maybe_signer_org_id: Option, + pub maybe_signer_country: Option, + pub maybe_cert_serial: Option, +} + +/// Google Veo provenance. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct VeoVideoInfo { + /// Always `"Google"`. + pub producer: String, + /// Whether a full Google C2PA manifest is present (`false` = recognized only + /// by the `encoder=Google` tag, C2PA likely stripped by a re-encode). + pub has_c2pa_manifest: bool, + pub maybe_encoder: Option, + pub maybe_created_description: Option, + pub has_synthid_watermark: bool, + pub maybe_synthid_description: Option, + pub maybe_digital_source_type: Option, + pub maybe_claim_generator: Option, + pub maybe_claim_generator_version: Option, + pub maybe_manifest_id: Option, + pub maybe_instance_id: Option, + pub maybe_cert_serial: Option, + /// Issuing intermediate-CA common name, e.g. `"Google C2PA Media Services 1P ICA G3"`. + pub maybe_signer_ca: Option, + /// Whether the manifest carries an RFC 3161 trusted timestamp. + pub is_timestamped: bool, + pub maybe_timestamp_authority: Option, +} + +/// OpenAI Sora provenance. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct SoraVideoInfo { + /// Always `"OpenAI"`. + pub producer: String, + pub maybe_model_name: Option, + pub maybe_created_description: Option, + pub maybe_digital_source_type: Option, + pub maybe_claim_generator: Option, + pub maybe_manifest_id: Option, + pub maybe_instance_id: Option, + pub maybe_cert_serial: Option, +} + +/// Dreamina (ByteDance/CapCut app) provenance from `ilst` metadata. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct DreaminaVideoInfo { + /// The product string, e.g. `"dreamina"`. + pub product: String, + pub maybe_export_type: Option, + pub maybe_os: Option, + pub maybe_source_info: Option, + pub maybe_aigc_label_type: Option, + pub maybe_video_id: Option, + pub has_c2pa: bool, + pub maybe_signer_org_id: Option, + pub maybe_signer_country: Option, + pub maybe_cert_serial: Option, +} + +/// Kling (Kuaishou) provenance from the AIGC label and/or in-stream watermark. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct KlingVideoInfo { + /// Model version when a clean export embeds it (e.g. `"2.5-pro"`); usually `null`. + pub maybe_model_version: Option, + /// Whether labeled AI-generated (`Label == "1"`, per China GB 45438). + pub is_ai_generated: bool, + pub maybe_label: Option, + pub maybe_content_producer: Option, + pub maybe_produce_id: Option, + pub maybe_content_propagator: Option, + pub maybe_propagate_id: Option, + pub maybe_reserved_code_1: Option, + pub maybe_reserved_code_2: Option, + /// Whether the in-stream `kling-ai` SEI watermark is present. + pub has_stream_watermark: bool, + pub maybe_watermark_uuid: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/upload.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/upload.rs new file mode 100644 index 0000000000..6ecf5b3a79 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/video_info/upload.rs @@ -0,0 +1,46 @@ +//! Response type for `POST /v1/video_info/upload`. +//! +//! Same detected provenance as the read-only endpoint, plus the persisted +//! [`UploadedVideoToken`] (used for follow-up requests, e.g. attaching a note). + +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::uploaded_videos::UploadedVideoToken; + +use crate::video_info::read_only::{ + DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo, + VideoProvenanceKind, +}; + +/// Response for `POST /v1/video_info/upload`. Mirrors +/// [`crate::video_info::read_only::VideoInfoReadOnlyResponse`] and adds the +/// persisted record's token. +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct VideoInfoUploadResponse { + pub success: bool, + + /// The persisted `uploaded_videos` record token (e.g. `uvt_…`). + pub uploaded_video_token: UploadedVideoToken, + + /// Which provenance family was detected (if any). + pub kind: VideoProvenanceKind, + + /// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`. + pub maybe_encoder: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`]. + pub maybe_seedance: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`]. + pub maybe_veo: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`]. + pub maybe_sora: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`]. + pub maybe_dreamina: Option, + + /// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`]. + pub maybe_kling: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/web_referrals/log_web_referral.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/web_referrals/log_web_referral.rs new file mode 100644 index 0000000000..c7d2e4616f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/web_referrals/log_web_referral.rs @@ -0,0 +1,17 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const LOG_WEB_REFERRAL_URL_PATH: &str = "/v1/web_referrals/record"; + +#[derive(Deserialize, ToSchema)] +pub struct LogWebReferralRequest { + /// Optional: The referral URL the user arrived from when signing up. + /// The browser can send `document.referrer` to the backend so we know how people are finding us. + /// If the browser doesn't send this parameter, we'll try the `referer` header. + pub maybe_referral_url: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct LogWebReferralResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/web_referrals/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/web_referrals/mod.rs new file mode 100644 index 0000000000..ea486b9a8e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/web_referrals/mod.rs @@ -0,0 +1 @@ +pub mod log_web_referral; diff --git a/crates/api_clients/artcraft/artcraft_client/Cargo.toml b/crates/api_clients/artcraft/artcraft_client/Cargo.toml new file mode 100644 index 0000000000..ec8c064abb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "artcraft_client" +edition = "2021" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +# [lints.rust] +# unused_imports = "forbid" + +[lib] +name = "artcraft_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +artcraft_api_defs.workspace = true +cloudflare_errors.workspace = true +enums.workspace = true +errors.workspace = true +uuid_utils.workspace = true +mimetypes.workspace = true +tokens.workspace = true + +# External +anyhow.workspace = true +chrono.workspace = true +cookie.workspace = true +log.workspace = true +reqwest = { workspace = true, features = ["multipart", "gzip"] } +url.workspace = true + +# Serialization +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +# HTTP +backoff = { version = "0.4.0", features = ["futures"] } # Exponential backoff retry +uuid = { version = "1.8.0", features = ["v4"] } + +# TODO: This is not a very mature, safe, or intuitive URL builder library. Please replace it! +url-builder = "0.1.1" + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/api_clients/artcraft/artcraft_client/src/credentials/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/credentials/mod.rs new file mode 100644 index 0000000000..94028babac --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/credentials/mod.rs @@ -0,0 +1,4 @@ +pub mod parse_multi_cookie_header; +pub mod storyteller_avt_cookie; +pub mod storyteller_credential_set; +pub mod storyteller_session_cookie; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/credentials/parse_multi_cookie_header.rs b/crates/api_clients/artcraft/artcraft_client/src/credentials/parse_multi_cookie_header.rs new file mode 100644 index 0000000000..50630fe4ba --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/credentials/parse_multi_cookie_header.rs @@ -0,0 +1,35 @@ +use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie; +use cookie::Cookie; + +/// Parses a multi-cookie header string (e.g. `"visitor=abc; session=xyz"`) and extracts +/// the known Storyteller cookies by name. +/// +/// Returns `Ok(Some(...))` if at least one recognised cookie is present, +/// `Ok(None)` if the input is empty or contains no recognised cookies, +/// and `Err` if any individual cookie segment fails to parse. +pub fn parse_multi_cookie_header(header: &str) -> Result, cookie::ParseError> { + let mut avt: Option = None; + let mut session: Option = None; + + for segment in header.split(';') { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + let cookie = Cookie::parse(segment.to_owned())?; + if avt.is_none() { + avt = StorytellerAvtCookie::maybe_from_cookie(&cookie); + } + if session.is_none() { + session = StorytellerSessionCookie::maybe_from_cookie(&cookie); + } + } + + if avt.is_none() && session.is_none() { + return Ok(None); + } + + Ok(Some(StorytellerCredentialSet::initialize(avt, session))) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_avt_cookie.rs b/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_avt_cookie.rs new file mode 100644 index 0000000000..fedd0c9f08 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_avt_cookie.rs @@ -0,0 +1,44 @@ +use cookie::Cookie; + +const AVT_COOKIE_NAME : &str = "visitor"; + +/// The "anonymous visitor token" cookie allows users to interact with +/// the service in a logged-out state with some continuity. +#[derive(Clone)] +pub struct StorytellerAvtCookie { + cookie: String, +} + +impl StorytellerAvtCookie { + pub fn new(cookie: String) -> Self { + Self { cookie } + } + + pub fn maybe_from_cookie(cookie: &Cookie) -> Option { + if cookie.name() == AVT_COOKIE_NAME { + Some(Self::new(cookie.value().to_string())) + } else { + None + } + } + + pub fn as_str(&self) -> &str { + &self.cookie + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookie.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookie.clone() + } + + pub fn equals(&self, other: &Self) -> bool { + self.cookie == other.cookie + } + + pub fn as_cookie_header(&self) -> String { + format!("{}={}", AVT_COOKIE_NAME, self.cookie) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_credential_set.rs b/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_credential_set.rs new file mode 100644 index 0000000000..3d2d69e1e9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_credential_set.rs @@ -0,0 +1,141 @@ +use crate::credentials::parse_multi_cookie_header::parse_multi_cookie_header; +use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie; + +#[derive(Clone)] +pub struct StorytellerCredentialSet { + pub avt: Option, + pub session: Option, +} + +impl StorytellerCredentialSet { + pub fn empty() -> Self { + Self { + avt: None, + session: None, + } + } + + pub fn initialize( + avt: Option, + session: Option, + ) -> Self { + Self { + avt, + session, + } + } + + pub fn parse_multi_cookie_header(header: &str) -> Result, cookie::ParseError> { + parse_multi_cookie_header(header) + } + + pub fn initialize_with_just_cookie(session: StorytellerSessionCookie) -> Self { + Self { + avt: None, + session: Some(session), + } + } + + pub fn initialize_with_just_avt(avt: StorytellerAvtCookie) -> Self { + Self { + avt: Some(avt), + session: None, + } + } + + pub fn is_empty(&self) -> bool { + self.avt.is_none() && self.session.is_none() + } + + pub fn equals(&self, other: &Self) -> bool { + match (&self.avt, &other.avt) { + (None, None) => {} // Fallthrough + (Some(avt), Some(other_avt)) => { + if !avt.equals(other_avt) { + return false; + } + } + _ => return false, + } + + match (&self.session, &other.session) { + (None, None) => {} // Fallthrough + (Some(session), Some(other_session)) => { + if !session.equals(other_session) { + return false; + } + } + _ => return false, + } + + true + } + + pub fn maybe_as_cookie_header(&self) -> Option { + let mut cookies = Vec::new(); + + if let Some(avt) = &self.avt { + cookies.push(avt.as_cookie_header()); + } + + if let Some(session) = &self.session { + cookies.push(session.as_cookie_header()); + } + + if cookies.is_empty() { + None + } else { + Some(cookies.join("; ")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie; + use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; + use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie; + + mod cookies_header { + use super::*; + + #[test] + fn no_cookie() { + let creds = StorytellerCredentialSet::empty(); + let header = creds.maybe_as_cookie_header(); + assert_eq!(header, None); + } + + #[test] + fn avt_cookies_header() { + let creds = StorytellerCredentialSet::initialize_with_just_avt( + StorytellerAvtCookie::new("bob".to_string()), + ); + + let header = creds.maybe_as_cookie_header(); + assert_eq!(header, Some("visitor=bob".to_string())); + } + + #[test] + fn session_cookies_header() { + let creds = StorytellerCredentialSet::initialize_with_just_cookie( + StorytellerSessionCookie::new("bob".to_string()), + ); + + let header = creds.maybe_as_cookie_header(); + assert_eq!(header, Some("session=bob".to_string())); + } + + #[test] + fn both_cookies_header() { + let creds = StorytellerCredentialSet::initialize( + Some(StorytellerAvtCookie::new("bob".to_string())), + Some(StorytellerSessionCookie::new("alice".to_string())), + ); + + let header = creds.maybe_as_cookie_header(); + assert_eq!(header, Some("visitor=bob; session=alice".to_string())); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_session_cookie.rs b/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_session_cookie.rs new file mode 100644 index 0000000000..fd089e4a16 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/credentials/storyteller_session_cookie.rs @@ -0,0 +1,43 @@ +use cookie::Cookie; + +const SESSION_COOKIE_NAME : &str = "session"; + +/// The primary FakeYou / Storyteller / Artcraft backend session identifier. +#[derive(Clone)] +pub struct StorytellerSessionCookie { + cookie: String, +} + +impl StorytellerSessionCookie { + pub fn new(cookie: String) -> Self { + Self { cookie } + } + + pub fn maybe_from_cookie(cookie: &Cookie) -> Option { + if cookie.name() == SESSION_COOKIE_NAME { + Some(Self::new(cookie.value().to_string())) + } else { + None + } + } + + pub fn as_str(&self) -> &str { + &self.cookie + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookie.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookie.clone() + } + + pub fn equals(&self, other: &Self) -> bool { + self.cookie == other.cookie + } + + pub fn as_cookie_header(&self) -> String { + format!("{}={}", SESSION_COOKIE_NAME, self.cookie) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_aspect_ratio.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_aspect_ratio.rs new file mode 100644 index 0000000000..c88e255702 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_aspect_ratio.rs @@ -0,0 +1,131 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `CommonAspectRatio` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientAspectRatio { + Auto, + Square, + WideThreeByTwo, + WideFourByThree, + WideFiveByFour, + WideSixteenByNine, + WideTwentyOneByNine, + TallTwoByThree, + TallThreeByFour, + TallFourByFive, + TallNineBySixteen, + TallNineByTwentyOne, + Wide, + Tall, + Auto2k, + Auto3k, + Auto4k, + SquareHd, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientAspectRatio { + fn from(value: String) -> Self { + match value.as_str() { + "auto" => Self::Auto, + "square" => Self::Square, + "wide_three_by_two" => Self::WideThreeByTwo, + "wide_four_by_three" => Self::WideFourByThree, + "wide_five_by_four" => Self::WideFiveByFour, + "wide_sixteen_by_nine" => Self::WideSixteenByNine, + "wide_twenty_one_by_nine" => Self::WideTwentyOneByNine, + "tall_two_by_three" => Self::TallTwoByThree, + "tall_three_by_four" => Self::TallThreeByFour, + "tall_four_by_five" => Self::TallFourByFive, + "tall_nine_by_sixteen" => Self::TallNineBySixteen, + "tall_nine_by_twenty_one" => Self::TallNineByTwentyOne, + "wide" => Self::Wide, + "tall" => Self::Tall, + "auto_2k" => Self::Auto2k, + "auto_3k" => Self::Auto3k, + "auto_4k" => Self::Auto4k, + "square_hd" => Self::SquareHd, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientAspectRatio) -> Self { + match value { + ApiClientAspectRatio::Auto => "auto".to_string(), + ApiClientAspectRatio::Square => "square".to_string(), + ApiClientAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(), + ApiClientAspectRatio::WideFourByThree => "wide_four_by_three".to_string(), + ApiClientAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(), + ApiClientAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(), + ApiClientAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(), + ApiClientAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(), + ApiClientAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(), + ApiClientAspectRatio::TallFourByFive => "tall_four_by_five".to_string(), + ApiClientAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(), + ApiClientAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(), + ApiClientAspectRatio::Wide => "wide".to_string(), + ApiClientAspectRatio::Tall => "tall".to_string(), + ApiClientAspectRatio::Auto2k => "auto_2k".to_string(), + ApiClientAspectRatio::Auto3k => "auto_3k".to_string(), + ApiClientAspectRatio::Auto4k => "auto_4k".to_string(), + ApiClientAspectRatio::SquareHd => "square_hd".to_string(), + ApiClientAspectRatio::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientAspectRatio = serde_json::from_str("\"auto\"").unwrap(); + assert_eq!(parsed, ApiClientAspectRatio::Auto); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"auto\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientAspectRatio = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientAspectRatio::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientAspectRatio::Auto, "auto"), + (ApiClientAspectRatio::Square, "square"), + (ApiClientAspectRatio::WideThreeByTwo, "wide_three_by_two"), + (ApiClientAspectRatio::WideFourByThree, "wide_four_by_three"), + (ApiClientAspectRatio::WideFiveByFour, "wide_five_by_four"), + (ApiClientAspectRatio::WideSixteenByNine, "wide_sixteen_by_nine"), + (ApiClientAspectRatio::WideTwentyOneByNine, "wide_twenty_one_by_nine"), + (ApiClientAspectRatio::TallTwoByThree, "tall_two_by_three"), + (ApiClientAspectRatio::TallThreeByFour, "tall_three_by_four"), + (ApiClientAspectRatio::TallFourByFive, "tall_four_by_five"), + (ApiClientAspectRatio::TallNineBySixteen, "tall_nine_by_sixteen"), + (ApiClientAspectRatio::TallNineByTwentyOne, "tall_nine_by_twenty_one"), + (ApiClientAspectRatio::Wide, "wide"), + (ApiClientAspectRatio::Tall, "tall"), + (ApiClientAspectRatio::Auto2k, "auto_2k"), + (ApiClientAspectRatio::Auto3k, "auto_3k"), + (ApiClientAspectRatio::Auto4k, "auto_4k"), + (ApiClientAspectRatio::SquareHd, "square_hd"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientAspectRatio = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_bitrate.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_bitrate.rs new file mode 100644 index 0000000000..a98bc37454 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_bitrate.rs @@ -0,0 +1,67 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `CommonBitrate` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientBitrate { + Normal, + High, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientBitrate { + fn from(value: String) -> Self { + match value.as_str() { + "normal" => Self::Normal, + "high" => Self::High, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientBitrate) -> Self { + match value { + ApiClientBitrate::Normal => "normal".to_string(), + ApiClientBitrate::High => "high".to_string(), + ApiClientBitrate::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientBitrate = serde_json::from_str("\"normal\"").unwrap(); + assert_eq!(parsed, ApiClientBitrate::Normal); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"normal\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientBitrate = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientBitrate::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientBitrate::Normal, "normal"), + (ApiClientBitrate::High, "high"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientBitrate = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_generation_provider.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_generation_provider.rs new file mode 100644 index 0000000000..9ee0f1dbb7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_generation_provider.rs @@ -0,0 +1,115 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `GenerationProvider` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientGenerationProvider { + Artcraft, + Fal, + Grok, + Midjourney, + Sora, + WorldLabs, + Higgsfield, + Krea, + Leonardo, + Magnific, + Openart, + Picsart, + Pixverse, + Runway, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientGenerationProvider { + fn from(value: String) -> Self { + match value.as_str() { + "artcraft" => Self::Artcraft, + "fal" => Self::Fal, + "grok" => Self::Grok, + "midjourney" => Self::Midjourney, + "sora" => Self::Sora, + "world_labs" => Self::WorldLabs, + "higgsfield" => Self::Higgsfield, + "krea" => Self::Krea, + "leonardo" => Self::Leonardo, + "magnific" => Self::Magnific, + "openart" => Self::Openart, + "picsart" => Self::Picsart, + "pixverse" => Self::Pixverse, + "runway" => Self::Runway, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientGenerationProvider) -> Self { + match value { + ApiClientGenerationProvider::Artcraft => "artcraft".to_string(), + ApiClientGenerationProvider::Fal => "fal".to_string(), + ApiClientGenerationProvider::Grok => "grok".to_string(), + ApiClientGenerationProvider::Midjourney => "midjourney".to_string(), + ApiClientGenerationProvider::Sora => "sora".to_string(), + ApiClientGenerationProvider::WorldLabs => "world_labs".to_string(), + ApiClientGenerationProvider::Higgsfield => "higgsfield".to_string(), + ApiClientGenerationProvider::Krea => "krea".to_string(), + ApiClientGenerationProvider::Leonardo => "leonardo".to_string(), + ApiClientGenerationProvider::Magnific => "magnific".to_string(), + ApiClientGenerationProvider::Openart => "openart".to_string(), + ApiClientGenerationProvider::Picsart => "picsart".to_string(), + ApiClientGenerationProvider::Pixverse => "pixverse".to_string(), + ApiClientGenerationProvider::Runway => "runway".to_string(), + ApiClientGenerationProvider::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientGenerationProvider = serde_json::from_str("\"artcraft\"").unwrap(); + assert_eq!(parsed, ApiClientGenerationProvider::Artcraft); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"artcraft\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientGenerationProvider = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientGenerationProvider::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientGenerationProvider::Artcraft, "artcraft"), + (ApiClientGenerationProvider::Fal, "fal"), + (ApiClientGenerationProvider::Grok, "grok"), + (ApiClientGenerationProvider::Midjourney, "midjourney"), + (ApiClientGenerationProvider::Sora, "sora"), + (ApiClientGenerationProvider::WorldLabs, "world_labs"), + (ApiClientGenerationProvider::Higgsfield, "higgsfield"), + (ApiClientGenerationProvider::Krea, "krea"), + (ApiClientGenerationProvider::Leonardo, "leonardo"), + (ApiClientGenerationProvider::Magnific, "magnific"), + (ApiClientGenerationProvider::Openart, "openart"), + (ApiClientGenerationProvider::Picsart, "picsart"), + (ApiClientGenerationProvider::Pixverse, "pixverse"), + (ApiClientGenerationProvider::Runway, "runway"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientGenerationProvider = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_image_model.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_image_model.rs new file mode 100644 index 0000000000..e4feb1d813 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_image_model.rs @@ -0,0 +1,147 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `CommonImageModel` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientImageModel { + Flux1Dev, + Flux1Schnell, + FluxPro11, + FluxPro11Ultra, + GptImage1, + GptImage1p5, + GptImage2, + GrokImagineImage, + GptImage2p5Flare, + GptImage2p5Sunburst, + GrokImagineImageQuality, + Midjourney7, + Midjourney7Niji, + Midjourney8, + NanoBanana, + NanoBanana2, + NanoBananaPro, + Seedream4, + Seedream4p5, + Seedream5Lite, + QwenEdit2511Angles, + Flux2LoraAngles, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientImageModel { + fn from(value: String) -> Self { + match value.as_str() { + "flux_1_dev" => Self::Flux1Dev, + "flux_1_schnell" => Self::Flux1Schnell, + "flux_pro_1p1" => Self::FluxPro11, + "flux_pro_1p1_ultra" => Self::FluxPro11Ultra, + "gpt_image_1" => Self::GptImage1, + "gpt_image_1p5" => Self::GptImage1p5, + "gpt_image_2" => Self::GptImage2, + "gpt_image_2p5_flare" => Self::GptImage2p5Flare, + "gpt_image_2p5_sunburst" => Self::GptImage2p5Sunburst, + "grok_imagine_image" => Self::GrokImagineImage, + "grok_imagine_image_q" => Self::GrokImagineImageQuality, + "midjourney_7" => Self::Midjourney7, + "midjourney_7_niji" => Self::Midjourney7Niji, + "midjourney_8" => Self::Midjourney8, + "nano_banana" => Self::NanoBanana, + "nano_banana_2" => Self::NanoBanana2, + "nano_banana_pro" => Self::NanoBananaPro, + "seedream_4" => Self::Seedream4, + "seedream_4p5" => Self::Seedream4p5, + "seedream_5_lite" => Self::Seedream5Lite, + "qwen_edit_2511_angles" => Self::QwenEdit2511Angles, + "flux_2_lora_angles" => Self::Flux2LoraAngles, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientImageModel) -> Self { + match value { + ApiClientImageModel::Flux1Dev => "flux_1_dev".to_string(), + ApiClientImageModel::Flux1Schnell => "flux_1_schnell".to_string(), + ApiClientImageModel::FluxPro11 => "flux_pro_1p1".to_string(), + ApiClientImageModel::FluxPro11Ultra => "flux_pro_1p1_ultra".to_string(), + ApiClientImageModel::GptImage1 => "gpt_image_1".to_string(), + ApiClientImageModel::GptImage1p5 => "gpt_image_1p5".to_string(), + ApiClientImageModel::GptImage2 => "gpt_image_2".to_string(), + ApiClientImageModel::GptImage2p5Flare => "gpt_image_2p5_flare".to_string(), + ApiClientImageModel::GptImage2p5Sunburst => "gpt_image_2p5_sunburst".to_string(), + ApiClientImageModel::GrokImagineImage => "grok_imagine_image".to_string(), + ApiClientImageModel::GrokImagineImageQuality => "grok_imagine_image_q".to_string(), + ApiClientImageModel::Midjourney7 => "midjourney_7".to_string(), + ApiClientImageModel::Midjourney7Niji => "midjourney_7_niji".to_string(), + ApiClientImageModel::Midjourney8 => "midjourney_8".to_string(), + ApiClientImageModel::NanoBanana => "nano_banana".to_string(), + ApiClientImageModel::NanoBanana2 => "nano_banana_2".to_string(), + ApiClientImageModel::NanoBananaPro => "nano_banana_pro".to_string(), + ApiClientImageModel::Seedream4 => "seedream_4".to_string(), + ApiClientImageModel::Seedream4p5 => "seedream_4p5".to_string(), + ApiClientImageModel::Seedream5Lite => "seedream_5_lite".to_string(), + ApiClientImageModel::QwenEdit2511Angles => "qwen_edit_2511_angles".to_string(), + ApiClientImageModel::Flux2LoraAngles => "flux_2_lora_angles".to_string(), + ApiClientImageModel::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientImageModel = serde_json::from_str("\"flux_1_dev\"").unwrap(); + assert_eq!(parsed, ApiClientImageModel::Flux1Dev); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"flux_1_dev\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientImageModel = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientImageModel::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientImageModel::Flux1Dev, "flux_1_dev"), + (ApiClientImageModel::Flux1Schnell, "flux_1_schnell"), + (ApiClientImageModel::FluxPro11, "flux_pro_1p1"), + (ApiClientImageModel::FluxPro11Ultra, "flux_pro_1p1_ultra"), + (ApiClientImageModel::GptImage1, "gpt_image_1"), + (ApiClientImageModel::GptImage1p5, "gpt_image_1p5"), + (ApiClientImageModel::GptImage2, "gpt_image_2"), + (ApiClientImageModel::GptImage2p5Flare, "gpt_image_2p5_flare"), + (ApiClientImageModel::GptImage2p5Sunburst, "gpt_image_2p5_sunburst"), + (ApiClientImageModel::GrokImagineImage, "grok_imagine_image"), + (ApiClientImageModel::GrokImagineImageQuality, "grok_imagine_image_q"), + (ApiClientImageModel::Midjourney7, "midjourney_7"), + (ApiClientImageModel::Midjourney7Niji, "midjourney_7_niji"), + (ApiClientImageModel::Midjourney8, "midjourney_8"), + (ApiClientImageModel::NanoBanana, "nano_banana"), + (ApiClientImageModel::NanoBanana2, "nano_banana_2"), + (ApiClientImageModel::NanoBananaPro, "nano_banana_pro"), + (ApiClientImageModel::Seedream4, "seedream_4"), + (ApiClientImageModel::Seedream4p5, "seedream_4p5"), + (ApiClientImageModel::Seedream5Lite, "seedream_5_lite"), + (ApiClientImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles"), + (ApiClientImageModel::Flux2LoraAngles, "flux_2_lora_angles"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientImageModel = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_model_creator.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_model_creator.rs new file mode 100644 index 0000000000..eeaafe1122 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_model_creator.rs @@ -0,0 +1,147 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `ModelCreator` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientModelCreator { + Alibaba, + ArtCraft, + BlackForestLabs, + Bytedance, + Fal, + Google, + Grok, + Hailuo, + Higgsfield, + Kling, + Krea, + Midjourney, + OpenAi, + OpenArt, + Recraft, + Replicate, + Runway, + Stability, + Tencent, + TensorArt, + Vidu, + WorldLabs, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientModelCreator { + fn from(value: String) -> Self { + match value.as_str() { + "alibaba" => Self::Alibaba, + "artcraft" => Self::ArtCraft, + "black_forest_labs" => Self::BlackForestLabs, + "bytedance" => Self::Bytedance, + "fal" => Self::Fal, + "google" => Self::Google, + "grok" => Self::Grok, + "hailuo" => Self::Hailuo, + "higgsfield" => Self::Higgsfield, + "kling" => Self::Kling, + "krea" => Self::Krea, + "midjourney" => Self::Midjourney, + "open_ai" => Self::OpenAi, + "open_art" => Self::OpenArt, + "recraft" => Self::Recraft, + "replicate" => Self::Replicate, + "runway" => Self::Runway, + "stability" => Self::Stability, + "tencent" => Self::Tencent, + "tensor_art" => Self::TensorArt, + "vidu" => Self::Vidu, + "world_labs" => Self::WorldLabs, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientModelCreator) -> Self { + match value { + ApiClientModelCreator::Alibaba => "alibaba".to_string(), + ApiClientModelCreator::ArtCraft => "artcraft".to_string(), + ApiClientModelCreator::BlackForestLabs => "black_forest_labs".to_string(), + ApiClientModelCreator::Bytedance => "bytedance".to_string(), + ApiClientModelCreator::Fal => "fal".to_string(), + ApiClientModelCreator::Google => "google".to_string(), + ApiClientModelCreator::Grok => "grok".to_string(), + ApiClientModelCreator::Hailuo => "hailuo".to_string(), + ApiClientModelCreator::Higgsfield => "higgsfield".to_string(), + ApiClientModelCreator::Kling => "kling".to_string(), + ApiClientModelCreator::Krea => "krea".to_string(), + ApiClientModelCreator::Midjourney => "midjourney".to_string(), + ApiClientModelCreator::OpenAi => "open_ai".to_string(), + ApiClientModelCreator::OpenArt => "open_art".to_string(), + ApiClientModelCreator::Recraft => "recraft".to_string(), + ApiClientModelCreator::Replicate => "replicate".to_string(), + ApiClientModelCreator::Runway => "runway".to_string(), + ApiClientModelCreator::Stability => "stability".to_string(), + ApiClientModelCreator::Tencent => "tencent".to_string(), + ApiClientModelCreator::TensorArt => "tensor_art".to_string(), + ApiClientModelCreator::Vidu => "vidu".to_string(), + ApiClientModelCreator::WorldLabs => "world_labs".to_string(), + ApiClientModelCreator::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientModelCreator = serde_json::from_str("\"alibaba\"").unwrap(); + assert_eq!(parsed, ApiClientModelCreator::Alibaba); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"alibaba\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientModelCreator = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientModelCreator::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientModelCreator::Alibaba, "alibaba"), + (ApiClientModelCreator::ArtCraft, "artcraft"), + (ApiClientModelCreator::BlackForestLabs, "black_forest_labs"), + (ApiClientModelCreator::Bytedance, "bytedance"), + (ApiClientModelCreator::Fal, "fal"), + (ApiClientModelCreator::Google, "google"), + (ApiClientModelCreator::Grok, "grok"), + (ApiClientModelCreator::Hailuo, "hailuo"), + (ApiClientModelCreator::Higgsfield, "higgsfield"), + (ApiClientModelCreator::Kling, "kling"), + (ApiClientModelCreator::Krea, "krea"), + (ApiClientModelCreator::Midjourney, "midjourney"), + (ApiClientModelCreator::OpenAi, "open_ai"), + (ApiClientModelCreator::OpenArt, "open_art"), + (ApiClientModelCreator::Recraft, "recraft"), + (ApiClientModelCreator::Replicate, "replicate"), + (ApiClientModelCreator::Runway, "runway"), + (ApiClientModelCreator::Stability, "stability"), + (ApiClientModelCreator::Tencent, "tencent"), + (ApiClientModelCreator::TensorArt, "tensor_art"), + (ApiClientModelCreator::Vidu, "vidu"), + (ApiClientModelCreator::WorldLabs, "world_labs"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientModelCreator = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_quality.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_quality.rs new file mode 100644 index 0000000000..92f04bed28 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_quality.rs @@ -0,0 +1,71 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `CommonQuality` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientQuality { + High, + Medium, + Low, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientQuality { + fn from(value: String) -> Self { + match value.as_str() { + "high" => Self::High, + "medium" => Self::Medium, + "low" => Self::Low, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientQuality) -> Self { + match value { + ApiClientQuality::High => "high".to_string(), + ApiClientQuality::Medium => "medium".to_string(), + ApiClientQuality::Low => "low".to_string(), + ApiClientQuality::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientQuality = serde_json::from_str("\"high\"").unwrap(); + assert_eq!(parsed, ApiClientQuality::High); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"high\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientQuality = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientQuality::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientQuality::High, "high"), + (ApiClientQuality::Medium, "medium"), + (ApiClientQuality::Low, "low"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientQuality = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_resolution.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_resolution.rs new file mode 100644 index 0000000000..807f24dc5a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_resolution.rs @@ -0,0 +1,91 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `CommonResolution` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientResolution { + OneK, + TwoK, + ThreeK, + FourK, + HalfK, + FourEightyP, + SevenTwentyP, + TenEightyP, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientResolution { + fn from(value: String) -> Self { + match value.as_str() { + "one_k" => Self::OneK, + "two_k" => Self::TwoK, + "three_k" => Self::ThreeK, + "four_k" => Self::FourK, + "half_k" => Self::HalfK, + "four_eighty_p" => Self::FourEightyP, + "seven_twenty_p" => Self::SevenTwentyP, + "ten_eighty_p" => Self::TenEightyP, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientResolution) -> Self { + match value { + ApiClientResolution::OneK => "one_k".to_string(), + ApiClientResolution::TwoK => "two_k".to_string(), + ApiClientResolution::ThreeK => "three_k".to_string(), + ApiClientResolution::FourK => "four_k".to_string(), + ApiClientResolution::HalfK => "half_k".to_string(), + ApiClientResolution::FourEightyP => "four_eighty_p".to_string(), + ApiClientResolution::SevenTwentyP => "seven_twenty_p".to_string(), + ApiClientResolution::TenEightyP => "ten_eighty_p".to_string(), + ApiClientResolution::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientResolution = serde_json::from_str("\"one_k\"").unwrap(); + assert_eq!(parsed, ApiClientResolution::OneK); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"one_k\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientResolution = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientResolution::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientResolution::OneK, "one_k"), + (ApiClientResolution::TwoK, "two_k"), + (ApiClientResolution::ThreeK, "three_k"), + (ApiClientResolution::FourK, "four_k"), + (ApiClientResolution::HalfK, "half_k"), + (ApiClientResolution::FourEightyP, "four_eighty_p"), + (ApiClientResolution::SevenTwentyP, "seven_twenty_p"), + (ApiClientResolution::TenEightyP, "ten_eighty_p"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientResolution = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_video_model.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_video_model.rs new file mode 100644 index 0000000000..25ee442203 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/api_client_video_model.rs @@ -0,0 +1,183 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Forward-compatible, self-contained API-client copy of the server's `CommonVideoModel` enum. +/// +/// Serialized as a string. Any value this client build does not recognize is preserved +/// verbatim in [`Unknown`], so newer server variants never break deserialization and +/// still round-trip back to the original string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum ApiClientVideoModel { + GrokVideo, + GrokImagineVideo, + GrokImagineVideo1p5, + Kling16Pro, + Kling21Pro, + Kling21Master, + Kling2p5TurboPro, + Kling2p6Pro, + Kling3p0Standard, + Kling3p0Pro, + HappyHorse1p0, + Seedance10Lite, + Seedance1p5Pro, + Seedance2p0, + Seedance2p0Fast, + Seedance2p0BytePlus, + Seedance2p0BytePlusFast, + Seedance2p0BytePlusUltra, + Seedance2p0BytePlusUltraFast, + Seedance2p0Mini, + Seedance2p0BytePlusMini, + Seedance2p0BytePlusUltraMini, + Sora2, + Sora2Pro, + Veo2, + Veo3, + Veo3Fast, + Veo3p1, + Veo3p1Fast, + PreviewModel, + PreviewModelFast, + /// A value not known to this client build (preserved as-is). + Unknown(String), +} + +impl From for ApiClientVideoModel { + fn from(value: String) -> Self { + match value.as_str() { + "grok_video" => Self::GrokVideo, + "grok_imagine_video" => Self::GrokImagineVideo, + "grok_imagine_video_1p5" => Self::GrokImagineVideo1p5, + "kling_1p6_pro" => Self::Kling16Pro, + "kling_2p1_pro" => Self::Kling21Pro, + "kling_2p1_master" => Self::Kling21Master, + "kling_2p5_turbo_pro" => Self::Kling2p5TurboPro, + "kling_2p6_pro" => Self::Kling2p6Pro, + "kling_3p0_standard" => Self::Kling3p0Standard, + "kling_3p0_pro" => Self::Kling3p0Pro, + "happy_horse_1p0" => Self::HappyHorse1p0, + "seedance_1p0_lite" => Self::Seedance10Lite, + "seedance_1p5_pro" => Self::Seedance1p5Pro, + "seedance_2p0" => Self::Seedance2p0, + "seedance_2p0_fast" => Self::Seedance2p0Fast, + "seedance_2p0_bp" => Self::Seedance2p0BytePlus, + "seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast, + "seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra, + "seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast, + "seedance_2p0_mini" => Self::Seedance2p0Mini, + "seedance_2p0_bp_mini" => Self::Seedance2p0BytePlusMini, + "seedance_2p0_bpu_mini" => Self::Seedance2p0BytePlusUltraMini, + "sora_2" => Self::Sora2, + "sora_2_pro" => Self::Sora2Pro, + "veo_2" => Self::Veo2, + "veo_3" => Self::Veo3, + "veo_3_fast" => Self::Veo3Fast, + "veo_3p1" => Self::Veo3p1, + "veo_3p1_fast" => Self::Veo3p1Fast, + "preview_model" => Self::PreviewModel, + "preview_model_fast" => Self::PreviewModelFast, + _ => Self::Unknown(value), + } + } +} + +impl From for String { + fn from(value: ApiClientVideoModel) -> Self { + match value { + ApiClientVideoModel::GrokVideo => "grok_video".to_string(), + ApiClientVideoModel::GrokImagineVideo => "grok_imagine_video".to_string(), + ApiClientVideoModel::GrokImagineVideo1p5 => "grok_imagine_video_1p5".to_string(), + ApiClientVideoModel::Kling16Pro => "kling_1p6_pro".to_string(), + ApiClientVideoModel::Kling21Pro => "kling_2p1_pro".to_string(), + ApiClientVideoModel::Kling21Master => "kling_2p1_master".to_string(), + ApiClientVideoModel::Kling2p5TurboPro => "kling_2p5_turbo_pro".to_string(), + ApiClientVideoModel::Kling2p6Pro => "kling_2p6_pro".to_string(), + ApiClientVideoModel::Kling3p0Standard => "kling_3p0_standard".to_string(), + ApiClientVideoModel::Kling3p0Pro => "kling_3p0_pro".to_string(), + ApiClientVideoModel::HappyHorse1p0 => "happy_horse_1p0".to_string(), + ApiClientVideoModel::Seedance10Lite => "seedance_1p0_lite".to_string(), + ApiClientVideoModel::Seedance1p5Pro => "seedance_1p5_pro".to_string(), + ApiClientVideoModel::Seedance2p0 => "seedance_2p0".to_string(), + ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(), + ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(), + ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(), + ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(), + ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(), + ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(), + ApiClientVideoModel::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini".to_string(), + ApiClientVideoModel::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini".to_string(), + ApiClientVideoModel::Sora2 => "sora_2".to_string(), + ApiClientVideoModel::Sora2Pro => "sora_2_pro".to_string(), + ApiClientVideoModel::Veo2 => "veo_2".to_string(), + ApiClientVideoModel::Veo3 => "veo_3".to_string(), + ApiClientVideoModel::Veo3Fast => "veo_3_fast".to_string(), + ApiClientVideoModel::Veo3p1 => "veo_3p1".to_string(), + ApiClientVideoModel::Veo3p1Fast => "veo_3p1_fast".to_string(), + ApiClientVideoModel::PreviewModel => "preview_model".to_string(), + ApiClientVideoModel::PreviewModelFast => "preview_model_fast".to_string(), + ApiClientVideoModel::Unknown(other) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_variant_round_trips() { + let parsed: ApiClientVideoModel = serde_json::from_str("\"grok_video\"").unwrap(); + assert_eq!(parsed, ApiClientVideoModel::GrokVideo); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"grok_video\""); + } + + #[test] + fn unknown_value_is_preserved_and_round_trips() { + let parsed: ApiClientVideoModel = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap(); + assert_eq!(parsed, ApiClientVideoModel::Unknown("zzz_not_a_real_value".to_string())); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\""); + } + + #[test] + fn every_known_variant_round_trips() { + let all = [ + (ApiClientVideoModel::GrokVideo, "grok_video"), + (ApiClientVideoModel::GrokImagineVideo, "grok_imagine_video"), + (ApiClientVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5"), + (ApiClientVideoModel::Kling16Pro, "kling_1p6_pro"), + (ApiClientVideoModel::Kling21Pro, "kling_2p1_pro"), + (ApiClientVideoModel::Kling21Master, "kling_2p1_master"), + (ApiClientVideoModel::Kling2p5TurboPro, "kling_2p5_turbo_pro"), + (ApiClientVideoModel::Kling2p6Pro, "kling_2p6_pro"), + (ApiClientVideoModel::Kling3p0Standard, "kling_3p0_standard"), + (ApiClientVideoModel::Kling3p0Pro, "kling_3p0_pro"), + (ApiClientVideoModel::HappyHorse1p0, "happy_horse_1p0"), + (ApiClientVideoModel::Seedance10Lite, "seedance_1p0_lite"), + (ApiClientVideoModel::Seedance1p5Pro, "seedance_1p5_pro"), + (ApiClientVideoModel::Seedance2p0, "seedance_2p0"), + (ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"), + (ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"), + (ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"), + (ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"), + (ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"), + (ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"), + (ApiClientVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"), + (ApiClientVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"), + (ApiClientVideoModel::Sora2, "sora_2"), + (ApiClientVideoModel::Sora2Pro, "sora_2_pro"), + (ApiClientVideoModel::Veo2, "veo_2"), + (ApiClientVideoModel::Veo3, "veo_3"), + (ApiClientVideoModel::Veo3Fast, "veo_3_fast"), + (ApiClientVideoModel::Veo3p1, "veo_3p1"), + (ApiClientVideoModel::Veo3p1Fast, "veo_3p1_fast"), + (ApiClientVideoModel::PreviewModel, "preview_model"), + (ApiClientVideoModel::PreviewModelFast, "preview_model_fast"), + ]; + for (variant, s) in all { + assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant); + let parsed: ApiClientVideoModel = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, variant); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/mod.rs new file mode 100644 index 0000000000..c1ecdda205 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/generation/mod.rs @@ -0,0 +1,8 @@ +pub mod api_client_aspect_ratio; +pub mod api_client_bitrate; +pub mod api_client_generation_provider; +pub mod api_client_image_model; +pub mod api_client_model_creator; +pub mod api_client_quality; +pub mod api_client_resolution; +pub mod api_client_video_model; diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/mod.rs new file mode 100644 index 0000000000..48b4c955f8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/common_enums/mod.rs @@ -0,0 +1 @@ +pub mod generation; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/datatypes/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/datatypes/mod.rs new file mode 100644 index 0000000000..c78cc97389 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/datatypes/mod.rs @@ -0,0 +1 @@ +pub mod common_enums; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/log_active_user.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/log_active_user.rs new file mode 100644 index 0000000000..a3a4c21562 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/log_active_user.rs @@ -0,0 +1,48 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_query_string_post_request::basic_query_string_post_request; +use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LogAppActiveUserResponse, LOG_ACTIVE_USER_V1_PATH}; +use std::collections::HashMap; + +pub async fn log_active_user( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: LogAppActiveUserRequest, +) -> Result { + + // TODO(bt): This is gross and doesn't automatically handle field changes. + + let mut query = HashMap::new(); + + if let Some(token) = request.maybe_app_session_token { + query.insert("maybe_app_session_token".to_string(), token.to_string()); + } + + if let Some(os_platform) = request.maybe_os_platform { + query.insert("maybe_os_platform".to_string(), os_platform); + } + + if let Some(os_version) = request.maybe_os_version { + query.insert("maybe_os_version".to_string(), os_version); + } + + if let Some(app_name) = request.maybe_app_name { + query.insert("maybe_app_name".to_string(), app_name); + } + + if let Some(app_version) = request.maybe_app_version { + query.insert("maybe_app_version".to_string(), app_version); + } + + if let Some(maybe_session_duration_seconds) = request.maybe_session_duration_seconds { + query.insert("maybe_session_duration_seconds".to_string(), maybe_session_duration_seconds.to_string()); + } + + Ok(basic_query_string_post_request( + api_host, + LOG_ACTIVE_USER_V1_PATH, + maybe_creds, + &query, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/log_active_user_v2.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/log_active_user_v2.rs new file mode 100644 index 0000000000..424f390007 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/log_active_user_v2.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LogAppActiveUserResponse, LOG_ACTIVE_USER_V2_PATH}; + +pub async fn log_active_user_v2( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: LogAppActiveUserRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + LOG_ACTIVE_USER_V2_PATH, + maybe_creds, + &request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/mod.rs new file mode 100644 index 0000000000..1a5a639663 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/analytics/mod.rs @@ -0,0 +1,2 @@ +pub mod log_active_user; +pub mod log_active_user_v2; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/credits/get_session_credits.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/credits/get_session_credits.rs new file mode 100644 index 0000000000..55a4bf7a2c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/credits/get_session_credits.rs @@ -0,0 +1,27 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_get_request::basic_json_get_request; +use artcraft_api_defs::credits::get_session_credits::GetSessionCreditsResponse; +use enums::common::payments_namespace::PaymentsNamespace; + +pub async fn get_session_credits( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + payments_namespace: PaymentsNamespace +) -> Result { + let url_path = get_url_path(payments_namespace); + + Ok(basic_json_get_request( + api_host, + &url_path, + maybe_creds, + ).await?) +} + + +fn get_url_path(payments_namespace: PaymentsNamespace) -> String { + let payments_namespace = payments_namespace.to_str(); + format!("/v1/credits/namespace/{}", payments_namespace) +} + diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/credits/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/credits/mod.rs new file mode 100644 index 0000000000..c6a4be341d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/credits/mod.rs @@ -0,0 +1 @@ +pub mod get_session_credits; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/image/estimate_image_cost.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/image/estimate_image_cost.rs new file mode 100644 index 0000000000..d3cc992cf2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/image/estimate_image_cost.rs @@ -0,0 +1,20 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{ + EstimateImageCostRequest, EstimateImageCostResponse, ESTIMATE_IMAGE_COST_PATH, +}; + +pub async fn estimate_image_cost( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: EstimateImageCostRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + ESTIMATE_IMAGE_COST_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/image/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/image/mod.rs new file mode 100644 index 0000000000..fc1b4c41f0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/image/mod.rs @@ -0,0 +1 @@ +pub mod estimate_image_cost; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/mod.rs new file mode 100644 index 0000000000..8b571e6700 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/mod.rs @@ -0,0 +1,3 @@ +pub mod image; +pub mod splat; +pub mod video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/splat/estimate_splat_cost.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/splat/estimate_splat_cost.rs new file mode 100644 index 0000000000..ed49619d4f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/splat/estimate_splat_cost.rs @@ -0,0 +1,20 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::cost_estimate::estimate_splat_cost::{ + EstimateSplatCostRequest, EstimateSplatCostResponse, ESTIMATE_SPLAT_COST_PATH, +}; + +pub async fn estimate_splat_cost( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: EstimateSplatCostRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + ESTIMATE_SPLAT_COST_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/splat/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/splat/mod.rs new file mode 100644 index 0000000000..1b208ee552 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/splat/mod.rs @@ -0,0 +1 @@ +pub mod estimate_splat_cost; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/video/estimate_video_cost.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/video/estimate_video_cost.rs new file mode 100644 index 0000000000..d9ba45d393 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/video/estimate_video_cost.rs @@ -0,0 +1,20 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{ + EstimateVideoCostRequest, EstimateVideoCostResponse, ESTIMATE_VIDEO_COST_PATH, +}; + +pub async fn estimate_video_cost( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: EstimateVideoCostRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + ESTIMATE_VIDEO_COST_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/video/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/video/mod.rs new file mode 100644 index 0000000000..65a10d3870 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/cost_estimate/video/mod.rs @@ -0,0 +1 @@ +pub mod estimate_video_cost; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs new file mode 100644 index 0000000000..785d92c095 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/flux_2_lora_edit_image_angle.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{ + Flux2LoraEditImageAngleRequest, Flux2LoraEditImageAngleResponse, + FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH, +}; + +pub async fn flux_2_lora_edit_image_angle( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Flux2LoraEditImageAngleRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs new file mode 100644 index 0000000000..fa3fe51645 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_2_lora_edit_image_angle; +pub mod qwen_edit_2511_edit_image_angle; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs new file mode 100644 index 0000000000..aec899c9fe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/angle/qwen_edit_2511_edit_image_angle.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{ + QwenEdit2511EditImageAngleRequest, QwenEdit2511EditImageAngleResponse, + QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH, +}; + +pub async fn qwen_edit_2511_edit_image_angle( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: QwenEdit2511EditImageAngleRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/bg_removal/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/bg_removal/mod.rs new file mode 100644 index 0000000000..243c3076ef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/bg_removal/mod.rs @@ -0,0 +1 @@ +pub mod remove_image_background; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/bg_removal/remove_image_background.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/bg_removal/remove_image_background.rs new file mode 100644 index 0000000000..55b561398b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/bg_removal/remove_image_background.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::bg_removal::remove_image_background::{RemoveImageBackgroundRequest, RemoveImageBackgroundResponse, REMOVE_IMAGE_BACKGROUND_PATH}; + +pub async fn remove_image_background( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: RemoveImageBackgroundRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + REMOVE_IMAGE_BACKGROUND_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image.rs new file mode 100644 index 0000000000..372179165a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/flux_pro_kontext_max_edit_image.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageRequest, FluxProKontextMaxEditImageResponse, FLUX_PRO_KONTEXT_MAX_EDIT_IMAGE_PATH}; + + +pub async fn flux_pro_kontext_max_edit_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: FluxProKontextMaxEditImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + FLUX_PRO_KONTEXT_MAX_EDIT_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/gemini_25_flash_edit_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/gemini_25_flash_edit_image.rs new file mode 100644 index 0000000000..37e6c3d292 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/gemini_25_flash_edit_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::{Gemini25FlashEditImageRequest, Gemini25FlashEditImageResponse, GEMINI_25_FLASH_EDIT_IMAGE_PATH}; + +pub async fn gemini_25_flash_edit_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Gemini25FlashEditImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GEMINI_25_FLASH_EDIT_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/gpt_image_1_edit_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/gpt_image_1_edit_image.rs new file mode 100644 index 0000000000..658be83ee7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/gpt_image_1_edit_image.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageRequest, GptImage1EditImageResponse, GPT_IMAGE_1_EDIT_IMAGE_PATH}; + + +pub async fn gpt_image_1_edit_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GptImage1EditImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GPT_IMAGE_1_EDIT_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/mod.rs new file mode 100644 index 0000000000..3b52212122 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/edit/mod.rs @@ -0,0 +1,3 @@ +pub mod flux_pro_kontext_max_edit_image; +pub mod gemini_25_flash_edit_image; +pub mod gpt_image_1_edit_image; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_image.rs new file mode 100644 index 0000000000..263b10ce49 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/flux_dev_juggernaut_inpaint_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::inpaint::flux_dev_juggernaut_inpaint_image::{FluxDevJuggernautInpaintImageRequest, FluxDevJuggernautInpaintImageResponse, FLUX_DEV_JUGGERNAUT_INPAINT_PATH}; + +pub async fn flux_dev_juggernaut_inpaint_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: FluxDevJuggernautInpaintImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + FLUX_DEV_JUGGERNAUT_INPAINT_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/flux_pro_1_inpaint_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/flux_pro_1_inpaint_image.rs new file mode 100644 index 0000000000..6759d020ce --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/flux_pro_1_inpaint_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageRequest, FluxPro1InpaintImageResponse, FLUX_PRO_1_INPAINT_PATH}; + +pub async fn flux_pro_1_inpaint_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: FluxPro1InpaintImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + FLUX_PRO_1_INPAINT_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/mod.rs new file mode 100644 index 0000000000..32f09986fb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/inpaint/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_pro_1_inpaint_image; +pub mod flux_dev_juggernaut_inpaint_image; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs new file mode 100644 index 0000000000..068f2ef5f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/mod.rs @@ -0,0 +1,6 @@ +pub mod angle; +pub mod bg_removal; +pub mod edit; +pub mod inpaint; +pub mod multi_function; +pub mod text; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..52bb84bef4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_5_lite_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_5_lite_multi_function_image_gen::{BytedanceSeedream5LiteMultiFunctionImageGenRequest, BytedanceSeedream5LiteMultiFunctionImageGenResponse, BYTEDANCE_SEEDREAM_5_LITE_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn bytedance_seedream_5_lite_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: BytedanceSeedream5LiteMultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + BYTEDANCE_SEEDREAM_5_LITE_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..4ee74544df --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_v4_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4_multi_function_image_gen::{BytedanceSeedreamV4MultiFunctionImageGenRequest, BytedanceSeedreamV4MultiFunctionImageGenResponse, BYTEDANCE_SEEDREAM_V4_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn bytedance_seedream_v4_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: BytedanceSeedreamV4MultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + BYTEDANCE_SEEDREAM_V4_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..c0f62f2b02 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/bytedance_seedream_v4p5_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4p5_multi_function_image_gen::{BytedanceSeedreamV4p5MultiFunctionImageGenRequest, BytedanceSeedreamV4p5MultiFunctionImageGenResponse, BYTEDANCE_SEEDREAM_V4P5_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn bytedance_seedream_v4p5_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: BytedanceSeedreamV4p5MultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + BYTEDANCE_SEEDREAM_V4P5_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..5b4ee6bcaa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/gpt_image_1p5_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{GptImage1p5MultiFunctionImageGenRequest, GptImage1p5MultiFunctionImageGenResponse, GPT_IMAGE_1P5_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn gpt_image_1p5_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GptImage1p5MultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GPT_IMAGE_1P5_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/mod.rs new file mode 100644 index 0000000000..cc3e6edc86 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/mod.rs @@ -0,0 +1,7 @@ +pub mod bytedance_seedream_v4_multi_function_image_gen_image; +pub mod bytedance_seedream_v4p5_multi_function_image_gen_image; +pub mod bytedance_seedream_5_lite_multi_function_image_gen_image; +pub mod gpt_image_1p5_multi_function_image_gen_image; +pub mod nano_banana_2_multi_function_image_gen_image; +pub mod nano_banana_multi_function_image_gen_image; +pub mod nano_banana_pro_multi_function_image_gen_image; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..2439adee1e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_2_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::nano_banana_2_multi_function_image_gen::{NanaBanana2MultiFunctionImageGenRequest, NanaBanana2MultiFunctionImageGenResponse, NANO_BANANA_2_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn nano_banana_2_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: NanaBanana2MultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + NANO_BANANA_2_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..c63118e435 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{NanoBananaMultiFunctionImageGenRequest, NanoBananaMultiFunctionImageGenResponse, NANO_BANANA_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn nano_banana_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: NanoBananaMultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + NANO_BANANA_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_image.rs new file mode 100644 index 0000000000..701143804d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/multi_function/nano_banana_pro_multi_function_image_gen_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{NanoBananaProMultiFunctionImageGenRequest, NanoBananaProMultiFunctionImageGenResponse, NANO_BANANA_PRO_MULTI_FUNCTION_IMAGE_GEN_PATH}; + +pub async fn nano_banana_pro_multi_function_image_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: NanoBananaProMultiFunctionImageGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + NANO_BANANA_PRO_MULTI_FUNCTION_IMAGE_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_1_dev_text_to_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_1_dev_text_to_image.rs new file mode 100644 index 0000000000..835786845c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_1_dev_text_to_image.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageRequest, GenerateFlux1DevTextToImageResponse, GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH}; + + +pub async fn generate_flux_1_dev_text_to_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateFlux1DevTextToImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image.rs new file mode 100644 index 0000000000..de30430c94 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_1_schnell_text_to_image.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageRequest, GenerateFlux1SchnellTextToImageResponse, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH}; + + +pub async fn generate_flux_1_schnell_text_to_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateFlux1SchnellTextToImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_pro_11_text_to_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_pro_11_text_to_image.rs new file mode 100644 index 0000000000..a3c73d7833 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_pro_11_text_to_image.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageRequest, GenerateFluxPro11TextToImageResponse, GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH}; + + +pub async fn generate_flux_pro_11_text_to_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateFluxPro11TextToImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image.rs new file mode 100644 index 0000000000..1b437532ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_flux_pro_11_ultra_text_to_image.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageRequest, GenerateFluxPro11UltraTextToImageResponse, GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH}; + +pub async fn generate_flux_pro_11_ultra_text_to_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateFluxPro11UltraTextToImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_gpt_image_1_text_to_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_gpt_image_1_text_to_image.rs new file mode 100644 index 0000000000..813c7361ab --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/generate_gpt_image_1_text_to_image.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageRequest, GenerateGptImage1TextToImageResponse, GPT_IMAGE_1_TEXT_TO_IMAGE_PATH}; + + +pub async fn generate_gpt_image_1_text_to_image( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateGptImage1TextToImageRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GPT_IMAGE_1_TEXT_TO_IMAGE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/mod.rs new file mode 100644 index 0000000000..baf99b0235 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/image/text/mod.rs @@ -0,0 +1,5 @@ +pub mod generate_flux_1_dev_text_to_image; +pub mod generate_flux_1_schnell_text_to_image; +pub mod generate_flux_pro_11_text_to_image; +pub mod generate_flux_pro_11_ultra_text_to_image; +pub mod generate_gpt_image_1_text_to_image; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/mod.rs new file mode 100644 index 0000000000..414181138d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/mod.rs @@ -0,0 +1,5 @@ +pub mod cost_estimate; +pub mod image; +pub mod object; +pub mod splat; +pub mod video; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/generate_hunyuan_3d_2_0_image_to_3d.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/generate_hunyuan_3d_2_0_image_to_3d.rs new file mode 100644 index 0000000000..5b92bc41a0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/generate_hunyuan_3d_2_0_image_to_3d.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::{GenerateHunyuan20ImageTo3dRequest, GenerateHunyuan20ImageTo3dResponse, GENERATE_HUNYUAN_2_0_IMAGE_TO_3D_URL_PATH}; + +pub async fn generate_hunyuan3d_2_0_image_to_3d( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateHunyuan20ImageTo3dRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_HUNYUAN_2_0_IMAGE_TO_3D_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/generate_hunyuan_3d_2_1_image_to_3d.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/generate_hunyuan_3d_2_1_image_to_3d.rs new file mode 100644 index 0000000000..d8fcacd3d3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/generate_hunyuan_3d_2_1_image_to_3d.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::{GenerateHunyuan21ImageTo3dRequest, GenerateHunyuan21ImageTo3dResponse, GENERATE_HUNYUAN_2_1_IMAGE_TO_3D_URL_PATH}; + +pub async fn generate_hunyuan3d_2_1_image_to_3d( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateHunyuan21ImageTo3dRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_HUNYUAN_2_1_IMAGE_TO_3D_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/mod.rs new file mode 100644 index 0000000000..205c136ec8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/mod.rs @@ -0,0 +1,3 @@ +pub mod generate_hunyuan_3d_2_0_image_to_3d; +pub mod generate_hunyuan_3d_2_1_image_to_3d; +pub mod multi_function; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/multi_function/hunyuan3d_v3_multi_function_object_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/multi_function/hunyuan3d_v3_multi_function_object_gen.rs new file mode 100644 index 0000000000..0c8682cc3f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/multi_function/hunyuan3d_v3_multi_function_object_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::object::multi_function::hunyuan3d_v3_multi_function_object_gen::{Hunyuan3dV3MultiFunctionObjectGenRequest, Hunyuan3dV3MultiFunctionObjectGenResponse, HUNYUAN3D_V3_MULTI_FUNCTION_OBJECT_GEN_PATH}; + +pub async fn hunyuan3d_v3_multi_function_object_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Hunyuan3dV3MultiFunctionObjectGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + HUNYUAN3D_V3_MULTI_FUNCTION_OBJECT_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/multi_function/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/multi_function/mod.rs new file mode 100644 index 0000000000..22285b9e3a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/object/multi_function/mod.rs @@ -0,0 +1 @@ +pub mod hunyuan3d_v3_multi_function_object_gen; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat.rs new file mode 100644 index 0000000000..95410177a4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::splat::generate_worldlabs_marble_0p1_mini_splat::{GenerateWorldlabsMarble0p1MiniSplatRequest, GenerateWorldlabsMarble0p1MiniSplatResponse, GENERATE_WORLDLABS_MARBLE_0P1_MINI_SPLAT_URL_PATH}; + +pub async fn generate_worldlabs_marble_0p1_mini_splat( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateWorldlabsMarble0p1MiniSplatRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_WORLDLABS_MARBLE_0P1_MINI_SPLAT_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat.rs new file mode 100644 index 0000000000..9dd97b21e3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::splat::generate_worldlabs_marble_0p1_plus_splat::{GenerateWorldlabsMarble0p1PlusSplatRequest, GenerateWorldlabsMarble0p1PlusSplatResponse, GENERATE_WORLDLABS_MARBLE_0P1_PLUS_SPLAT_URL_PATH}; + +pub async fn generate_worldlabs_marble_0p1_plus_splat( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateWorldlabsMarble0p1PlusSplatRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_WORLDLABS_MARBLE_0P1_PLUS_SPLAT_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/mod.rs new file mode 100644 index 0000000000..68557f5370 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/splat/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_worldlabs_marble_0p1_mini_splat; +pub mod generate_worldlabs_marble_0p1_plus_splat; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/edit/beeble_switchx_edit_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/edit/beeble_switchx_edit_video.rs new file mode 100644 index 0000000000..d4ccaf0db8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/edit/beeble_switchx_edit_video.rs @@ -0,0 +1,20 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{ + BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse, BEEBLE_SWITCHX_EDIT_VIDEO_PATH, +}; + +pub async fn beeble_switchx_edit_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: BeebleSwitchXEditVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + BEEBLE_SWITCHX_EDIT_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/edit/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/edit/mod.rs new file mode 100644 index 0000000000..ad8f1b29f4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/edit/mod.rs @@ -0,0 +1 @@ +pub mod beeble_switchx_edit_video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_16_pro_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_16_pro_image_to_video.rs new file mode 100644 index 0000000000..96d0247d66 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_16_pro_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{GenerateKling16ProImageToVideoRequest, GenerateKling16ProImageToVideoResponse, GENERATE_KLING_1_6_PRO_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_kling_16_pro_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateKling16ProImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_KLING_1_6_PRO_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_21_master_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_21_master_image_to_video.rs new file mode 100644 index 0000000000..10af6506d0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_21_master_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{GenerateKling21MasterImageToVideoRequest, GenerateKling21MasterImageToVideoResponse, GENERATE_KLING_2_1_MASTER_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_kling_21_master_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateKling21MasterImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_KLING_2_1_MASTER_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_21_pro_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_21_pro_image_to_video.rs new file mode 100644 index 0000000000..098401113f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_kling_21_pro_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProImageToVideoRequest, GenerateKling21ProImageToVideoResponse, GENERATE_KLING_2_1_PRO_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_kling_21_pro_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateKling21ProImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_KLING_2_1_PRO_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_seedance_1_0_lite_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_seedance_1_0_lite_image_to_video.rs new file mode 100644 index 0000000000..743bea4e81 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_seedance_1_0_lite_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::{GenerateSeedance10LiteImageToVideoRequest, GenerateSeedance10LiteImageToVideoResponse, GENERATE_SEEDANCE_1_0_LITE_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_seedance_1_0_lite_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateSeedance10LiteImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_SEEDANCE_1_0_LITE_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_2_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_2_image_to_video.rs new file mode 100644 index 0000000000..cb7897fb53 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_2_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{GenerateVeo2ImageToVideoRequest, GenerateVeo2ImageToVideoResponse, GENERATE_VEO_2_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_veo_2_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateVeo2ImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_VEO_2_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_3_fast_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_3_fast_image_to_video.rs new file mode 100644 index 0000000000..1f5b29dbef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_3_fast_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_veo_3_fast_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateVeo3FastImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_3_image_to_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_3_image_to_video.rs new file mode 100644 index 0000000000..acb5bf073d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/generate_veo_3_image_to_video.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH}; + +pub async fn generate_veo_3_image_to_video( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: GenerateVeo3ImageToVideoRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/mod.rs new file mode 100644 index 0000000000..ebae4b56f8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/mod.rs @@ -0,0 +1,9 @@ +pub mod edit; +pub mod generate_kling_16_pro_image_to_video; +pub mod generate_kling_21_master_image_to_video; +pub mod generate_kling_21_pro_image_to_video; +pub mod generate_seedance_1_0_lite_image_to_video; +pub mod generate_veo_2_image_to_video; +pub mod generate_veo_3_fast_image_to_video; +pub mod generate_veo_3_image_to_video; +pub mod multi_function; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..8987dfb6c6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_2p5_turbo_pro_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::kling_2_5_turbo_multi_function_video_gen::{Kling2p5TurboProMultiFunctionVideoGenRequest, Kling2p5TurboProMultiFunctionVideoGenResponse, KLING_2P5_TURBO_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH}; + +pub async fn kling_2p5_turbo_pro_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Kling2p5TurboProMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + KLING_2P5_TURBO_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..a52b0b7874 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_2p6_pro_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::kling_2_6_multi_function_video_gen::{Kling2p6ProMultiFunctionVideoGenRequest, Kling2p6ProMultiFunctionVideoGenResponse, KLING_2P6_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH}; + +pub async fn kling_2p6_pro_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Kling2p6ProMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + KLING_2P6_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..a65486b846 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_3p0_pro_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::kling_3p0_pro_multi_function_video_gen::{Kling3p0ProMultiFunctionVideoGenRequest, Kling3p0ProMultiFunctionVideoGenResponse, KLING_3P0_PRO_MULTI_FUNCTION_VIDEO_PATH}; + +pub async fn kling_3p0_pro_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Kling3p0ProMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + KLING_3P0_PRO_MULTI_FUNCTION_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen.rs new file mode 100644 index 0000000000..c84bb70ee7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/kling_3p0_standard_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::kling_3p0_standard_multi_function_video_gen::{Kling3p0StandardMultiFunctionVideoGenRequest, Kling3p0StandardMultiFunctionVideoGenResponse, KLING_3P0_STANDARD_MULTI_FUNCTION_VIDEO_PATH}; + +pub async fn kling_3p0_standard_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Kling3p0StandardMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + KLING_3P0_STANDARD_MULTI_FUNCTION_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/mod.rs new file mode 100644 index 0000000000..f829677416 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/mod.rs @@ -0,0 +1,10 @@ +pub mod kling_2p5_turbo_pro_multi_function_video_gen; +pub mod seedance_1p5_pro_multi_function_video_gen; +pub mod seedance_2p0_multi_function_video_gen; +pub mod kling_2p6_pro_multi_function_video_gen; +pub mod kling_3p0_pro_multi_function_video_gen; +pub mod kling_3p0_standard_multi_function_video_gen; +pub mod sora_2_multi_function_video_gen; +pub mod sora_2_pro_multi_function_video_gen; +pub mod veo_3p1_fast_multi_function_video_gen; +pub mod veo_3p1_multi_function_video_gen; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..2d1d0505a0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/seedance_1p5_pro_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::seedance_1p5_pro_multi_function_video_gen::{Seedance1p5ProMultiFunctionVideoGenRequest, Seedance1p5ProMultiFunctionVideoGenResponse, SEEDANCE_1P5_PRO_MULTI_FUNCTION_VIDEO_GEN_PATH}; + +pub async fn seedance_1p5_pro_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Seedance1p5ProMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + SEEDANCE_1P5_PRO_MULTI_FUNCTION_VIDEO_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs new file mode 100644 index 0000000000..b3c8c8b73a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0MultiFunctionVideoGenResponse, SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH}; + +pub async fn seedance_2p0_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Seedance2p0MultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen.rs new file mode 100644 index 0000000000..359e1f31d0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::sora_2_multi_function_video_gen::{Sora2MultiFunctionVideoGenRequest, Sora2MultiFunctionVideoGenResponse, SORA_2_MULTI_FUNCTION_VIDEO_VIDEO_PATH}; + +pub async fn sora_2_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Sora2MultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + SORA_2_MULTI_FUNCTION_VIDEO_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen.rs new file mode 100644 index 0000000000..a825f0b04c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::sora_2_pro_multi_function_video_gen::{Sora2ProMultiFunctionVideoGenRequest, Sora2ProMultiFunctionVideoGenResponse, SORA_2_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH}; + +pub async fn sora_2_pro_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Sora2ProMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + SORA_2_PRO_MULTI_FUNCTION_VIDEO_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen.rs new file mode 100644 index 0000000000..a8b871c822 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/veo_3p1_fast_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::veo_3p1_fast_multi_function_video_gen::{Veo3p1FastMultiFunctionVideoGenRequest, Veo3p1FastMultiFunctionVideoGenResponse, VEO_3P1_FAST_MULTI_FUNCTION_VIDEO_VIDEO_PATH}; + +pub async fn veo_3p1_fast_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Veo3p1FastMultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + VEO_3P1_FAST_MULTI_FUNCTION_VIDEO_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen.rs new file mode 100644 index 0000000000..4627396942 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/generate/video/multi_function/veo_3p1_multi_function_video_gen.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::generate::video::multi_function::veo_3p1_multi_function_video_gen::{Veo3p1MultiFunctionVideoGenRequest, Veo3p1MultiFunctionVideoGenResponse, VEO_3P1_MULTI_FUNCTION_VIDEO_VIDEO_PATH}; + +pub async fn veo_3p1_multi_function_video_gen( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Veo3p1MultiFunctionVideoGenRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + VEO_3P1_MULTI_FUNCTION_VIDEO_VIDEO_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/jobs/list_session_jobs.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/jobs/list_session_jobs.rs new file mode 100644 index 0000000000..accf2a6043 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/jobs/list_session_jobs.rs @@ -0,0 +1,43 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_get_request::basic_json_get_request; +use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsSuccessResponse, LIST_SESSION_JOBS_URL_PATH}; +use enums::common::job_status_plus::JobStatusPlus; +use std::collections::HashSet; + +#[derive(Clone)] +pub enum States { + All, + Include(HashSet), + Exclude(HashSet), +} + +pub async fn list_session_jobs( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + job_states: States, +) -> Result { + + let url = match job_states { + States::All => + LIST_SESSION_JOBS_URL_PATH.to_string(), + States::Include(states) => + format!("{}?include_states={}", LIST_SESSION_JOBS_URL_PATH, states_string(&states)), + States::Exclude(states) => + format!("{}?exclude_states={}", LIST_SESSION_JOBS_URL_PATH, states_string(&states)), + }; + + Ok(basic_json_get_request( + api_host, + &url, + maybe_creds, + ).await?) +} + +fn states_string(states: &HashSet) -> String { + states.iter() + .map(|state| state.to_str()) + .collect::>() + .join(",") +} \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/jobs/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/jobs/mod.rs new file mode 100644 index 0000000000..164271aad7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/jobs/mod.rs @@ -0,0 +1 @@ +pub mod list_session_jobs; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/delete_media_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/delete_media_file.rs new file mode 100644 index 0000000000..ca2a79b496 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/delete_media_file.rs @@ -0,0 +1,36 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_delete_request::basic_json_delete_request; +use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess; +use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest; +use artcraft_api_defs::subscriptions::get_session_subscription::GetSessionSubscriptionResponse; +use log::debug; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn delete_media_file( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + media_file_token: &MediaFileToken, +) -> Result { + let url_path = get_url_path(media_file_token); + + debug!("Requesting {:?}", &url_path); + + // NB: We probably don't need to expose these options from FakeYou anymore. + let request = DeleteMediaFileRequest { + set_delete: true, + as_mod: None, + }; + + Ok(basic_json_delete_request( + api_host, + &url_path, + maybe_creds, + request, + ).await?) +} + +fn get_url_path(token: &MediaFileToken) -> String { + format!("/v1/media_files/file/{}", token.as_str()) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs new file mode 100644 index 0000000000..7beef4ed4b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs @@ -0,0 +1,221 @@ +use crate::error::api_error::ApiError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::http_get_anonymous::http_get_anonymous; +use artcraft_api_defs::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails; +use artcraft_api_defs::common::responses::media_links::MediaLinks; +use artcraft_api_defs::common::responses::simple_entity_stats::SimpleEntityStats; +use artcraft_api_defs::common::responses::user_details_light::UserDetailsLight; +use chrono::{DateTime, Utc}; +use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::by_table::model_weights::weights_category::WeightsCategory; +use enums::common::visibility::Visibility; +use enums::no_table::style_transfer::style_transfer_name::StyleTransferName; +use enums::by_table::model_weights::weights_types::WeightsType; +use log::debug; +use serde_derive::Deserialize; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; + +/// Get details about a media file from our backend +pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result { + let url = get_media_file_token_route(api_host, media_file_token); + + debug!("Requesting {:?}", &url); + + let response = http_get_anonymous(url).await?; + let response = filter_bad_response(response).await?; + let response_body = &response.text().await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let media_file = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(media_file) +} + +fn get_media_file_token_route(api_host: &ApiHost, media_file_token: &MediaFileToken) -> String { + let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme(); + let media_file_token = media_file_token.as_str(); + format!("{}/v1/media_files/file/{}", api_hostname_and_scheme, media_file_token) +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize, Debug)] +pub struct GetMediaFileSuccessResponse { + pub success: bool, + pub media_file: MediaFileInfo, +} + +#[derive(Deserialize, Debug)] +pub struct MediaFileInfo { + pub token: MediaFileToken, + + /// The coarse-grained class of media file: image, video, etc. + pub media_class: MediaFileClass, + + /// Type of media will dictate which fields are populated and what + /// the frontend should display (eg. video player vs audio player). + /// This is closer in meaning to a "mime type". + pub media_type: MediaFileType, + + /// If this is an engine/3D asset, this is the broad category (scene, + /// animation, etc.) of that object. + /// This can also be used for filtering in list/batch endpoints. + pub maybe_engine_category: Option, + + /// If this is an engine/3D asset for an animation or a rig that can + /// be animated with either (or both) skeletal or blend shape animations, + /// this describes the animation regime used or supported. + pub maybe_animation_type: Option, + + // /// Extension for the engine to load over remote:// URLs. + // #[deprecated(note="This was for the Bevy engine. Do not use.")] + // pub maybe_engine_extension: Option, + + /// If the file was generated as part of a batch, this is the token for the batch. + pub maybe_batch_token: Option, + + /// If this media file was generated by a scene from the engine, this is a + /// link to that file. + pub maybe_scene_source_media_file_token: Option, + + // /// (DEPRECATED) URL path to the media file + // #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")] + // pub public_bucket_path: String, + + // /// (DEPRECATED) Full URL to the media file + // #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")] + // pub public_bucket_url: Url, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// Information about the cover image. Many media files do not require a cover image, + /// e.g. image files, video files with thumbnails, audio files, etc. + /// 3D files require them. + pub cover_image: MediaFileCoverImageDetails, + + //// Provenance Data + //pub origin_product: MediaFileOriginProductCategory, + //pub origin_category: MediaFileOriginCategory, + //pub maybe_origin_model_type: Option, + //pub maybe_origin_model_token: Option, + //pub maybe_origin_filename: Option, + + /// Possible model info (if generated by inference) + pub maybe_model_weight_info: Option, + + /// User info + pub maybe_creator_user: Option, + + pub creator_set_visibility: Visibility, + + /// The file was uploaded by the user. + /// This does not include files generated on the client side, like studio renders. + pub is_user_upload: bool, + + /// The file was created by the system. + /// This includes files generated on the client side, like studio renders. + pub is_intermediate_system_file: bool, + + /// The name or title of the media file (optional) + pub maybe_title: Option, + + /// Text transcripts for TTS, etc. + pub maybe_text_transcript: Option, + + // /// OPTIONAL. Details on live portrait generated media files, + // /// if this is a live portrait generated file. + // pub maybe_live_portrait_details: Option, + + /// For Comfy / Video Style Transfer jobs, this might include + /// the name of the selected style. + pub maybe_style_name: Option, + + /// For Comfy / Video Style Transfer jobs, this indicates use of face detailer. + pub used_face_detailer: bool, + + /// For Comfy / Video Style Transfer jobs, this indicates use of upscaling. + pub used_upscaler: bool, + + /// The foreign key to the prompt used to generate the media, if applicable. + pub maybe_prompt_token: Option, + + /// The original filename for uploaded files, if they were provided. + /// In the future we'll provide our own internal optional filenames. + pub maybe_original_filename: Option, + + /// Duration for audio and video files, if available. + /// Measured in milliseconds. + pub maybe_duration_millis: Option, + + /// We can simulate media files for "tts_results" records. + /// If this is set as true, this informs the frontend and API callers not to treat + /// the token as a media file and improperly assume it can be used with the rest of + /// the media file APIs. + pub is_emulated_media_file: bool, + + /// Whether the media file is featured by staff (the featured_items API) or not. + pub is_featured: bool, + + /// Statistics about the media file + pub stats: SimpleEntityStats, + + pub created_at: DateTime, + pub updated_at: DateTime, + + pub maybe_moderator_fields: Option, +} + +#[derive(Deserialize, Debug)] +pub struct GetMediaFileModeratorFields { + /// This may become public in the future. + pub maybe_style_transfer_source_media_file_token: Option, + //pub creator_ip_address: String, + //pub creator_is_banned_if_user: bool, + //pub maybe_creator_deleted_at: Option>, + //pub maybe_mod_deleted_at: Option>, +} + +#[derive(Deserialize, Debug)] +pub struct GetMediaFileModelInfo { + pub weight_token: ModelWeightToken, + pub weight_type: WeightsType, + pub weight_category: WeightsCategory, + pub title: String, + + /// Cover images are small descriptive images that can be set for any model. + /// If a cover image is set, this is the path to the asset. + pub maybe_cover_image_public_bucket_path: Option, + + /// NB: Technically this shouldn't be a nullable field, but since there are so many + /// joins we'll relax that this must exist (for now). + pub maybe_weight_creator: Option, +} + +#[cfg(test)] +mod tests { + use crate::endpoints::media_files::get_media_file::get_media_file; + use crate::utils::api_host::ApiHost; + use tokens::tokens::media_files::MediaFileToken; + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn test_request() { + let host = ApiHost::Storyteller; + let token = MediaFileToken::new_from_str("m_gff67btr810vg3ng9szj85zskztcgy"); + let result = get_media_file(&host, &token).await.unwrap(); + + println!("Result: {:?}", &result); + + assert_eq!(result.success, false); // NB: Will fail so we can see debug printed result. + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs new file mode 100644 index 0000000000..7454297470 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs @@ -0,0 +1,137 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::http_get_anonymous::http_get_anonymous; +use chrono::{DateTime, Utc}; +use enums::common::generation_provider::GenerationProvider; +use uuid_utils::uuid::generate_random_uuid; +use log::debug; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_derive::{Deserialize, Serialize}; +use std::path::Path; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use uuid::uuid; + +pub struct LegacyUploadMediaFileFromFileArgs<'a, P: AsRef> { + pub api_host: &'a ApiHost, + pub maybe_creds: Option<&'a StorytellerCredentialSet>, + + // NB: Path needs to be owned for the request. + pub path: P, + + // /// If true, we should hide the image from the user's gallery. + // pub is_intermediate_system_file: bool, + + // /// If provided, this is the prompt that this image is associated with. + // /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + // pub maybe_prompt_token: Option<&'a PromptToken>, + + /// If provided, the third-party provider that generated this file. + pub maybe_generation_provider: Option, + + // /// If provided, this groups the file into a batch + // /// TODO: This shouldn't be set clientside without the backend generating the token + // /// and cryptographically securing it. But we need to go fast here. + // pub maybe_batch_token: Option<&'a BatchGenerationToken>, +} + +/// Upload using the legacy endpoint (spz, etc.) +pub async fn legacy_upload_media_file_from_file>( + args: LegacyUploadMediaFileFromFileArgs<'_, P>, +) -> Result { + + validate_args(&args)?; + + let url = get_route(args.api_host); + + debug!("Requesting {:?}", &url); + + //let mut file = File::open(path)?; + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let file_bytes = std::fs::read(args.path.as_ref()) + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + let file_name = args.path.as_ref().file_name() + .and_then(|n| n.to_str()).unwrap_or("file").to_string(); + let mut form = Form::new() + .text("uuid_idempotency_token", generate_random_uuid()) + .part("file", Part::bytes(file_bytes).file_name(file_name)); + + //if args.is_intermediate_system_file { + // form = form.text("is_intermediate_system_file", "true"); + //} + + //if let Some(prompt_token) = &args.maybe_prompt_token { + // form = form.text("maybe_prompt_token", prompt_token.to_string()); + //} + + //if let Some(batch_token) = &args.maybe_batch_token { + // form = form.text("maybe_batch_token", batch_token.to_string()); + //} + + if let Some(provider) = &args.maybe_generation_provider { + form = form.text("maybe_generation_provider", provider.to_str().to_string()); + } + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = args.maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .multipart(form) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response = filter_bad_response(response).await?; + let response_body = &response.text().await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let media_file = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(media_file) +} + +fn get_route(api_host: &ApiHost) -> String { + let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme(); + format!("{}/v1/media_files/upload", api_hostname_and_scheme) +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize, Debug)] +pub struct UploadImageMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} + +fn validate_args>( + args: &LegacyUploadMediaFileFromFileArgs<'_, P>, +) -> Result<(), StorytellerError> { + // if args.is_intermediate_system_file && args.maybe_prompt_token.is_some() { + // return Err(StorytellerError::Client(ClientError::InvalidPreflightRequest( + // "Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided." + // .to_string()))); + // } + + Ok(()) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/list_batch_generated_redux_media_files.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/list_batch_generated_redux_media_files.rs new file mode 100644 index 0000000000..9f493d34db --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/list_batch_generated_redux_media_files.rs @@ -0,0 +1,19 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_get_request::basic_json_get_request; +use artcraft_api_defs::media_file::list_batch_generated_media_files::{ListBatchGeneratedReduxMediaFilesSuccessResponse, LIST_BATCH_GENERATED_REDUX_MEDIA_FILES_URL_PATH}; +use tokens::tokens::batch_generations::BatchGenerationToken; + +pub async fn list_batch_generated_redux_media_files( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + batch_token: &BatchGenerationToken, +) -> Result { + let url = format!("{}/{}", LIST_BATCH_GENERATED_REDUX_MEDIA_FILES_URL_PATH, batch_token.to_string()); + Ok(basic_json_get_request( + api_host, + &url, + maybe_creds, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/mod.rs new file mode 100644 index 0000000000..99cc13aa5f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/mod.rs @@ -0,0 +1,8 @@ +pub mod delete_media_file; +pub mod get_media_file; +pub mod list_batch_generated_redux_media_files; +pub mod upload_image_media_file_from_bytes; +pub mod upload_image_media_file_from_file; +pub mod upload_new_engine_asset_from_file; +pub mod upload_video_media_file_from_file; +pub mod legacy_upload_media_file_from_file; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs new file mode 100644 index 0000000000..f496671b6b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs @@ -0,0 +1,134 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::http_get_anonymous::http_get_anonymous; +use enums::common::generation_provider::GenerationProvider; +use chrono::{DateTime, Utc}; +use uuid_utils::uuid::generate_random_uuid; +use log::debug; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_derive::{Deserialize, Serialize}; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use uuid::uuid; + +pub struct UploadImageBytesArgs<'a> { + pub api_host: &'a ApiHost, + pub maybe_creds: Option<&'a StorytellerCredentialSet>, + + // NB: Bytes need to be owned for the request. + pub image_bytes: Vec, + pub image_type: ImageType, + + /// If true, we should hide the image from the user's gallery. + pub is_intermediate_system_file: bool, + + /// If provided, the third-party provider that generated this file. + pub maybe_generation_provider: Option, +} + +#[derive(Clone, Copy, Debug)] +pub enum ImageType { + Png, + Jpeg, + Gif, +} + +impl ImageType { + pub fn mime_type(&self) -> &str { + match self { + ImageType::Png => "image/png", + ImageType::Jpeg => "image/jpeg", + ImageType::Gif => "image/gif", + } + } + pub fn file_extension(&self) -> &str { + match self { + ImageType::Png => "png", + ImageType::Jpeg => "jpg", + ImageType::Gif => "gif", + } + } +} + +/// Upload an image media file from a file. +/// NB: File type is not checked, so caller needs to enforce. +/// NB: We need owned bytes for the request. +pub async fn upload_image_media_file_from_bytes( + args: UploadImageBytesArgs<'_> +) -> Result { + + let url = get_route(args.api_host); + + debug!("Requesting {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let part = Part::bytes(args.image_bytes) + .file_name(format!("image.{}", args.image_type.file_extension())) + .mime_str(args.image_type.mime_type()) + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let mut form = Form::new() + .text("uuid_idempotency_token", generate_random_uuid()) + .part("file", part); + + if args.is_intermediate_system_file { + form = form.text("is_intermediate_system_file", "true"); + } + + if let Some(provider) = &args.maybe_generation_provider { + form = form.text("maybe_generation_provider", provider.to_str().to_string()); + } + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = args.maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .multipart(form) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response = filter_bad_response(response).await?; + let response_body = &response.text().await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let media_file = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(media_file) +} + +fn get_route(api_host: &ApiHost) -> String { + let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme(); + format!("{}/v1/media_files/upload/image", api_hostname_and_scheme) +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize, Debug)] +pub struct UploadImageMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs new file mode 100644 index 0000000000..193b9575f7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs @@ -0,0 +1,137 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::http_get_anonymous::http_get_anonymous; +use chrono::{DateTime, Utc}; +use enums::common::generation_provider::GenerationProvider; +use uuid_utils::uuid::generate_random_uuid; +use log::debug; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_derive::{Deserialize, Serialize}; +use std::path::Path; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use uuid::uuid; + +pub struct UploadImageFromFileArgs<'a, P: AsRef> { + pub api_host: &'a ApiHost, + pub maybe_creds: Option<&'a StorytellerCredentialSet>, + + // NB: Path needs to be owned for the request. + pub path: P, + + /// If true, we should hide the image from the user's gallery. + pub is_intermediate_system_file: bool, + + /// If provided, this is the prompt that this image is associated with. + /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + pub maybe_prompt_token: Option<&'a PromptToken>, + + /// If provided, the third-party provider that generated this file. + pub maybe_generation_provider: Option, + + /// If provided, this groups the file into a batch + /// TODO: This shouldn't be set clientside without the backend generating the token + /// and cryptographically securing it. But we need to go fast here. + pub maybe_batch_token: Option<&'a BatchGenerationToken>, +} + +/// Upload an image media file from a file. +pub async fn upload_image_media_file_from_file>( + args: UploadImageFromFileArgs<'_, P>, +) -> Result { + + validate_args(&args)?; + + let url = get_route(args.api_host); + + debug!("Requesting {:?}", &url); + + //let mut file = File::open(path)?; + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let file_bytes = std::fs::read(args.path.as_ref()) + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + let file_name = args.path.as_ref().file_name() + .and_then(|n| n.to_str()).unwrap_or("file").to_string(); + let mut form = Form::new() + .text("uuid_idempotency_token", generate_random_uuid()) + .part("file", Part::bytes(file_bytes).file_name(file_name)); + + if args.is_intermediate_system_file { + form = form.text("is_intermediate_system_file", "true"); + } + + if let Some(prompt_token) = &args.maybe_prompt_token { + form = form.text("maybe_prompt_token", prompt_token.to_string()); + } + + if let Some(batch_token) = &args.maybe_batch_token { + form = form.text("maybe_batch_token", batch_token.to_string()); + } + + if let Some(provider) = &args.maybe_generation_provider { + form = form.text("maybe_generation_provider", provider.to_str().to_string()); + } + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = args.maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .multipart(form) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response = filter_bad_response(response).await?; + let response_body = &response.text().await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let media_file = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(media_file) +} + +fn get_route(api_host: &ApiHost) -> String { + let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme(); + format!("{}/v1/media_files/upload/image", api_hostname_and_scheme) +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize, Debug)] +pub struct UploadImageMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} + +fn validate_args>( + args: &UploadImageFromFileArgs<'_, P>, +) -> Result<(), StorytellerError> { + if args.is_intermediate_system_file && args.maybe_prompt_token.is_some() { + return Err(StorytellerError::Client(ClientError::InvalidPreflightRequest( + "Cannot set `is_intermediate_system_file` to true if `maybe_prompt_token` is provided." + .to_string()))); + } + + Ok(()) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_new_engine_asset_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_new_engine_asset_from_file.rs new file mode 100644 index 0000000000..1f98ea282c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_new_engine_asset_from_file.rs @@ -0,0 +1,85 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::http_get_anonymous::http_get_anonymous; +use enums::common::generation_provider::GenerationProvider; +use chrono::{DateTime, Utc}; +use uuid_utils::uuid::generate_random_uuid; +use log::debug; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_derive::{Deserialize, Serialize}; +use std::path::Path; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use uuid::uuid; + +// TODO: The naming of these methods is ridiculous. It needs to be cleaned up. + +/// Upload an image media file from a file. +pub async fn upload_new_engine_asset_from_file>( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + path: P, + maybe_generation_provider: Option, +) -> Result { + + let url = get_route(api_host); + + debug!("Requesting {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build()?; + + let file_bytes = std::fs::read(path.as_ref())?; + let file_name = path.as_ref().file_name() + .and_then(|n| n.to_str()).unwrap_or("file").to_string(); + let mut form = Form::new() + .text("uuid_idempotency_token", generate_random_uuid()) + .text("engine_category", "object") // TODO: Which type should we use? + .part("file", Part::bytes(file_bytes).file_name(file_name)); + + if let Some(provider) = &maybe_generation_provider { + form = form.text("maybe_generation_provider", provider.to_str().to_string()); + } + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .multipart(form) + .send() + .await?; + + let response = filter_bad_response(response).await?; + let response_body = &response.text().await?; + + let media_file = serde_json::from_str(&response_body)?; + + Ok(media_file) +} + +fn get_route(api_host: &ApiHost) -> String { + let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme(); + format!("{}/v1/media_files/upload/new_engine_asset", api_hostname_and_scheme) +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize, Debug)] +pub struct UploadImageMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs new file mode 100644 index 0000000000..7c5a522d15 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs @@ -0,0 +1,107 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::http_get_anonymous::http_get_anonymous; +use enums::common::generation_provider::GenerationProvider; +use chrono::{DateTime, Utc}; +use uuid_utils::uuid::generate_random_uuid; +use log::debug; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_derive::{Deserialize, Serialize}; +use std::path::Path; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use uuid::uuid; + +pub struct UploadVideoFromFileArgs<'a, P: AsRef> { + pub api_host: &'a ApiHost, + pub maybe_creds: Option<&'a StorytellerCredentialSet>, + + // NB: Path needs to be owned for the request. + pub path: P, + + // /// If true, we should hide the image from the user's gallery. + // pub is_intermediate_system_file: bool, + + /// If provided, this is the prompt that this image is associated with. + /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + pub maybe_prompt_token: Option<&'a PromptToken>, + + /// If provided, the third-party provider that generated this file. + pub maybe_generation_provider: Option, + + // /// If provided, this groups the file into a batch + // /// TODO: This shouldn't be set clientside without the backend generating the token + // /// and cryptographically securing it. But we need to go fast here. + // pub maybe_batch_token: Option<&'a BatchGenerationToken>, +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize, Debug)] +pub struct UploadVideoMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} + +/// Upload a video media file from a file. +pub async fn upload_video_media_file_from_file>( + args: UploadVideoFromFileArgs<'_, P>, +) -> Result { + + let url = get_route(args.api_host); + + debug!("Requesting {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build()?; + + let file_bytes = std::fs::read(args.path.as_ref())?; + let file_name = args.path.as_ref().file_name() + .and_then(|n| n.to_str()).unwrap_or("file").to_string(); + let mut form = Form::new() + .text("uuid_idempotency_token", generate_random_uuid()) + .part("file", Part::bytes(file_bytes).file_name(file_name)); + + if let Some(prompt_token) = &args.maybe_prompt_token { + form = form.text("maybe_prompt_token", prompt_token.to_string()); + } + + if let Some(provider) = &args.maybe_generation_provider { + form = form.text("maybe_generation_provider", provider.to_str().to_string()); + } + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = args.maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .multipart(form) + .send() + .await?; + + let response = filter_bad_response(response).await?; + let response_body = &response.text().await?; + + let media_file = serde_json::from_str(&response_body)?; + + Ok(media_file) +} + +fn get_route(api_host: &ApiHost) -> String { + let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme(); + format!("{}/v1/media_files/upload/new_video", api_hostname_and_scheme) +} + diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs new file mode 100644 index 0000000000..7c92178019 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs @@ -0,0 +1,9 @@ +pub mod analytics; +pub mod credits; +pub mod generate; +pub mod jobs; +pub mod omni_gen; +pub mod media_files; +pub mod prompts; +pub mod stripe_artcraft; +pub mod subscriptions; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/audio/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/audio/mod.rs new file mode 100644 index 0000000000..a055a243a9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/audio/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_audio; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/audio/omni_gen_audio.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/audio/omni_gen_audio.rs new file mode 100644 index 0000000000..7e8664d74b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/audio/omni_gen_audio.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_audio_generate_response::OmniGenAudioGenerateResponse; + +pub const OMNI_GEN_AUDIO_GENERATE_PATH: &str = "/v1/omni_gen/generate/audio"; + +pub async fn omni_gen_audio_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenAudioCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_AUDIO_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/image/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/image/mod.rs new file mode 100644 index 0000000000..d36418bcd2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/image/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_image; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/image/omni_gen_image.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/image/omni_gen_image.rs new file mode 100644 index 0000000000..7e6b010061 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/image/omni_gen_image.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +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; + +pub const OMNI_GEN_IMAGE_GENERATE_PATH: &str = "/v1/omni_gen/generate/image"; + +pub async fn omni_gen_image_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenImageCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_IMAGE_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/mod.rs new file mode 100644 index 0000000000..40ebdf21bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_mesh; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/omni_gen_mesh.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/omni_gen_mesh.rs new file mode 100644 index 0000000000..8444d3fc23 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/omni_gen_mesh.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::OmniGenMeshGenerateResponse; + +pub const OMNI_GEN_MESH_GENERATE_PATH: &str = "/v1/omni_gen/generate/mesh"; + +pub async fn omni_gen_mesh_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenMeshCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_MESH_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs new file mode 100644 index 0000000000..cd3fbb9d3f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs @@ -0,0 +1,5 @@ +pub mod audio; +pub mod image; +pub mod mesh; +pub mod splat; +pub mod video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/mod.rs new file mode 100644 index 0000000000..014c2d2661 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_splat; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/omni_gen_splat.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/omni_gen_splat.rs new file mode 100644 index 0000000000..86ff3c5a7e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/omni_gen_splat.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::OmniGenSplatGenerateResponse; + +pub const OMNI_GEN_SPLAT_GENERATE_PATH: &str = "/v1/omni_gen/generate/splat"; + +pub async fn omni_gen_splat_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenSplatCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_SPLAT_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs new file mode 100644 index 0000000000..d4efe9eea8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs new file mode 100644 index 0000000000..f6014de1fe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +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; + +pub const OMNI_GEN_VIDEO_GENERATE_PATH: &str = "/v1/omni_gen/generate/video"; + +pub async fn omni_gen_video_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenVideoCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_VIDEO_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs new file mode 100644 index 0000000000..34d75adee5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs @@ -0,0 +1,2 @@ +pub mod generate; +pub mod models; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/image/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/image/mod.rs new file mode 100644 index 0000000000..c6d40165a6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/image/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_list_image_models; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/image/omni_gen_list_image_models.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/image/omni_gen_list_image_models.rs new file mode 100644 index 0000000000..32c9957f0e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/image/omni_gen_list_image_models.rs @@ -0,0 +1,237 @@ +//! Self-contained, forward-compatible binding for `GET /v1/omni_gen/models/image`. +//! +//! IMPORTANT: This module deliberately does NOT reuse any types from +//! `artcraft_api_defs`. Client builds may be deployed in the wild long after the +//! server's models, enums, and response shapes have changed, so every request / +//! response type is copied here and made permissive: +//! - Unknown JSON fields are ignored (serde default behavior). +//! - Missing collection/flag fields default rather than fail (`serde(default)`). +//! - Every string-valued enum has an `Unknown(String)` catch-all so new server +//! variants deserialize instead of erroring. + +use serde_derive::{Deserialize, Serialize}; + +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio; +use crate::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider; +use crate::datatypes::common_enums::generation::api_client_image_model::ApiClientImageModel; +use crate::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator; +use crate::datatypes::common_enums::generation::api_client_quality::ApiClientQuality; +use crate::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_get_request::basic_json_get_request; + +pub const OMNI_GEN_IMAGE_MODELS_PATH: &str = "/v1/omni_gen/models/image"; + +/// Arguments for [`omni_gen_list_image_models`]. +pub struct OmniGenListImageModelsArgs<'a> { + pub api_host: &'a ApiHost, + pub maybe_creds: Option<&'a StorytellerCredentialSet>, + /// Which provider's models to list. `None` lets the server default (artcraft). + pub provider: Option, +} + +/// List available image models. +pub async fn omni_gen_list_image_models( + args: OmniGenListImageModelsArgs<'_>, +) -> Result { + let path = match args.provider { + Some(provider) => format!("{}?provider={}", OMNI_GEN_IMAGE_MODELS_PATH, provider.as_query_value()), + None => OMNI_GEN_IMAGE_MODELS_PATH.to_string(), + }; + + Ok(basic_json_get_request(args.api_host, &path, args.maybe_creds).await?) +} + +/// The provider filter for the models endpoint (a client-supplied request value). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OmniGenImageModelsProvider { + /// Only models available through ArtCraft. + Artcraft, + /// All known models across all providers. + All, +} + +impl OmniGenImageModelsProvider { + pub fn as_query_value(self) -> &'static str { + match self { + Self::Artcraft => "artcraft", + Self::All => "all", + } + } +} + +impl Default for OmniGenImageModelsProvider { + fn default() -> Self { + Self::Artcraft + } +} + +// ============================ Response types ============================ + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenImageModelsResponse { + #[serde(default)] + pub success: bool, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub providers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenImageModelProviderDetails { + pub provider: ApiClientGenerationProvider, + #[serde(default)] + pub models: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenImageProviderModelDetails { + pub model: ApiClientImageModel, + #[serde(default)] + pub overrides: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenImageModelDetails { + pub model: ApiClientImageModel, + #[serde(default)] + pub model_creator: Option, + #[serde(default)] + pub full_name: Option, + #[serde(default)] + pub text_prompt_supported: Option, + #[serde(default)] + pub text_prompt_max_length: Option, + #[serde(default)] + pub negative_text_prompt_supported: Option, + #[serde(default)] + pub negative_text_prompt_max_length: Option, + #[serde(default)] + pub image_refs_supported: Option, + #[serde(default)] + pub image_refs_max: Option, + #[serde(default)] + pub has_fixed_editing_aspect_ratio: Option, + #[serde(default)] + pub aspect_ratio_options: Option>, + #[serde(default)] + pub aspect_ratio_default: Option, + #[serde(default)] + pub aspect_ratio_default_when_editing: Option, + #[serde(default)] + pub resolution_options: Option>, + #[serde(default)] + pub resolution_default: Option, + #[serde(default)] + pub quality_options: Option>, + #[serde(default)] + pub default_quality: Option, + #[serde(default)] + pub batch_size_min: Option, + #[serde(default)] + pub batch_size_max: Option, + #[serde(default)] + pub batch_size_options: Option>, + #[serde(default)] + pub batch_size_default: Option, + #[serde(default)] + pub is_disabled: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_tolerates_unknown_fields_and_variants() { + let json = r#"{ + "success": true, + "models": [ + { + "model": "brand_new_image_model", + "model_creator": "some_new_studio", + "aspect_ratio_options": ["square", "some_new_ratio"], + "surprise_new_field": 123 + } + ], + "providers": [ + { "provider": "new_provider", "models": [ { "model": "nano_banana_pro" } ] } + ], + "another_unexpected_top_level_field": "ignored" + }"#; + + let resp: OmniGenImageModelsResponse = serde_json::from_str(json).unwrap(); + assert!(resp.success); + assert_eq!(resp.models.len(), 1); + assert_eq!(resp.models[0].model, ApiClientImageModel::Unknown("brand_new_image_model".to_string())); + assert_eq!(resp.models[0].model_creator, Some(ApiClientModelCreator::Unknown("some_new_studio".to_string()))); + let ratios = resp.models[0].aspect_ratio_options.as_ref().unwrap(); + assert_eq!(ratios[0], ApiClientAspectRatio::Square); + assert_eq!(ratios[1], ApiClientAspectRatio::Unknown("some_new_ratio".to_string())); + assert_eq!(resp.models[0].is_disabled, None); + assert_eq!(resp.providers[0].provider, ApiClientGenerationProvider::Unknown("new_provider".to_string())); + assert_eq!(resp.providers[0].models[0].model, ApiClientImageModel::NanoBananaPro); + } + + #[test] + fn empty_object_uses_defaults() { + let resp: OmniGenImageModelsResponse = serde_json::from_str("{}").unwrap(); + assert!(!resp.success); + assert!(resp.models.is_empty()); + assert!(resp.providers.is_empty()); + } +} + +/// Live tests that hit real servers. All `#[ignore]` so they never run by default. +/// Run explicitly, e.g.: +/// cargo test -p artcraft_client omni_gen_list_image_models::live_tests -- --ignored --nocapture +#[cfg(test)] +mod live_tests { + mod localhost { + use super::super::{omni_gen_list_image_models, OmniGenListImageModelsArgs}; + use crate::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // live: hits http://localhost:12345 + async fn list_image_models() { + let api_host = ApiHost::Localhost { port: 12345 }; + let response = omni_gen_list_image_models(OmniGenListImageModelsArgs { + api_host: &api_host, + maybe_creds: None, + provider: None, + }) + .await + .expect("request should succeed"); + + println!("[localhost] image models response: {:#?}", response); + assert!(response.success); + assert!(!response.models.is_empty(), "expected at least one image model"); + } + } + + mod production { + use super::super::{omni_gen_list_image_models, OmniGenListImageModelsArgs}; + use crate::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // live: hits https://api.storyteller.ai + async fn list_image_models() { + let api_host = ApiHost::Storyteller; + let response = omni_gen_list_image_models(OmniGenListImageModelsArgs { + api_host: &api_host, + maybe_creds: None, + provider: None, + }) + .await + .expect("request should succeed"); + + println!("[production] image models response: {:#?}", response); + assert!(response.success); + assert!(!response.models.is_empty(), "expected at least one image model"); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/mod.rs new file mode 100644 index 0000000000..82a9d3c9e0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/mod.rs @@ -0,0 +1,2 @@ +pub mod image; +pub mod video; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/mod.rs new file mode 100644 index 0000000000..7a4a26b3ca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_list_video_models; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs new file mode 100644 index 0000000000..16073e8bea --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs @@ -0,0 +1,291 @@ +//! Self-contained, forward-compatible binding for `GET /v1/omni_gen/models/video`. +//! +//! IMPORTANT: This module deliberately does NOT reuse any types from +//! `artcraft_api_defs`. Client builds may be deployed in the wild long after the +//! server's models, enums, and response shapes have changed, so every request / +//! response type is copied here and made permissive: +//! - Unknown JSON fields are ignored (serde default behavior). +//! - Missing collection/flag fields default rather than fail (`serde(default)`). +//! - Every string-valued enum has an `Unknown(String)` catch-all so new server +//! variants deserialize instead of erroring. + +use serde_derive::{Deserialize, Serialize}; + +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_get_request::basic_json_get_request; +use crate::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio; +use crate::datatypes::common_enums::generation::api_client_bitrate::ApiClientBitrate; +use crate::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider; +use crate::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator; +use crate::datatypes::common_enums::generation::api_client_quality::ApiClientQuality; +use crate::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution; +use crate::datatypes::common_enums::generation::api_client_video_model::ApiClientVideoModel; + +pub const OMNI_GEN_VIDEO_MODELS_PATH: &str = "/v1/omni_gen/models/video"; + +/// Arguments for [`omni_gen_list_video_models`]. +pub struct OmniGenListVideoModelsArgs<'a> { + pub api_host: &'a ApiHost, + pub maybe_creds: Option<&'a StorytellerCredentialSet>, + /// Which provider's models to list. `None` lets the server default (artcraft). + pub provider: Option, +} + +/// List available video models. +pub async fn omni_gen_list_video_models( + args: OmniGenListVideoModelsArgs<'_>, +) -> Result { + let path = match args.provider { + Some(provider) => format!("{}?provider={}", OMNI_GEN_VIDEO_MODELS_PATH, provider.as_query_value()), + None => OMNI_GEN_VIDEO_MODELS_PATH.to_string(), + }; + + Ok(basic_json_get_request(args.api_host, &path, args.maybe_creds).await?) +} + +/// The provider filter for the models endpoint (a client-supplied request value). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OmniGenVideoModelsProvider { + /// Only models available through ArtCraft. + Artcraft, + /// All known models across all providers. + All, +} + +impl OmniGenVideoModelsProvider { + pub fn as_query_value(self) -> &'static str { + match self { + Self::Artcraft => "artcraft", + Self::All => "all", + } + } +} + +impl Default for OmniGenVideoModelsProvider { + fn default() -> Self { + Self::Artcraft + } +} + +// ============================ Response types ============================ + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenVideoModelsResponse { + #[serde(default)] + pub success: bool, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub providers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenVideoModelProviderDetails { + pub provider: ApiClientGenerationProvider, + #[serde(default)] + pub models: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenVideoProviderModelDetails { + pub model: ApiClientVideoModel, + #[serde(default)] + pub overrides: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniGenVideoModelDetails { + pub model: ApiClientVideoModel, + #[serde(default)] + pub model_creator: Option, + #[serde(default)] + pub full_name: Option, + #[serde(default)] + pub extra_info: Option, + #[serde(default)] + pub extra_info_short: Option, + #[serde(default)] + pub text_to_video_supported: Option, + #[serde(default)] + pub text_prompt_supported: Option, + #[serde(default)] + pub text_prompt_max_length: Option, + #[serde(default)] + pub negative_text_prompt_supported: Option, + #[serde(default)] + pub negative_text_prompt_max_length: Option, + #[serde(default)] + pub starting_keyframe_supported: Option, + #[serde(default)] + pub starting_keyframe_required: Option, + #[serde(default)] + pub ending_keyframe_supported: Option, + #[serde(default)] + pub image_references_supported: Option, + #[serde(default)] + pub image_references_max: Option, + #[serde(default)] + pub video_references_supported: Option, + #[serde(default)] + pub video_references_max: Option, + #[serde(default)] + pub video_references_max_total_duration_seconds: Option, + #[serde(default)] + pub audio_references_supported: Option, + #[serde(default)] + pub audio_references_max: Option, + #[serde(default)] + pub audio_references_max_total_duration_seconds: Option, + #[serde(default)] + pub character_references_supported: Option, + #[serde(default)] + pub character_references_max: Option, + #[serde(default)] + pub show_generate_with_sound_toggle: Option, + #[serde(default)] + pub aspect_ratio_options: Option>, + #[serde(default)] + pub aspect_ratio_default: Option, + #[serde(default)] + pub resolution_options: Option>, + #[serde(default)] + pub resolution_default: Option, + #[serde(default)] + pub bitrate_options: Option>, + #[serde(default)] + pub bitrate_default: Option, + #[serde(default)] + pub quality_options: Option>, + #[serde(default)] + pub default_quality: Option, + #[serde(default)] + pub duration_seconds_min: Option, + #[serde(default)] + pub duration_seconds_max: Option, + #[serde(default)] + pub duration_seconds_max_with_image_references: Option, + #[serde(default)] + pub duration_seconds_options: Option>, + #[serde(default)] + pub duration_seconds_default: Option, + #[serde(default)] + pub batch_size_min: Option, + #[serde(default)] + pub batch_size_max: Option, + #[serde(default)] + pub batch_size_options: Option>, + #[serde(default)] + pub batch_size_default: Option, + #[serde(default)] + pub is_disabled: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_enum_value_is_preserved_and_round_trips() { + let known: ApiClientVideoModel = serde_json::from_str("\"seedance_2p0\"").unwrap(); + assert_eq!(known, ApiClientVideoModel::Seedance2p0); + assert_eq!(serde_json::to_string(&known).unwrap(), "\"seedance_2p0\""); + + // A value this build has never heard of does NOT fail — it is captured verbatim. + let unknown: ApiClientVideoModel = serde_json::from_str("\"future_model_9000\"").unwrap(); + assert_eq!(unknown, ApiClientVideoModel::Unknown("future_model_9000".to_string())); + // ...and serializes back to exactly what came in. + assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"future_model_9000\""); + } + + #[test] + fn response_tolerates_unknown_fields_and_variants() { + let json = r#"{ + "success": true, + "models": [ + { + "model": "brand_new_model", + "model_creator": "some_new_studio", + "aspect_ratio_options": ["wide_sixteen_by_nine", "some_new_ratio"], + "surprise_new_field": 123 + } + ], + "providers": [ + { "provider": "new_provider", "models": [ { "model": "seedance_2p0" } ] } + ], + "another_unexpected_top_level_field": "ignored" + }"#; + + let resp: OmniGenVideoModelsResponse = serde_json::from_str(json).unwrap(); + assert!(resp.success); + assert_eq!(resp.models.len(), 1); + assert_eq!(resp.models[0].model, ApiClientVideoModel::Unknown("brand_new_model".to_string())); + assert_eq!(resp.models[0].model_creator, Some(ApiClientModelCreator::Unknown("some_new_studio".to_string()))); + let ratios = resp.models[0].aspect_ratio_options.as_ref().unwrap(); + assert_eq!(ratios[0], ApiClientAspectRatio::WideSixteenByNine); + assert_eq!(ratios[1], ApiClientAspectRatio::Unknown("some_new_ratio".to_string())); + assert_eq!(resp.models[0].is_disabled, None); // missing optional -> None + assert_eq!(resp.providers[0].provider, ApiClientGenerationProvider::Unknown("new_provider".to_string())); + assert_eq!(resp.providers[0].models[0].model, ApiClientVideoModel::Seedance2p0); + } + + #[test] + fn empty_object_uses_defaults() { + let resp: OmniGenVideoModelsResponse = serde_json::from_str("{}").unwrap(); + assert!(!resp.success); + assert!(resp.models.is_empty()); + assert!(resp.providers.is_empty()); + } +} + +/// Live tests that hit real servers. All `#[ignore]` so they never run by default. +/// Run explicitly, e.g.: +/// cargo test -p artcraft_client omni_gen_list_video_models::live_tests -- --ignored --nocapture +#[cfg(test)] +mod live_tests { + mod localhost { + use super::super::{omni_gen_list_video_models, OmniGenListVideoModelsArgs}; + use crate::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // live: hits http://localhost:12345 + async fn list_video_models() { + let api_host = ApiHost::Localhost { port: 12345 }; + let response = omni_gen_list_video_models(OmniGenListVideoModelsArgs { + api_host: &api_host, + maybe_creds: None, + provider: None, + }) + .await + .expect("request should succeed"); + + println!("[localhost] video models response: {:#?}", response); + assert!(response.success); + assert!(!response.models.is_empty(), "expected at least one video model"); + } + } + + mod production { + use super::super::{omni_gen_list_video_models, OmniGenListVideoModelsArgs}; + use crate::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // live: hits https://api.storyteller.ai + async fn list_video_models() { + let api_host = ApiHost::Storyteller; + let response = omni_gen_list_video_models(OmniGenListVideoModelsArgs { + api_host: &api_host, + maybe_creds: None, + provider: None, + }) + .await + .expect("request should succeed"); + + println!("[production] video models response: {:#?}", response); + assert!(response.success); + assert!(!response.models.is_empty(), "expected at least one video model"); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/prompts/create_prompt.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/prompts/create_prompt.rs new file mode 100644 index 0000000000..6586b6f1e8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/prompts/create_prompt.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::prompts::create_prompt::{CreatePromptRequest, CreatePromptResponse, CREATE_PROMPT_PATH}; + +pub async fn create_prompt( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: CreatePromptRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CREATE_PROMPT_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/prompts/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/prompts/mod.rs new file mode 100644 index 0000000000..11da7a9fea --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/prompts/mod.rs @@ -0,0 +1 @@ +pub mod create_prompt; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/create_credits_pack_checkout.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/create_credits_pack_checkout.rs new file mode 100644 index 0000000000..b27204031f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/create_credits_pack_checkout.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::stripe_artcraft::create_credits_pack_checkout::{StripeArtcraftCreateCreditsPackCheckoutRequest, StripeArtcraftCreateCreditsPackCheckoutResponse, CREATE_CREDITS_PACK_CHECKOUT_URL_PATH}; + +pub async fn create_credits_pack_checkout( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: StripeArtcraftCreateCreditsPackCheckoutRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CREATE_CREDITS_PACK_CHECKOUT_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/create_subscription_checkout.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/create_subscription_checkout.rs new file mode 100644 index 0000000000..59d39b7cb8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/create_subscription_checkout.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::{StripeArtcraftCreateSubscriptionCheckoutRequest, StripeArtcraftCreateSubscriptionCheckoutResponse, CREATE_SUBSCRIPTION_CHECKOUT_URL_PATH}; + +pub async fn create_subscription_checkout( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: StripeArtcraftCreateSubscriptionCheckoutRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CREATE_SUBSCRIPTION_CHECKOUT_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_cancel_plan.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_cancel_plan.rs new file mode 100644 index 0000000000..0a69ac6c9b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_cancel_plan.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::stripe_artcraft::customer_portal_cancel_plan::{StripeArtcraftCustomerPortalCancelPlanRequest, StripeArtcraftCustomerPortalCancelPlanResponse, CUSTOMER_PORTAL_CANCEL_PLAN_URL_PATH}; + +pub async fn customer_portal_cancel_plan( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: StripeArtcraftCustomerPortalCancelPlanRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CUSTOMER_PORTAL_CANCEL_PLAN_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_manage_plan.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_manage_plan.rs new file mode 100644 index 0000000000..b247db984f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_manage_plan.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::stripe_artcraft::customer_portal_manage_plan::{StripeArtcraftCustomerPortalManagePlanRequest, StripeArtcraftCustomerPortalManagePlanResponse, CUSTOMER_PORTAL_MANAGE_PLAN_URL_PATH}; + +pub async fn customer_portal_manage_plan( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: StripeArtcraftCustomerPortalManagePlanRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CUSTOMER_PORTAL_MANAGE_PLAN_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_switch_plan.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_switch_plan.rs new file mode 100644 index 0000000000..b3db286dbb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_switch_plan.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::stripe_artcraft::customer_portal_switch_plan::{StripeArtcraftCustomerPortalSwitchPlanRequest, StripeArtcraftCustomerPortalSwitchPlanResponse, CUSTOMER_PORTAL_SWITCH_PLAN_URL_PATH}; + +pub async fn customer_portal_switch_plan( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: StripeArtcraftCustomerPortalSwitchPlanRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CUSTOMER_PORTAL_SWITCH_PLAN_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_update_payment_method.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_update_payment_method.rs new file mode 100644 index 0000000000..08a6d5b3c5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/customer_portal_update_payment_method.rs @@ -0,0 +1,18 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::stripe_artcraft::customer_portal_update_payment_method::{StripeArtcraftCustomerPortalUpdatePaymentMethodRequest, StripeArtcraftCustomerPortalUpdatePaymentMethodResponse, CUSTOMER_PORTAL_UPDATE_PAYMENT_METHOD_URL_PATH}; + +pub async fn customer_portal_update_payment_method( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: StripeArtcraftCustomerPortalUpdatePaymentMethodRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + CUSTOMER_PORTAL_UPDATE_PAYMENT_METHOD_URL_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/mod.rs new file mode 100644 index 0000000000..538dc98f37 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/stripe_artcraft/mod.rs @@ -0,0 +1,6 @@ +pub mod create_credits_pack_checkout; +pub mod create_subscription_checkout; +pub mod customer_portal_cancel_plan; +pub mod customer_portal_manage_plan; +pub mod customer_portal_switch_plan; +pub mod customer_portal_update_payment_method; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/subscriptions/get_session_subscription.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/subscriptions/get_session_subscription.rs new file mode 100644 index 0000000000..e0fb5b0142 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/subscriptions/get_session_subscription.rs @@ -0,0 +1,28 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_get_request::basic_json_get_request; +use artcraft_api_defs::subscriptions::get_session_subscription::GetSessionSubscriptionResponse; +use enums::common::payments_namespace::PaymentsNamespace; +use log::debug; + +pub async fn get_session_subscription( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + payments_namespace: PaymentsNamespace +) -> Result { + let url_path = get_url_path(payments_namespace); + + debug!("Requesting {:?}", &url_path); + + Ok(basic_json_get_request( + api_host, + &url_path, + maybe_creds, + ).await?) +} + +fn get_url_path(payments_namespace: PaymentsNamespace) -> String { + let payments_namespace = payments_namespace.to_str(); + format!("/v1/subscriptions/namespace/{}", payments_namespace) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/subscriptions/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/subscriptions/mod.rs new file mode 100644 index 0000000000..1cc61e22e6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/subscriptions/mod.rs @@ -0,0 +1 @@ +pub mod get_session_subscription; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs b/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs new file mode 100644 index 0000000000..cca046f703 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs @@ -0,0 +1,129 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use errors::AnyhowError; +use reqwest::StatusCode; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; + +#[derive(Debug)] +pub enum ApiError { + /// 400. The request was invalid. + InvalidRequest(String), + + /// 401. The request was not authorized. + Unauthorized(String), + + // 402. Payment required. + PaymentRequired(String), + + /// 403. The request was forbidden. + Forbidden(String), + + /// 404. The requested resource was not found. + NotFound(String), + + /// 429. Too many requests. + TooManyRequests(String), + + /// 500. An internal server error occurred. + InternalServerError { + body: String, + backend_hostname: Option, + }, + + /// An uncategorized bad HTTP response with status code and body. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// Cloudflare errors. + CloudflareError(CloudflareError), + + /// A deserialization error with the response. + DeserializationError(serde_json::Error), + + /// The request timed out. + Timeout(String), + + /// A network error occurred. + NetworkError(String), + + /// Uncategorized reqwest error. + OtherReqwestError(reqwest::Error), + + /// An error doing file I/O (on our side) + IoError(io::Error), + + /// Another type of error. + Other(AnyhowError), +} + +impl Error for ApiError {} + +impl Display for ApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + // Server response code errors + ApiError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg), + ApiError::PaymentRequired(msg) => write!(f, "Payment required: {}", msg), + ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), + ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg), + ApiError::NotFound(msg) => write!(f, "Not found: {}", msg), + ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg), + ApiError::InternalServerError {body, backend_hostname} => + write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body), + ApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body } => + write!(f, "Uncategorized bad response with status code {} and body: {}", status_code, body), + // Server response handling errors + ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error), + // Network errors + ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg), + ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg), + // Cloudflare errors + ApiError::CloudflareError(error) => write!(f, "Cloudflare Error: {}", error), + // I/O errors + ApiError::IoError(error) => write!(f, "IO error: {}", error), + // Other + ApiError::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error), + ApiError::Other(error) => write!(f, "Other error: {}", error), + } + } +} + +impl From for ApiError { + fn from(error: reqwest::Error) -> Self { + if let Some(status) = error.status() { + let status = status.as_u16(); + match status { + 402 => return ApiError::PaymentRequired(error.to_string()), + _ => {} // NB: Fallthrough. + } + } + if error.is_timeout() { + ApiError::Timeout(error.to_string()) + } else if error.is_connect() { + ApiError::NetworkError(error.to_string()) + } else { + ApiError::OtherReqwestError(error) + } + } +} + +impl From for ApiError { + fn from(error: serde_json::Error) -> Self { + ApiError::DeserializationError(error) + } +} + +impl From for ApiError { + fn from(error: io::Error) -> Self { + ApiError::IoError(error) + } +} + +impl From for ApiError { + fn from(error: CloudflareError) -> Self { + ApiError::CloudflareError(error) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/error/client_error.rs b/crates/api_clients/artcraft/artcraft_client/src/error/client_error.rs new file mode 100644 index 0000000000..75dc554c2e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/error/client_error.rs @@ -0,0 +1,60 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; +use std::path::PathBuf; +use crate::error::api_error::ApiError; + +/// An error arising from the client, either before or after the request is sent. +#[derive(Debug)] +pub enum ClientError { + /// An invalid request was detected before it was sent. + InvalidPreflightRequest(String), + + /// Could not determine the filetype for the file. + FileTypeNotKnown(PathBuf), + + /// Could not handle the file type. + FileTypeNotHandled(PathBuf), + + /// An error doing file I/O (on our side) + IoError(io::Error), + + /// An error setting up the client + ReqwestError(reqwest::Error), + + /// A serialization error with the request. + SerializationError(serde_json::Error), +} + +impl Error for ClientError {} + +impl Display for ClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidPreflightRequest(msg) => write!(f, "Invalid preflight request: {}", msg), + Self::FileTypeNotKnown(path) => write!(f, "Could not determine the filetype for the file: {:?}", path), + Self::FileTypeNotHandled(path) => write!(f, "Could not handle the file type for the file: {:?}", path), + Self::IoError(err) => write!(f, "IO error: {}", err), + Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err), + Self::SerializationError(err) => write!(f, "Request serialization error: {}", err), + } + } +} + +impl From for ClientError { + fn from(error: io::Error) -> Self { + ClientError::IoError(error) + } +} + +impl From for ClientError { + fn from(error: serde_json::Error) -> Self { + ClientError::SerializationError(error) + } +} + +impl From for ClientError { + fn from(error: reqwest::Error) -> Self { + ClientError::ReqwestError(error) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/error/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/error/mod.rs new file mode 100644 index 0000000000..4b238e7786 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/error/mod.rs @@ -0,0 +1,3 @@ +pub mod api_error; +pub mod client_error; +pub mod storyteller_error; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/error/storyteller_error.rs b/crates/api_clients/artcraft/artcraft_client/src/error/storyteller_error.rs new file mode 100644 index 0000000000..30dbf8546c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/error/storyteller_error.rs @@ -0,0 +1,32 @@ +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use std::error::Error; + +#[derive(Debug)] +pub enum StorytellerError { + Api(ApiError), + Client(ClientError), +} + +impl Error for StorytellerError {} + +impl std::fmt::Display for StorytellerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorytellerError::Api(e) => write!(f, "Storyteller API error: {:?}", e), + StorytellerError::Client(e) => write!(f, "Storyteller Client error: {:?}", e), + } + } +} + +impl From for StorytellerError { + fn from(error: ApiError) -> Self { + StorytellerError::Api(error) + } +} + +impl From for StorytellerError { + fn from(error: ClientError) -> Self { + StorytellerError::Client(error) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/lib.rs b/crates/api_clients/artcraft/artcraft_client/src/lib.rs new file mode 100644 index 0000000000..ea7f08a30c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/lib.rs @@ -0,0 +1,8 @@ +//! An API client for Artcraft / Storyteller / FakeYou + +pub mod credentials; +pub mod datatypes; +pub mod endpoints; +pub mod error; +pub mod recipes; +pub mod utils; diff --git a/crates/api_clients/artcraft/artcraft_client/src/recipes/download_media_file.rs b/crates/api_clients/artcraft/artcraft_client/src/recipes/download_media_file.rs new file mode 100644 index 0000000000..a4eb3903b6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/recipes/download_media_file.rs @@ -0,0 +1,157 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use log::{info, warn}; +use reqwest::Client; +use url::Url; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse}; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; + +pub struct DownloadMediaFileArgs<'a, P: AsRef> { + pub media_token: &'a MediaFileToken, + pub api_host: &'a ApiHost, + pub download_path: DownloadPath

, +} + +pub enum DownloadPath> { + /// Write the file to this exact path. + ExactFilename(P), + /// Write the file into this directory, generating a filename from the CDN URL extension. + Directory(P), +} + +pub struct DownloadMediaFileResult { + /// The path the file was written to. + pub downloaded_file_path: PathBuf, + + /// The full media file response from the API. + pub media_file_response: GetMediaFileSuccessResponse, + + /// Size of downloaded file in bytes. + pub filesize_bytes: usize, +} + +pub async fn download_media_file>( + args: DownloadMediaFileArgs<'_, P>, +) -> Result { + let DownloadMediaFileArgs { + media_token, + api_host, + download_path + } = args; + + // 1. Fetch media file info from the API. + let response = get_media_file(api_host, media_token).await?; + let media_class = &response.media_file.media_class; + let cdn_url = &response.media_file.media_links.cdn_url; + + info!("Downloading media file {} of class {} from CDN: {}", + media_token.as_str(), media_class.to_str(), cdn_url); + + // 2. Determine the output file path. + let output_path = match &download_path { + DownloadPath::ExactFilename(path) => path.as_ref().to_path_buf(), + DownloadPath::Directory(dir) => { + let filename = derive_filename_from_url(cdn_url, &media_token); + dir.as_ref().join(filename) + } + }; + + // 3. Download the bytes from the CDN. + let bytes = download_bytes(cdn_url).await?; + + // 4. Write to disk. + let mut file = fs::File::create(&output_path) + .map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?; + + file.write_all(&bytes) + .map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?; + + file.flush() + .map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?; + + info!("Downloaded {} bytes to {:?}", bytes.len(), output_path); + + Ok(DownloadMediaFileResult { + downloaded_file_path: output_path, + media_file_response: response, + filesize_bytes: bytes.len(), + }) +} + +// ── Helpers ── + +async fn download_bytes(url: &Url) -> Result, StorytellerError> { + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?; + + let response = client.get(url.as_str()) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::OtherReqwestError(err)))?; + + let status_code = response.status(); + + if !status_code.is_success() { + let body = response.text().await.unwrap_or_else(|err| { + warn!("Failed to retrieve response body: {}", err); + "".to_string() + }); + return Err(StorytellerError::Api(ApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body, + })); + } + + let bytes = response.bytes() + .await + .map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?; + + Ok(bytes.to_vec()) +} + +/// Derive a filename from the CDN URL's path extension, falling back to the media token. +fn derive_filename_from_url(url: &Url, media_token: &MediaFileToken) -> String { + let path = url.path(); + let extension = Path::new(path) + .extension() // NB: without dot '.' + .and_then(|ext| ext.to_str()) + .unwrap_or("bin"); + + format!("{}.{}", media_token.as_str(), extension) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_filename_from_url_extracts_extension() { + let url = Url::parse("https://cdn.example.com/files/abc123.png").unwrap(); + let token = MediaFileToken::new_from_str("mf_test123"); + assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.png"); + } + + #[test] + fn derive_filename_from_url_handles_no_extension() { + let url = Url::parse("https://cdn.example.com/files/abc123").unwrap(); + let token = MediaFileToken::new_from_str("mf_test123"); + assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.bin"); + } + + #[test] + fn derive_filename_from_url_handles_query_params() { + let url = Url::parse("https://cdn.example.com/files/abc123.mp4?token=xyz").unwrap(); + let token = MediaFileToken::new_from_str("mf_test123"); + assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.mp4"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs new file mode 100644 index 0000000000..f007a12fa4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs @@ -0,0 +1,2 @@ +pub mod download_media_file; +pub mod upload_media_file_from_file; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/recipes/upload_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/recipes/upload_media_file_from_file.rs new file mode 100644 index 0000000000..1c32a12442 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/recipes/upload_media_file_from_file.rs @@ -0,0 +1,80 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use crate::endpoints::media_files::upload_new_engine_asset_from_file::upload_new_engine_asset_from_file; +use crate::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs}; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use mimetypes::mimetype_info::file_extension::FileExtension; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use serde_derive::Deserialize; +use std::path::Path; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +#[derive(Deserialize, Debug)] +pub struct UploadMediaFileSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} + +/// Upload an image media file from a file. +pub async fn upload_media_file_from_file>( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + path: P, + maybe_prompt_token: Option<&PromptToken>, +) -> Result { + + let maybe_type = MimetypeInfo::get_for_path(path.as_ref()) + .map_err(|err| ClientError::from(err))? + .map(|mime| mime.file_extension()) + .flatten(); + + match maybe_type { + Some(FileExtension::Glb) => { + match upload_new_engine_asset_from_file(api_host, maybe_creds, path, None).await { + Ok(result) => Ok(UploadMediaFileSuccessResponse { + success: result.success, + media_file_token: result.media_file_token, + }), + Err(err) => Err(StorytellerError::Api(err)), + } + } + Some(FileExtension::Mp4) => { + match upload_video_media_file_from_file(UploadVideoFromFileArgs { + api_host, + maybe_creds, + path, + maybe_prompt_token, + maybe_generation_provider: None, + }).await { + Ok(result) => Ok(UploadMediaFileSuccessResponse { + success: result.success, + media_file_token: result.media_file_token, + }), + Err(err) => Err(StorytellerError::Api(err)), + } + } + Some(FileExtension::Png) | Some(FileExtension::Jpg) | Some(FileExtension::Gif) | Some(FileExtension::Webp) => { + let result = upload_image_media_file_from_file(UploadImageFromFileArgs { + api_host, + maybe_creds, + path, + is_intermediate_system_file: false, + maybe_prompt_token, + maybe_batch_token: None, + maybe_generation_provider: None, + }).await; + match result { + Ok(result) => Ok(UploadMediaFileSuccessResponse { + success: result.success, + media_file_token: result.media_file_token, + }), + Err(err) => Err(err), + } + } + Some(_) => Err(StorytellerError::Client(ClientError::FileTypeNotHandled(path.as_ref().to_path_buf()))), + None => Err(StorytellerError::Client(ClientError::FileTypeNotKnown(path.as_ref().to_path_buf()))), + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs new file mode 100644 index 0000000000..f8fd711af3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs @@ -0,0 +1,37 @@ + +const HTTP_SCHEME: &str = "http"; + +const HTTPS_SCHEME: &str = "https"; + +#[derive(Clone, Debug)] +pub enum ApiHost { + Storyteller, + FakeYou, + Localhost { port: u32 }, +} + +impl ApiHost { + pub fn to_api_hostname(&self) -> String { + match self { + ApiHost::Storyteller => "api.storyteller.ai".to_string(), + ApiHost::FakeYou => "api.fakeyou.com".to_string(), + ApiHost::Localhost { port } => format!("localhost:{}", port), + } + } + + pub fn to_api_hostname_and_scheme(&self) -> String { + match self { + ApiHost::Storyteller => "https://api.storyteller.ai".to_string(), + ApiHost::FakeYou => "https://api.fakeyou.com".to_string(), + ApiHost::Localhost { port } => format!("http://localhost:{}", port), + } + } + + pub fn scheme(&self) -> &'static str { + match self { + ApiHost::Storyteller => HTTPS_SCHEME, + ApiHost::FakeYou => HTTPS_SCHEME, + ApiHost::Localhost { .. } => HTTP_SCHEME, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs new file mode 100644 index 0000000000..fbdddaa57d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs @@ -0,0 +1,80 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use log::{debug, info}; +use reqwest::Client; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +pub async fn basic_json_delete_request( + api_host: &ApiHost, + route_path: &str, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Req, +) -> Result { + + let url = get_route(api_host, route_path); + + info!("DELETE {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let mut request_builder = client.delete(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON) + .header("Content-Type", APPLICATION_JSON); + + if let Some(creds) = maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let request_body = serde_json::to_string(&request) + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + debug!("Request body: {:?}", request_body); + + let response = request_builder + .body(request_body) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let hostname = response.headers().get("x-backend-hostname") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + let build_sha = response.headers().get("x-build-sha") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha); + + let response = filter_bad_response(response) + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response_body = &response.text() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + debug!("Response body: {:?}", response_body); + + let response = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(response) +} + +fn get_route(api_host: &ApiHost, route_path: &str) -> String { + let api_hostname = api_host.to_api_hostname_and_scheme(); + format!("{}{}", api_hostname, route_path) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs new file mode 100644 index 0000000000..2d033bf3bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs @@ -0,0 +1,72 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use log::{debug, info}; +use reqwest::Client; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +pub async fn basic_json_get_request( + api_host: &ApiHost, + route_path: &str, + maybe_creds: Option<&StorytellerCredentialSet>, +) -> Result { + + let url = get_route(api_host, route_path); + + debug!("Requesting {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let mut request_builder = client.get(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let hostname = response.headers().get("x-backend-hostname") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + let build_sha = response.headers().get("x-build-sha") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha); + + let response = filter_bad_response(response) + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response_body = &response.text() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + debug!("Response body: {:?}", response_body); + + let response = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(response) +} + +fn get_route(api_host: &ApiHost, route_path: &str) -> String { + let api_hostname = api_host.to_api_hostname_and_scheme(); + format!("{}{}", api_hostname, route_path) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs new file mode 100644 index 0000000000..b430a7a532 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs @@ -0,0 +1,89 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use log::{debug, info}; +use reqwest::Client; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +pub async fn basic_json_post_request( + api_host: &ApiHost, + route_path: &str, + maybe_creds: Option<&StorytellerCredentialSet>, + request: Req, +) -> Result { + + let url = get_route(api_host, route_path); + + // NB: We record lightweight analytics telemetry to understand which areas + // of the tool are useful and which models and services are important. + let debug_log = url.ends_with("/v1/analytics/active_user_v2"); + + // Suppress "spammy" logs at the debug level. + if debug_log { + debug!("Requesting {:?}", &url); + } else { + info!("Requesting {:?}", &url); + } + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON) + .header("Content-Type", APPLICATION_JSON); + + if let Some(creds) = maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let request_body = serde_json::to_string(&request) + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + debug!("Request body: {:?}", request_body); + + let response = request_builder + .body(request_body) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let hostname = response.headers().get("x-backend-hostname") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + let build_sha = response.headers().get("x-build-sha") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha); + + let response = filter_bad_response(response) + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response_body = &response.text() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + debug!("Response body: {:?}", response_body); + + let response = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(response) +} + +fn get_route(api_host: &ApiHost, route_path: &str) -> String { + let api_hostname = api_host.to_api_hostname_and_scheme(); + format!("{}{}", api_hostname, route_path) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs new file mode 100644 index 0000000000..2a15d09730 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs @@ -0,0 +1,146 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use errors::AnyhowResult; +use log::{debug, info}; +use reqwest::Client; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use url::Url; +use url_builder::URLBuilder; + +pub async fn basic_query_string_post_request( + api_host: &ApiHost, + route_path: &str, + maybe_creds: Option<&StorytellerCredentialSet>, + query_params: &HashMap, +) -> Result { + + // TODO: Please stop using this URL Builder library. It's not very safe or intuitive. + let url = get_route(api_host, route_path, query_params); + + debug!("Requesting {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let hostname = response.headers().get("x-backend-hostname") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + let build_sha = response.headers().get("x-build-sha") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + + debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha); + + let response = filter_bad_response(response) + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response_body = &response.text() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + debug!("Response body: {:?}", response_body); + + let response = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(response) +} + +// TODO: Please stop using this URL Builder library. It's not very safe or intuitive. +fn get_route(api_host: &ApiHost, route_path: &str, query_params: &HashMap) -> String { + let mut builder = URLBuilder::new(); + + builder.set_protocol(api_host.scheme()) + .set_host(&api_host.to_api_hostname()); + + // NB: This stupid library auto-inserts a starting slash, so we need to remove it. + // This is a bad code smell. I shouldn't be using this thing. + match route_path.split_once("/") { + None => { + builder.add_route(route_path); + } + Some((_slash, rest)) => { + builder.add_route(rest); + } + } + + // NB: This is not safe. It doesn't handle URL encoding, "?", "&", etc. + for (key, value) in query_params { + let value = filter_string(value); + builder.add_param(key, &value); + } + + // NB: This isn't very safe. + builder.build() +} + +// TODO: Please stop using this URL Builder library. It's not very safe or intuitive. +fn filter_string(string: &str) -> String { + string.trim() + .replace(" ", "") + .replace("&", "") + .replace("=", "") + .replace("?", "") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_get_route() { + let api_host = ApiHost::Storyteller; + let route_path = "/v1/test"; + let mut query_params = HashMap::new(); + query_params.insert("param1".to_string(), "value1".to_string()); + query_params.insert("param2".to_string(), "value2".to_string()); + + let url = get_route(&api_host, route_path, &query_params); + + assert!(url.starts_with("https://api.storyteller.ai/v1/test?")); + assert!(url.contains("param1=value1")); + assert!(url.contains("param2=value2")); + } + + #[test] + fn test_get_localhost_route() { + let api_host = ApiHost::Localhost { port: 12345 }; + let route_path = "/v1/test"; + let mut query_params = HashMap::new(); + query_params.insert("param1".to_string(), "value1".to_string()); + query_params.insert("param2".to_string(), "value2".to_string()); + + let url = get_route(&api_host, route_path, &query_params); + + assert!(url.starts_with("http://localhost:12345/v1/test?")); + assert!(url.contains("param1=value1")); + assert!(url.contains("param2=value2")); + } +} \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs new file mode 100644 index 0000000000..ce9bdc5a18 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs @@ -0,0 +1,4 @@ + +pub const USER_AGENT: &str = "storyteller-client/1.0"; + +pub const APPLICATION_JSON : &str = "application/json"; diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/filter_bad_response.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/filter_bad_response.rs new file mode 100644 index 0000000000..59be0e1fa3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/filter_bad_response.rs @@ -0,0 +1,49 @@ +use crate::error::api_error::ApiError; +use crate::utils::status_codes::*; +use anyhow::anyhow; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use log::error; + +/// Pass request errors to this method for standard error handling behavior. +/// This function needs to take temporary ownership, but will return it to the caller. +/// +/// Example use: +/// let response = client.get().await?; +/// let response = filter_bad_response(response).await?; +/// ... 200 or other valid response +pub async fn filter_bad_response(response: reqwest::Response) -> Result { + let status = response.status(); + + if status.is_success() { + return Ok(response); + } + + // The host in the fleet that handled the request. + let backend_hostname= response.headers() + .get("x-backend-hostname") + .map(|header| header.to_str().unwrap_or_else(|err| { + error!("Failed to parse x-backend-hostname header: {:?}", err); + "unknown" + })) + .map(|s| s.to_string()); + + let response_body = match response.text().await { + Ok(text) => text.to_string(), + Err(err) => format!("Could not read response body: {:?}", err), + }; + + let status_code = status.as_u16(); + + filter_cloudflare_errors(status_code, &response_body)?; + + match status_code { + STATUS_400_BAD_REQUEST => Err(ApiError::InvalidRequest(response_body)), + STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)), + STATUS_402_PAYMENT_REQUIRED => Err(ApiError::PaymentRequired(response_body)), + STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)), + STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)), + STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)), + STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError { body: response_body, backend_hostname }), + _ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))), + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs new file mode 100644 index 0000000000..f4c66d20c7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs @@ -0,0 +1,18 @@ +use crate::error::api_error::ApiError; +use reqwest::Client; +const USER_AGENT: &str = "storyteller-client/1.0"; + +pub async fn http_get_anonymous(url: String) -> Result { + let client = Client::builder() + .gzip(true) + .build()?; + + let response = client.get(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + //.header("Accept-Encoding", "gzip, deflate, br") + .send() + .await?; + + Ok(response) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs new file mode 100644 index 0000000000..070f199125 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs @@ -0,0 +1,9 @@ +pub (crate) mod basic_json_delete_request; +pub (crate) mod basic_json_get_request; +pub mod basic_json_post_request; +pub (crate) mod basic_query_string_post_request; +pub (crate) mod filter_bad_response; +pub (crate) mod http_get_anonymous; +pub mod api_host; +pub mod constants; +pub mod status_codes; diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/status_codes.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/status_codes.rs new file mode 100644 index 0000000000..879e0a647b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/status_codes.rs @@ -0,0 +1,27 @@ +pub const STATUS_400_BAD_REQUEST: u16 = 400; + +pub const STATUS_401_UNAUTHORIZED : u16 = 401u16; + +pub const STATUS_402_PAYMENT_REQUIRED : u16 = 402u16; +pub const STATUS_403_FORBIDDEN : u16 = 403u16; + +pub const STATUS_404_NOT_FOUND : u16 = 404u16; + +pub const STATUS_429_TOO_MANY_REQUESTS : u16 = 429u16; + +pub const STATUS_500_INTERNAL_SERVER_ERROR : u16 = 500u16; + +#[cfg(test)] +mod tests { + use crate::utils::status_codes::*; + + #[test] + pub fn test_codes() { + assert_eq!(STATUS_401_UNAUTHORIZED, 401); + assert_eq!(STATUS_402_PAYMENT_REQUIRED, 402); + assert_eq!(STATUS_403_FORBIDDEN, 403); + assert_eq!(STATUS_404_NOT_FOUND, 404); + assert_eq!(STATUS_429_TOO_MANY_REQUESTS, 429); + assert_eq!(STATUS_500_INTERNAL_SERVER_ERROR, 500); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/Cargo.toml b/crates/api_clients/artcraft/artcraft_router/Cargo.toml new file mode 100644 index 0000000000..cb0b82c726 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "artcraft_router" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "artcraft_router" +path = "src/lib.rs" + +[dependencies] + +# Internal +artcraft_api_defs.workspace = true +enums.workspace = true +artcraft_client.workspace = true +fal_client.workspace = true +gmicloud_client.workspace = true +grok_api_client.workspace = true +kinovi_web_client.workspace = true +tokens.workspace = true +url_utils.workspace = true +worldlabs_api_client.workspace = true + +# External +anyhow.workspace = true +log.workspace = true +reqwest.workspace = true +serde.workspace = true +wreq.workspace = true +serde_derive.workspace = true +tokio.workspace = true +uuid.workspace = true + +[dev-dependencies] +test_data.workspace = true +test_utils.workspace = true +strum.workspace = true +serde_json.workspace = true + diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs new file mode 100644 index 0000000000..6f409dca10 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/audio_list_ref.rs @@ -0,0 +1,7 @@ +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Debug)] +pub enum AudioListRef { + MediaFileTokens(Vec), + Urls(Vec), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs new file mode 100644 index 0000000000..bb24191ae1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/character_list_ref.rs @@ -0,0 +1,9 @@ +use tokens::tokens::characters::CharacterToken; + +/// Forward-compatible character reference list. +#[derive(Clone, Debug)] +pub enum CharacterListRef { + CharacterTokens(Vec), + + // In the future, we may have other identifiers for characters. +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs new file mode 100644 index 0000000000..e3df9423e5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/image_list_ref.rs @@ -0,0 +1,7 @@ +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Debug)] +pub enum ImageListRef { + MediaFileTokens(Vec), + Urls(Vec), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs new file mode 100644 index 0000000000..fa6a80d702 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/image_ref.rs @@ -0,0 +1,7 @@ +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Debug)] +pub enum ImageRef { + MediaFileToken(MediaFileToken), + Url(String), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/mesh_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/mesh_ref.rs new file mode 100644 index 0000000000..dd0dd6b2f1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/mesh_ref.rs @@ -0,0 +1,9 @@ +use tokens::tokens::media_files::MediaFileToken; + +/// Reference to an input 3D mesh file (e.g. GLB/OBJ/FBX) for mesh-to-mesh +/// models like part splitting and retopology. +#[derive(Clone, Debug)] +pub enum MeshRef { + MediaFileToken(MediaFileToken), + Url(String), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs new file mode 100644 index 0000000000..5083335585 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs @@ -0,0 +1,17 @@ +pub mod audio_list_ref; +pub mod character_list_ref; +pub mod image_list_ref; +pub mod image_ref; +pub mod mesh_ref; +pub mod router_aspect_ratio; +pub mod router_audio_model; +pub mod router_bitrate; +pub mod router_image_model; +pub mod router_mesh_model; +pub mod router_provider; +pub mod router_quality; +pub mod router_resolution; +pub mod router_splat_model; +pub mod router_video_model; +pub mod video_list_ref; +pub mod video_ref; diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_aspect_ratio.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_aspect_ratio.rs new file mode 100644 index 0000000000..1e146a751a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_aspect_ratio.rs @@ -0,0 +1,49 @@ +use serde_derive::{Deserialize, Serialize}; +#[cfg(test)] +use strum::EnumIter; + +/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation. +/// Not every model will support every aspect ratio. +/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(test, derive(EnumIter))] +#[serde(rename_all = "snake_case")] +pub enum RouterAspectRatio { + // Auto (eg. for image editing to use the source; used by Nano Banana Pro edit image, but not text-to-image) + Auto, + + // Square + Square, + + // Wide + WideThreeByTwo, + WideFourByThree, + WideFiveByFour, + WideSixteenByNine, + WideTwentyOneByNine, + + // Tall + TallTwoByThree, + TallThreeByFour, + TallFourByFive, + TallNineBySixteen, + TallNineByTwentyOne, + + // Imprecise semantic values that we probably remap to other meanings + // on a model-by-model basis. + Wide, + Tall, + + // Auto values that bake in resolution + // These are from the Seedream models + #[serde(rename = "auto_2k")] + Auto2k, + #[serde(rename = "auto_3k")] + Auto3k, + #[serde(rename = "auto_4k")] + Auto4k, + + // Defined aspect ratios that bake in resolution + // These are from the Seedream models + SquareHd, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_audio_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_audio_model.rs new file mode 100644 index 0000000000..870988257a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_audio_model.rs @@ -0,0 +1,65 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common audio models supported by the router. +/// Not all models are available through all providers. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterAudioModel { + #[serde(rename = "suno_music")] + SunoMusic, + + #[serde(rename = "suno_remix")] + SunoRemix, + + #[serde(rename = "suno_sounds")] + SunoSounds, + + #[serde(rename = "suno_sample")] + SunoSample, + + #[serde(rename = "seed_audio_1p0")] + SeedAudio1p0, +} + +#[cfg(test)] +mod tests { + use super::*; + + // NB: These strings must match `CommonAudioModel` — the two enums convert + // via serde string round-trip. + #[test] + fn all_variants_serialize_to_common_audio_model_strings() { + assert_serde_round_trip(RouterAudioModel::SunoMusic, "suno_music"); + assert_serde_round_trip(RouterAudioModel::SunoRemix, "suno_remix"); + assert_serde_round_trip(RouterAudioModel::SunoSounds, "suno_sounds"); + assert_serde_round_trip(RouterAudioModel::SunoSample, "suno_sample"); + assert_serde_round_trip(RouterAudioModel::SeedAudio1p0, "seed_audio_1p0"); + } + + #[test] + fn round_trips_through_common_audio_model() { + use enums::common::generation::common_audio_model::CommonAudioModel; + + let cases = [ + (RouterAudioModel::SunoMusic, CommonAudioModel::SunoMusic), + (RouterAudioModel::SunoRemix, CommonAudioModel::SunoRemix), + (RouterAudioModel::SunoSounds, CommonAudioModel::SunoSounds), + (RouterAudioModel::SunoSample, CommonAudioModel::SunoSample), + (RouterAudioModel::SeedAudio1p0, CommonAudioModel::SeedAudio1p0), + ]; + for (router_model, expected_common) in cases { + let json = serde_json::to_string(&router_model).unwrap(); + let common: CommonAudioModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("CommonAudioModel failed to parse {json}: {e}")); + assert_eq!(common, expected_common, "for {router_model:?}"); + } + } + + fn assert_serde_round_trip(model: RouterAudioModel, expected: &str) { + let json = serde_json::to_string(&model).unwrap(); + assert_eq!(json, format!("\"{expected}\"")); + let parsed: RouterAudioModel = serde_json::from_str(&json).unwrap(); + // RouterAudioModel isn't PartialEq, so round-trip back to the wire form. + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_bitrate.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_bitrate.rs new file mode 100644 index 0000000000..ae9806a79f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_bitrate.rs @@ -0,0 +1,10 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Output bitrate levels you can specify when enqueuing a generation. +/// Not every model will use this; models that don't simply ignore it. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RouterBitrate { + Normal, + High, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs new file mode 100644 index 0000000000..17f90236a4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs @@ -0,0 +1,104 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common image models supported by the router. +/// Not all models are available through all providers. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterImageModel { + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_pro_1p1")] + FluxPro11, + #[serde(rename = "flux_pro_1p1_ultra")] + FluxPro11Ultra, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + #[serde(rename = "grok_imagine_image")] + GrokImagineImage, + #[serde(rename = "grok_imagine_image_q")] + GrokImagineImageQuality, + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + #[serde(rename = "seedream_5p0_pro")] + Seedream5p0Pro, + #[serde(rename = "seedream_5p0_pro_u")] + Seedream5p0ProUltra, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_round_trip(model: RouterImageModel, expected_wire: &str) { + let json = serde_json::to_string(&model).unwrap(); + let unquoted = json.trim_matches('"'); + assert_eq!(unquoted, expected_wire, "serialize {:?}", model); + let back: RouterImageModel = serde_json::from_str(&json).unwrap(); + assert_eq!(back, model, "deserialize round-trip for {:?}", model); + } + + #[test] + fn midjourney_7_serializes_to_midjourney_7() { + assert_round_trip(RouterImageModel::Midjourney7, "midjourney_7"); + } + + #[test] + fn midjourney_7_niji_serializes_to_midjourney_7_niji() { + assert_round_trip(RouterImageModel::Midjourney7Niji, "midjourney_7_niji"); + } + + #[test] + fn midjourney_8_serializes_to_midjourney_8() { + assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8"); + } + + #[test] + fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() { + assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro"); + } + + #[test] + fn seedream_5p0_pro_ultra_serializes_to_seedream_5p0_pro_u() { + assert_round_trip(RouterImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u"); + } + + #[test] + fn gpt_image_2p5_flare_serializes_to_gpt_image_2p5_flare() { + assert_round_trip(RouterImageModel::GptImage2p5Flare, "gpt_image_2p5_flare"); + } + + #[test] + fn gpt_image_2p5_sunburst_serializes_to_gpt_image_2p5_sunburst() { + assert_round_trip(RouterImageModel::GptImage2p5Sunburst, "gpt_image_2p5_sunburst"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_mesh_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_mesh_model.rs new file mode 100644 index 0000000000..8fa4faa744 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_mesh_model.rs @@ -0,0 +1,107 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common mesh (3D object) models supported by the router. +/// Not all models are available through all providers. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterMeshModel { + #[serde(rename = "hunyuan_3d_2p0")] + Hunyuan3d2p0, + + #[serde(rename = "hunyuan_3d_2p1")] + Hunyuan3d2p1, + + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + + /// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as + /// [`Self::Hunyuan3d3`], but takes a sketch image plus a prompt as input. + #[serde(rename = "hunyuan_3d_3_sketch")] + Hunyuan3d3Sketch, + + /// Hunyuan 3D v3.1 Pro: text or (multi-view) image input. + #[serde(rename = "hunyuan_3d_3p1_pro")] + Hunyuan3d3p1Pro, + + /// Hunyuan 3D v3.1 Rapid: the fast, low-cost tier. Text or single-image + /// input. + #[serde(rename = "hunyuan_3d_3p1_rapid")] + Hunyuan3d3p1Rapid, + + /// Hunyuan 3D v3.1 Part: splits an existing mesh into semantic parts. + /// Takes a mesh file as input, not text/images. + #[serde(rename = "hunyuan_3d_3p1_part")] + Hunyuan3d3p1Part, + + /// Hunyuan 3D v3.1 Smart Topology: retopologizes an existing mesh. + /// Takes a mesh file as input, not text/images. + #[serde(rename = "hunyuan_3d_3p1_topology")] + Hunyuan3d3p1SmartTopology, + + /// Tripo3D H3.1: text, single-image, or multi-view image input. + #[serde(rename = "tripo3d_h3p1")] + Tripo3dH3p1, + + /// Meshy 6: text or single-image input. + #[serde(rename = "meshy_v6")] + MeshyV6, + + /// Hyper3D Rodin v2.5 Fast: text or image(s) input. + #[serde(rename = "rodin_2p5_fast")] + Rodin2p5Fast, +} + +#[cfg(test)] +mod tests { + use super::*; + + // NB: These strings must match `CommonMeshModel` — the two enums convert + // via serde string round-trip. + #[test] + fn all_variants_serialize_to_common_mesh_model_strings() { + assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3, "hunyuan_3d_3"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Pro, "hunyuan_3d_3p1_pro"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Rapid, "hunyuan_3d_3p1_rapid"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Part, "hunyuan_3d_3p1_part"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1SmartTopology, "hunyuan_3d_3p1_topology"); + assert_serde_round_trip(RouterMeshModel::Tripo3dH3p1, "tripo3d_h3p1"); + assert_serde_round_trip(RouterMeshModel::MeshyV6, "meshy_v6"); + assert_serde_round_trip(RouterMeshModel::Rodin2p5Fast, "rodin_2p5_fast"); + } + + #[test] + fn round_trips_through_common_mesh_model() { + use enums::common::generation::common_mesh_model::CommonMeshModel; + + let cases = [ + (RouterMeshModel::Hunyuan3d2p0, CommonMeshModel::Hunyuan3d2p0), + (RouterMeshModel::Hunyuan3d2p1, CommonMeshModel::Hunyuan3d2p1), + (RouterMeshModel::Hunyuan3d3, CommonMeshModel::Hunyuan3d3), + (RouterMeshModel::Hunyuan3d3Sketch, CommonMeshModel::Hunyuan3d3Sketch), + (RouterMeshModel::Hunyuan3d3p1Pro, CommonMeshModel::Hunyuan3d3p1Pro), + (RouterMeshModel::Hunyuan3d3p1Rapid, CommonMeshModel::Hunyuan3d3p1Rapid), + (RouterMeshModel::Hunyuan3d3p1Part, CommonMeshModel::Hunyuan3d3p1Part), + (RouterMeshModel::Hunyuan3d3p1SmartTopology, CommonMeshModel::Hunyuan3d3p1SmartTopology), + (RouterMeshModel::Tripo3dH3p1, CommonMeshModel::Tripo3dH3p1), + (RouterMeshModel::MeshyV6, CommonMeshModel::MeshyV6), + (RouterMeshModel::Rodin2p5Fast, CommonMeshModel::Rodin2p5Fast), + ]; + for (router_model, expected_common) in cases { + let json = serde_json::to_string(&router_model).unwrap(); + let common: CommonMeshModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("CommonMeshModel failed to parse {json}: {e}")); + assert_eq!(common, expected_common, "for {router_model:?}"); + } + } + + fn assert_serde_round_trip(model: RouterMeshModel, expected: &str) { + let json = serde_json::to_string(&model).unwrap(); + assert_eq!(json, format!("\"{expected}\"")); + let parsed: RouterMeshModel = serde_json::from_str(&json).unwrap(); + // RouterMeshModel isn't PartialEq, so round-trip back to the wire form. + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs new file mode 100644 index 0000000000..69f736d47f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs @@ -0,0 +1,13 @@ +use serde_derive::{Deserialize, Serialize}; + +/// The provider to route a generation request to. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterProvider { + Artcraft, + Fal, + GmiCloud, + GrokApi, + KinoviWeb, + WorldLabs, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs new file mode 100644 index 0000000000..b195a84bf3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs @@ -0,0 +1,11 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common quality levels you can specify when enqueuing a generation. +/// Not every model will use this; models that don't simply ignore it. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RouterQuality { + High, + Medium, + Low, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_resolution.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_resolution.rs new file mode 100644 index 0000000000..22ae64cdce --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_resolution.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; + +/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation. +/// Not every model will support every resolution. +/// In the case a model doesn't support the resolution, gracefully pick the nearest option. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterResolution { + /// Models: Nano Banana Pro + OneK, + /// Models: Nano Banana Pro + TwoK, + /// Models: (None) + ThreeK, + /// Models: Nano Banana Pro + FourK, + + /// Used by Nano Banana 2 (and others) + HalfK, + + /// Used by the Seedance family (and others) + FourEightyP, + + /// Used by the Seedance family (and others) + SevenTwentyP, + + /// Used by the Seedance family (and others) + TenEightyP, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs new file mode 100644 index 0000000000..606614018d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs @@ -0,0 +1,78 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common splat models supported by the router. +/// Not all models are available through all providers. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterSplatModel { + /// Deprecated upstream; treated as [`Self::Marble1p0Draft`]. + #[serde(rename = "marble_0p1_mini")] + Marble0p1Mini, + + /// Deprecated upstream; treated as [`Self::Marble1p0`]. + #[serde(rename = "marble_0p1_plus")] + Marble0p1Plus, + + #[serde(rename = "marble_1p0")] + Marble1p0, + + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + + #[serde(rename = "marble_1p1")] + Marble1p1, + + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, + + /// TripoSplat: single-image to 3D Gaussian splat (via Fal). + #[serde(rename = "triposplat")] + TripoSplat, +} + +#[cfg(test)] +mod tests { + use super::*; + + // NB: These strings must match `CommonSplatModel` — the two enums convert + // via serde string round-trip. + #[test] + fn all_variants_serialize_to_common_splat_model_strings() { + assert_serde_round_trip(RouterSplatModel::Marble0p1Mini, "marble_0p1_mini"); + assert_serde_round_trip(RouterSplatModel::Marble0p1Plus, "marble_0p1_plus"); + assert_serde_round_trip(RouterSplatModel::Marble1p0, "marble_1p0"); + assert_serde_round_trip(RouterSplatModel::Marble1p0Draft, "marble_1p0_draft"); + assert_serde_round_trip(RouterSplatModel::Marble1p1, "marble_1p1"); + assert_serde_round_trip(RouterSplatModel::Marble1p1Plus, "marble_1p1_plus"); + assert_serde_round_trip(RouterSplatModel::TripoSplat, "triposplat"); + } + + #[test] + fn round_trips_through_common_splat_model() { + use enums::common::generation::common_splat_model::CommonSplatModel; + + let cases = [ + (RouterSplatModel::Marble0p1Mini, CommonSplatModel::Marble0p1Mini), + (RouterSplatModel::Marble0p1Plus, CommonSplatModel::Marble0p1Plus), + (RouterSplatModel::Marble1p0, CommonSplatModel::Marble1p0), + (RouterSplatModel::Marble1p0Draft, CommonSplatModel::Marble1p0Draft), + (RouterSplatModel::Marble1p1, CommonSplatModel::Marble1p1), + (RouterSplatModel::Marble1p1Plus, CommonSplatModel::Marble1p1Plus), + (RouterSplatModel::TripoSplat, CommonSplatModel::TripoSplat), + ]; + for (router_model, expected_common) in cases { + let json = serde_json::to_string(&router_model).unwrap(); + let common: CommonSplatModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("CommonSplatModel failed to parse {json}: {e}")); + assert_eq!(common, expected_common, "for {router_model:?}"); + } + } + + fn assert_serde_round_trip(model: RouterSplatModel, expected: &str) { + let json = serde_json::to_string(&model).unwrap(); + assert_eq!(json, format!("\"{expected}\"")); + let parsed: RouterSplatModel = serde_json::from_str(&json).unwrap(); + // RouterSplatModel isn't PartialEq, so round-trip back to the wire form. + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs new file mode 100644 index 0000000000..816e7d0789 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs @@ -0,0 +1,206 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common video models supported by the router. +/// Not all models are available through all providers. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterVideoModel { + #[serde(rename = "grok_video")] + GrokVideo, + + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + + #[serde(rename = "flux_3")] + Flux3, + + #[serde(rename = "flux_3_draft")] + Flux3Draft, + + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + + #[serde(rename = "kling_2p1_master")] + Kling21Master, + + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + + #[serde(rename = "minimax_h3")] + MinimaxH3, + + #[serde(rename = "minimax_h3_turbo")] + MinimaxH3Turbo, + + #[serde(rename = "minimax_h3_ultra")] + MinimaxH3Ultra, + + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + + #[serde(rename = "seedance_2p0")] + Seedance2p0, + + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + + + + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + + #[serde(rename = "seedance_2p0_mini")] + Seedance2p0Mini, + + #[serde(rename = "seedance_2p0_bp_mini")] + Seedance2p0BytePlusMini, + + #[serde(rename = "seedance_2p0_bpu_mini")] + Seedance2p0BytePlusUltraMini, + + #[serde(rename = "seedance_2p5_preview")] + Seedance2p5Preview, + + #[serde(rename = "seedance_2p5")] + Seedance2p5, + + #[serde(rename = "seedance_2p5_u")] + Seedance2p5Ultra, + + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + + #[serde(rename = "sora_2")] + Sora2, + + #[serde(rename = "sora_2_pro")] + Sora2Pro, + + #[serde(rename = "veo_2")] + Veo2, + + #[serde(rename = "veo_3")] + Veo3, + + #[serde(rename = "veo_3_fast")] + Veo3Fast, + + #[serde(rename = "veo_3p1")] + Veo3p1, + + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, + + #[serde(rename = "veo_3p1_lite")] + Veo3p1Lite, + + #[serde(rename = "vidu_q3")] + ViduQ3, + + #[serde(rename = "vidu_q3_turbo")] + ViduQ3Turbo, + + #[serde(rename = "preview_model")] + PreviewModel, + + #[serde(rename = "preview_model_fast")] + PreviewModelFast, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_serde_round_trip(model: RouterVideoModel, expected: &str) { + let json = serde_json::to_string(&model).unwrap(); + assert_eq!(json, format!("\"{expected}\"")); + let parsed: RouterVideoModel = serde_json::from_str(&json).unwrap(); + // RouterVideoModel isn't PartialEq, so round-trip back to the wire form. + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } + + #[test] + fn seedance_2p0_mini_variants_serialize() { + assert_serde_round_trip(RouterVideoModel::Seedance2p0Mini, "seedance_2p0_mini"); + assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"); + assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"); + } + + #[test] + fn seedance_2p5_preview_serializes() { + assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview"); + } + + #[test] + fn seedance_2p5_serializes() { + // NB: This string must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::Seedance2p5, "seedance_2p5"); + } + + #[test] + fn seedance_2p5_ultra_serializes() { + // NB: This string must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::Seedance2p5Ultra, "seedance_2p5_u"); + } + + #[test] + fn veo_3p1_lite_and_vidu_variants_serialize() { + // NB: These strings must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::Veo3p1Lite, "veo_3p1_lite"); + assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3"); + assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo"); + } + + #[test] + fn flux_3_variants_serialize() { + // NB: These strings must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::Flux3, "flux_3"); + assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft"); + } + + #[test] + fn minimax_h3_serializes() { + // NB: This string must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3"); + } + + #[test] + fn minimax_h3_turbo_and_ultra_serialize() { + // NB: These strings must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::MinimaxH3Turbo, "minimax_h3_turbo"); + assert_serde_round_trip(RouterVideoModel::MinimaxH3Ultra, "minimax_h3_ultra"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs new file mode 100644 index 0000000000..d48f3ce292 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/video_list_ref.rs @@ -0,0 +1,7 @@ +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Debug)] +pub enum VideoListRef { + MediaFileTokens(Vec), + Urls(Vec), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/video_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/video_ref.rs new file mode 100644 index 0000000000..e3f4a9e8f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/video_ref.rs @@ -0,0 +1,7 @@ +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Debug)] +pub enum VideoRef { + MediaFileToken(MediaFileToken), + Url(String), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/generation_mode_mismatch_strategy.rs b/crates/api_clients/artcraft/artcraft_router/src/client/generation_mode_mismatch_strategy.rs new file mode 100644 index 0000000000..2a920a84f3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/generation_mode_mismatch_strategy.rs @@ -0,0 +1,12 @@ + +/// Strategy for handling requests where the model only supports text-to-image +/// but the request includes image inputs (image editing mode). +#[derive(Copy, Clone, Debug)] +pub enum GenerationModeMismatchStrategy { + /// Abort the generation: return an error if image inputs are provided + /// for a text-to-image-only model. + AbortGeneration, + + /// Proceed anyway, ignoring the image inputs for the generation. + GenerateAnyway, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs new file mode 100644 index 0000000000..3d317551fb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs @@ -0,0 +1,11 @@ +pub mod generation_mode_mismatch_strategy; +pub mod multi_router_client; +pub mod multi_router_client_builder; +pub mod request_mismatch_mitigation_strategy; +pub mod router_artcraft_client; +pub mod router_client; +pub mod router_fal_client; +pub mod router_gmicloud_client; +pub mod router_grok_api_client; +pub mod router_kinovi_web_client; +pub mod router_worldlabs_client; diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs new file mode 100644 index 0000000000..49e8c5a44d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs @@ -0,0 +1,48 @@ +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::client::router_fal_client::RouterFalClient; +use crate::client::router_gmicloud_client::RouterGmiCloudClient; +use crate::client::router_grok_api_client::RouterGrokApiClient; +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::client_error::{ClientError, ClientType}; + +pub struct MultiRouterClient { + pub(crate) artcraft_client: Option, + pub(crate) fal_client: Option, + pub(crate) gmicloud_client: Option, + pub(crate) grok_api_client: Option, + pub(crate) kinovi_web_client: Option, + pub(crate) worldlabs_client: Option, +} + +impl MultiRouterClient { + pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> { + self.artcraft_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft)) + } + + pub fn get_fal_client_ref(&self) -> Result<&RouterFalClient, ClientError> { + self.fal_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::Fal)) + } + + pub fn get_gmicloud_client_ref(&self) -> Result<&RouterGmiCloudClient, ClientError> { + self.gmicloud_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::GmiCloud)) + } + + pub fn get_grok_api_client_ref(&self) -> Result<&RouterGrokApiClient, ClientError> { + self.grok_api_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::GrokApi)) + } + + pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> { + self.kinovi_web_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::KinoviWeb)) + } + + pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> { + self.worldlabs_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::WorldLabs)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs new file mode 100644 index 0000000000..2a7e947e42 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs @@ -0,0 +1,70 @@ +use crate::client::multi_router_client::MultiRouterClient; +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::client::router_fal_client::RouterFalClient; +use crate::client::router_gmicloud_client::RouterGmiCloudClient; +use crate::client::router_grok_api_client::RouterGrokApiClient; +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; + +pub struct MultiRouterClientBuilder { + artcraft_client: Option, + fal_client: Option, + gmicloud_client: Option, + grok_api_client: Option, + kinovi_web_client: Option, + worldlabs_client: Option, +} + +impl MultiRouterClientBuilder { + pub fn new() -> Self { + Self { + artcraft_client: None, + fal_client: None, + gmicloud_client: None, + grok_api_client: None, + kinovi_web_client: None, + worldlabs_client: None, + } + } + + pub fn set_artcraft_client(mut self, client: RouterArtcraftClient) -> Self { + self.artcraft_client = Some(client); + self + } + + pub fn set_fal_client(mut self, client: RouterFalClient) -> Self { + self.fal_client = Some(client); + self + } + + pub fn set_gmicloud_client(mut self, client: RouterGmiCloudClient) -> Self { + self.gmicloud_client = Some(client); + self + } + + pub fn set_grok_api_client(mut self, client: RouterGrokApiClient) -> Self { + self.grok_api_client = Some(client); + self + } + + pub fn set_kinovi_web_client(mut self, client: RouterKinoviWebClient) -> Self { + self.kinovi_web_client = Some(client); + self + } + + pub fn set_worldlabs_client(mut self, client: RouterWorldLabsClient) -> Self { + self.worldlabs_client = Some(client); + self + } + + pub fn build(self) -> MultiRouterClient { + MultiRouterClient { + artcraft_client: self.artcraft_client, + fal_client: self.fal_client, + gmicloud_client: self.gmicloud_client, + grok_api_client: self.grok_api_client, + kinovi_web_client: self.kinovi_web_client, + worldlabs_client: self.worldlabs_client, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/request_mismatch_mitigation_strategy.rs b/crates/api_clients/artcraft/artcraft_router/src/client/request_mismatch_mitigation_strategy.rs new file mode 100644 index 0000000000..2c57e2e28d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/request_mismatch_mitigation_strategy.rs @@ -0,0 +1,12 @@ + +#[derive(Copy, Clone, Debug)] +pub enum RequestMismatchMitigationStrategy { + /// Upgrade the request to pay more. + PayMoreUpgrade, + + /// Downgrade the request to pay less + PayLessDowngrade, + + /// Fail the request. + ErrorOut, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_artcraft_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_artcraft_client.rs new file mode 100644 index 0000000000..a8038c34d9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_artcraft_client.rs @@ -0,0 +1,13 @@ +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::utils::api_host::ApiHost; + +pub struct RouterArtcraftClient { + pub(crate) api_host: ApiHost, + pub(crate) credentials: StorytellerCredentialSet, +} + +impl RouterArtcraftClient { + pub fn new(api_host: ApiHost, credentials: StorytellerCredentialSet) -> Self { + Self { api_host, credentials } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs new file mode 100644 index 0000000000..16b48568a4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs @@ -0,0 +1,68 @@ +use crate::client::multi_router_client::MultiRouterClient; +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::client::router_fal_client::RouterFalClient; +use crate::client::router_gmicloud_client::RouterGmiCloudClient; +use crate::client::router_grok_api_client::RouterGrokApiClient; +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::client_error::{ClientError, ClientType}; + +pub enum RouterClient { + Multi(MultiRouterClient), + Artcraft(RouterArtcraftClient), + Fal(RouterFalClient), + GmiCloud(RouterGmiCloudClient), + GrokApi(RouterGrokApiClient), + KinoviWeb(RouterKinoviWebClient), + WorldLabs(RouterWorldLabsClient), +} + +impl RouterClient { + pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> { + match self { + RouterClient::Artcraft(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_artcraft_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::Artcraft)), + } + } + + pub fn get_fal_client_ref(&self) -> Result<&RouterFalClient, ClientError> { + match self { + RouterClient::Fal(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_fal_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::Fal)), + } + } + + pub fn get_gmicloud_client_ref(&self) -> Result<&RouterGmiCloudClient, ClientError> { + match self { + RouterClient::GmiCloud(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_gmicloud_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::GmiCloud)), + } + } + + pub fn get_grok_api_client_ref(&self) -> Result<&RouterGrokApiClient, ClientError> { + match self { + RouterClient::GrokApi(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_grok_api_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::GrokApi)), + } + } + + pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> { + match self { + RouterClient::KinoviWeb(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_kinovi_web_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::KinoviWeb)), + } + } + + pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> { + match self { + RouterClient::WorldLabs(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_worldlabs_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::WorldLabs)), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs new file mode 100644 index 0000000000..7ffdf931d8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_fal_client.rs @@ -0,0 +1,30 @@ +use fal_client::creds::fal_api_key::FalApiKey; + +/// A unified Fal client. The caller picks the dispatch mode: +/// +/// - `Some(webhook_url)` → endpoints will use Fal's webhook flow. +/// - `None` → endpoints will use the queue/polling flow (where available). +/// +/// Some endpoints are webhook-only (their fal_client wrapper lives under the +/// `requests_old::webhook::*` namespace and has no `api::` queue variant). Those +/// will return `ClientError::WebhookUrlRequired` when constructed with `None`. +pub struct RouterFalClient { + pub(crate) api_key: FalApiKey, + pub(crate) webhook_url: Option, +} + +impl RouterFalClient { + /// Build a client for webhook dispatch. + pub fn new_with_webhook(api_key: FalApiKey, webhook_url: String) -> Self { + Self { api_key, webhook_url: Some(webhook_url) } + } + + /// Build a client for queue/polling dispatch (no webhook URL). + pub fn new_polling_only(api_key: FalApiKey) -> Self { + Self { api_key, webhook_url: None } + } + + pub fn new_polling_only_from_raw_key(api_key: &str) -> Self { + Self::new_polling_only(FalApiKey::from_str(api_key)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_gmicloud_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_gmicloud_client.rs new file mode 100644 index 0000000000..8660ffd2c1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_gmicloud_client.rs @@ -0,0 +1,11 @@ +use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey; + +pub struct RouterGmiCloudClient { + pub(crate) api_key: GmiCloudApiKey, +} + +impl RouterGmiCloudClient { + pub fn new(api_key: GmiCloudApiKey) -> Self { + RouterGmiCloudClient { api_key } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_grok_api_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_grok_api_client.rs new file mode 100644 index 0000000000..816f5730ad --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_grok_api_client.rs @@ -0,0 +1,11 @@ +use grok_api_client::creds::grok_api_key::GrokApiKey; + +pub struct RouterGrokApiClient { + pub(crate) api_key: GrokApiKey, +} + +impl RouterGrokApiClient { + pub fn new(api_key: GrokApiKey) -> Self { + RouterGrokApiClient { api_key } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_kinovi_web_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_kinovi_web_client.rs new file mode 100644 index 0000000000..366eeb2d65 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_kinovi_web_client.rs @@ -0,0 +1,11 @@ +use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + +pub struct RouterKinoviWebClient { + pub(crate) session: KinoviWebSession, +} + +impl RouterKinoviWebClient { + pub fn new(session: KinoviWebSession) -> Self { + RouterKinoviWebClient { session } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_worldlabs_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_worldlabs_client.rs new file mode 100644 index 0000000000..1967085fe9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_worldlabs_client.rs @@ -0,0 +1,16 @@ +use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds; + +/// A client for the official World Labs (Marble) API. +pub struct RouterWorldLabsClient { + pub(crate) creds: WorldLabsApiCreds, +} + +impl RouterWorldLabsClient { + pub fn new(creds: WorldLabsApiCreds) -> Self { + Self { creds } + } + + pub fn new_from_raw_key(api_key: &str) -> Self { + Self::new(WorldLabsApiCreds::new(api_key.to_string())) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/artcraft_router_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/artcraft_router_error.rs new file mode 100644 index 0000000000..88507750ec --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/artcraft_router_error.rs @@ -0,0 +1,87 @@ +use crate::errors::client_error::ClientError; +use crate::errors::download_error::DownloadError; +use crate::errors::provider_error::ProviderError; +use fal_client::error::fal_error_plus::FalErrorPlus; +use kinovi_web_client::error::kinovi_web_error::KinoviWebError; +use kinovi_web_client::error::kinovi_web_specific_api_error::KinoviWebSpecificApiError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; + +#[derive(Debug)] +pub enum ArtcraftRouterError { + /// A client configuration error. + Client(ClientError), + + /// Failed to download a file from a URL (e.g. when re-uploading to a provider's CDN). + Download(DownloadError), + + /// The requested model is not yet supported by the router. + UnsupportedModel(String), + + /// [Temporary during migration] The requested model/provider is not yet supported by the router. + UnsupportedProviderAndModelForNewApi(String), + + /// Invalid or missing input arguments. + InvalidInput(String), + + /// An error from an underlying provider. + Provider(ProviderError), + + /// A billing error from an underlying provider. + ProviderBillingError(ProviderError), +} + +impl Error for ArtcraftRouterError {} + +impl Display for ArtcraftRouterError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "Client error: {}", e), + Self::Download(e) => write!(f, "Download error: {}", e), + Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model), + Self::UnsupportedProviderAndModelForNewApi(msg) => write!(f, "Unsupported provider/model (for new API during migration): {}", msg), + Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), + Self::Provider(e) => write!(f, "Provider error: {}", e), + Self::ProviderBillingError(e) => write!(f, "Provider billing error: {}", e), + } + } +} + +impl From for ArtcraftRouterError { + fn from(error: ClientError) -> Self { + Self::Client(error) + } +} + +impl From for ArtcraftRouterError { + fn from(error: DownloadError) -> Self { + Self::Download(error) + } +} + +impl From for ArtcraftRouterError { + fn from(error: ProviderError) -> Self { + let is_billing_error = match &error { + ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true, + ProviderError::KinoviWeb(KinoviWebError::ApiSpecific(KinoviWebSpecificApiError::BillingError { .. })) => true, + ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true, + _ => false, + }; + if is_billing_error { + Self::ProviderBillingError(error) + } else { + Self::Provider(error) + } + } +} + +impl From for ArtcraftRouterError { + fn from(error: FalErrorPlus) -> Self { + match &error { + FalErrorPlus::FalBillingError(e) => ArtcraftRouterError::ProviderBillingError(ProviderError::Fal(error)), + _ => ArtcraftRouterError::Provider(ProviderError::Fal(error)), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs new file mode 100644 index 0000000000..94733d4b55 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs @@ -0,0 +1,114 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Debug, Clone, Copy)] +pub enum ClientType { + Artcraft, + Fal, + GmiCloud, + GrokApi, + KinoviWeb, + WorldLabs, +} + +impl Display for ClientType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Artcraft => write!(f, "Artcraft"), + Self::Fal => write!(f, "Fal"), + Self::GmiCloud => write!(f, "GmiCloud"), + Self::GrokApi => write!(f, "GrokApi"), + Self::KinoviWeb => write!(f, "KinoviWeb"), + Self::WorldLabs => write!(f, "WorldLabs"), + } + } +} + +#[derive(Debug)] +pub enum ClientError { + /// A RouterClient was required but not provided on the draft context. + RouterClientNotProvided, + + /// The requested client is not configured on the RouterClient. + ClientNotConfigured(ClientType), + + /// The model does not support the given option value. + /// `field` is the request field name, `value` is what was requested. + ModelDoesNotSupportOption { field: &'static str, value: String }, + + /// The caller requested zero generations, which is never valid. + UserRequestedZeroGenerations, + + /// ArtCraft only accepts media tokens for image inputs, not raw URLs. + ArtcraftOnlySupportsMediaTokens, + + /// Fal only accepts image URLs for image inputs, not media tokens. + FalOnlySupportsUrls, + + /// KinoviWeb only accepts URLs for media inputs, not media tokens. + KinoviWebOnlySupportsUrls, + + /// The Fal endpoint requires a webhook URL but the caller built the client + /// in polling/queue mode (no webhook URL). Returned by webhook-only + /// endpoints (those whose fal_client wrapper has no `api::` queue variant). + WebhookUrlRequired, + + /// The pre-dispatch context of media file token to URL map was not supplied. + MediaFileToUrlMapNotProvided, + + /// A media file token was not found in the provided media-file-to-URL map. + MediaFileTokenNotFoundInMap { token: MediaFileToken }, + + /// The pre-dispatch context of character token to ID map was not supplied. + CharacterTokenToKinoviCharacterIdNotProvided, + + /// A character token was not found in the provided character-token-to-id map + CharacterTokenNotFoundInMap { token: CharacterToken }, +} + +impl Error for ClientError {} + +impl Display for ClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::RouterClientNotProvided => { + write!(f, "A RouterClient is required but was not provided on the draft context") + } + Self::ClientNotConfigured(client_type) => { + write!(f, "{} client is not configured on the RouterClient", client_type) + } + Self::ModelDoesNotSupportOption { field, value } => { + write!(f, "Model does not support '{}' for field '{}'", value, field) + } + Self::UserRequestedZeroGenerations => { + write!(f, "Cannot request zero generations") + } + Self::ArtcraftOnlySupportsMediaTokens => { + write!(f, "ArtCraft only supports media tokens for image inputs; upload the image first to obtain a media token") + } + Self::FalOnlySupportsUrls => { + write!(f, "Fal only supports image URLs for image inputs, not media tokens") + } + Self::KinoviWebOnlySupportsUrls => { + write!(f, "KinoviWeb only supports URLs for media inputs; resolve media tokens to URLs before calling this provider") + } + Self::WebhookUrlRequired => { + write!(f, "This Fal endpoint only supports webhook dispatch; the caller built RouterFalClient in polling-only mode (no webhook URL)") + } + Self::MediaFileToUrlMapNotProvided => { + write!(f, "Media file to URL map was not provided") + } + Self::MediaFileTokenNotFoundInMap { token } => { + write!(f, "Media file token '{}' was not found in the provided URL map", token.as_str()) + } + Self::CharacterTokenToKinoviCharacterIdNotProvided => { + write!(f, "Character token to Kinovi character ID map was not provided") + } + Self::CharacterTokenNotFoundInMap { token } => { + write!(f, "Character token '{}' was not found in the provided character-token-to-id map", token.as_str()) + } + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/download_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/download_error.rs new file mode 100644 index 0000000000..302effc121 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/download_error.rs @@ -0,0 +1,40 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum DownloadError { + BadStatus { + url: String, + status_code: u16, + }, + + ReqwestDownload { + url: String, + error: reqwest::Error, + maybe_status: Option, + }, + + WreqDownload { + url: String, + error: wreq::Error, + maybe_status: Option, + }, +} + +impl Error for DownloadError {} + +impl Display for DownloadError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BadStatus { url, status_code } => { + write!(f, "Download failed for {} with status {}", url, status_code) + } + Self::ReqwestDownload { url, error, maybe_status } => { + write!(f, "Reqwest download error for {}: {} (status: {:?})", url, error, maybe_status) + } + Self::WreqDownload { url, error, maybe_status } => { + write!(f, "Wreq download error for {}: {} (status: {:?})", url, error, maybe_status) + } + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/mod.rs new file mode 100644 index 0000000000..29c2b05109 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/mod.rs @@ -0,0 +1,4 @@ +pub mod artcraft_router_error; +pub mod client_error; +pub mod download_error; +pub mod provider_error; diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs new file mode 100644 index 0000000000..eac4f7038f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs @@ -0,0 +1,69 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use artcraft_client::error::storyteller_error::StorytellerError; +use fal_client::error::fal_error_plus::FalErrorPlus; +use gmicloud_client::error::gmicloud_error::GmiCloudError; +use grok_api_client::error::grok_error::GrokError; +use kinovi_web_client::error::kinovi_web_error::KinoviWebError; +use worldlabs_api_client::error::world_labs_error::WorldLabsError; + +#[derive(Debug)] +pub enum ProviderError { + Storyteller(StorytellerError), + Fal(FalErrorPlus), + GmiCloud(GmiCloudError), + Grok(GrokError), + KinoviWeb(KinoviWebError), + WorldLabs(WorldLabsError), +} + +impl Error for ProviderError {} + +impl Display for ProviderError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e), + Self::Fal(e) => write!(f, "Fal provider error: {}", e), + Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e), + Self::Grok(e) => write!(f, "Grok provider error: {}", e), + Self::KinoviWeb(e) => write!(f, "KinoviWeb provider error: {}", e), + Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e), + } + } +} + +impl From for ProviderError { + fn from(error: StorytellerError) -> Self { + Self::Storyteller(error) + } +} + +impl From for ProviderError { + fn from(error: FalErrorPlus) -> Self { + Self::Fal(error) + } +} + +impl From for ProviderError { + fn from(error: GmiCloudError) -> Self { + Self::GmiCloud(error) + } +} + +impl From for ProviderError { + fn from(error: GrokError) -> Self { + Self::Grok(error) + } +} + +impl From for ProviderError { + fn from(error: KinoviWebError) -> Self { + Self::KinoviWeb(error) + } +} + +impl From for ProviderError { + fn from(error: WorldLabsError) -> Self { + Self::WorldLabs(error) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_cost_estimate.rs new file mode 100644 index 0000000000..3838822af6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_cost_estimate.rs @@ -0,0 +1,16 @@ +/// The result of estimating the cost of an audio generation plan. +#[derive(Clone, Debug)] +pub struct AudioGenerationCostEstimate { + pub cost_in_credits: Option, + pub cost_in_usd_cents: Option, + pub is_free: bool, + pub is_unlimited: bool, + pub is_rate_limited: bool, + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft.rs new file mode 100644 index 0000000000..283cc26965 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft.rs @@ -0,0 +1,56 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::providers::kinovi::suno_remix::cost::KinoviSunoRemixCostState; +use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState; +use crate::generate::generate_audio::providers::kinovi::suno_sample::cost::KinoviSunoSampleCostState; +use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState; + +/** + * Wrapper for all audio generation draft requests. + * + * Only the Kinovi remix/sample models need a draft phase: they take a user + * audio reference that must be downloaded from our CDN and re-uploaded to the + * Kinovi CDN before the request can be sent. All other audio providers return + * a `Request` directly from `build2()`. + */ +#[derive(Clone, Debug)] +pub enum AudioGenerationDraftRequest { + KinoviSunoRemix(KinoviSunoRemixDraftState), + KinoviSunoSample(KinoviSunoSampleDraftState), +} + +impl AudioGenerationDraftRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb, + Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + AudioGenerationDraftRequest::KinoviSunoRemix(draft) => Ok(KinoviSunoRemixCostState::from_draft(draft).estimate_cost()), + AudioGenerationDraftRequest::KinoviSunoSample(draft) => Ok(KinoviSunoSampleCostState::from_draft(draft).estimate_cost()), + } + } + + /// Finalize the draft request before generation + /// This may involve uploading media to the provider. + pub async fn finalize(self, draft_context: AudioGenerationDraftContext<'_>) -> Result { + match self { + AudioGenerationDraftRequest::KinoviSunoRemix(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(AudioGenerationRequest::KinoviSunoRemix(result)) + }, + AudioGenerationDraftRequest::KinoviSunoSample(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(AudioGenerationRequest::KinoviSunoSample(result)) + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft_context.rs new file mode 100644 index 0000000000..0833b5aee6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft_context.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +#[derive(Clone, Default)] +pub struct AudioGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require authentication. + pub client: Option<&'a RouterClient>, + + /// Optional context: a map of Media File Tokens to their ArtCraft URLs + /// Only needed if we have to fetch these assets and upload them to another provider. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl <'a> AudioGenerationDraftContext<'a> { + pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> { + let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?; + client.get_kinovi_web_client_ref() + .map_err(|err| ArtcraftRouterError::Client(err)) + } +} + +impl Debug for AudioGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AudioGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft_or_request.rs new file mode 100644 index 0000000000..d204723f99 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_draft_or_request.rs @@ -0,0 +1,28 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; + +#[derive(Debug, Clone)] +pub enum AudioGenerationDraftOrRequest { + Draft(AudioGenerationDraftRequest), + Request(AudioGenerationRequest), +} + +impl AudioGenerationDraftOrRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + AudioGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), + AudioGenerationDraftOrRequest::Request(request) => request.estimate_cost(), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_request.rs new file mode 100644 index 0000000000..8968aa1892 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/audio_generation_request.rs @@ -0,0 +1,120 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; +use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::cost::ArtcraftSeedAudio1p0CostState; +use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState; +use crate::generate::generate_audio::providers::artcraft::suno_music::cost::ArtcraftSunoMusicCostState; +use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState; +use crate::generate::generate_audio::providers::artcraft::suno_remix::cost::ArtcraftSunoRemixCostState; +use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState; +use crate::generate::generate_audio::providers::artcraft::suno_sample::cost::ArtcraftSunoSampleCostState; +use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState; +use crate::generate::generate_audio::providers::artcraft::suno_sounds::cost::ArtcraftSunoSoundsCostState; +use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState; +use crate::generate::generate_audio::providers::fal::seed_audio_1p0::cost::FalSeedAudio1p0CostState; +use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState; +use crate::generate::generate_audio::providers::kinovi::suno_music::cost::KinoviSunoMusicCostState; +use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState; +use crate::generate::generate_audio::providers::kinovi::suno_remix::cost::KinoviSunoRemixCostState; +use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState; +use crate::generate::generate_audio::providers::kinovi::suno_sample::cost::KinoviSunoSampleCostState; +use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState; +use crate::generate::generate_audio::providers::kinovi::suno_sounds::cost::KinoviSunoSoundsCostState; +use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState; + +#[derive(Clone, Debug)] +pub enum AudioGenerationRequest { + ArtcraftSunoMusic(ArtcraftSunoMusicRequestState), + ArtcraftSunoRemix(ArtcraftSunoRemixRequestState), + ArtcraftSunoSounds(ArtcraftSunoSoundsRequestState), + ArtcraftSunoSample(ArtcraftSunoSampleRequestState), + ArtcraftSeedAudio1p0(ArtcraftSeedAudio1p0RequestState), + FalSeedAudio1p0(FalSeedAudio1p0RequestState), + KinoviSunoMusic(KinoviSunoMusicRequestState), + KinoviSunoRemix(KinoviSunoRemixRequestState), + KinoviSunoSounds(KinoviSunoSoundsRequestState), + KinoviSunoSample(KinoviSunoSampleRequestState), +} + +impl AudioGenerationRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftSunoMusic(_) => RouterProvider::Artcraft, + Self::ArtcraftSunoRemix(_) => RouterProvider::Artcraft, + Self::ArtcraftSunoSounds(_) => RouterProvider::Artcraft, + Self::ArtcraftSunoSample(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedAudio1p0(_) => RouterProvider::Artcraft, + Self::FalSeedAudio1p0(_) => RouterProvider::Fal, + Self::KinoviSunoMusic(_) => RouterProvider::KinoviWeb, + Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb, + Self::KinoviSunoSounds(_) => RouterProvider::KinoviWeb, + Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + AudioGenerationRequest::ArtcraftSunoMusic(request) => Ok(ArtcraftSunoMusicCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::ArtcraftSunoRemix(request) => Ok(ArtcraftSunoRemixCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::ArtcraftSunoSounds(request) => Ok(ArtcraftSunoSoundsCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::ArtcraftSunoSample(request) => Ok(ArtcraftSunoSampleCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::ArtcraftSeedAudio1p0(request) => Ok(ArtcraftSeedAudio1p0CostState::from_request(request).estimate_cost()), + AudioGenerationRequest::FalSeedAudio1p0(request) => Ok(FalSeedAudio1p0CostState::from_request(request).estimate_cost()), + AudioGenerationRequest::KinoviSunoMusic(request) => Ok(KinoviSunoMusicCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::KinoviSunoRemix(request) => Ok(KinoviSunoRemixCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::KinoviSunoSounds(request) => Ok(KinoviSunoSoundsCostState::from_request(request).estimate_cost()), + AudioGenerationRequest::KinoviSunoSample(request) => Ok(KinoviSunoSampleCostState::from_request(request).estimate_cost()), + } + } + + /// Send the audio generation request + /// If successful, returns the job IDs. + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + AudioGenerationRequest::ArtcraftSunoMusic(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::ArtcraftSunoRemix(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::ArtcraftSunoSounds(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::ArtcraftSunoSample(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::ArtcraftSeedAudio1p0(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::FalSeedAudio1p0(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::KinoviSunoMusic(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::KinoviSunoRemix(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::KinoviSunoSounds(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + AudioGenerationRequest::KinoviSunoSample(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/generate_audio_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/generate_audio_request_builder.rs new file mode 100644 index 0000000000..8be18b17f6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/generate_audio_request_builder.rs @@ -0,0 +1,302 @@ +use enums::common::generation::common_musical_key::CommonMusicalKey; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_audio_model::RouterAudioModel; +use crate::api::router_provider::RouterProvider; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::build::build_artcraft_seed_audio_1p0; +use crate::generate::generate_audio::providers::artcraft::suno_music::build::build_artcraft_suno_music; +use crate::generate::generate_audio::providers::artcraft::suno_remix::build::build_artcraft_suno_remix; +use crate::generate::generate_audio::providers::artcraft::suno_sample::build::build_artcraft_suno_sample; +use crate::generate::generate_audio::providers::artcraft::suno_sounds::build::build_artcraft_suno_sounds; +use crate::generate::generate_audio::providers::fal::seed_audio_1p0::build::build_fal_seed_audio_1p0; +use crate::generate::generate_audio::providers::kinovi::suno_music::build::build_kinovi_suno_music; +use crate::generate::generate_audio::providers::kinovi::suno_remix::build::build_kinovi_suno_remix; +use crate::generate::generate_audio::providers::kinovi::suno_sample::build::build_kinovi_suno_sample; +use crate::generate::generate_audio::providers::kinovi::suno_sounds::build::build_kinovi_suno_sounds; + +/// RouterProvider-agnostic audio generation request. Distilled by `build2()` +/// into an `AudioGenerationDraftOrRequest` for the selected (provider, model) +/// pair. +#[derive(Clone, Debug)] +pub struct GenerateAudioRequestBuilder { + /// Which model to use. + pub model: RouterAudioModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The prompt for the audio generation. + pub prompt: Option, + + /// Style/genre direction (Suno's "tags"), e.g. "EDM style meets dance". + pub style_prompt: Option, + + /// Reference audio (optional). + /// The remix/sample source, or Seed Audio @Audio references (up to 3). + pub audio_references: Option, + + /// Reference images (optional). + /// Seed Audio supports a single reference image; it cannot be combined + /// with audio references. + pub image_references: Option, + + /// Whether to keep the original lyrics (Suno Remix). + pub keep_lyrics: Option, + + /// Whether to generate instrumental-only audio (Suno Music / Sample). + pub is_instrumental: Option, + + /// Whether the sound should loop vs a single hit (Suno Sounds). + pub is_loopable: Option, + + /// Beats per minute (Suno Sounds). + pub bpm: Option, + + /// The musical key to use (Suno Sounds). + pub musical_key: Option, + + /// Output sample rate in Hz (Seed Audio). + pub sample_rate_hz: Option, + + /// Playback speed multiplier (Seed Audio: 0.5–2.0). + pub speed: Option, + + /// Volume multiplier (Seed Audio: 0.5–2.0). + pub volume: Option, + + /// Pitch shift in semitones (Seed Audio: -12..=12). + pub pitch: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl Default for GenerateAudioRequestBuilder { + fn default() -> Self { + Self { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::Artcraft, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + prompt: None, + style_prompt: None, + audio_references: None, + image_references: None, + keep_lyrics: None, + is_instrumental: None, + is_loopable: None, + bpm: None, + musical_key: None, + sample_rate_hz: None, + speed: None, + volume: None, + pitch: None, + idempotency_token: None, + } + } +} + +impl GenerateAudioRequestBuilder { + + pub fn build2(self) -> Result { + match (self.provider, self.model) { + // Artcraft + (RouterProvider::Artcraft, RouterAudioModel::SunoMusic) => build_artcraft_suno_music(self), + (RouterProvider::Artcraft, RouterAudioModel::SunoRemix) => build_artcraft_suno_remix(self), + (RouterProvider::Artcraft, RouterAudioModel::SunoSounds) => build_artcraft_suno_sounds(self), + (RouterProvider::Artcraft, RouterAudioModel::SunoSample) => build_artcraft_suno_sample(self), + (RouterProvider::Artcraft, RouterAudioModel::SeedAudio1p0) => build_artcraft_seed_audio_1p0(self), + // Fal + (RouterProvider::Fal, RouterAudioModel::SeedAudio1p0) => build_fal_seed_audio_1p0(self), + // Kinovi + (RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self), + (RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self), + (RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self), + (RouterProvider::KinoviWeb, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self), + _ => self.unsupported_provider_and_model(), + } + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Audio generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest; + use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; + + use super::*; + + mod dispatch_tests { + use super::*; + + #[test] + fn artcraft_suno_music_dispatches() { + let result = builder(RouterProvider::Artcraft, RouterAudioModel::SunoMusic).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoMusic(_)) + )); + } + + #[test] + fn artcraft_suno_remix_dispatches() { + let result = builder_with_audio_token_ref(RouterProvider::Artcraft, RouterAudioModel::SunoRemix).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoRemix(_)) + )); + } + + #[test] + fn artcraft_suno_sounds_dispatches() { + let result = builder(RouterProvider::Artcraft, RouterAudioModel::SunoSounds).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSounds(_)) + )); + } + + #[test] + fn artcraft_suno_sample_dispatches() { + let result = builder_with_audio_token_ref(RouterProvider::Artcraft, RouterAudioModel::SunoSample).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSample(_)) + )); + } + + #[test] + fn artcraft_seed_audio_1p0_dispatches() { + let result = builder(RouterProvider::Artcraft, RouterAudioModel::SeedAudio1p0).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSeedAudio1p0(_)) + )); + } + + #[test] + fn fal_seed_audio_1p0_dispatches() { + let result = builder(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::FalSeedAudio1p0(_)) + )); + } + + #[test] + fn kinovi_suno_music_dispatches_to_request() { + let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(_)) + )); + } + + #[test] + fn kinovi_suno_sounds_dispatches_to_request() { + let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(_)) + )); + } + + #[test] + fn kinovi_suno_remix_dispatches_to_draft() { + let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(_)) + )); + } + + #[test] + fn kinovi_suno_sample_dispatches_to_draft() { + let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample).build2().expect("build"); + assert!(matches!( + result, + AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(_)) + )); + } + } + + mod unsupported_combo_tests { + use super::*; + + #[test] + fn kinovi_seed_audio_1p0_is_unsupported() { + let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SeedAudio1p0).build2(); + assert!(matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_)))); + } + + #[test] + fn fal_suno_models_are_unsupported() { + for model in [ + RouterAudioModel::SunoMusic, + RouterAudioModel::SunoRemix, + RouterAudioModel::SunoSounds, + RouterAudioModel::SunoSample, + ] { + let result = builder(RouterProvider::Fal, model).build2(); + assert!( + matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))), + "expected unsupported error for Fal + {model:?}", + ); + } + } + + #[test] + fn gmicloud_and_grok_are_unsupported() { + for provider in [RouterProvider::GmiCloud, RouterProvider::GrokApi] { + let result = builder(provider, RouterAudioModel::SunoMusic).build2(); + assert!( + matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))), + "expected unsupported error for {provider:?}", + ); + } + } + } + + // ── Helpers ── + + fn builder(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + provider, + model, + prompt: Some("a song about corgis".to_string()), + ..Default::default() + } + } + + fn builder_with_audio_ref(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])), + ..builder(provider, model) + } + } + + fn builder_with_audio_token_ref(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder { + use tokens::tokens::media_files::MediaFileToken; + GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::MediaFileTokens(vec![MediaFileToken::new("mf_test123".to_string())])), + ..builder(provider, model) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/generate_audio_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/generate_audio_response.rs new file mode 100644 index 0000000000..a27143aca9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/generate_audio_response.rs @@ -0,0 +1,65 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +#[derive(Clone, Debug)] +pub struct ArtcraftAudioResponsePayload { + pub inference_job_token: InferenceJobToken, + pub all_inference_job_tokens: Vec, +} + +#[derive(Clone, Debug)] +pub struct KinoviWebAudioResponsePayload { + pub order_id: String, + pub task_id: String, +} + +#[derive(Clone, Debug)] +pub struct FalAudioResponsePayload { + pub request_id: Option, + pub gateway_request_id: Option, + + /// Queue-mode status URL (fal's queue/polling flow). `None` for webhook + /// dispatch — the webhook callback drives status updates instead. + pub maybe_status_url: Option, + + /// Queue-mode response URL (fal's queue/polling flow). `None` for webhook + /// dispatch. + pub maybe_response_url: Option, + + /// The outbound request that was sent to Fal. + /// Stored as a trait object so any Request type can be captured. + /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. + pub maybe_outbound_request: Option>, +} + +#[derive(Clone, Debug)] +pub enum GenerateAudioResponse { + Artcraft(ArtcraftAudioResponsePayload), + KinoviWeb(KinoviWebAudioResponsePayload), + Fal(FalAudioResponsePayload), +} + +impl GenerateAudioResponse { + pub fn get_artcraft_payload(&self) -> Option { + match self { + Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_kinovi_web_payload(&self) -> Option { + match self { + Self::KinoviWeb(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_fal_payload(&self) -> Option { + match self { + Self::Fal(p) => Some(p.clone()), + _ => None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/mod.rs new file mode 100644 index 0000000000..2110437f85 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/mod.rs @@ -0,0 +1,8 @@ +pub mod audio_generation_cost_estimate; +pub mod audio_generation_draft; +pub mod audio_generation_draft_context; +pub mod audio_generation_draft_or_request; +pub mod audio_generation_request; +pub mod generate_audio_request_builder; +pub mod generate_audio_response; +pub mod providers; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..19fd945ce9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/build_common.rs @@ -0,0 +1,143 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; +use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::artcraft::resolve::{ + resolve_audio_list_ref, resolve_image_list_ref, +}; + +/// Build an `OmniGenAudioCostAndGenerateRequest` from the builder. The +/// Artcraft provider is a thin forwarding shim: the storyteller-web omni +/// audio endpoint owns per-model validation and option mapping, so the +/// builder's options are passed through as-is. +pub fn build_artcraft_omni_audio_request( + mut builder: GenerateAudioRequestBuilder, + model: CommonAudioModelEnum, +) -> Result { + let audio_media_tokens = resolve_audio_list_ref(builder.audio_references.take())?; + let image_media_tokens = resolve_image_list_ref(builder.image_references.take())?; + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenAudioCostAndGenerateRequest { + idempotency_token: Some(idempotency_token), + model: Some(model), + prompt: builder.prompt.take(), + style_prompt: builder.style_prompt.take(), + audio_media_tokens, + image_media_tokens, + keep_lyrics: builder.keep_lyrics, + is_instrumental: builder.is_instrumental, + is_loopable: builder.is_loopable, + bpm: builder.bpm, + musical_key: builder.musical_key, + sample_rate_hz: builder.sample_rate_hz, + speed: builder.speed, + volume: builder.volume, + pitch: builder.pitch, + }) +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::errors::client_error::ClientError; + + use super::*; + + #[test] + fn all_fields_are_forwarded() { + use enums::common::generation::common_musical_key::CommonMusicalKey; + + let builder = GenerateAudioRequestBuilder { + prompt: Some("a song".to_string()), + style_prompt: Some("EDM".to_string()), + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_audio".to_string()), + ])), + image_references: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image".to_string()), + ])), + keep_lyrics: Some(true), + is_instrumental: Some(false), + is_loopable: Some(true), + bpm: Some(120), + musical_key: Some(CommonMusicalKey::AMinor), + sample_rate_hz: Some(48_000), + speed: Some(1.5), + volume: Some(0.8), + pitch: Some(-2.0), + idempotency_token: Some("test-token".to_string()), + ..base_builder() + }; + + let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoMusic) + .expect("build"); + + assert_eq!(request.idempotency_token.as_deref(), Some("test-token")); + assert_eq!(request.model, Some(CommonAudioModelEnum::SunoMusic)); + assert_eq!(request.prompt.as_deref(), Some("a song")); + assert_eq!(request.style_prompt.as_deref(), Some("EDM")); + assert_eq!(request.audio_media_tokens.as_ref().map(|t| t.len()), Some(1)); + assert_eq!(request.image_media_tokens.as_ref().map(|t| t.len()), Some(1)); + assert_eq!(request.keep_lyrics, Some(true)); + assert_eq!(request.is_instrumental, Some(false)); + assert_eq!(request.is_loopable, Some(true)); + assert_eq!(request.bpm, Some(120)); + assert_eq!(request.musical_key, Some(CommonMusicalKey::AMinor)); + assert_eq!(request.sample_rate_hz, Some(48_000)); + assert_eq!(request.speed, Some(1.5)); + assert_eq!(request.volume, Some(0.8)); + assert_eq!(request.pitch, Some(-2.0)); + } + + #[test] + fn idempotency_token_is_generated_when_missing() { + let request = build_artcraft_omni_audio_request(base_builder(), CommonAudioModelEnum::SunoMusic) + .expect("build"); + assert!(request.idempotency_token.is_some()); + assert!(!request.idempotency_token.unwrap().is_empty()); + } + + #[test] + fn audio_url_references_are_rejected() { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])), + ..base_builder() + }; + let result = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoRemix); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + #[test] + fn image_url_references_are_rejected() { + let builder = GenerateAudioRequestBuilder { + image_references: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])), + ..base_builder() + }; + let result = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SeedAudio1p0); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::Artcraft, + prompt: Some("a song".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/mod.rs new file mode 100644 index 0000000000..6aa841b11a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod build_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod seed_audio_1p0; +pub mod suno_music; +pub mod suno_remix; +pub mod suno_sample; +pub mod suno_sounds; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..78f588122a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/request_common.rs @@ -0,0 +1,29 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::audio::omni_gen_audio::omni_gen_audio_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_audio::generate_audio_response::{ + ArtcraftAudioResponsePayload, GenerateAudioResponse, +}; + +/// Send an audio generation request to the Artcraft omni-gen endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_audio_request( + request: &OmniGenAudioCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_audio_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateAudioResponse::Artcraft(ArtcraftAudioResponsePayload { + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..9d73d795ef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/resolve.rs @@ -0,0 +1,34 @@ +//! Shared resolve helpers for the Artcraft audio provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_audio_list_ref( + audio_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match audio_list_ref { + None => Ok(None), + Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(AudioListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/build.rs new file mode 100644 index 0000000000..061931ec88 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request; +use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState; + +pub fn build_artcraft_seed_audio_1p0(builder: GenerateAudioRequestBuilder) -> Result { + let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SeedAudio1p0)?; + let state = ArtcraftSeedAudio1p0RequestState { request }; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSeedAudio1p0(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/cost.rs new file mode 100644 index 0000000000..bb9f3aa70c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/cost.rs @@ -0,0 +1,51 @@ +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState; + +const COST_IN_USD_CENTS: u64 = 21; + +/// Seed Audio 1.0 via Artcraft is flat priced, so the cost state carries no +/// request fields. +#[derive(Clone, Debug)] +pub struct ArtcraftSeedAudio1p0CostState; + +impl ArtcraftSeedAudio1p0CostState { + pub fn from_request(_request: &ArtcraftSeedAudio1p0RequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + AudioGenerationCostEstimate { + cost_in_credits: Some(COST_IN_USD_CENTS), + cost_in_usd_cents: Some(COST_IN_USD_CENTS), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + + use super::*; + + #[test] + fn flat_cost_is_twenty_one_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SeedAudio1p0, + provider: RouterProvider::Artcraft, + prompt: Some("a suspense radio drama".to_string()), + ..Default::default() + }; + let estimate = builder.build2().unwrap().estimate_cost().unwrap(); + assert_eq!(estimate.cost_in_usd_cents, Some(21)); + assert_eq!(estimate.cost_in_credits, Some(21)); + // Direct cost-state path matches the builder path. + assert_eq!(ArtcraftSeedAudio1p0CostState.estimate_cost().cost_in_usd_cents, Some(21)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/request.rs new file mode 100644 index 0000000000..e565cae751 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/seed_audio_1p0/request.rs @@ -0,0 +1,78 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; +use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedAudio1p0RequestState { + pub request: OmniGenAudioCostAndGenerateRequest, +} + +impl ArtcraftSeedAudio1p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_audio_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_speech_drama() { + let response = run_pipeline(GenerateAudioRequestBuilder { + model: RouterAudioModel::SeedAudio1p0, + provider: RouterProvider::Artcraft, + prompt: Some("A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateAudioResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/build.rs new file mode 100644 index 0000000000..5ad7a3e910 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request; +use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState; + +pub fn build_artcraft_suno_music(builder: GenerateAudioRequestBuilder) -> Result { + let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoMusic)?; + let state = ArtcraftSunoMusicRequestState { request }; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoMusic(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/cost.rs new file mode 100644 index 0000000000..780caf9ed5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/cost.rs @@ -0,0 +1,51 @@ +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState; + +const COST_IN_USD_CENTS: u64 = 8; + +/// Suno Music via Artcraft is flat priced, so the cost state carries no +/// request fields. +#[derive(Clone, Debug)] +pub struct ArtcraftSunoMusicCostState; + +impl ArtcraftSunoMusicCostState { + pub fn from_request(_request: &ArtcraftSunoMusicRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + AudioGenerationCostEstimate { + cost_in_credits: Some(COST_IN_USD_CENTS), + cost_in_usd_cents: Some(COST_IN_USD_CENTS), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + + use super::*; + + #[test] + fn flat_cost_is_eight_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::Artcraft, + prompt: Some("a song".to_string()), + ..Default::default() + }; + let estimate = builder.build2().unwrap().estimate_cost().unwrap(); + assert_eq!(estimate.cost_in_usd_cents, Some(8)); + assert_eq!(estimate.cost_in_credits, Some(8)); + // Direct cost-state path matches the builder path. + assert_eq!(ArtcraftSunoMusicCostState.estimate_cost().cost_in_usd_cents, Some(8)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/request.rs new file mode 100644 index 0000000000..c47826d940 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_music/request.rs @@ -0,0 +1,79 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; +use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSunoMusicRequestState { + pub request: OmniGenAudioCostAndGenerateRequest, +} + +impl ArtcraftSunoMusicRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_audio_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_music() { + let response = run_pipeline(GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::Artcraft, + prompt: Some("A song about a corgi who learns to sail the open sea".to_string()), + style_prompt: Some("Sea shanty, folk".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateAudioResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/build.rs new file mode 100644 index 0000000000..9baa718cb1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request; +use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState; + +pub fn build_artcraft_suno_remix(builder: GenerateAudioRequestBuilder) -> Result { + let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoRemix)?; + let state = ArtcraftSunoRemixRequestState { request }; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoRemix(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/cost.rs new file mode 100644 index 0000000000..605dd89891 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/cost.rs @@ -0,0 +1,57 @@ +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState; + +const COST_IN_USD_CENTS: u64 = 8; + +/// Suno Remix via Artcraft is flat priced, so the cost state carries no +/// request fields. +#[derive(Clone, Debug)] +pub struct ArtcraftSunoRemixCostState; + +impl ArtcraftSunoRemixCostState { + pub fn from_request(_request: &ArtcraftSunoRemixRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + AudioGenerationCostEstimate { + cost_in_credits: Some(COST_IN_USD_CENTS), + cost_in_usd_cents: Some(COST_IN_USD_CENTS), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + + use super::*; + + #[test] + fn flat_cost_is_eight_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoRemix, + provider: RouterProvider::Artcraft, + prompt: Some("make this electronic".to_string()), + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..Default::default() + }; + let estimate = builder.build2().unwrap().estimate_cost().unwrap(); + assert_eq!(estimate.cost_in_usd_cents, Some(8)); + assert_eq!(estimate.cost_in_credits, Some(8)); + // Direct cost-state path matches the builder path. + assert_eq!(ArtcraftSunoRemixCostState.estimate_cost().cost_in_usd_cents, Some(8)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/request.rs new file mode 100644 index 0000000000..8d27c7dfa3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_remix/request.rs @@ -0,0 +1,88 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; +use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSunoRemixRequestState { + pub request: OmniGenAudioCostAndGenerateRequest, +} + +impl ArtcraftSunoRemixRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_audio_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + /// Replace with a real uploaded audio media token before running. + /// (test_data doesn't have a production audio media token yet.) + const AUDIO_MEDIA_TOKEN: &str = "m_replace_with_uploaded_audio_token"; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn remix_uploaded_audio() { + let response = run_pipeline(GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoRemix, + provider: RouterProvider::Artcraft, + prompt: Some("Make this electronic".to_string()), + style_prompt: Some("EDM style".to_string()), + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new(AUDIO_MEDIA_TOKEN.to_string()), + ])), + ..Default::default() + }).await; + assert!(matches!(response, GenerateAudioResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/build.rs new file mode 100644 index 0000000000..6e1f0c2882 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request; +use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState; + +pub fn build_artcraft_suno_sample(builder: GenerateAudioRequestBuilder) -> Result { + let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoSample)?; + let state = ArtcraftSunoSampleRequestState { request }; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSample(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/cost.rs new file mode 100644 index 0000000000..e68c3c686d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/cost.rs @@ -0,0 +1,57 @@ +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState; + +const COST_IN_USD_CENTS: u64 = 8; + +/// Suno Sample via Artcraft is flat priced, so the cost state carries no +/// request fields. +#[derive(Clone, Debug)] +pub struct ArtcraftSunoSampleCostState; + +impl ArtcraftSunoSampleCostState { + pub fn from_request(_request: &ArtcraftSunoSampleRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + AudioGenerationCostEstimate { + cost_in_credits: Some(COST_IN_USD_CENTS), + cost_in_usd_cents: Some(COST_IN_USD_CENTS), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + + use super::*; + + #[test] + fn flat_cost_is_eight_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSample, + provider: RouterProvider::Artcraft, + prompt: Some("mystical RPG adventure".to_string()), + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..Default::default() + }; + let estimate = builder.build2().unwrap().estimate_cost().unwrap(); + assert_eq!(estimate.cost_in_usd_cents, Some(8)); + assert_eq!(estimate.cost_in_credits, Some(8)); + // Direct cost-state path matches the builder path. + assert_eq!(ArtcraftSunoSampleCostState.estimate_cost().cost_in_usd_cents, Some(8)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/request.rs new file mode 100644 index 0000000000..6dd065c913 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sample/request.rs @@ -0,0 +1,89 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; +use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSunoSampleRequestState { + pub request: OmniGenAudioCostAndGenerateRequest, +} + +impl ArtcraftSunoSampleRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_audio_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + /// Replace with a real uploaded audio media token before running. + /// (test_data doesn't have a production audio media token yet.) + const AUDIO_MEDIA_TOKEN: &str = "m_replace_with_uploaded_audio_token"; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn sample_uploaded_audio() { + let response = run_pipeline(GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSample, + provider: RouterProvider::Artcraft, + prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()), + style_prompt: Some("Fantasy video game score".to_string()), + is_instrumental: Some(true), + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new(AUDIO_MEDIA_TOKEN.to_string()), + ])), + ..Default::default() + }).await; + assert!(matches!(response, GenerateAudioResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/build.rs new file mode 100644 index 0000000000..91822e7794 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request; +use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState; + +pub fn build_artcraft_suno_sounds(builder: GenerateAudioRequestBuilder) -> Result { + let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoSounds)?; + let state = ArtcraftSunoSoundsRequestState { request }; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSounds(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/cost.rs new file mode 100644 index 0000000000..17690fef24 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/cost.rs @@ -0,0 +1,51 @@ +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState; + +const COST_IN_USD_CENTS: u64 = 3; + +/// Suno Sounds via Artcraft is flat priced, so the cost state carries no +/// request fields. +#[derive(Clone, Debug)] +pub struct ArtcraftSunoSoundsCostState; + +impl ArtcraftSunoSoundsCostState { + pub fn from_request(_request: &ArtcraftSunoSoundsRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + AudioGenerationCostEstimate { + cost_in_credits: Some(COST_IN_USD_CENTS), + cost_in_usd_cents: Some(COST_IN_USD_CENTS), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + + use super::*; + + #[test] + fn flat_cost_is_three_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSounds, + provider: RouterProvider::Artcraft, + prompt: Some("rain sound effects".to_string()), + ..Default::default() + }; + let estimate = builder.build2().unwrap().estimate_cost().unwrap(); + assert_eq!(estimate.cost_in_usd_cents, Some(3)); + assert_eq!(estimate.cost_in_credits, Some(3)); + // Direct cost-state path matches the builder path. + assert_eq!(ArtcraftSunoSoundsCostState.estimate_cost().cost_in_usd_cents, Some(3)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/request.rs new file mode 100644 index 0000000000..c7010e763d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/artcraft/suno_sounds/request.rs @@ -0,0 +1,82 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; +use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSunoSoundsRequestState { + pub request: OmniGenAudioCostAndGenerateRequest, +} + +impl ArtcraftSunoSoundsRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_audio_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use enums::common::generation::common_musical_key::CommonMusicalKey; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn loopable_sound_effect() { + let response = run_pipeline(GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSounds, + provider: RouterProvider::Artcraft, + prompt: Some("Lo-fi vinyl crackle groove".to_string()), + is_loopable: Some(true), + bpm: Some(85), + musical_key: Some(CommonMusicalKey::AMinor), + ..Default::default() + }).await; + assert!(matches!(response, GenerateAudioResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/mod.rs new file mode 100644 index 0000000000..b93956a4ca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/mod.rs @@ -0,0 +1 @@ +pub mod seed_audio_1p0; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/build.rs new file mode 100644 index 0000000000..66417f9d5f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/build.rs @@ -0,0 +1,588 @@ +use fal_client::requests::api::audio::omni::seed_audio_1p0::api::{ + SeedAudio1p0OutputFormat, SeedAudio1p0Request, SeedAudio1p0SampleRate, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState; +use crate::generate::generate_audio::providers::reject_unsupported::reject_unsupported_option; + +const MAX_AUDIO_REFERENCES: usize = 3; +const MIN_SPEED: f64 = 0.5; +const MAX_SPEED: f64 = 2.0; +const MIN_VOLUME: f64 = 0.5; +const MAX_VOLUME: f64 = 2.0; +const MIN_PITCH: i8 = -12; +const MAX_PITCH: i8 = 12; + +pub fn build_fal_seed_audio_1p0(builder: GenerateAudioRequestBuilder) -> Result { + let state = build_fal_seed_audio_1p0_state(builder)?; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::FalSeedAudio1p0(state))) +} + +pub(crate) fn build_fal_seed_audio_1p0_state( + mut builder: GenerateAudioRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options Seed Audio has no equivalent for. + reject_unsupported_option("style_prompt", builder.style_prompt.as_ref(), strategy)?; + reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?; + reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?; + reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?; + reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?; + reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput("A prompt is required for Seed Audio 1.0".to_string()) + })?; + + let audio_urls = plan_audio_urls(builder.audio_references.take(), strategy)?; + let image_url = plan_image_url(builder.image_references.take(), strategy)?; + + // fal rejects requests that combine an image reference with audio + // references, so the router always rejects the combination too. + if audio_urls.is_some() && image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_references", + value: "Seed Audio 1.0 cannot combine an image reference with audio references".to_string(), + })); + } + + let request = SeedAudio1p0Request { + prompt, + // The omni audio API doesn't expose voice selection yet, so the model + // always picks the voice. + voice: None, + audio_urls, + image_url, + output_format: Some(SeedAudio1p0OutputFormat::Mp3), + sample_rate: plan_sample_rate(builder.sample_rate_hz, strategy)?, + speed: plan_multiplier("speed", builder.speed, MIN_SPEED, MAX_SPEED, strategy)?, + volume: plan_multiplier("volume", builder.volume, MIN_VOLUME, MAX_VOLUME, strategy)?, + pitch: plan_pitch(builder.pitch, strategy)?, + }; + + Ok(FalSeedAudio1p0RequestState { request }) +} + +// ── Plan helpers ── + +/// Seed Audio takes up to 3 reference audio URLs. Fal only accepts URLs (not +/// media tokens). More than 3 references reject under `ErrorOut`; the other +/// strategies keep the first 3. +fn plan_audio_urls( + audio_references: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result>, ArtcraftRouterError> { + let mut urls = match audio_references { + None => return Ok(None), + Some(AudioListRef::Urls(urls)) => urls, + Some(AudioListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(AudioListRef::MediaFileTokens(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + }; + if urls.is_empty() { + return Ok(None); + } + if urls.len() > MAX_AUDIO_REFERENCES { + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "audio_references", + value: format!( + "Seed Audio 1.0 supports at most {} audio references, got {}", + MAX_AUDIO_REFERENCES, + urls.len(), + ), + })); + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + urls.truncate(MAX_AUDIO_REFERENCES); + } + } + } + Ok(Some(urls)) +} + +/// Seed Audio takes a single reference image URL. Fal only accepts URLs (not +/// media tokens). More than 1 reference rejects under `ErrorOut`; the other +/// strategies keep the first. +fn plan_image_url( + image_references: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let urls = match image_references { + None => return Ok(None), + Some(ImageListRef::Urls(urls)) => urls, + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(ImageListRef::MediaFileTokens(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + }; + if urls.is_empty() { + return Ok(None); + } + if urls.len() > 1 { + if let RequestMismatchMitigationStrategy::ErrorOut = strategy { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_references", + value: format!("Seed Audio 1.0 supports at most 1 image reference, got {}", urls.len()), + })); + } + } + Ok(urls.into_iter().next()) +} + +/// Map a requested sample rate to Seed Audio's supported rates. An exact +/// match is required under `ErrorOut`; the other strategies pick the nearest +/// supported rate (ties resolve to the lower rate). +fn plan_sample_rate( + sample_rate_hz: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const SUPPORTED: [(u32, SeedAudio1p0SampleRate); 6] = [ + (8_000, SeedAudio1p0SampleRate::Hz8000), + (16_000, SeedAudio1p0SampleRate::Hz16000), + (24_000, SeedAudio1p0SampleRate::Hz24000), + (32_000, SeedAudio1p0SampleRate::Hz32000), + (44_100, SeedAudio1p0SampleRate::Hz44100), + (48_000, SeedAudio1p0SampleRate::Hz48000), + ]; + + let Some(requested) = sample_rate_hz else { + return Ok(None); + }; + + if let Some((_, rate)) = SUPPORTED.iter().find(|(hz, _)| *hz == requested) { + return Ok(Some(*rate)); + } + + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "sample_rate_hz", + value: format!("{}", requested), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + let (_, nearest) = SUPPORTED.iter() + .min_by_key(|(hz, _)| requested.abs_diff(*hz)) + .expect("SUPPORTED is non-empty"); + Ok(Some(*nearest)) + } + } +} + +/// Clamp a 0.5–2.0 multiplier (speed / volume) per strategy: out-of-range +/// values reject under `ErrorOut` and clamp otherwise. +fn plan_multiplier( + field: &'static str, + value: Option, + min: f64, + max: f64, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let Some(value) = value else { + return Ok(None); + }; + if value >= min && value <= max { + return Ok(Some(value)); + } + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: format!("{}", value), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(value.clamp(min, max))), + } +} + +/// Pitch is a semitone shift: round to the nearest whole semitone, then +/// reject (under `ErrorOut`) or clamp values outside ±12. +fn plan_pitch( + pitch: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let Some(pitch) = pitch else { + return Ok(None); + }; + let rounded = pitch.round(); + if rounded >= MIN_PITCH as f64 && rounded <= MAX_PITCH as f64 { + return Ok(Some(rounded as i8)); + } + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "pitch", + value: format!("{}", pitch), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(rounded.clamp(MIN_PITCH as f64, MAX_PITCH as f64) as i8)) + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + const AUDIO_URL: &str = "https://example.com/a.mp3"; + const IMAGE_URL: &str = "https://example.com/scene.png"; + + mod basics { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build"); + assert_eq!(state.request.prompt, "a suspense radio drama"); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() }; + assert!(build_fal_seed_audio_1p0_state(builder).is_err()); + } + + #[test] + fn voice_is_always_none() { + // The omni audio API doesn't expose voice selection. + let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build"); + assert!(state.request.voice.is_none()); + } + + #[test] + fn output_format_is_mp3() { + let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build"); + assert_eq!(state.request.output_format, Some(SeedAudio1p0OutputFormat::Mp3)); + } + } + + mod reference_tests { + use super::*; + + #[test] + fn audio_urls_pass_through() { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec![AUDIO_URL.to_string()])), + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.audio_urls, Some(vec![AUDIO_URL.to_string()])); + } + + #[test] + fn three_audio_urls_are_accepted() { + let builder = builder_with_audio_urls(3); + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.audio_urls.as_ref().map(|u| u.len()), Some(3)); + } + + #[test] + fn four_audio_urls_error_out() { + let mut builder = builder_with_audio_urls(4); + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_seed_audio_1p0_state(builder).is_err()); + } + + #[test] + fn four_audio_urls_truncate_under_lenient_strategies() { + let mut builder = builder_with_audio_urls(4); + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.audio_urls.as_ref().map(|u| u.len()), Some(3)); + } + + #[test] + fn audio_media_tokens_are_rejected() { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_seed_audio_1p0_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn image_url_passes_through() { + let builder = GenerateAudioRequestBuilder { + image_references: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.image_url.as_deref(), Some(IMAGE_URL)); + } + + #[test] + fn two_image_urls_error_out() { + let builder = GenerateAudioRequestBuilder { + image_references: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_seed_audio_1p0_state(builder).is_err()); + } + + #[test] + fn combining_image_and_audio_references_is_always_rejected() { + // fal rejects the combination, so the router rejects it under every + // mitigation strategy. + for strategy in [ + RequestMismatchMitigationStrategy::ErrorOut, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec![AUDIO_URL.to_string()])), + image_references: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + request_mismatch_mitigation_strategy: strategy, + ..base_builder() + }; + assert!( + build_fal_seed_audio_1p0_state(builder).is_err(), + "expected rejection under {strategy:?}", + ); + } + } + } + + mod sample_rate_tests { + use super::*; + + #[test] + fn exact_rates_map_directly() { + let cases = [ + (8_000, SeedAudio1p0SampleRate::Hz8000), + (16_000, SeedAudio1p0SampleRate::Hz16000), + (24_000, SeedAudio1p0SampleRate::Hz24000), + (32_000, SeedAudio1p0SampleRate::Hz32000), + (44_100, SeedAudio1p0SampleRate::Hz44100), + (48_000, SeedAudio1p0SampleRate::Hz48000), + ]; + for (hz, expected) in cases { + let builder = GenerateAudioRequestBuilder { + sample_rate_hz: Some(hz), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.sample_rate, Some(expected), "for {hz} Hz"); + } + } + + #[test] + fn inexact_rate_errors_out_under_error_out() { + let builder = GenerateAudioRequestBuilder { + sample_rate_hz: Some(22_050), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_seed_audio_1p0_state(builder).is_err()); + } + + #[test] + fn inexact_rate_maps_to_nearest_under_lenient_strategies() { + let cases = [ + (7_000, SeedAudio1p0SampleRate::Hz8000), + (22_050, SeedAudio1p0SampleRate::Hz24000), + (40_000, SeedAudio1p0SampleRate::Hz44100), + (96_000, SeedAudio1p0SampleRate::Hz48000), + ]; + for (hz, expected) in cases { + let builder = GenerateAudioRequestBuilder { + sample_rate_hz: Some(hz), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.sample_rate, Some(expected), "for {hz} Hz"); + } + } + + #[test] + fn no_rate_stays_none() { + let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build"); + assert!(state.request.sample_rate.is_none()); + } + } + + mod speed_and_volume_tests { + use super::*; + + #[test] + fn in_range_values_pass_through() { + let builder = GenerateAudioRequestBuilder { + speed: Some(1.5), + volume: Some(0.5), + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.speed, Some(1.5)); + assert_eq!(state.request.volume, Some(0.5)); + } + + #[test] + fn out_of_range_speed_errors_out() { + let builder = GenerateAudioRequestBuilder { + speed: Some(3.0), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_seed_audio_1p0_state(builder).is_err()); + } + + #[test] + fn out_of_range_speed_clamps_under_lenient_strategies() { + let builder = GenerateAudioRequestBuilder { + speed: Some(3.0), + volume: Some(0.1), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.speed, Some(2.0)); + assert_eq!(state.request.volume, Some(0.5)); + } + } + + mod pitch_tests { + use super::*; + + #[test] + fn pitch_rounds_to_nearest_semitone() { + let cases = [ + (2.4, 2i8), + (2.5, 3i8), + (-2.4, -2i8), + (-2.5, -3i8), + (0.0, 0i8), + ]; + for (input, expected) in cases { + let builder = GenerateAudioRequestBuilder { + pitch: Some(input), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.pitch, Some(expected), "for pitch {input}"); + } + } + + #[test] + fn out_of_range_pitch_errors_out() { + for pitch in [13.0, -13.0] { + let builder = GenerateAudioRequestBuilder { + pitch: Some(pitch), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_seed_audio_1p0_state(builder).is_err(), "for pitch {pitch}"); + } + } + + #[test] + fn out_of_range_pitch_clamps_under_lenient_strategies() { + let cases = [(20.0, 12i8), (-20.0, -12i8)]; + for (input, expected) in cases { + let builder = GenerateAudioRequestBuilder { + pitch: Some(input), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.pitch, Some(expected), "for pitch {input}"); + } + } + + #[test] + fn boundary_pitch_is_accepted() { + for (input, expected) in [(12.0, 12i8), (-12.0, -12i8)] { + let builder = GenerateAudioRequestBuilder { + pitch: Some(input), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + let state = build_fal_seed_audio_1p0_state(builder).expect("build"); + assert_eq!(state.request.pitch, Some(expected)); + } + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out() { + use enums::common::generation::common_musical_key::CommonMusicalKey; + let cases: Vec = vec![ + GenerateAudioRequestBuilder { style_prompt: Some("EDM".to_string()), ..base_builder() }, + GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() }, + GenerateAudioRequestBuilder { musical_key: Some(CommonMusicalKey::CMajor), ..base_builder() }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_seed_audio_1p0_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateAudioRequestBuilder { + style_prompt: Some("EDM".to_string()), + keep_lyrics: Some(true), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert!(build_fal_seed_audio_1p0_state(builder).is_ok()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SeedAudio1p0, + provider: RouterProvider::Fal, + prompt: Some("a suspense radio drama".to_string()), + ..Default::default() + } + } + + fn builder_with_audio_urls(count: usize) -> GenerateAudioRequestBuilder { + let urls = (0..count).map(|i| format!("https://example.com/a{i}.mp3")).collect(); + GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(urls)), + ..base_builder() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/cost.rs new file mode 100644 index 0000000000..08e41c79d8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/cost.rs @@ -0,0 +1,78 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState; + +#[derive(Clone, Debug)] +pub struct FalSeedAudio1p0CostState { + pub cost_in_usd_cents: u64, +} + +impl FalSeedAudio1p0CostState { + pub fn from_request(request: &FalSeedAudio1p0RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + AudioGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::providers::fal::seed_audio_1p0::build::build_fal_seed_audio_1p0_state; + + use super::*; + + #[test] + fn estimate_is_nineteen_cents() { + let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build"); + let estimate = FalSeedAudio1p0CostState::from_request(&state).estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(19)); + assert_eq!(estimate.cost_in_credits, Some(19)); + } + + #[test] + fn options_do_not_change_the_estimate() { + let tuned = GenerateAudioRequestBuilder { + sample_rate_hz: Some(48_000), + speed: Some(2.0), + volume: Some(0.5), + pitch: Some(-12.0), + ..base_builder() + }; + let base_state = build_fal_seed_audio_1p0_state(base_builder()).expect("build"); + let tuned_state = build_fal_seed_audio_1p0_state(tuned).expect("build"); + assert_eq!( + FalSeedAudio1p0CostState::from_request(&base_state).cost_in_usd_cents, + FalSeedAudio1p0CostState::from_request(&tuned_state).cost_in_usd_cents, + ); + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SeedAudio1p0, + provider: RouterProvider::Fal, + prompt: Some("a calm narrator describes ocean waves".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/request.rs new file mode 100644 index 0000000000..4dac750e19 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/fal/seed_audio_1p0/request.rs @@ -0,0 +1,118 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::audio::omni::seed_audio_1p0::api::SeedAudio1p0Request; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::generate_audio_response::{ + FalAudioResponsePayload, GenerateAudioResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalSeedAudio1p0RequestState { + /// Final materialized request; ready to fire. + pub request: SeedAudio1p0Request, +} + +impl FalSeedAudio1p0RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let outbound: Arc = Arc::new(self.request.clone()); + + let payload = if let Some(webhook_url) = &client.webhook_url { + let response = self.request.send_webhook_request(&client.api_key, webhook_url).await?; + FalAudioResponsePayload { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + } + } else { + let response = self.request.send_queue_request(&client.api_key).await?; + FalAudioResponsePayload { + request_id: Some(response.request_id), + gateway_request_id: None, + maybe_status_url: Some(response.status_url), + maybe_response_url: Some(response.response_url), + maybe_outbound_request: Some(outbound), + } + }; + + Ok(GenerateAudioResponse::Fal(payload)) + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_seed_audio_webhook() { + let response = run_pipeline(GenerateAudioRequestBuilder { + prompt: Some("A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string()), + ..seed_audio_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_seed_audio_tuned() { + let response = run_pipeline(GenerateAudioRequestBuilder { + prompt: Some("Welcome aboard the midnight express. Please keep your arms inside the train.".to_string()), + sample_rate_hz: Some(44_100), + speed: Some(0.9), + volume: Some(1.2), + pitch: Some(-2.0), + ..seed_audio_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn seed_audio_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SeedAudio1p0, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Seed Audio skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/mod.rs new file mode 100644 index 0000000000..4e3a927841 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/mod.rs @@ -0,0 +1,5 @@ +pub mod suno_music; +pub mod suno_remix; +pub mod suno_sample; +pub mod suno_sounds; +pub(crate) mod resolve; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/resolve.rs new file mode 100644 index 0000000000..c3ee460c32 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/resolve.rs @@ -0,0 +1,133 @@ +//! Shared resolve helpers for the Kinovi audio provider. +//! +//! The remix/sample models take exactly one user audio reference, which must +//! be downloaded from our CDN and re-uploaded to the Kinovi CDN during the +//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi +//! video provider (`generate_video::providers::kinovi::upload`). + +use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::audio_list_ref::AudioListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext; +use crate::generate::generate_video::providers::kinovi::upload::upload_to_kinovi_web; + +/// A single unresolved audio reference: either an ArtCraft/public URL or a +/// media file token that resolves to one via the draft context's map. +#[derive(Debug, Clone)] +pub enum SingleAudioRef { + Url(String), + MediaFileToken(MediaFileToken), +} + +/// Extract exactly one audio reference from the builder's audio list. +/// The Kinovi remix/sample models operate on a single source track, so zero +/// or multiple references are rejected regardless of mitigation strategy. +pub(crate) fn require_single_audio_ref( + audio_references: Option, +) -> Result { + let refs = match audio_references { + None => { + return Err(ArtcraftRouterError::InvalidInput( + "Exactly one audio reference is required, but none was provided".to_string(), + )); + } + Some(AudioListRef::Urls(urls)) => urls.into_iter().map(SingleAudioRef::Url).collect::>(), + Some(AudioListRef::MediaFileTokens(tokens)) => { + tokens.into_iter().map(SingleAudioRef::MediaFileToken).collect::>() + } + }; + + match refs.len() { + 0 => Err(ArtcraftRouterError::InvalidInput( + "Exactly one audio reference is required, but none was provided".to_string(), + )), + 1 => Ok(refs.into_iter().next().expect("length checked above")), + count => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "audio_references", + value: format!("Exactly one audio reference is required, got {}", count), + })), + } +} + +/// Resolve a single audio reference to a source URL and re-upload it to the +/// Kinovi CDN, returning the Kinovi CDN URL. +pub(crate) async fn resolve_and_upload_audio_ref( + session: &KinoviWebSession, + audio_ref: &SingleAudioRef, + draft_context: &AudioGenerationDraftContext<'_>, +) -> Result { + let source_url = match audio_ref { + SingleAudioRef::Url(url) => url.clone(), + SingleAudioRef::MediaFileToken(token) => { + let map = draft_context.media_file_to_artcraft_url_map + .ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + })? + } + }; + upload_to_kinovi_web(session, &source_url, None).await +} + +#[cfg(test)] +mod tests { + use super::*; + + mod require_single_audio_ref_tests { + use super::*; + + #[test] + fn none_is_rejected() { + assert!(require_single_audio_ref(None).is_err()); + } + + #[test] + fn empty_url_list_is_rejected() { + assert!(require_single_audio_ref(Some(AudioListRef::Urls(vec![]))).is_err()); + } + + #[test] + fn empty_token_list_is_rejected() { + assert!(require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![]))).is_err()); + } + + #[test] + fn single_url_is_accepted() { + let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![ + "https://example.com/a.mp3".to_string(), + ]))).expect("should accept a single URL"); + assert!(matches!(result, SingleAudioRef::Url(url) if url == "https://example.com/a.mp3")); + } + + #[test] + fn single_token_is_accepted() { + let result = require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ]))).expect("should accept a single token"); + assert!(matches!(result, SingleAudioRef::MediaFileToken(t) if t.as_str() == "mf_test123")); + } + + #[test] + fn two_urls_are_rejected() { + let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![ + "https://example.com/a.mp3".to_string(), + "https://example.com/b.mp3".to_string(), + ]))); + assert!(result.is_err()); + } + + #[test] + fn two_tokens_are_rejected() { + let result = require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ]))); + assert!(result.is_err()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/build.rs new file mode 100644 index 0000000000..827d4ebf03 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/build.rs @@ -0,0 +1,128 @@ +use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState; +use crate::generate::generate_audio::providers::reject_unsupported::{ + reject_unsupported_audio_references, reject_unsupported_image_references, + reject_unsupported_option, +}; + +pub fn build_kinovi_suno_music(builder: GenerateAudioRequestBuilder) -> Result { + let state = build_kinovi_suno_music_state(builder)?; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(state))) +} + +pub(crate) fn build_kinovi_suno_music_state( + mut builder: GenerateAudioRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options Suno Music has no equivalent for. + reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?; + reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?; + reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?; + reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?; + reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?; + reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?; + reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?; + reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?; + reject_unsupported_audio_references(builder.audio_references.as_ref(), strategy)?; + reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput("A prompt is required for Suno Music".to_string()) + })?; + + let request = GenerateSunoMusicRequest { + prompt, + style_tags: builder.style_prompt.take(), + instrumental: builder.is_instrumental.unwrap_or(false), + }; + + Ok(KinoviSunoMusicRequestState { request }) +} + +#[cfg(test)] +mod tests { + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + #[test] + fn prompt_and_style_are_passed_through() { + let state = build_kinovi_suno_music_state(base_builder()).expect("build"); + assert_eq!(state.request.prompt, "a song about corgis"); + assert_eq!(state.request.style_tags.as_deref(), Some("sea shanty")); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() }; + assert!(build_kinovi_suno_music_state(builder).is_err()); + } + + #[test] + fn instrumental_defaults_to_false() { + let state = build_kinovi_suno_music_state(base_builder()).expect("build"); + assert!(!state.request.instrumental); + } + + #[test] + fn instrumental_passes_through() { + let builder = GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() }; + let state = build_kinovi_suno_music_state(builder).expect("build"); + assert!(state.request.instrumental); + } + + #[test] + fn unsupported_options_error_out() { + let error_out = RequestMismatchMitigationStrategy::ErrorOut; + let cases: Vec = vec![ + GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() }, + GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() }, + GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() }, + GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() }, + GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() }, + GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])), + ..base_builder() + }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = error_out; + assert!(build_kinovi_suno_music_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateAudioRequestBuilder { + keep_lyrics: Some(true), + bpm: Some(120), + speed: Some(1.5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert!(build_kinovi_suno_music_state(builder).is_ok()); + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::KinoviWeb, + prompt: Some("a song about corgis".to_string()), + style_prompt: Some("sea shanty".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/cost.rs new file mode 100644 index 0000000000..53cf0dff8a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/cost.rs @@ -0,0 +1,65 @@ +use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest; + +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState; + +/// Suno Music is flat priced, so the cost state carries no request fields. +#[derive(Clone, Debug)] +pub struct KinoviSunoMusicCostState; + +impl KinoviSunoMusicCostState { + pub fn from_request(_request: &KinoviSunoMusicRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + // Cost math is owned by kinovi_web_client's binding — the router just + // forwards the result so router cost ≡ binding cost by construction. + let pricing_request = GenerateSunoMusicRequest { + prompt: String::new(), + style_tags: None, + instrumental: false, + }; + let costs = pricing_request.calculate_costs(); + + AudioGenerationCostEstimate { + cost_in_credits: Some(costs.kinovi_credits), + cost_in_usd_cents: Some(costs.usd_cents_rounded_up), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::providers::kinovi::suno_music::build::build_kinovi_suno_music_state; + + use super::*; + + #[test] + fn flat_cost_is_seven_cents() { + let estimate = KinoviSunoMusicCostState.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(7)); + assert_eq!(estimate.cost_in_credits, Some(16)); + } + + #[test] + fn cost_from_built_request_is_seven_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::KinoviWeb, + prompt: Some("a song".to_string()), + ..Default::default() + }; + let state = build_kinovi_suno_music_state(builder).expect("build"); + let estimate = KinoviSunoMusicCostState::from_request(&state).estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(7)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/request.rs new file mode 100644 index 0000000000..9c01435cf2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_music/request.rs @@ -0,0 +1,113 @@ +use kinovi_web_client::generate::audio::generate_suno_music::{ + generate_suno_music, GenerateSunoMusicArgs, GenerateSunoMusicRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_audio::generate_audio_response::{ + GenerateAudioResponse, KinoviWebAudioResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviSunoMusicRequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSunoMusicRequest, +} + +impl KinoviSunoMusicRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSunoMusicArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_suno_music(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + })) + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn music_with_vocals() { + let response = run_pipeline(GenerateAudioRequestBuilder { + prompt: Some("A song about a corgi who learns to sail the open sea".to_string()), + style_prompt: Some("Sea shanty, folk".to_string()), + ..suno_music_builder() + }).await; + assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn music_instrumental() { + let response = run_pipeline(GenerateAudioRequestBuilder { + prompt: Some("An epic journey across a frozen mountain range".to_string()), + style_prompt: Some("Cinematic orchestral score".to_string()), + is_instrumental: Some(true), + ..suno_music_builder() + }).await; + assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn suno_music_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoMusic, + provider: RouterProvider::KinoviWeb, + ..Default::default() + } + } + + fn get_kinovi_web_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_kinovi_web_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Suno Music skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::KinoviWeb(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/build.rs new file mode 100644 index 0000000000..9527b468c0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/build.rs @@ -0,0 +1,178 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::kinovi::resolve::require_single_audio_ref; +use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState; +use crate::generate::generate_audio::providers::reject_unsupported::{ + reject_unsupported_image_references, reject_unsupported_option, +}; + +pub fn build_kinovi_suno_remix(builder: GenerateAudioRequestBuilder) -> Result { + let draft = build_kinovi_suno_remix_draft(builder)?; + Ok(AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(draft))) +} + +pub(crate) fn build_kinovi_suno_remix_draft( + mut builder: GenerateAudioRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options Suno Remix has no equivalent for. + reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?; + reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?; + reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?; + reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?; + reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?; + reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?; + reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?; + reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?; + reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput("A prompt is required for Suno Remix".to_string()) + })?; + + let audio_source = require_single_audio_ref(builder.audio_references.take())?; + + Ok(KinoviSunoRemixDraftState { + prompt, + style_tags: builder.style_prompt.take(), + keep_lyrics: builder.keep_lyrics.unwrap_or(false), + audio_source: Some(audio_source), + }) +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_audio::providers::kinovi::resolve::SingleAudioRef; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_and_style_are_passed_through() { + let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build"); + assert_eq!(draft.prompt, "make this electronic"); + assert_eq!(draft.style_tags.as_deref(), Some("EDM style")); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() }; + assert!(build_kinovi_suno_remix_draft(builder).is_err()); + } + + #[test] + fn keep_lyrics_defaults_to_false() { + let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build"); + assert!(!draft.keep_lyrics); + } + + #[test] + fn keep_lyrics_passes_through() { + let builder = GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() }; + let draft = build_kinovi_suno_remix_draft(builder).expect("build"); + assert!(draft.keep_lyrics); + } + } + + mod audio_reference_requirements { + use super::*; + + #[test] + fn exactly_one_url_reference_is_accepted() { + let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build"); + assert!(matches!( + draft.audio_source, + Some(SingleAudioRef::Url(url)) if url == "https://example.com/a.mp3" + )); + } + + #[test] + fn a_media_token_reference_is_accepted() { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + let draft = build_kinovi_suno_remix_draft(builder).expect("build"); + assert!(matches!( + draft.audio_source, + Some(SingleAudioRef::MediaFileToken(t)) if t.as_str() == "mf_test123" + )); + } + + #[test] + fn zero_references_are_rejected() { + let builder = GenerateAudioRequestBuilder { audio_references: None, ..base_builder() }; + assert!(build_kinovi_suno_remix_draft(builder).is_err()); + } + + #[test] + fn two_references_are_rejected() { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec![ + "https://example.com/a.mp3".to_string(), + "https://example.com/b.mp3".to_string(), + ])), + ..base_builder() + }; + assert!(build_kinovi_suno_remix_draft(builder).is_err()); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() }, + GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() }, + GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() }, + GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() }, + GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_kinovi_suno_remix_draft(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateAudioRequestBuilder { + is_instrumental: Some(true), + bpm: Some(120), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert!(build_kinovi_suno_remix_draft(builder).is_ok()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoRemix, + provider: RouterProvider::KinoviWeb, + prompt: Some("make this electronic".to_string()), + style_prompt: Some("EDM style".to_string()), + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/cost.rs new file mode 100644 index 0000000000..e5b4ccccf9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/cost.rs @@ -0,0 +1,75 @@ +use kinovi_web_client::generate::audio::generate_suno_remix::{ + GenerateSunoRemixRequest, KinoviSunoRemixSource, +}; + +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState; +use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState; + +/// Suno Remix is flat priced, so the cost state carries no request fields. +#[derive(Clone, Debug)] +pub struct KinoviSunoRemixCostState; + +impl KinoviSunoRemixCostState { + pub fn from_request(_request: &KinoviSunoRemixRequestState) -> Self { + Self + } + + pub fn from_draft(_draft: &KinoviSunoRemixDraftState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + // Cost math is owned by kinovi_web_client's binding — the router just + // forwards the result so router cost ≡ binding cost by construction. + let pricing_request = GenerateSunoRemixRequest { + prompt: String::new(), + source: KinoviSunoRemixSource::AudioUrl("pricing-placeholder".to_string()), + style_tags: None, + keep_lyrics: false, + }; + let costs = pricing_request.calculate_costs(); + + AudioGenerationCostEstimate { + cost_in_credits: Some(costs.kinovi_credits), + cost_in_usd_cents: Some(costs.usd_cents_rounded_up), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::providers::kinovi::suno_remix::build::build_kinovi_suno_remix_draft; + + use super::*; + + #[test] + fn flat_cost_is_seven_cents() { + let estimate = KinoviSunoRemixCostState.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(7)); + assert_eq!(estimate.cost_in_credits, Some(16)); + } + + #[test] + fn cost_from_draft_is_seven_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoRemix, + provider: RouterProvider::KinoviWeb, + prompt: Some("make this electronic".to_string()), + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])), + ..Default::default() + }; + let draft = build_kinovi_suno_remix_draft(builder).expect("build"); + let estimate = KinoviSunoRemixCostState::from_draft(&draft).estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(7)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/draft.rs new file mode 100644 index 0000000000..a5dfeb7b2a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/draft.rs @@ -0,0 +1,49 @@ +use kinovi_web_client::generate::audio::generate_suno_remix::{ + GenerateSunoRemixRequest, KinoviSunoRemixSource, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext; +use crate::generate::generate_audio::providers::kinovi::resolve::{ + resolve_and_upload_audio_ref, SingleAudioRef, +}; +use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState; + +#[derive(Debug, Clone)] +pub struct KinoviSunoRemixDraftState { + // Materialized / finalized types + + pub prompt: String, + pub style_tags: Option, + pub keep_lyrics: bool, + + // Pending types that need to be resolved and uploaded to the Kinovi CDN. + pub audio_source: Option, +} + +impl KinoviSunoRemixDraftState { + pub async fn to_request( + &mut self, + draft_context: &AudioGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let audio_source = self.audio_source.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Suno Remix draft has no audio source (already finalized?)".to_string(), + ) + })?; + + let kinovi_audio_url = resolve_and_upload_audio_ref(session, &audio_source, draft_context).await?; + + let request = GenerateSunoRemixRequest { + prompt: self.prompt.clone(), + source: KinoviSunoRemixSource::AudioUrl(kinovi_audio_url), + style_tags: self.style_tags.clone(), + keep_lyrics: self.keep_lyrics, + }; + + Ok(KinoviSunoRemixRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/request.rs new file mode 100644 index 0000000000..09ba84d7e3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_remix/request.rs @@ -0,0 +1,134 @@ +use kinovi_web_client::generate::audio::generate_suno_remix::{ + generate_suno_remix, GenerateSunoRemixArgs, GenerateSunoRemixRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_audio::generate_audio_response::{ + GenerateAudioResponse, KinoviWebAudioResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviSunoRemixRequestState { + /// Final materialized request; ready to fire. The audio source has been + /// re-uploaded to the Kinovi CDN by the draft's `to_request()`. + pub request: GenerateSunoRemixRequest, +} + +impl KinoviSunoRemixRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSunoRemixArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_suno_remix(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + })) + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use test_utils::test_file_path::test_file_path; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + const TEST_AUDIO_PATH: &str = "test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3"; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file. + async fn remix_uploaded_audio() { + let client = get_kinovi_web_client(); + let audio_url = upload_test_audio(&client).await; + println!("Uploaded audio: {}", audio_url); + + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoRemix, + provider: RouterProvider::KinoviWeb, + prompt: Some("Make this electronic".to_string()), + style_prompt: Some("EDM style".to_string()), + audio_references: Some(AudioListRef::Urls(vec![audio_url])), + ..Default::default() + }; + + let response = run_pipeline(&client, builder).await; + assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_kinovi_web_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let draft_or_request = builder.build2().expect("build2 should succeed"); + let draft = match draft_or_request { + AudioGenerationDraftOrRequest::Draft(d) => d, + _ => panic!("expected Draft variant (Suno Remix uses the draft phase)"), + }; + + let draft_context = AudioGenerationDraftContext { + client: Some(client), + ..Default::default() + }; + + let request = draft.finalize(draft_context).await.expect("finalize should succeed"); + let response = request.send_request(client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::KinoviWeb(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } + + async fn upload_test_audio(client: &RouterClient) -> String { + let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session; + + let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist"); + let audio_bytes = std::fs::read(&audio_path).expect("read test audio"); + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "mp3".to_string(), + host_override: None, + }).await.expect("prepare upload"); + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: audio_bytes, + host_override: None, + }).await.expect("upload"); + + upload_result.public_url + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/build.rs new file mode 100644 index 0000000000..843da424b9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/build.rs @@ -0,0 +1,172 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::kinovi::resolve::require_single_audio_ref; +use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState; +use crate::generate::generate_audio::providers::reject_unsupported::{ + reject_unsupported_image_references, reject_unsupported_option, +}; + +/// The omni audio API doesn't carry a sample window yet, so every sample +/// generation chops the first 30 seconds of the source audio. +pub(crate) const DEFAULT_CHOP_SAMPLE_START_SECONDS: u32 = 0; +pub(crate) const DEFAULT_CHOP_SAMPLE_END_SECONDS: u32 = 30; + +pub fn build_kinovi_suno_sample(builder: GenerateAudioRequestBuilder) -> Result { + let draft = build_kinovi_suno_sample_draft(builder)?; + Ok(AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(draft))) +} + +pub(crate) fn build_kinovi_suno_sample_draft( + mut builder: GenerateAudioRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options Suno Sample has no equivalent for. + reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?; + reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?; + reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?; + reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?; + reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?; + reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?; + reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?; + reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?; + reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput("A prompt is required for Suno Sample".to_string()) + })?; + + let audio_source = require_single_audio_ref(builder.audio_references.take())?; + + Ok(KinoviSunoSampleDraftState { + prompt, + style_tags: builder.style_prompt.take(), + instrumental: builder.is_instrumental.unwrap_or(false), + chop_sample_start_seconds: DEFAULT_CHOP_SAMPLE_START_SECONDS, + chop_sample_end_seconds: DEFAULT_CHOP_SAMPLE_END_SECONDS, + audio_source: Some(audio_source), + }) +} + +#[cfg(test)] +mod tests { + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_audio::providers::kinovi::resolve::SingleAudioRef; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_and_style_are_passed_through() { + let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build"); + assert_eq!(draft.prompt, "mystical RPG adventure"); + assert_eq!(draft.style_tags.as_deref(), Some("fantasy video game")); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() }; + assert!(build_kinovi_suno_sample_draft(builder).is_err()); + } + + #[test] + fn instrumental_defaults_to_false() { + let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build"); + assert!(!draft.instrumental); + } + + #[test] + fn instrumental_passes_through() { + let builder = GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() }; + let draft = build_kinovi_suno_sample_draft(builder).expect("build"); + assert!(draft.instrumental); + } + + #[test] + fn chop_window_defaults_to_first_thirty_seconds() { + let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build"); + assert_eq!(draft.chop_sample_start_seconds, 0); + assert_eq!(draft.chop_sample_end_seconds, 30); + } + } + + mod audio_reference_requirements { + use super::*; + + #[test] + fn exactly_one_reference_is_accepted() { + let draft = build_kinovi_suno_sample_draft(base_builder()).expect("build"); + assert!(matches!(draft.audio_source, Some(SingleAudioRef::Url(_)))); + } + + #[test] + fn zero_references_are_rejected() { + let builder = GenerateAudioRequestBuilder { audio_references: None, ..base_builder() }; + assert!(build_kinovi_suno_sample_draft(builder).is_err()); + } + + #[test] + fn two_references_are_rejected() { + let builder = GenerateAudioRequestBuilder { + audio_references: Some(AudioListRef::Urls(vec![ + "https://example.com/a.mp3".to_string(), + "https://example.com/b.mp3".to_string(), + ])), + ..base_builder() + }; + assert!(build_kinovi_suno_sample_draft(builder).is_err()); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() }, + GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() }, + GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() }, + GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() }, + GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_kinovi_suno_sample_draft(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateAudioRequestBuilder { + keep_lyrics: Some(true), + speed: Some(1.5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert!(build_kinovi_suno_sample_draft(builder).is_ok()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSample, + provider: RouterProvider::KinoviWeb, + prompt: Some("mystical RPG adventure".to_string()), + style_prompt: Some("fantasy video game".to_string()), + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/cost.rs new file mode 100644 index 0000000000..85fa56ea61 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/cost.rs @@ -0,0 +1,75 @@ +use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest; + +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState; +use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState; + +/// Suno Sample is flat priced, so the cost state carries no request fields. +#[derive(Clone, Debug)] +pub struct KinoviSunoSampleCostState; + +impl KinoviSunoSampleCostState { + pub fn from_request(_request: &KinoviSunoSampleRequestState) -> Self { + Self + } + + pub fn from_draft(_draft: &KinoviSunoSampleDraftState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + // Cost math is owned by kinovi_web_client's binding — the router just + // forwards the result so router cost ≡ binding cost by construction. + let pricing_request = GenerateSunoSampleRequest { + prompt: String::new(), + audio_url: "pricing-placeholder".to_string(), + chop_sample_start_seconds: 0, + chop_sample_end_seconds: 30, + style_tags: None, + instrumental: false, + }; + let costs = pricing_request.calculate_costs(); + + AudioGenerationCostEstimate { + cost_in_credits: Some(costs.kinovi_credits), + cost_in_usd_cents: Some(costs.usd_cents_rounded_up), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::providers::kinovi::suno_sample::build::build_kinovi_suno_sample_draft; + + use super::*; + + #[test] + fn flat_cost_is_seven_cents() { + let estimate = KinoviSunoSampleCostState.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(7)); + assert_eq!(estimate.cost_in_credits, Some(16)); + } + + #[test] + fn cost_from_draft_is_seven_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSample, + provider: RouterProvider::KinoviWeb, + prompt: Some("mystical RPG adventure".to_string()), + audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])), + ..Default::default() + }; + let draft = build_kinovi_suno_sample_draft(builder).expect("build"); + let estimate = KinoviSunoSampleCostState::from_draft(&draft).estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(7)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/draft.rs new file mode 100644 index 0000000000..5cd3c0fac9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/draft.rs @@ -0,0 +1,59 @@ +use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext; +use crate::generate::generate_audio::providers::kinovi::resolve::{ + resolve_and_upload_audio_ref, SingleAudioRef, +}; +use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState; + +#[derive(Debug, Clone)] +pub struct KinoviSunoSampleDraftState { + // Materialized / finalized types + + pub prompt: String, + pub style_tags: Option, + pub instrumental: bool, + + /// Start of the sample window within the audio, in seconds. + /// NB: the omni audio API doesn't carry a sample window yet, so builds + /// default this to 0. + pub chop_sample_start_seconds: u32, + + /// End of the sample window within the audio, in seconds. + /// NB: the omni audio API doesn't carry a sample window yet, so builds + /// default this to 30. + pub chop_sample_end_seconds: u32, + + // Pending types that need to be resolved and uploaded to the Kinovi CDN. + pub audio_source: Option, +} + +impl KinoviSunoSampleDraftState { + pub async fn to_request( + &mut self, + draft_context: &AudioGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let audio_source = self.audio_source.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Suno Sample draft has no audio source (already finalized?)".to_string(), + ) + })?; + + let kinovi_audio_url = resolve_and_upload_audio_ref(session, &audio_source, draft_context).await?; + + let request = GenerateSunoSampleRequest { + prompt: self.prompt.clone(), + audio_url: kinovi_audio_url, + chop_sample_start_seconds: self.chop_sample_start_seconds, + chop_sample_end_seconds: self.chop_sample_end_seconds, + style_tags: self.style_tags.clone(), + instrumental: self.instrumental, + }; + + Ok(KinoviSunoSampleRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/request.rs new file mode 100644 index 0000000000..ed9d878ab5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sample/request.rs @@ -0,0 +1,135 @@ +use kinovi_web_client::generate::audio::generate_suno_sample::{ + generate_suno_sample, GenerateSunoSampleArgs, GenerateSunoSampleRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_audio::generate_audio_response::{ + GenerateAudioResponse, KinoviWebAudioResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviSunoSampleRequestState { + /// Final materialized request; ready to fire. The audio source has been + /// re-uploaded to the Kinovi CDN by the draft's `to_request()`. + pub request: GenerateSunoSampleRequest, +} + +impl KinoviSunoSampleRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSunoSampleArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_suno_sample(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + })) + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use test_utils::test_file_path::test_file_path; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + const TEST_AUDIO_PATH: &str = "test_data/audio/aac/golden_sun_elemental_stars_cyanne.aac"; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file. + async fn sample_instrumental() { + let client = get_kinovi_web_client(); + let audio_url = upload_test_audio(&client).await; + println!("Uploaded audio: {}", audio_url); + + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSample, + provider: RouterProvider::KinoviWeb, + prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()), + style_prompt: Some("Fantasy video game score".to_string()), + is_instrumental: Some(true), + audio_references: Some(AudioListRef::Urls(vec![audio_url])), + ..Default::default() + }; + + let response = run_pipeline(&client, builder).await; + assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_kinovi_web_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let draft_or_request = builder.build2().expect("build2 should succeed"); + let draft = match draft_or_request { + AudioGenerationDraftOrRequest::Draft(d) => d, + _ => panic!("expected Draft variant (Suno Sample uses the draft phase)"), + }; + + let draft_context = AudioGenerationDraftContext { + client: Some(client), + ..Default::default() + }; + + let request = draft.finalize(draft_context).await.expect("finalize should succeed"); + let response = request.send_request(client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::KinoviWeb(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } + + async fn upload_test_audio(client: &RouterClient) -> String { + let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session; + + let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist"); + let audio_bytes = std::fs::read(&audio_path).expect("read test audio"); + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "aac".to_string(), + host_override: None, + }).await.expect("prepare upload"); + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: audio_bytes, + host_override: None, + }).await.expect("upload"); + + upload_result.public_url + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/build.rs new file mode 100644 index 0000000000..917a4fa744 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/build.rs @@ -0,0 +1,209 @@ +use enums::common::generation::common_musical_key::CommonMusicalKey; +use kinovi_web_client::generate::audio::generate_suno_sound::{ + GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; +use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest; +use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; +use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState; +use crate::generate::generate_audio::providers::reject_unsupported::{ + reject_unsupported_audio_references, reject_unsupported_image_references, + reject_unsupported_option, +}; + +pub fn build_kinovi_suno_sounds(builder: GenerateAudioRequestBuilder) -> Result { + let state = build_kinovi_suno_sounds_state(builder)?; + Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(state))) +} + +pub(crate) fn build_kinovi_suno_sounds_state( + mut builder: GenerateAudioRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options Suno Sounds has no equivalent for. + reject_unsupported_option("style_prompt", builder.style_prompt.as_ref(), strategy)?; + reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?; + reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?; + reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?; + reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?; + reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?; + reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?; + reject_unsupported_audio_references(builder.audio_references.as_ref(), strategy)?; + reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput("A prompt is required for Suno Sounds".to_string()) + })?; + + let request = GenerateSunoSoundRequest { + prompt, + sound_type: plan_sound_type(builder.is_loopable), + bpm: builder.bpm, + key: plan_key(builder.musical_key), + }; + + Ok(KinoviSunoSoundsRequestState { request }) +} + +// ── Plan helpers ── + +fn plan_sound_type(is_loopable: Option) -> KinoviSunoSoundType { + match is_loopable { + Some(true) => KinoviSunoSoundType::Loopable, + Some(false) | None => KinoviSunoSoundType::SingleHit, + } +} + +fn plan_key(musical_key: Option) -> KinoviSunoSoundKey { + match musical_key { + None | Some(CommonMusicalKey::Auto) => KinoviSunoSoundKey::Auto, + Some(CommonMusicalKey::CMajor) => KinoviSunoSoundKey::CMajor, + Some(CommonMusicalKey::CMinor) => KinoviSunoSoundKey::CMinor, + Some(CommonMusicalKey::DMajor) => KinoviSunoSoundKey::DMajor, + Some(CommonMusicalKey::DMinor) => KinoviSunoSoundKey::DMinor, + Some(CommonMusicalKey::FMajor) => KinoviSunoSoundKey::FMajor, + Some(CommonMusicalKey::FMinor) => KinoviSunoSoundKey::FMinor, + Some(CommonMusicalKey::GMajor) => KinoviSunoSoundKey::GMajor, + Some(CommonMusicalKey::GMinor) => KinoviSunoSoundKey::GMinor, + Some(CommonMusicalKey::AMajor) => KinoviSunoSoundKey::AMajor, + Some(CommonMusicalKey::AMinor) => KinoviSunoSoundKey::AMinor, + Some(CommonMusicalKey::BMajor) => KinoviSunoSoundKey::BMajor, + Some(CommonMusicalKey::BMinor) => KinoviSunoSoundKey::BMinor, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let state = build_kinovi_suno_sounds_state(base_builder()).expect("build"); + assert_eq!(state.request.prompt, "rain sound effects"); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() }; + assert!(build_kinovi_suno_sounds_state(builder).is_err()); + } + + #[test] + fn bpm_passes_through() { + let builder = GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() }; + let state = build_kinovi_suno_sounds_state(builder).expect("build"); + assert_eq!(state.request.bpm, Some(120)); + } + + #[test] + fn loopable_true_maps_to_loop() { + let builder = GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() }; + let state = build_kinovi_suno_sounds_state(builder).expect("build"); + assert!(matches!(state.request.sound_type, KinoviSunoSoundType::Loopable)); + } + + #[test] + fn loopable_false_maps_to_single_hit() { + let builder = GenerateAudioRequestBuilder { is_loopable: Some(false), ..base_builder() }; + let state = build_kinovi_suno_sounds_state(builder).expect("build"); + assert!(matches!(state.request.sound_type, KinoviSunoSoundType::SingleHit)); + } + + #[test] + fn loopable_none_defaults_to_single_hit() { + let state = build_kinovi_suno_sounds_state(base_builder()).expect("build"); + assert!(matches!(state.request.sound_type, KinoviSunoSoundType::SingleHit)); + } + } + + mod key_mapping { + use super::*; + + #[test] + fn every_common_key_maps_to_the_kinovi_key() { + let cases = [ + (Some(CommonMusicalKey::Auto), KinoviSunoSoundKey::Auto), + (Some(CommonMusicalKey::CMajor), KinoviSunoSoundKey::CMajor), + (Some(CommonMusicalKey::CMinor), KinoviSunoSoundKey::CMinor), + (Some(CommonMusicalKey::DMajor), KinoviSunoSoundKey::DMajor), + (Some(CommonMusicalKey::DMinor), KinoviSunoSoundKey::DMinor), + (Some(CommonMusicalKey::FMajor), KinoviSunoSoundKey::FMajor), + (Some(CommonMusicalKey::FMinor), KinoviSunoSoundKey::FMinor), + (Some(CommonMusicalKey::GMajor), KinoviSunoSoundKey::GMajor), + (Some(CommonMusicalKey::GMinor), KinoviSunoSoundKey::GMinor), + (Some(CommonMusicalKey::AMajor), KinoviSunoSoundKey::AMajor), + (Some(CommonMusicalKey::AMinor), KinoviSunoSoundKey::AMinor), + (Some(CommonMusicalKey::BMajor), KinoviSunoSoundKey::BMajor), + (Some(CommonMusicalKey::BMinor), KinoviSunoSoundKey::BMinor), + ]; + assert_eq!(cases.len(), 13, "expected all 13 CommonMusicalKey variants covered"); + for (common_key, expected) in cases { + let builder = GenerateAudioRequestBuilder { musical_key: common_key, ..base_builder() }; + let state = build_kinovi_suno_sounds_state(builder).expect("build"); + assert!( + matches!(state.request.key, k if format!("{k:?}") == format!("{expected:?}")), + "expected {expected:?} for {common_key:?}, got {:?}", state.request.key, + ); + } + } + + #[test] + fn missing_key_defaults_to_auto() { + let state = build_kinovi_suno_sounds_state(base_builder()).expect("build"); + assert!(matches!(state.request.key, KinoviSunoSoundKey::Auto)); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateAudioRequestBuilder { style_prompt: Some("EDM".to_string()), ..base_builder() }, + GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() }, + GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() }, + GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() }, + GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() }, + GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_kinovi_suno_sounds_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateAudioRequestBuilder { + style_prompt: Some("EDM".to_string()), + is_instrumental: Some(true), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..base_builder() + }; + assert!(build_kinovi_suno_sounds_state(builder).is_ok()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSounds, + provider: RouterProvider::KinoviWeb, + prompt: Some("rain sound effects".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/cost.rs new file mode 100644 index 0000000000..4d047fbede --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/cost.rs @@ -0,0 +1,70 @@ +use kinovi_web_client::generate::audio::generate_suno_sound::{ + GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType, +}; + +use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate; +use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState; + +/// Suno Sounds is flat priced, so the cost state carries no request fields. +#[derive(Clone, Debug)] +pub struct KinoviSunoSoundsCostState; + +impl KinoviSunoSoundsCostState { + pub fn from_request(_request: &KinoviSunoSoundsRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> AudioGenerationCostEstimate { + // Cost math is owned by kinovi_web_client's binding — the router just + // forwards the result so router cost ≡ binding cost by construction. + let pricing_request = GenerateSunoSoundRequest { + prompt: String::new(), + sound_type: KinoviSunoSoundType::SingleHit, + bpm: None, + key: KinoviSunoSoundKey::Auto, + }; + let costs = pricing_request.calculate_costs(); + + AudioGenerationCostEstimate { + cost_in_credits: Some(costs.kinovi_credits), + cost_in_usd_cents: Some(costs.usd_cents_rounded_up), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::providers::kinovi::suno_sounds::build::build_kinovi_suno_sounds_state; + + use super::*; + + #[test] + fn flat_cost_is_two_cents() { + let estimate = KinoviSunoSoundsCostState.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(2)); + assert_eq!(estimate.cost_in_credits, Some(4)); + } + + #[test] + fn cost_from_built_request_is_two_cents() { + let builder = GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSounds, + provider: RouterProvider::KinoviWeb, + prompt: Some("thunder".to_string()), + is_loopable: Some(true), + bpm: Some(60), + ..Default::default() + }; + let state = build_kinovi_suno_sounds_state(builder).expect("build"); + let estimate = KinoviSunoSoundsCostState::from_request(&state).estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(2)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/request.rs new file mode 100644 index 0000000000..68fa464092 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/kinovi/suno_sounds/request.rs @@ -0,0 +1,114 @@ +use kinovi_web_client::generate::audio::generate_suno_sound::{ + generate_suno_sound, GenerateSunoSoundArgs, GenerateSunoSoundRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_audio::generate_audio_response::{ + GenerateAudioResponse, KinoviWebAudioResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviSunoSoundsRequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSunoSoundRequest, +} + +impl KinoviSunoSoundsRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSunoSoundArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_suno_sound(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + })) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_musical_key::CommonMusicalKey; + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + + use crate::api::router_audio_model::RouterAudioModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest; + use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder; + use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn single_hit() { + let response = run_pipeline(GenerateAudioRequestBuilder { + prompt: Some("A heavy castle door creaking open".to_string()), + ..suno_sounds_builder() + }).await; + assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn loop_with_bpm_and_key() { + let response = run_pipeline(GenerateAudioRequestBuilder { + prompt: Some("Lo-fi vinyl crackle groove".to_string()), + is_loopable: Some(true), + bpm: Some(85), + musical_key: Some(CommonMusicalKey::AMinor), + ..suno_sounds_builder() + }).await; + assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn suno_sounds_builder() -> GenerateAudioRequestBuilder { + GenerateAudioRequestBuilder { + model: RouterAudioModel::SunoSounds, + provider: RouterProvider::KinoviWeb, + ..Default::default() + } + } + + fn get_kinovi_web_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse { + let client = get_kinovi_web_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + AudioGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Suno Sounds skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateAudioResponse::KinoviWeb(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/mod.rs new file mode 100644 index 0000000000..0ff9bf52da --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/mod.rs @@ -0,0 +1,4 @@ +pub mod artcraft; +pub mod fal; +pub mod kinovi; +pub(crate) mod reject_unsupported; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/reject_unsupported.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/reject_unsupported.rs new file mode 100644 index 0000000000..e42695f7e8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_audio/providers/reject_unsupported.rs @@ -0,0 +1,69 @@ +//! Shared helpers for rejecting builder options that a model does not support. +//! +//! Per the request-mismatch mitigation strategy: `ErrorOut` rejects the +//! request when an unsupported option is set; the other strategies silently +//! drop the option. + +use std::fmt::Debug; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Reject (under `ErrorOut`) or silently drop a set option the model doesn't +/// support. +pub(crate) fn reject_unsupported_option( + field: &'static str, + value: Option<&T>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let Some(value) = value else { + return Ok(()); + }; + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: format!("{:?}", value), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + } +} + +/// Reject (under `ErrorOut`) or silently drop audio references for models +/// that take none. Empty lists are treated as absent. +pub(crate) fn reject_unsupported_audio_references( + refs: Option<&AudioListRef>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let has_refs = match refs { + None => false, + Some(AudioListRef::Urls(urls)) => !urls.is_empty(), + Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_refs { + return reject_unsupported_option("audio_references", refs, strategy); + } + Ok(()) +} + +/// Reject (under `ErrorOut`) or silently drop image references for models +/// that take none. Empty lists are treated as absent. +pub(crate) fn reject_unsupported_image_references( + refs: Option<&ImageListRef>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let has_refs = match refs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_refs { + return reject_unsupported_option("image_references", refs, strategy); + } + Ok(()) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs new file mode 100644 index 0000000000..2a92513333 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs @@ -0,0 +1,178 @@ +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_image_model::RouterImageModel; +use crate::api::router_quality::RouterQuality; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_provider::RouterProvider; +use crate::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::providers::artcraft::flux_1_dev::build::build_artcraft_flux_1_dev; +use crate::generate::generate_image::providers::artcraft::flux_1_schnell::build::build_artcraft_flux_1_schnell; +use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::build::build_artcraft_flux_2_lora_angles; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1::build::build_artcraft_flux_pro_1p1; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::build::build_artcraft_flux_pro_1p1_ultra; +use crate::generate::generate_image::providers::artcraft::gpt_image_1::build::build_artcraft_gpt_image_1; +use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::build::build_artcraft_gpt_image_1p5; +use crate::generate::generate_image::providers::artcraft::gpt_image_2::build::build_artcraft_gpt_image_2; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_flare::build::build_artcraft_gpt_image_2p5_flare; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_sunburst::build::build_artcraft_gpt_image_2p5_sunburst; +use crate::generate::generate_image::providers::artcraft::midjourney_7::build::build_artcraft_midjourney_7; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::build::build_artcraft_midjourney_7_niji; +use crate::generate::generate_image::providers::artcraft::midjourney_8::build::build_artcraft_midjourney_8; +use crate::generate::generate_image::providers::artcraft::nano_banana::build::build_artcraft_nano_banana; +use crate::generate::generate_image::providers::artcraft::nano_banana_2::build::build_artcraft_nano_banana_2; +use crate::generate::generate_image::providers::artcraft::nano_banana_pro::build::build_artcraft_nano_banana_pro; +use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles::build::build_artcraft_qwen_edit_2511_angles; +use crate::generate::generate_image::providers::artcraft::seedream_4::build::build_artcraft_seedream_4; +use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5; +use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::build::build_artcraft_seedream_5p0_pro_ultra; +use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev; +use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell; +use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles; +use crate::generate::generate_image::providers::fal::flux_pro_1p1::build::build_fal_flux_pro_1p1; +use crate::generate::generate_image::providers::fal::flux_pro_1p1_ultra::build::build_fal_flux_pro_1p1_ultra; +use crate::generate::generate_image::providers::fal::gpt_image_1::build::build_fal_gpt_image_1; +use crate::generate::generate_image::providers::fal::gpt_image_1p5::build::build_fal_gpt_image_1p5; +use crate::generate::generate_image::providers::fal::gpt_image_2::build::build_fal_gpt_image_2; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_flare::build::build_fal_gpt_image_2p5_flare; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_sunburst::build::build_fal_gpt_image_2p5_sunburst; +use crate::generate::generate_image::providers::fal::nano_banana::build::build_fal_nano_banana; +use crate::generate::generate_image::providers::fal::nano_banana_2::build::build_fal_nano_banana_2; +use crate::generate::generate_image::providers::fal::nano_banana_pro::build::build_fal_nano_banana_pro; +use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::build::build_fal_qwen_edit_2511_angles; +use crate::generate::generate_image::providers::fal::seedream_4::build::build_fal_seedream_4; +use crate::generate::generate_image::providers::fal::seedream_4p5::build::build_fal_seedream_4p5; +use crate::generate::generate_image::providers::fal::seedream_5_lite::build::build_fal_seedream_5_lite; +use crate::generate::generate_image::providers::kinovi::midjourney_7::build::build_kinovi_midjourney_7; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build::build_kinovi_midjourney_7_niji; +use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::build::build_kinovi_seedream_5p0_pro; + +#[derive(Clone, Debug)] +pub struct GenerateImageRequestBuilder { + /// Which model to use. + pub model: RouterImageModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The prompt for the image generation. + pub prompt: Option, + + /// Input images for image editing. + /// If present, we're doing image editing (image-to-image). + /// If absent, we're doing text-to-image. + pub image_inputs: Option, + + /// The resolution to use. + pub resolution: Option, + + /// The aspect ratio to use. + pub aspect_ratio: Option, + + /// The quality level for generation. Not all models use this. + pub quality: Option, + + /// How many images to generate. + pub image_batch_count: Option, + + /// Only for angle manipulation models. + pub horizontal_angle: Option, + + /// Only for angle manipulation models. + pub vertical_angle: Option, + + /// Only for angle manipulation models. + pub zoom: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Controls behavior when `image_inputs` are supplied to a text-to-image-only model. + /// `None` is equivalent to `GenerateAnyway` — image inputs are silently ignored. + /// Set `AbortGeneration` to return an error instead. + pub generation_mode_mismatch_strategy: Option, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl GenerateImageRequestBuilder { + + /// Build an executable image generation request for the configured + /// (provider, model) pair. Returns either a directly-sendable `Request` + /// or a `Draft` that needs media-token resolution before sending. + /// + /// Named `build2` for historical reasons — this is the only build method + /// since v1 was removed. The name is preserved for consistency with + /// `GenerateVideoRequestBuilder::build2`. + pub fn build2(self) -> Result { + match (self.provider, self.model) { + (RouterProvider::Artcraft, RouterImageModel::Flux1Dev) => build_artcraft_flux_1_dev(self), + (RouterProvider::Artcraft, RouterImageModel::Flux1Schnell) => build_artcraft_flux_1_schnell(self), + (RouterProvider::Artcraft, RouterImageModel::FluxPro11) => build_artcraft_flux_pro_1p1(self), + (RouterProvider::Artcraft, RouterImageModel::FluxPro11Ultra) => build_artcraft_flux_pro_1p1_ultra(self), + (RouterProvider::Artcraft, RouterImageModel::GptImage1) => build_artcraft_gpt_image_1(self), + (RouterProvider::Artcraft, RouterImageModel::GptImage1p5) => build_artcraft_gpt_image_1p5(self), + (RouterProvider::Artcraft, RouterImageModel::GptImage2) => build_artcraft_gpt_image_2(self), + (RouterProvider::Artcraft, RouterImageModel::GptImage2p5Flare) => build_artcraft_gpt_image_2p5_flare(self), + (RouterProvider::Artcraft, RouterImageModel::GptImage2p5Sunburst) => build_artcraft_gpt_image_2p5_sunburst(self), + (RouterProvider::Artcraft, RouterImageModel::NanoBanana) => build_artcraft_nano_banana(self), + (RouterProvider::Artcraft, RouterImageModel::NanoBanana2) => build_artcraft_nano_banana_2(self), + (RouterProvider::Artcraft, RouterImageModel::NanoBananaPro) => build_artcraft_nano_banana_pro(self), + (RouterProvider::Artcraft, RouterImageModel::Seedream4) => build_artcraft_seedream_4(self), + (RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self), + (RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self), + (RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self), + (RouterProvider::Artcraft, RouterImageModel::Seedream5p0ProUltra) => build_artcraft_seedream_5p0_pro_ultra(self), + (RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self), + (RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self), + (RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self), + (RouterProvider::Artcraft, RouterImageModel::Midjourney7Niji) => build_artcraft_midjourney_7_niji(self), + (RouterProvider::Artcraft, RouterImageModel::Midjourney8) => build_artcraft_midjourney_8(self), + + (RouterProvider::Fal, RouterImageModel::Flux1Dev) => build_fal_flux_1_dev(self), + (RouterProvider::Fal, RouterImageModel::Flux1Schnell) => build_fal_flux_1_schnell(self), + (RouterProvider::Fal, RouterImageModel::FluxPro11) => build_fal_flux_pro_1p1(self), + (RouterProvider::Fal, RouterImageModel::FluxPro11Ultra) => build_fal_flux_pro_1p1_ultra(self), + (RouterProvider::Fal, RouterImageModel::GptImage1) => build_fal_gpt_image_1(self), + (RouterProvider::Fal, RouterImageModel::GptImage1p5) => build_fal_gpt_image_1p5(self), + (RouterProvider::Fal, RouterImageModel::GptImage2) => build_fal_gpt_image_2(self), + (RouterProvider::Fal, RouterImageModel::GptImage2p5Flare) => build_fal_gpt_image_2p5_flare(self), + (RouterProvider::Fal, RouterImageModel::GptImage2p5Sunburst) => build_fal_gpt_image_2p5_sunburst(self), + (RouterProvider::Fal, RouterImageModel::NanoBanana) => build_fal_nano_banana(self), + (RouterProvider::Fal, RouterImageModel::NanoBanana2) => build_fal_nano_banana_2(self), + (RouterProvider::Fal, RouterImageModel::NanoBananaPro) => build_fal_nano_banana_pro(self), + (RouterProvider::Fal, RouterImageModel::Seedream4) => build_fal_seedream_4(self), + (RouterProvider::Fal, RouterImageModel::Seedream4p5) => build_fal_seedream_4p5(self), + (RouterProvider::Fal, RouterImageModel::Seedream5Lite) => build_fal_seedream_5_lite(self), + (RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self), + (RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self), + + // Kinovi / KinoviWeb (Midjourney + Seedream) + (RouterProvider::KinoviWeb, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self), + (RouterProvider::KinoviWeb, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self), + (RouterProvider::KinoviWeb, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self), + (RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self), + (RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self), + + _ => self.unsupported_provider_and_model(), + } + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Image generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs new file mode 100644 index 0000000000..643e0ecdbe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs @@ -0,0 +1,67 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +#[derive(Clone, Debug)] +pub struct ArtcraftImageResponsePayload { + pub inference_job_token: InferenceJobToken, +} + +#[derive(Clone, Debug)] +pub struct FalImageResponsePayload { + pub request_id: Option, + pub gateway_request_id: Option, + + /// The queue status URL (for polling job progress). + pub maybe_status_url: Option, + + /// The queue response URL (for fetching completed results). + pub maybe_response_url: Option, + + /// The outbound request that was sent to Fal. + /// Stored as a trait object so any Request type can be captured. + /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. + pub maybe_outbound_request: Option>, +} + +/// Response from the KinoviWeb/Kinovi provider (used for Midjourney +/// image generation). Mirrors `KinoviWebVideoResponsePayload` on the +/// video side. +#[derive(Clone, Debug)] +pub struct KinoviWebImageResponsePayload { + pub order_id: String, + pub task_id: String, + pub maybe_order_ids: Option>, + pub maybe_task_ids: Option>, +} + +#[derive(Clone, Debug)] +pub enum GenerateImageResponse { + Artcraft(ArtcraftImageResponsePayload), + Fal(FalImageResponsePayload), + KinoviWeb(KinoviWebImageResponsePayload), +} + +impl GenerateImageResponse { + pub fn get_artcraft_payload(&self) -> Option { + match self { + Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_fal_payload(&self) -> Option { + match self { + Self::Fal(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_kinovi_web_payload(&self) -> Option { + match self { + Self::KinoviWeb(p) => Some(p.clone()), + _ => None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_cost_estimate.rs new file mode 100644 index 0000000000..0c6ea35e99 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_cost_estimate.rs @@ -0,0 +1,16 @@ +/// The result of estimating the cost of an image generation plan. +#[derive(Clone, Debug)] +pub struct ImageGenerationCostEstimate { + pub cost_in_credits: Option, + pub cost_in_usd_cents: Option, + pub is_free: bool, + pub is_unlimited: bool, + pub is_rate_limited: bool, + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs new file mode 100644 index 0000000000..1791eaba4c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs @@ -0,0 +1,69 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState; +use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState; + +/// Wrapper for all image generation draft requests. +/// +/// Drafts hold validated/planned parameters but may still need media +/// resolution (e.g. uploading local files to a provider) before they can +/// be sent. Today only the Kinovi models need this — when the caller +/// supplies reference images, we must upload them to the KinoviWeb +/// CDN before sending the request. +#[derive(Clone, Debug)] +pub enum ImageGenerationDraftRequest { + KinoviMidjourney7(KinoviMidjourney7DraftState), + KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState), + KinoviMidjourney8(KinoviMidjourney8DraftState), + KinoviSeedream5p0Pro(KinoviSeedream5p0ProDraftState), +} + +impl ImageGenerationDraftRequest { + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb, + Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb, + Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb, + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()), + Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()), + Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()), + Self::KinoviSeedream5p0Pro(draft) => Ok(KinoviSeedream5p0ProCostState::from_draft(draft).estimate_cost()), + } + } + + pub async fn finalize(self, draft_context: ImageGenerationDraftContext<'_>) -> Result { + match self { + Self::KinoviMidjourney7(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviMidjourney7(result)) + } + Self::KinoviMidjourney7Niji(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviMidjourney7Niji(result)) + } + Self::KinoviMidjourney8(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviMidjourney8(result)) + } + Self::KinoviSeedream5p0Pro(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviSeedream5p0Pro(result)) + } + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs new file mode 100644 index 0000000000..ece586a72a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Context passed when finalizing an image draft into a concrete request. +/// +/// Drafts only exist for providers that need to upload assets before +/// sending the request. Today that's the KinoviWeb/Kinovi pipeline +/// (used for Midjourney image generation with reference images); other +/// providers — Artcraft, Fal — accept image URLs directly and skip the +/// draft phase entirely. +#[derive(Clone, Default)] +pub struct ImageGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require + /// authenticated calls (e.g. KinoviWeb/Kinovi uploads). + pub client: Option<&'a RouterClient>, + + /// Optional: a map of Media File Tokens to their ArtCraft URLs. + /// Required only when the draft has unresolved `MediaFileToken` image + /// inputs that must be downloaded from ArtCraft and re-uploaded to the + /// target provider's CDN. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl<'a> ImageGenerationDraftContext<'a> { + pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> { + let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?; + client.get_kinovi_web_client_ref() + .map_err(ArtcraftRouterError::Client) + } +} + +impl Debug for ImageGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImageGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map.map(|m| m.len())) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_or_request.rs new file mode 100644 index 0000000000..40c769673c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_or_request.rs @@ -0,0 +1,27 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + +#[derive(Debug, Clone)] +pub enum ImageGenerationDraftOrRequest { + Draft(ImageGenerationDraftRequest), + Request(ImageGenerationRequest), +} + +impl ImageGenerationDraftOrRequest { + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + Self::Draft(draft) => draft.estimate_cost(), + Self::Request(request) => request.estimate_cost(), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs new file mode 100644 index 0000000000..5eed144b5c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs @@ -0,0 +1,482 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::flux_1_dev::cost::ArtcraftFlux1DevCostState; +use crate::generate::generate_image::providers::artcraft::flux_1_dev::request::ArtcraftFlux1DevRequestState; +use crate::generate::generate_image::providers::artcraft::flux_1_schnell::cost::ArtcraftFlux1SchnellCostState; +use crate::generate::generate_image::providers::artcraft::flux_1_schnell::request::ArtcraftFlux1SchnellRequestState; +use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::cost::ArtcraftFlux2LoraAnglesCostState; +use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::request::ArtcraftFlux2LoraAnglesRequestState; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1::cost::ArtcraftFluxPro1p1CostState; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1::request::ArtcraftFluxPro1p1RequestState; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::cost::ArtcraftFluxPro1p1UltraCostState; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::request::ArtcraftFluxPro1p1UltraRequestState; +use crate::generate::generate_image::providers::artcraft::gpt_image_1::cost::ArtcraftGptImage1CostState; +use crate::generate::generate_image::providers::artcraft::gpt_image_1::request::ArtcraftGptImage1RequestState; +use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::cost::ArtcraftGptImage1p5CostState; +use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::request::ArtcraftGptImage1p5RequestState; +use crate::generate::generate_image::providers::artcraft::gpt_image_2::cost::ArtcraftGptImage2CostState; +use crate::generate::generate_image::providers::artcraft::gpt_image_2::request::ArtcraftGptImage2RequestState; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_flare::cost::ArtcraftGptImage2p5FlareCostState; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_flare::request::ArtcraftGptImage2p5FlareRequestState; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_sunburst::cost::ArtcraftGptImage2p5SunburstCostState; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_sunburst::request::ArtcraftGptImage2p5SunburstRequestState; +use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState; +use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::cost::ArtcraftMidjourney7NijiCostState; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState; +use crate::generate::generate_image::providers::artcraft::midjourney_8::cost::ArtcraftMidjourney8CostState; +use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState; +use crate::generate::generate_image::providers::artcraft::nano_banana::cost::ArtcraftNanoBananaCostState; +use crate::generate::generate_image::providers::artcraft::nano_banana::request::ArtcraftNanoBananaRequestState; +use crate::generate::generate_image::providers::artcraft::nano_banana_2::cost::ArtcraftNanoBanana2CostState; +use crate::generate::generate_image::providers::artcraft::nano_banana_2::request::ArtcraftNanoBanana2RequestState; +use crate::generate::generate_image::providers::artcraft::nano_banana_pro::cost::ArtcraftNanoBananaProCostState; +use crate::generate::generate_image::providers::artcraft::nano_banana_pro::request::ArtcraftNanoBananaProRequestState; +use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles::cost::ArtcraftQwenEdit2511AnglesCostState; +use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles::request::ArtcraftQwenEdit2511AnglesRequestState; +use crate::generate::generate_image::providers::artcraft::seedream_4::cost::ArtcraftSeedream4CostState; +use crate::generate::generate_image::providers::artcraft::seedream_4::request::ArtcraftSeedream4RequestState; +use crate::generate::generate_image::providers::artcraft::seedream_4p5::cost::ArtcraftSeedream4p5CostState; +use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState; +use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost::ArtcraftSeedream5LiteCostState; +use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::cost::ArtcraftSeedream5p0ProUltraCostState; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState; +use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState; +use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState; +use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState; +use crate::generate::generate_image::providers::fal::flux_1_schnell::request::FalFlux1SchnellRequestState; +use crate::generate::generate_image::providers::fal::flux_2_lora_angles::cost::FalFlux2LoraAnglesCostState; +use crate::generate::generate_image::providers::fal::flux_2_lora_angles::request::FalFlux2LoraAnglesRequestState; +use crate::generate::generate_image::providers::fal::flux_pro_1p1::cost::FalFluxPro1p1CostState; +use crate::generate::generate_image::providers::fal::flux_pro_1p1::request::FalFluxPro1p1RequestState; +use crate::generate::generate_image::providers::fal::flux_pro_1p1_ultra::cost::FalFluxPro1p1UltraCostState; +use crate::generate::generate_image::providers::fal::flux_pro_1p1_ultra::request::FalFluxPro1p1UltraRequestState; +use crate::generate::generate_image::providers::fal::gpt_image_1::cost::FalGptImage1CostState; +use crate::generate::generate_image::providers::fal::gpt_image_1::request::FalGptImage1RequestState; +use crate::generate::generate_image::providers::fal::gpt_image_1p5::cost::FalGptImage1p5CostState; +use crate::generate::generate_image::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState; +use crate::generate::generate_image::providers::fal::gpt_image_2::cost::FalGptImage2CostState; +use crate::generate::generate_image::providers::fal::gpt_image_2::request::FalGptImage2RequestState; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_flare::cost::FalGptImage2p5FlareCostState; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_flare::request::FalGptImage2p5FlareRequestState; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_sunburst::cost::FalGptImage2p5SunburstCostState; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_sunburst::request::FalGptImage2p5SunburstRequestState; +use crate::generate::generate_image::providers::fal::nano_banana::cost::FalNanoBananaCostState; +use crate::generate::generate_image::providers::fal::nano_banana::request::FalNanoBananaRequestState; +use crate::generate::generate_image::providers::fal::nano_banana_2::cost::FalNanoBanana2CostState; +use crate::generate::generate_image::providers::fal::nano_banana_2::request::FalNanoBanana2RequestState; +use crate::generate::generate_image::providers::fal::nano_banana_pro::cost::FalNanoBananaProCostState; +use crate::generate::generate_image::providers::fal::nano_banana_pro::request::FalNanoBananaProRequestState; +use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::cost::FalQwenEdit2511AnglesCostState; +use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::request::FalQwenEdit2511AnglesRequestState; +use crate::generate::generate_image::providers::fal::seedream_4::cost::FalSeedream4CostState; +use crate::generate::generate_image::providers::fal::seedream_4::request::FalSeedream4RequestState; +use crate::generate::generate_image::providers::fal::seedream_4p5::cost::FalSeedream4p5CostState; +use crate::generate::generate_image::providers::fal::seedream_4p5::request::FalSeedream4p5RequestState; +use crate::generate::generate_image::providers::fal::seedream_5_lite::cost::FalSeedream5LiteCostState; +use crate::generate::generate_image::providers::fal::seedream_5_lite::request::FalSeedream5LiteRequestState; +use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState; + +#[derive(Clone, Debug)] +pub enum ImageGenerationRequest { + // ── Artcraft provider (omni-gen image endpoint) ── + ArtcraftFlux1Dev(ArtcraftFlux1DevRequestState), + ArtcraftFlux1Schnell(ArtcraftFlux1SchnellRequestState), + ArtcraftFluxPro1p1(ArtcraftFluxPro1p1RequestState), + ArtcraftFluxPro1p1Ultra(ArtcraftFluxPro1p1UltraRequestState), + ArtcraftGptImage1(ArtcraftGptImage1RequestState), + ArtcraftGptImage1p5(ArtcraftGptImage1p5RequestState), + ArtcraftGptImage2(ArtcraftGptImage2RequestState), + ArtcraftGptImage2p5Flare(ArtcraftGptImage2p5FlareRequestState), + ArtcraftGptImage2p5Sunburst(ArtcraftGptImage2p5SunburstRequestState), + ArtcraftNanoBanana(ArtcraftNanoBananaRequestState), + ArtcraftNanoBanana2(ArtcraftNanoBanana2RequestState), + ArtcraftNanoBananaPro(ArtcraftNanoBananaProRequestState), + ArtcraftSeedream4(ArtcraftSeedream4RequestState), + ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState), + ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState), + ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState), + ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState), + ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState), + ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState), + ArtcraftMidjourney7(ArtcraftMidjourney7RequestState), + ArtcraftMidjourney7Niji(ArtcraftMidjourney7NijiRequestState), + ArtcraftMidjourney8(ArtcraftMidjourney8RequestState), + + // ── Fal provider ── + FalFlux1Dev(FalFlux1DevRequestState), + FalFlux1Schnell(FalFlux1SchnellRequestState), + FalFluxPro1p1(FalFluxPro1p1RequestState), + FalFluxPro1p1Ultra(FalFluxPro1p1UltraRequestState), + FalGptImage1(FalGptImage1RequestState), + FalGptImage1p5(FalGptImage1p5RequestState), + FalGptImage2(FalGptImage2RequestState), + FalGptImage2p5Flare(FalGptImage2p5FlareRequestState), + FalGptImage2p5Sunburst(FalGptImage2p5SunburstRequestState), + FalNanoBanana(FalNanoBananaRequestState), + FalNanoBanana2(FalNanoBanana2RequestState), + FalNanoBananaPro(FalNanoBananaProRequestState), + FalSeedream4(FalSeedream4RequestState), + FalSeedream4p5(FalSeedream4p5RequestState), + FalSeedream5Lite(FalSeedream5LiteRequestState), + FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState), + FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState), + + // ── Kinovi / KinoviWeb provider (Midjourney + Seedream image generation) ── + KinoviMidjourney7(KinoviMidjourney7RequestState), + KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState), + KinoviMidjourney8(KinoviMidjourney8RequestState), + KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState), +} + +impl ImageGenerationRequest { + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftFlux1Dev(_) => RouterProvider::Artcraft, + Self::ArtcraftFlux1Schnell(_) => RouterProvider::Artcraft, + Self::ArtcraftFluxPro1p1(_) => RouterProvider::Artcraft, + Self::ArtcraftFluxPro1p1Ultra(_) => RouterProvider::Artcraft, + Self::ArtcraftGptImage1(_) => RouterProvider::Artcraft, + Self::ArtcraftGptImage1p5(_) => RouterProvider::Artcraft, + Self::ArtcraftGptImage2(_) => RouterProvider::Artcraft, + Self::ArtcraftGptImage2p5Flare(_) => RouterProvider::Artcraft, + Self::ArtcraftGptImage2p5Sunburst(_) => RouterProvider::Artcraft, + Self::ArtcraftNanoBanana(_) => RouterProvider::Artcraft, + Self::ArtcraftNanoBanana2(_) => RouterProvider::Artcraft, + Self::ArtcraftNanoBananaPro(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedream4(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedream5p0ProUltra(_) => RouterProvider::Artcraft, + Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft, + Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft, + Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft, + Self::ArtcraftMidjourney7Niji(_) => RouterProvider::Artcraft, + Self::ArtcraftMidjourney8(_) => RouterProvider::Artcraft, + + Self::FalFlux1Dev(_) => RouterProvider::Fal, + Self::FalFlux1Schnell(_) => RouterProvider::Fal, + Self::FalFluxPro1p1(_) => RouterProvider::Fal, + Self::FalFluxPro1p1Ultra(_) => RouterProvider::Fal, + Self::FalGptImage1(_) => RouterProvider::Fal, + Self::FalGptImage1p5(_) => RouterProvider::Fal, + Self::FalGptImage2(_) => RouterProvider::Fal, + Self::FalGptImage2p5Flare(_) => RouterProvider::Fal, + Self::FalGptImage2p5Sunburst(_) => RouterProvider::Fal, + Self::FalNanoBanana(_) => RouterProvider::Fal, + Self::FalNanoBanana2(_) => RouterProvider::Fal, + Self::FalNanoBananaPro(_) => RouterProvider::Fal, + Self::FalSeedream4(_) => RouterProvider::Fal, + Self::FalSeedream4p5(_) => RouterProvider::Fal, + Self::FalSeedream5Lite(_) => RouterProvider::Fal, + Self::FalQwenEdit2511Angles(_) => RouterProvider::Fal, + Self::FalFlux2LoraAngles(_) => RouterProvider::Fal, + + Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb, + Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb, + Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb, + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + // ── Artcraft ── + Self::ArtcraftFlux1Dev(request) => { + Ok(ArtcraftFlux1DevCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftFlux1Schnell(request) => { + Ok(ArtcraftFlux1SchnellCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftFluxPro1p1(request) => { + Ok(ArtcraftFluxPro1p1CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftFluxPro1p1Ultra(request) => { + Ok(ArtcraftFluxPro1p1UltraCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftGptImage1(request) => { + Ok(ArtcraftGptImage1CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftGptImage1p5(request) => { + Ok(ArtcraftGptImage1p5CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftGptImage2(request) => { + Ok(ArtcraftGptImage2CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftGptImage2p5Flare(request) => { + Ok(ArtcraftGptImage2p5FlareCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftGptImage2p5Sunburst(request) => { + Ok(ArtcraftGptImage2p5SunburstCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftNanoBanana(request) => { + Ok(ArtcraftNanoBananaCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftNanoBanana2(request) => { + Ok(ArtcraftNanoBanana2CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftNanoBananaPro(request) => { + Ok(ArtcraftNanoBananaProCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftSeedream4(request) => { + Ok(ArtcraftSeedream4CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftSeedream4p5(request) => { + Ok(ArtcraftSeedream4p5CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftSeedream5Lite(request) => { + Ok(ArtcraftSeedream5LiteCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftSeedream5p0Pro(request) => { + Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftSeedream5p0ProUltra(request) => { + Ok(ArtcraftSeedream5p0ProUltraCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftQwenEdit2511Angles(request) => { + Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftFlux2LoraAngles(request) => { + Ok(ArtcraftFlux2LoraAnglesCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftMidjourney7(request) => { + Ok(ArtcraftMidjourney7CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftMidjourney7Niji(request) => { + Ok(ArtcraftMidjourney7NijiCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftMidjourney8(request) => { + Ok(ArtcraftMidjourney8CostState::from_request(request).estimate_cost()) + } + + // ── Fal ── + Self::FalFlux1Dev(request) => Ok(FalFlux1DevCostState::from_request(request).estimate_cost()), + Self::FalFlux1Schnell(request) => Ok(FalFlux1SchnellCostState::from_request(request).estimate_cost()), + Self::FalFluxPro1p1(request) => Ok(FalFluxPro1p1CostState::from_request(request).estimate_cost()), + Self::FalFluxPro1p1Ultra(request) => Ok(FalFluxPro1p1UltraCostState::from_request(request).estimate_cost()), + Self::FalGptImage1(request) => Ok(FalGptImage1CostState::from_request(request).estimate_cost()), + Self::FalGptImage1p5(request) => Ok(FalGptImage1p5CostState::from_request(request).estimate_cost()), + Self::FalGptImage2(request) => Ok(FalGptImage2CostState::from_request(request).estimate_cost()), + Self::FalGptImage2p5Flare(request) => Ok(FalGptImage2p5FlareCostState::from_request(request).estimate_cost()), + Self::FalGptImage2p5Sunburst(request) => Ok(FalGptImage2p5SunburstCostState::from_request(request).estimate_cost()), + Self::FalNanoBanana(request) => Ok(FalNanoBananaCostState::from_request(request).estimate_cost()), + Self::FalNanoBanana2(request) => Ok(FalNanoBanana2CostState::from_request(request).estimate_cost()), + Self::FalNanoBananaPro(request) => Ok(FalNanoBananaProCostState::from_request(request).estimate_cost()), + Self::FalSeedream4(request) => Ok(FalSeedream4CostState::from_request(request).estimate_cost()), + Self::FalSeedream4p5(request) => Ok(FalSeedream4p5CostState::from_request(request).estimate_cost()), + Self::FalSeedream5Lite(request) => Ok(FalSeedream5LiteCostState::from_request(request).estimate_cost()), + Self::FalQwenEdit2511Angles(request) => Ok(FalQwenEdit2511AnglesCostState::from_request(request).estimate_cost()), + Self::FalFlux2LoraAngles(request) => Ok(FalFlux2LoraAnglesCostState::from_request(request).estimate_cost()), + + Self::KinoviMidjourney7(request) => Ok(KinoviMidjourney7CostState::from_request(request).estimate_cost()), + Self::KinoviMidjourney7Niji(request) => Ok(KinoviMidjourney7NijiCostState::from_request(request).estimate_cost()), + Self::KinoviMidjourney8(request) => Ok(KinoviMidjourney8CostState::from_request(request).estimate_cost()), + Self::KinoviSeedream5p0Pro(request) => Ok(KinoviSeedream5p0ProCostState::from_request(request).estimate_cost()), + } + } + + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + // ── Artcraft (omni-gen image endpoint) ── + Self::ArtcraftFlux1Dev(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftFlux1Schnell(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftFluxPro1p1(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftFluxPro1p1Ultra(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftGptImage1(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftGptImage1p5(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftGptImage2(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftGptImage2p5Flare(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftGptImage2p5Sunburst(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftNanoBanana(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftNanoBanana2(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftNanoBananaPro(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftSeedream4(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftSeedream4p5(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftSeedream5Lite(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftSeedream5p0Pro(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftSeedream5p0ProUltra(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftQwenEdit2511Angles(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftFlux2LoraAngles(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftMidjourney7(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftMidjourney7Niji(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftMidjourney8(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + + // ── Fal ── + Self::FalFlux1Dev(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalFlux1Schnell(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalFluxPro1p1(request) => { + // Flux Pro 1.1 is webhook-required — uses the webhook-only RouterFalClient. + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalFluxPro1p1Ultra(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalGptImage1(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalGptImage1p5(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalGptImage2(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalGptImage2p5Flare(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalGptImage2p5Sunburst(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalNanoBanana(request) => { + // nano_banana (Gemini 2.5 Flash) is webhook-required. + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalNanoBanana2(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalNanoBananaPro(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalSeedream4(request) => { + // Seedream v4 is webhook-required. + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalSeedream4p5(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalSeedream5Lite(request) => { + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalQwenEdit2511Angles(request) => { + // Webhook-required endpoint. + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalFlux2LoraAngles(request) => { + // Webhook-required endpoint. + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + + // ── Kinovi / KinoviWeb (Midjourney + Seedream) ── + Self::KinoviMidjourney7(request) => { + let seedance_client = client.get_kinovi_web_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } + Self::KinoviMidjourney7Niji(request) => { + let seedance_client = client.get_kinovi_web_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } + Self::KinoviMidjourney8(request) => { + let seedance_client = client.get_kinovi_web_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } + Self::KinoviSeedream5p0Pro(request) => { + let seedance_client = client.get_kinovi_web_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/mod.rs new file mode 100644 index 0000000000..f2c7600591 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/mod.rs @@ -0,0 +1,8 @@ +pub mod generate_image_request_builder; +pub mod generate_image_response; +pub mod image_generation_cost_estimate; +pub mod image_generation_draft; +pub mod image_generation_draft_context; +pub mod image_generation_draft_or_request; +pub mod image_generation_request; +pub mod providers; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/angle_validation.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/angle_validation.rs new file mode 100644 index 0000000000..d3a5588e0a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/angle_validation.rs @@ -0,0 +1,27 @@ +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Angle models (qwen_edit_2511_angles, flux_2_lora_angles) require at least +/// one input image — both v1 plans error out otherwise. Mirror that strict +/// requirement here so v1↔v2 parity holds for the "no inputs" case. +pub fn require_at_least_one_image_input( + image_inputs: &Option, +) -> Result<(), ArtcraftRouterError> { + match image_inputs { + None => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Angle models require exactly one input image".to_string(), + })), + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Angle models require exactly one input image, got {}", tokens.len()), + })) + } + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + Some(ImageListRef::MediaFileTokens(_)) => Ok(()), + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..201a147fce --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs @@ -0,0 +1,158 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; +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 tokens::tokens::media_files::MediaFileToken; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + +/// Build an `OmniGenImageCostAndGenerateRequest` from the builder. All Artcraft +/// image models delegate to this helper — the omni-gen endpoint handles all +/// model-specific transformations server-side, so the client request stays +/// extremely lightweight. +/// +/// Per-model build functions are expected to validate model-specific +/// constraints (e.g. unsupported aspect ratios) BEFORE calling this helper. +/// Validation that's identical across every Artcraft image model — batch count +/// (1..=[`max_batch_count`]) and image_inputs shape — lives here. +pub fn build_artcraft_omni_image_request( + builder: GenerateImageRequestBuilder, + model: CommonImageModelEnum, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let image_batch_count = + plan_batch_count_with_max(builder.image_batch_count, strategy, max_batch_count(model))?; + let image_media_tokens = resolve_image_list_ref(builder.image_inputs.clone())?; + + let aspect_ratio = builder.aspect_ratio.map(to_aspect_ratio_enum); + let resolution = builder.resolution.map(to_resolution_enum); + let quality = builder.quality.map(to_quality_enum); + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenImageCostAndGenerateRequest { + idempotency_token: Some(idempotency_token), + model: Some(model), + prompt: builder.prompt.clone(), + image_media_tokens, + resolution, + aspect_ratio, + quality, + image_batch_count: Some(image_batch_count), + adjust_horizontal_angle: builder.horizontal_angle, + adjust_vertical_angle: builder.vertical_angle, + adjust_zoom: builder.zoom, + }) +} + +/// The largest batch each Artcraft image model accepts. Seedream 5.0 Pro and +/// Ultra accept batches of up to 8; every other model caps at 4. +pub fn max_batch_count(model: CommonImageModelEnum) -> u16 { + match model { + CommonImageModelEnum::Seedream5p0Pro | CommonImageModelEnum::Seedream5p0ProUltra => 8, + _ => 4, + } +} + +/// Validate batch count (1..=4). Mirrors the v1 plan-level validation that +/// every Artcraft image model performs. +pub fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + plan_batch_count_with_max(image_batch_count, strategy, 4) +} + +/// Validate batch count (1..=`max`), clamping or erroring per the mitigation +/// strategy when the request exceeds the model's cap. +pub fn plan_batch_count_with_max( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, + max: u16, +) -> Result { + let count = image_batch_count.unwrap_or(1); + if count == 0 { + return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)); + } + if count <= max { + return Ok(count); + } + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(max), + } +} + +/// Resolve `image_inputs` to a `Vec` for the omni-gen request. +/// +/// Mirrors v1's behavior: URLs are accepted and dropped, because the omni-gen +/// distillation hydrates media tokens to URLs before the cost path runs. +/// Cost only depends on `num_images` + mode (derived from `image_inputs` +/// presence by callers), so URL-form inputs flow through cleanly. +pub fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => Ok(None), + } +} + +fn to_aspect_ratio_enum(ar: RouterAspectRatio) -> CommonAspectRatioEnum { + match ar { + RouterAspectRatio::Auto => CommonAspectRatioEnum::Auto, + RouterAspectRatio::Auto2k => CommonAspectRatioEnum::Auto2k, + RouterAspectRatio::Auto3k => CommonAspectRatioEnum::Auto3k, + RouterAspectRatio::Auto4k => CommonAspectRatioEnum::Auto4k, + RouterAspectRatio::Square => CommonAspectRatioEnum::Square, + RouterAspectRatio::SquareHd => CommonAspectRatioEnum::SquareHd, + RouterAspectRatio::WideFourByThree => CommonAspectRatioEnum::WideFourByThree, + RouterAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFiveByFour, + RouterAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideThreeByTwo, + RouterAspectRatio::WideSixteenByNine => CommonAspectRatioEnum::WideSixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideTwentyOneByNine, + RouterAspectRatio::Wide => CommonAspectRatioEnum::Wide, + RouterAspectRatio::TallThreeByFour => CommonAspectRatioEnum::TallThreeByFour, + RouterAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallFourByFive, + RouterAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallTwoByThree, + RouterAspectRatio::TallNineBySixteen => CommonAspectRatioEnum::TallNineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineByTwentyOne, + RouterAspectRatio::Tall => CommonAspectRatioEnum::Tall, + } +} + +fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum { + match r { + RouterResolution::HalfK => CommonResolutionEnum::HalfK, + RouterResolution::OneK => CommonResolutionEnum::OneK, + RouterResolution::TwoK => CommonResolutionEnum::TwoK, + RouterResolution::ThreeK => CommonResolutionEnum::ThreeK, + RouterResolution::FourK => CommonResolutionEnum::FourK, + RouterResolution::FourEightyP => CommonResolutionEnum::FourEightyP, + RouterResolution::SevenTwentyP => CommonResolutionEnum::SevenTwentyP, + RouterResolution::TenEightyP => CommonResolutionEnum::TenEightyP, + } +} + +fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum { + match q { + RouterQuality::Low => CommonQualityEnum::Low, + RouterQuality::Medium => CommonQualityEnum::Medium, + RouterQuality::High => CommonQualityEnum::High, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/build.rs new file mode 100644 index 0000000000..2de611c7d0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::flux_1_dev::request::ArtcraftFlux1DevRequestState; + +pub fn build_artcraft_flux_1_dev( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Flux1Dev)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftFlux1Dev(ArtcraftFlux1DevRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/cost.rs new file mode 100644 index 0000000000..6cb22e583f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/cost.rs @@ -0,0 +1,72 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::flux_1_dev::request::ArtcraftFlux1DevRequestState; + +/// Cost state for Artcraft Flux 1 Dev. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_flux_1_dev`): flat 2¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftFlux1DevCostState { + pub num_images: u16, +} + +impl ArtcraftFlux1DevCostState { + pub fn from_request(request: &ArtcraftFlux1DevRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Pricing: $0.02/image. 1 credit = 1 USD cent. + let cost_in_usd_cents = 2u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Flux1Dev, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_2_cents() { assert_eq!(cost_cents(1), 2); } + + #[test] + fn two_images_costs_4_cents() { assert_eq!(cost_cents(2), 4); } + + #[test] + fn three_images_costs_6_cents() { assert_eq!(cost_cents(3), 6); } + + #[test] + fn four_images_costs_8_cents() { assert_eq!(cost_cents(4), 8); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/request.rs new file mode 100644 index 0000000000..f603cb74a2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_dev/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux1DevRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftFlux1DevRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/build.rs new file mode 100644 index 0000000000..6596c68c7a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::flux_1_schnell::request::ArtcraftFlux1SchnellRequestState; + +pub fn build_artcraft_flux_1_schnell( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Flux1Schnell)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftFlux1Schnell(ArtcraftFlux1SchnellRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/cost.rs new file mode 100644 index 0000000000..15b6c2d2d5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/cost.rs @@ -0,0 +1,71 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::flux_1_schnell::request::ArtcraftFlux1SchnellRequestState; + +/// Cost state for Artcraft Flux 1 Schnell. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_flux_1_schnell`): free, no per-image cost, +/// rate-limited by Artcraft. +#[derive(Clone, Debug)] +pub struct ArtcraftFlux1SchnellCostState; + +impl ArtcraftFlux1SchnellCostState { + pub fn from_request(_request: &ArtcraftFlux1SchnellRequestState) -> Self { + Self + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + cost_in_credits: None, + cost_in_usd_cents: None, + is_free: true, + is_unlimited: true, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn build_estimate() -> crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Flux1Schnell, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap() + } + + #[test] + fn cost_is_none() { + let estimate = build_estimate(); + assert!(estimate.cost_in_usd_cents.is_none()); + assert!(estimate.cost_in_credits.is_none()); + } + + #[test] + fn is_free_is_true() { + assert!(build_estimate().is_free); + } + + #[test] + fn is_unlimited_is_true() { + assert!(build_estimate().is_unlimited); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/request.rs new file mode 100644 index 0000000000..66b8d9d5f0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_1_schnell/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux1SchnellRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftFlux1SchnellRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/build.rs new file mode 100644 index 0000000000..547afc0b04 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/build.rs @@ -0,0 +1,19 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::angle_validation::require_at_least_one_image_input; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::request::ArtcraftFlux2LoraAnglesRequestState; + +pub fn build_artcraft_flux_2_lora_angles( + builder: GenerateImageRequestBuilder, +) -> Result { + require_at_least_one_image_input(&builder.image_inputs)?; + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Flux2LoraAngles)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/cost.rs new file mode 100644 index 0000000000..603dbefbe5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/cost.rs @@ -0,0 +1,72 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::request::ArtcraftFlux2LoraAnglesRequestState; + +/// Cost state for Artcraft Flux 2 LoRA Angles. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftFlux2LoraAnglesCostState { + pub num_images: u16, +} + +impl ArtcraftFlux2LoraAnglesCostState { + pub fn from_request(request: &ArtcraftFlux2LoraAnglesRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 4u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Flux2LoraAngles, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new_from_str("mf_test")])), + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: Some(45.0), + vertical_angle: Some(-15.0), + zoom: Some(2.0), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); } + + #[test] + fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); } + + #[test] + fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); } + + #[test] + fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/request.rs new file mode 100644 index 0000000000..4cadcf5874 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_2_lora_angles/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux2LoraAnglesRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftFlux2LoraAnglesRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/build.rs new file mode 100644 index 0000000000..cc8fa19caf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1::request::ArtcraftFluxPro1p1RequestState; + +pub fn build_artcraft_flux_pro_1p1( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::FluxPro11)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftFluxPro1p1(ArtcraftFluxPro1p1RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/cost.rs new file mode 100644 index 0000000000..3e40ac2888 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/cost.rs @@ -0,0 +1,71 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1::request::ArtcraftFluxPro1p1RequestState; + +/// Cost state for Artcraft Flux Pro 1.1. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_flux_pro_1p1`): 4¢ per output image (Fal's +/// $0.04/MP rate at the default ~1MP). +#[derive(Clone, Debug)] +pub struct ArtcraftFluxPro1p1CostState { + pub num_images: u16, +} + +impl ArtcraftFluxPro1p1CostState { + pub fn from_request(request: &ArtcraftFluxPro1p1RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 4u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::FluxPro11, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); } + + #[test] + fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); } + + #[test] + fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); } + + #[test] + fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/request.rs new file mode 100644 index 0000000000..388bbbe990 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFluxPro1p1RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftFluxPro1p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/build.rs new file mode 100644 index 0000000000..ff2db5e9d2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::request::ArtcraftFluxPro1p1UltraRequestState; + +pub fn build_artcraft_flux_pro_1p1_ultra( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::FluxPro11Ultra)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftFluxPro1p1Ultra(ArtcraftFluxPro1p1UltraRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/cost.rs new file mode 100644 index 0000000000..d1d922b366 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/cost.rs @@ -0,0 +1,70 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::request::ArtcraftFluxPro1p1UltraRequestState; + +/// Cost state for Artcraft Flux Pro 1.1 Ultra. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_flux_pro_1p1_ultra`): 6¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftFluxPro1p1UltraCostState { + pub num_images: u16, +} + +impl ArtcraftFluxPro1p1UltraCostState { + pub fn from_request(request: &ArtcraftFluxPro1p1UltraRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 6u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::FluxPro11Ultra, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_6_cents() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn two_images_costs_12_cents() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn three_images_costs_18_cents() { assert_eq!(cost_cents(3), 18); } + + #[test] + fn four_images_costs_24_cents() { assert_eq!(cost_cents(4), 24); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/request.rs new file mode 100644 index 0000000000..bb70eebefd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/flux_pro_1p1_ultra/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFluxPro1p1UltraRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftFluxPro1p1UltraRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/build.rs new file mode 100644 index 0000000000..1a32e8b1cc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::gpt_image_1::request::ArtcraftGptImage1RequestState; + +pub fn build_artcraft_gpt_image_1( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::GptImage1)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage1(ArtcraftGptImage1RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs new file mode 100644 index 0000000000..c67a06a987 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs @@ -0,0 +1,194 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::gpt_image_1::request::ArtcraftGptImage1RequestState; + +/// Cost state for Artcraft GPT Image 1. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_gpt_image_1`): +/// +/// Output image cost (per output image): +/// Low: 2¢ all sizes +/// Medium: 5¢ square, 7¢ wide/tall +/// High: 17¢ square, 25¢ wide/tall +/// +/// Plus 2¢ per input image (high-fidelity estimate) in edit mode. +/// Quality defaults to High when unspecified. +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage1CostState { + pub quality: Option, + pub aspect_ratio: Option, + pub num_images: u16, + pub num_input_images: u64, +} + +impl ArtcraftGptImage1CostState { + pub fn from_request(request: &ArtcraftGptImage1RequestState) -> Self { + Self { + quality: request.request.quality, + aspect_ratio: request.request.aspect_ratio, + num_images: request.request.image_batch_count.unwrap_or(1), + num_input_images: request.request.image_media_tokens + .as_ref() + .map(|t| t.len() as u64) + .unwrap_or(0), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let is_square = is_square_or_none(self.aspect_ratio); + + let output_cost_per_image: u64 = match (quality, is_square) { + (CommonQualityEnum::Low, _) => 2, + (CommonQualityEnum::Medium, true) => 5, + (CommonQualityEnum::Medium, false) => 7, + (CommonQualityEnum::High, true) => 17, + (CommonQualityEnum::High, false) => 25, + }; + + let output_cost = output_cost_per_image * self.num_images as u64; + let input_image_cost = 2 * self.num_input_images; + let cost_in_usd_cents = output_cost + input_image_cost; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn is_square_or_none(aspect_ratio: Option) -> bool { + use CommonAspectRatioEnum as Ar; + matches!( + aspect_ratio, + None + | Some(Ar::Auto) + | Some(Ar::Auto2k) + | Some(Ar::Auto3k) + | Some(Ar::Auto4k) + | Some(Ar::Square) + | Some(Ar::SquareHd) + ) +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_quality::RouterQuality; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn base() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage1, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn cost_text(quality: Option, aspect_ratio: Option, batch: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + quality, + aspect_ratio, + image_batch_count: Some(batch), + ..base() + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn cost_edit(quality: Option, aspect_ratio: Option, batch: u16, num_inputs: usize) -> u64 { + let tokens: Vec = (0..num_inputs) + .map(|i| MediaFileToken::new_from_str(&format!("mf_test{:028}", i))) + .collect(); + let builder = GenerateImageRequestBuilder { + quality, + aspect_ratio, + image_batch_count: Some(batch), + image_inputs: Some(ImageListRef::MediaFileTokens(tokens)), + ..base() + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // ── Default (High square) → 17¢ ─────────────────────────────────────────── + + #[test] + fn default_one_image_is_17c() { assert_eq!(cost_text(None, None, 1), 17); } + + #[test] + fn default_four_images_is_68c() { assert_eq!(cost_text(None, None, 4), 68); } + + // ── Low → 2¢ all sizes ──────────────────────────────────────────────────── + + #[test] + fn low_square_one() { assert_eq!(cost_text(Some(RouterQuality::Low), Some(RouterAspectRatio::Square), 1), 2); } + + #[test] + fn low_wide_one() { assert_eq!(cost_text(Some(RouterQuality::Low), Some(RouterAspectRatio::WideSixteenByNine), 1), 2); } + + #[test] + fn low_tall_one() { assert_eq!(cost_text(Some(RouterQuality::Low), Some(RouterAspectRatio::TallNineBySixteen), 1), 2); } + + #[test] + fn low_four_images() { assert_eq!(cost_text(Some(RouterQuality::Low), None, 4), 8); } + + // ── Medium ──────────────────────────────────────────────────────────────── + + #[test] + fn medium_square_one() { assert_eq!(cost_text(Some(RouterQuality::Medium), Some(RouterAspectRatio::Square), 1), 5); } + + #[test] + fn medium_wide_one() { assert_eq!(cost_text(Some(RouterQuality::Medium), Some(RouterAspectRatio::WideSixteenByNine), 1), 7); } + + #[test] + fn medium_tall_one() { assert_eq!(cost_text(Some(RouterQuality::Medium), Some(RouterAspectRatio::TallNineBySixteen), 1), 7); } + + // ── High ────────────────────────────────────────────────────────────────── + + #[test] + fn high_square_one() { assert_eq!(cost_text(Some(RouterQuality::High), Some(RouterAspectRatio::Square), 1), 17); } + + #[test] + fn high_wide_one() { assert_eq!(cost_text(Some(RouterQuality::High), Some(RouterAspectRatio::WideSixteenByNine), 1), 25); } + + #[test] + fn high_wide_four() { assert_eq!(cost_text(Some(RouterQuality::High), Some(RouterAspectRatio::WideSixteenByNine), 4), 100); } + + // ── Edit mode adds 2¢ per input image ───────────────────────────────────── + + #[test] + fn edit_one_input_adds_2c() { + assert_eq!(cost_edit(Some(RouterQuality::High), Some(RouterAspectRatio::Square), 1, 1), 19); + } + + #[test] + fn edit_three_inputs_adds_6c() { + assert_eq!(cost_edit(Some(RouterQuality::High), Some(RouterAspectRatio::Square), 1, 3), 23); + } + + #[test] + fn edit_five_inputs_adds_10c() { + assert_eq!(cost_edit(Some(RouterQuality::Medium), Some(RouterAspectRatio::WideSixteenByNine), 2, 5), 24); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/request.rs new file mode 100644 index 0000000000..808e472024 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage1RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftGptImage1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/build.rs new file mode 100644 index 0000000000..cf8bcce6c3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::request::ArtcraftGptImage1p5RequestState; + +pub fn build_artcraft_gpt_image_1p5( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::GptImage1p5)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage1p5(ArtcraftGptImage1p5RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs new file mode 100644 index 0000000000..624167c739 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs @@ -0,0 +1,173 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::request::ArtcraftGptImage1p5RequestState; + +/// Cost state for Artcraft GPT Image 1.5. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_gpt_image_1p5`): +/// +/// Output image cost (per output image): +/// Low: 1¢ square, 2¢ wide/tall +/// Medium: 4¢ square, 5¢ wide, 6¢ tall +/// High: 14¢ square, 20¢ wide/tall +/// +/// Quality defaults to High when unspecified. +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage1p5CostState { + pub quality: Option, + pub aspect_ratio: Option, + pub num_images: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SizeBucket { + Square, + Wide, + Tall, +} + +impl ArtcraftGptImage1p5CostState { + pub fn from_request(request: &ArtcraftGptImage1p5RequestState) -> Self { + Self { + quality: request.request.quality, + aspect_ratio: request.request.aspect_ratio, + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let size = size_bucket(self.aspect_ratio); + + let cost_per_image: u64 = match (quality, size) { + (CommonQualityEnum::Low, SizeBucket::Square) => 1, + (CommonQualityEnum::Low, _) => 2, + + (CommonQualityEnum::Medium, SizeBucket::Square) => 4, + (CommonQualityEnum::Medium, SizeBucket::Wide) => 5, + (CommonQualityEnum::Medium, SizeBucket::Tall) => 6, + + (CommonQualityEnum::High, SizeBucket::Square) => 14, + (CommonQualityEnum::High, _) => 20, + }; + + let cost_in_usd_cents = cost_per_image * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn size_bucket(aspect_ratio: Option) -> SizeBucket { + use CommonAspectRatioEnum as Ar; + match aspect_ratio { + None + | Some(Ar::Auto) + | Some(Ar::Auto2k) + | Some(Ar::Auto3k) + | Some(Ar::Auto4k) + | Some(Ar::Square) + | Some(Ar::SquareHd) => SizeBucket::Square, + + Some(Ar::WideThreeByTwo) + | Some(Ar::WideFourByThree) + | Some(Ar::WideFiveByFour) + | Some(Ar::WideSixteenByNine) + | Some(Ar::WideTwentyOneByNine) + | Some(Ar::Wide) => SizeBucket::Wide, + + Some(Ar::TallTwoByThree) + | Some(Ar::TallThreeByFour) + | Some(Ar::TallFourByFive) + | Some(Ar::TallNineBySixteen) + | Some(Ar::TallNineByTwentyOne) + | Some(Ar::Tall) => SizeBucket::Tall, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_quality::RouterQuality; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(quality: Option, aspect_ratio: Option, batch: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::GptImage1p5, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio, + quality, + image_batch_count: Some(batch), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // ── Default (None → High) ───────────────────────────────────────────────── + + #[test] + fn default_square_one() { assert_eq!(cost_cents(None, Some(RouterAspectRatio::Square), 1), 14); } + + #[test] + fn default_unset_one() { assert_eq!(cost_cents(None, None, 1), 14); } + + #[test] + fn default_wide_one() { assert_eq!(cost_cents(None, Some(RouterAspectRatio::WideSixteenByNine), 1), 20); } + + #[test] + fn default_tall_one() { assert_eq!(cost_cents(None, Some(RouterAspectRatio::TallNineBySixteen), 1), 20); } + + #[test] + fn default_square_four() { assert_eq!(cost_cents(None, Some(RouterAspectRatio::Square), 4), 56); } + + // ── Low ─────────────────────────────────────────────────────────────────── + + #[test] + fn low_square_one() { assert_eq!(cost_cents(Some(RouterQuality::Low), Some(RouterAspectRatio::Square), 1), 1); } + + #[test] + fn low_wide_one() { assert_eq!(cost_cents(Some(RouterQuality::Low), Some(RouterAspectRatio::WideSixteenByNine), 1), 2); } + + #[test] + fn low_tall_one() { assert_eq!(cost_cents(Some(RouterQuality::Low), Some(RouterAspectRatio::TallNineBySixteen), 1), 2); } + + // ── Medium ──────────────────────────────────────────────────────────────── + + #[test] + fn medium_square_one() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::Square), 1), 4); } + + #[test] + fn medium_wide_one() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::WideSixteenByNine), 1), 5); } + + #[test] + fn medium_tall_one() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::TallNineBySixteen), 1), 6); } + + #[test] + fn medium_tall_four() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::TallNineBySixteen), 4), 24); } + + // ── High ────────────────────────────────────────────────────────────────── + + #[test] + fn high_square_one() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::Square), 1), 14); } + + #[test] + fn high_wide_four() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::WideSixteenByNine), 4), 80); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/request.rs new file mode 100644 index 0000000000..08d2aba201 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage1p5RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftGptImage1p5RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/build.rs new file mode 100644 index 0000000000..5e38e1f535 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::gpt_image_2::request::ArtcraftGptImage2RequestState; + +pub fn build_artcraft_gpt_image_2( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::GptImage2)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage2(ArtcraftGptImage2RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs new file mode 100644 index 0000000000..6b8c502021 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs @@ -0,0 +1,201 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::gpt_image_2::request::ArtcraftGptImage2RequestState; + +/// Cost state for Artcraft GPT Image 2. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_gpt_image_2`): +/// +/// Output image cost (per output image), rounded up to whole cents: +/// Low: 1¢ all sizes +/// Medium: 4¢ landscape/portrait 4:3 or 16:9; 6¢ square or auto +/// High: 15¢ landscape/portrait 4:3; 16¢ landscape/portrait 16:9; +/// 22¢ square; 23¢ square_hd or auto +/// +/// Quality defaults to High when unspecified. +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2CostState { + pub quality: Option, + pub aspect_ratio: Option, + pub num_images: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SizeBucket { + Square, + SquareHd, + Landscape4x3, + Landscape16x9, + Portrait4x3, + Portrait16x9, + Auto, +} + +impl ArtcraftGptImage2CostState { + pub fn from_request(request: &ArtcraftGptImage2RequestState) -> Self { + Self { + quality: request.request.quality, + aspect_ratio: request.request.aspect_ratio, + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let size = size_bucket(self.aspect_ratio); + + let cost_per_image: u64 = match (quality, size) { + (CommonQualityEnum::Low, _) => 1, + + (CommonQualityEnum::Medium, SizeBucket::Square) + | (CommonQualityEnum::Medium, SizeBucket::SquareHd) + | (CommonQualityEnum::Medium, SizeBucket::Auto) => 6, + (CommonQualityEnum::Medium, SizeBucket::Landscape4x3) + | (CommonQualityEnum::Medium, SizeBucket::Portrait4x3) + | (CommonQualityEnum::Medium, SizeBucket::Landscape16x9) + | (CommonQualityEnum::Medium, SizeBucket::Portrait16x9) => 4, + + (CommonQualityEnum::High, SizeBucket::Square) => 22, + (CommonQualityEnum::High, SizeBucket::SquareHd) | (CommonQualityEnum::High, SizeBucket::Auto) => 23, + (CommonQualityEnum::High, SizeBucket::Landscape4x3) + | (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15, + (CommonQualityEnum::High, SizeBucket::Landscape16x9) + | (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16, + }; + + let cost_in_usd_cents = cost_per_image * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file +/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`. +fn size_bucket(aspect_ratio: Option) -> SizeBucket { + use CommonAspectRatioEnum as Ar; + match aspect_ratio { + None + | Some(Ar::Auto) + | Some(Ar::Auto2k) + | Some(Ar::Auto3k) + | Some(Ar::Auto4k) => SizeBucket::Auto, + + Some(Ar::Square) => SizeBucket::Square, + Some(Ar::SquareHd) => SizeBucket::SquareHd, + + Some(Ar::WideFourByThree) | Some(Ar::WideFiveByFour) => SizeBucket::Landscape4x3, + + Some(Ar::WideThreeByTwo) + | Some(Ar::WideSixteenByNine) + | Some(Ar::WideTwentyOneByNine) + | Some(Ar::Wide) => SizeBucket::Landscape16x9, + + Some(Ar::TallThreeByFour) | Some(Ar::TallFourByFive) => SizeBucket::Portrait4x3, + + Some(Ar::TallTwoByThree) + | Some(Ar::TallNineBySixteen) + | Some(Ar::TallNineByTwentyOne) + | Some(Ar::Tall) => SizeBucket::Portrait16x9, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_quality::RouterQuality; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(quality: Option, aspect_ratio: Option, batch: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio, + quality, + image_batch_count: Some(batch), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // ── Low (1¢ all sizes) ──────────────────────────────────────────────────── + + #[test] + fn low_square() { assert_eq!(cost_cents(Some(RouterQuality::Low), Some(RouterAspectRatio::Square), 1), 1); } + + #[test] + fn low_square_hd() { assert_eq!(cost_cents(Some(RouterQuality::Low), Some(RouterAspectRatio::SquareHd), 1), 1); } + + #[test] + fn low_landscape_4x3() { assert_eq!(cost_cents(Some(RouterQuality::Low), Some(RouterAspectRatio::WideFourByThree), 1), 1); } + + #[test] + fn low_four() { assert_eq!(cost_cents(Some(RouterQuality::Low), None, 4), 4); } + + // ── Medium ──────────────────────────────────────────────────────────────── + + #[test] + fn medium_square_is_6c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::Square), 1), 6); } + + #[test] + fn medium_square_hd_is_6c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::SquareHd), 1), 6); } + + #[test] + fn medium_auto_is_6c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), None, 1), 6); } + + #[test] + fn medium_landscape_4x3_is_4c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::WideFourByThree), 1), 4); } + + #[test] + fn medium_portrait_4x3_is_4c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::TallThreeByFour), 1), 4); } + + #[test] + fn medium_landscape_16x9_is_4c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::WideSixteenByNine), 1), 4); } + + #[test] + fn medium_portrait_16x9_is_4c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::TallNineBySixteen), 1), 4); } + + #[test] + fn medium_square_four_is_24c() { assert_eq!(cost_cents(Some(RouterQuality::Medium), Some(RouterAspectRatio::Square), 4), 24); } + + // ── High ────────────────────────────────────────────────────────────────── + + #[test] + fn high_square_is_22c() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::Square), 1), 22); } + + #[test] + fn high_square_hd_is_23c() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::SquareHd), 1), 23); } + + #[test] + fn high_auto_is_23c() { assert_eq!(cost_cents(Some(RouterQuality::High), None, 1), 23); } + + #[test] + fn high_landscape_4x3_is_15c() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::WideFourByThree), 1), 15); } + + #[test] + fn high_landscape_16x9_is_16c() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::WideSixteenByNine), 1), 16); } + + #[test] + fn high_square_four_is_88c() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::Square), 4), 88); } + + #[test] + fn high_square_hd_four_is_92c() { assert_eq!(cost_cents(Some(RouterQuality::High), Some(RouterAspectRatio::SquareHd), 4), 92); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/request.rs new file mode 100644 index 0000000000..ada50f72f1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftGptImage2RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs new file mode 100644 index 0000000000..bc015b334f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs @@ -0,0 +1,290 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; +use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; +use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; + +/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and +/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of +/// the same dimensions price identically. Dimensions come from the aspect ratio preset and +/// resolution tier the same way the Fal request builder derives them. +const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[ + // 1:1 + (1024, 1024, [ 65, 152, 582]), + (2048, 2048, [103, 239, 919]), + (2880, 2880, [151, 352, 1357]), + // 4:3 + (1024, 768, [ 46, 105, 400]), + (2048, 1536, [ 73, 170, 652]), + (3072, 2304, [120, 279, 1073]), + (3312, 2480, [134, 311, 1194]), + // 16:9 + (1088, 608, [ 32, 75, 285]), + (1920, 1080, [ 49, 114, 436]), + (2048, 1152, [ 53, 121, 467]), + (3072, 1728, [ 87, 204, 783]), + (3840, 2160, [124, 286, 1102]), +]; + +/// Artcraft price per reference image on an edit request, in hundredths of a US cent. +const REFERENCE_IMAGE_PRICE: u64 = 88; + +/// The inputs that determine an Artcraft GPT Image 2.5 price. Shared by Flare and Sunburst, +/// which are priced identically. +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2p5CostInputs { + pub quality: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub num_reference_images: usize, + pub num_images: u16, +} + +impl ArtcraftGptImage2p5CostInputs { + pub fn from_omni_request(request: &OmniGenImageCostAndGenerateRequest) -> Self { + Self { + quality: request.quality, + aspect_ratio: request.aspect_ratio, + resolution: request.resolution, + num_reference_images: request.image_media_tokens.as_ref().map(|tokens| tokens.len()).unwrap_or(0), + num_images: request.image_batch_count.unwrap_or(1), + } + } + + /// Per-image price rounded up to whole cents, times the batch size. Quality defaults to + /// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto, + /// matching the Fal defaults the request would fall back to. + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let (width, height) = self.output_dimensions(); + let hundredths_per_image = output_image_price(quality, width, height) + + REFERENCE_IMAGE_PRICE * self.num_reference_images as u64; + let cost_in_usd_cents = hundredths_per_image.div_ceil(100) * self.num_images as u64; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + fn output_dimensions(&self) -> (u32, u32) { + let is_edit = self.num_reference_images > 0; + let default_size = if is_edit { GptImage2p5ImageSize::Auto } else { GptImage2p5ImageSize::Landscape4x3 }; + let size = self.aspect_ratio.map(to_image_size).unwrap_or(default_size); + size.estimated_output_dimensions(self.resolution.map(to_resolution)) + } +} + +/// Look up the per-image price. Dimensions not in the table fall back to the largest row, +/// which is the most expensive. +fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u64 { + let prices = lookup_price_row(width, height) + .unwrap_or_else(|| OUTPUT_IMAGE_PRICE_TABLE[OUTPUT_IMAGE_PRICE_TABLE.len() - 1].2); + match quality { + CommonQualityEnum::Low => prices[0], + CommonQualityEnum::Medium => prices[1], + CommonQualityEnum::High => prices[2], + } +} + +fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> { + let long_edge = width.max(height); + let short_edge = width.min(height); + OUTPUT_IMAGE_PRICE_TABLE.iter() + .find(|(w, h, _)| *w == long_edge && *h == short_edge) + .map(|(_, _, prices)| *prices) +} + +/// Mirrors the Fal request builder's aspect ratio mapping. +fn to_image_size(aspect_ratio: CommonAspectRatioEnum) -> GptImage2p5ImageSize { + use CommonAspectRatioEnum as Ar; + match aspect_ratio { + Ar::Auto | Ar::Auto2k | Ar::Auto3k | Ar::Auto4k => GptImage2p5ImageSize::Auto, + Ar::Square => GptImage2p5ImageSize::Square, + Ar::SquareHd => GptImage2p5ImageSize::SquareHd, + Ar::WideFourByThree | Ar::WideFiveByFour => GptImage2p5ImageSize::Landscape4x3, + Ar::WideThreeByTwo | Ar::WideSixteenByNine | Ar::WideTwentyOneByNine | Ar::Wide => GptImage2p5ImageSize::Landscape16x9, + Ar::TallThreeByFour | Ar::TallFourByFive => GptImage2p5ImageSize::Portrait4x3, + Ar::TallTwoByThree | Ar::TallNineBySixteen | Ar::TallNineByTwentyOne | Ar::Tall => GptImage2p5ImageSize::Portrait16x9, + } +} + +/// Mirrors the Fal request builder's resolution mapping. +fn to_resolution(resolution: CommonResolutionEnum) -> GptImage2Resolution { + use CommonResolutionEnum as R; + match resolution { + R::HalfK | R::FourEightyP | R::SevenTwentyP | R::OneK => GptImage2Resolution::OneK, + R::TenEightyP | R::TwoK => GptImage2Resolution::TwoK, + R::ThreeK => GptImage2Resolution::ThreeK, + R::FourK => GptImage2Resolution::FourK, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use CommonAspectRatioEnum as Ar; + use CommonQualityEnum as Q; + use CommonResolutionEnum as R; + + const ALL_ASPECT_RATIOS: &[Ar] = &[ + Ar::Auto, Ar::Auto2k, Ar::Auto3k, Ar::Auto4k, Ar::Square, Ar::SquareHd, + Ar::WideFourByThree, Ar::WideFiveByFour, Ar::WideThreeByTwo, Ar::WideSixteenByNine, + Ar::WideTwentyOneByNine, Ar::Wide, Ar::TallThreeByFour, Ar::TallFourByFive, + Ar::TallTwoByThree, Ar::TallNineBySixteen, Ar::TallNineByTwentyOne, Ar::Tall, + ]; + + const ALL_RESOLUTIONS: &[Option] = &[ + None, Some(R::HalfK), Some(R::FourEightyP), Some(R::SevenTwentyP), Some(R::OneK), + Some(R::TenEightyP), Some(R::TwoK), Some(R::ThreeK), Some(R::FourK), + ]; + + // (aspect_ratio, resolution, [low, medium, high]) — cents per image, text-to-image + const TEXT_GRID: &[(Option, Option, [u64; 3])] = &[ + (None, None, [1, 2, 4]), // landscape 4:3 default + (Some(Ar::Square), None, [1, 2, 6]), + (Some(Ar::Square), Some(R::OneK), [1, 2, 6]), + (Some(Ar::Square), Some(R::TwoK), [2, 3, 10]), + (Some(Ar::Square), Some(R::ThreeK), [2, 4, 14]), + (Some(Ar::Square), Some(R::FourK), [2, 4, 14]), + (Some(Ar::SquareHd), None, [2, 3, 10]), + (Some(Ar::SquareHd), Some(R::OneK), [1, 2, 6]), + (Some(Ar::WideFourByThree), None, [1, 2, 4]), + (Some(Ar::WideFourByThree), Some(R::TwoK), [1, 2, 7]), + (Some(Ar::WideFourByThree), Some(R::ThreeK), [2, 3, 11]), + (Some(Ar::WideFourByThree), Some(R::FourK), [2, 4, 12]), + (Some(Ar::TallThreeByFour), None, [1, 2, 4]), + (Some(Ar::TallFourByFive), Some(R::FourK), [2, 4, 12]), + (Some(Ar::WideSixteenByNine), None, [1, 2, 5]), + (Some(Ar::WideSixteenByNine), Some(R::OneK), [1, 1, 3]), + (Some(Ar::WideSixteenByNine), Some(R::TenEightyP), [1, 2, 5]), + (Some(Ar::WideSixteenByNine), Some(R::TwoK), [1, 2, 5]), + (Some(Ar::WideSixteenByNine), Some(R::ThreeK), [1, 3, 8]), + (Some(Ar::WideSixteenByNine), Some(R::FourK), [2, 3, 12]), + (Some(Ar::TallNineBySixteen), None, [1, 2, 5]), + (Some(Ar::Wide), Some(R::HalfK), [1, 1, 3]), + (Some(Ar::Tall), Some(R::ThreeK), [1, 3, 8]), + (Some(Ar::Auto), None, [2, 3, 12]), + (Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]), + ]; + + mod text_to_image_tests { + use super::*; + + #[test] + fn grid() { + for &(aspect_ratio, resolution, expected) in TEXT_GRID { + for (quality, expected) in [Q::Low, Q::Medium, Q::High].into_iter().zip(expected) { + let actual = make_inputs(0, Some(quality), aspect_ratio, resolution, 1).estimate_cost().cost_in_usd_cents.unwrap(); + assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}"); + } + } + } + + #[test] + fn quality_defaults_to_high() { + assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6); + } + + #[test] + fn batch_multiplies_per_image_cents() { + for &(aspect_ratio, resolution, _) in TEXT_GRID { + let per_image = cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)); + for batch in 2..=4u16 { + assert_eq!(cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, batch)), per_image * batch as u64); + } + } + } + } + + mod edit_image_tests { + use super::*; + + #[test] + fn no_aspect_ratio_prices_as_auto() { + // 1102 + 88 = 1190 + assert_eq!(cents(make_inputs(1, Some(Q::High), None, None, 1)), 12); + assert_eq!(cents(make_inputs(1, Some(Q::High), Some(Ar::Auto), None, 1)), 12); + } + + #[test] + fn reference_images_add_to_price() { + // Square high 582, plus 88 per reference image + assert_eq!(cents(make_inputs(1, Some(Q::High), Some(Ar::Square), None, 1)), 7); // 670 + assert_eq!(cents(make_inputs(4, Some(Q::High), Some(Ar::Square), None, 1)), 10); // 934 + assert_eq!(cents(make_inputs(16, Some(Q::High), Some(Ar::Square), None, 1)), 20); // 1990 + assert_eq!(cents(make_inputs(1, Some(Q::Low), Some(Ar::Square), None, 1)), 2); // 153 + } + + #[test] + fn batch_multiplies_per_image_cents() { + assert_eq!(cents(make_inputs(1, Some(Q::High), Some(Ar::Square), None, 4)), 28); + } + } + + mod table_coverage_tests { + use super::*; + + #[test] + fn every_reachable_dimension_has_a_table_row() { + for &aspect_ratio in ALL_ASPECT_RATIOS { + for &resolution in ALL_RESOLUTIONS { + for refs in [0, 1] { + let (w, h) = make_inputs(refs, None, Some(aspect_ratio), resolution, 1).output_dimensions(); + assert!(lookup_price_row(w, h).is_some(), "{aspect_ratio:?} {resolution:?} => {w}x{h} missing from price table"); + } + } + } + for refs in [0, 1] { + for &resolution in ALL_RESOLUTIONS { + let (w, h) = make_inputs(refs, None, None, resolution, 1).output_dimensions(); + assert!(lookup_price_row(w, h).is_some(), "no aspect, {resolution:?}, {refs} refs => {w}x{h} missing from price table"); + } + } + } + + #[test] + fn unknown_dimensions_fall_back_to_most_expensive_row() { + assert_eq!(output_image_price(Q::High, 640, 640), 1102); + } + + #[test] + fn orientation_does_not_change_price() { + assert_eq!(output_image_price(Q::High, 768, 1024), output_image_price(Q::High, 1024, 768)); + assert_eq!(output_image_price(Q::Medium, 1080, 1920), output_image_price(Q::Medium, 1920, 1080)); + } + + #[test] + fn prices_rise_with_quality_and_size() { + for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE { + assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}"); + } + assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048)); + assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880)); + assert!(output_image_price(Q::High, 1088, 608) < output_image_price(Q::High, 1920, 1080)); + assert!(output_image_price(Q::High, 3072, 1728) < output_image_price(Q::High, 3840, 2160)); + } + } + + fn cents(inputs: ArtcraftGptImage2p5CostInputs) -> u64 { + inputs.estimate_cost().cost_in_usd_cents.unwrap() + } + + fn make_inputs( + num_reference_images: usize, + quality: Option, + aspect_ratio: Option, + resolution: Option, + num_images: u16, + ) -> ArtcraftGptImage2p5CostInputs { + ArtcraftGptImage2p5CostInputs { quality, aspect_ratio, resolution, num_reference_images, num_images } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/build.rs new file mode 100644 index 0000000000..4d0bd784bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/build.rs @@ -0,0 +1,114 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_flare::request::ArtcraftGptImage2p5FlareRequestState; + +pub fn build_artcraft_gpt_image_2p5_flare( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::GptImage2p5Flare)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage2p5Flare(ArtcraftGptImage2p5FlareRequestState { request }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; + use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + #[test] + fn build2_routes_to_omni_request_with_flare_model() { + let request = unwrap_request(base_builder().build2()); + assert_eq!(request.model, Some(CommonImageModelEnum::GptImage2p5Flare)); + assert_eq!(request.prompt.as_deref(), Some("a cat in space")); + assert_eq!(request.image_batch_count, Some(1)); + assert!(request.image_media_tokens.is_none()); + assert!(request.aspect_ratio.is_none()); + assert!(request.resolution.is_none()); + assert!(request.quality.is_none()); + assert!(request.idempotency_token.is_some()); + } + + #[test] + fn omni_request_carries_all_generation_fields() { + let request = unwrap_request(GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::TwoK), + quality: Some(RouterQuality::Medium), + image_batch_count: Some(3), + idempotency_token: Some("idem-123".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::generate(), MediaFileToken::generate()])), + ..base_builder() + }.build2()); + assert_eq!(request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + assert_eq!(request.resolution, Some(CommonResolutionEnum::TwoK)); + assert_eq!(request.quality, Some(CommonQualityEnum::Medium)); + assert_eq!(request.image_batch_count, Some(3)); + assert_eq!(request.idempotency_token.as_deref(), Some("idem-123")); + assert_eq!(request.image_media_tokens.map(|tokens| tokens.len()), Some(2)); + } + + #[test] + fn batch_count_is_validated() { + assert!(matches!( + GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }.build2(), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + GenerateImageRequestBuilder { image_batch_count: Some(5), ..base_builder() }.build2(), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + let request = unwrap_request(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..base_builder() + }.build2()); + assert_eq!(request.image_batch_count, Some(4)); + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Flare, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_request(result: Result) -> OmniGenImageCostAndGenerateRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage2p5Flare(state) + ) = result.expect("build should succeed") else { + panic!("expected Artcraft GPT Image 2.5 Flare request") + }; + state.request + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/cost.rs new file mode 100644 index 0000000000..1525d76795 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/cost.rs @@ -0,0 +1,106 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_common_cost::ArtcraftGptImage2p5CostInputs; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_flare::request::ArtcraftGptImage2p5FlareRequestState; + +/// Cost state for Artcraft GPT Image 2.5 Flare. The price table is shared with the other +/// GPT Image 2.5 variant; see `gpt_image_2p5_common_cost`. +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2p5FlareCostState { + inputs: ArtcraftGptImage2p5CostInputs, +} + +impl ArtcraftGptImage2p5FlareCostState { + pub fn from_request(request: &ArtcraftGptImage2p5FlareRequestState) -> Self { + Self { inputs: ArtcraftGptImage2p5CostInputs::from_omni_request(&request.request) } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + self.inputs.estimate_cost() + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use RouterAspectRatio as Ar; + use RouterQuality as Q; + use RouterResolution as R; + + // (reference_images, quality, aspect_ratio, resolution, batch, expected_cents) + const CASES: &[(usize, Option, Option, Option, u16, u64)] = &[ + (0, None, None, None, 1, 4), + (0, Some(Q::Low), Some(Ar::Square), None, 1, 1), + (0, Some(Q::Medium), Some(Ar::Square), None, 1, 2), + (0, Some(Q::High), Some(Ar::Square), None, 1, 6), + (0, Some(Q::High), Some(Ar::Square), None, 4, 24), + (0, Some(Q::High), Some(Ar::SquareHd), None, 1, 10), + (0, Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 10), + (0, Some(Q::High), Some(Ar::Square), Some(R::FourK), 1, 14), + (0, Some(Q::High), Some(Ar::WideFourByThree), None, 1, 4), + (0, Some(Q::High), Some(Ar::WideSixteenByNine), None, 1, 5), + (0, Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::OneK), 1, 3), + (0, Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::FourK), 1, 12), + (0, Some(Q::Medium), Some(Ar::TallNineBySixteen), Some(R::ThreeK), 2, 6), + (0, Some(Q::High), Some(Ar::Auto), None, 1, 12), + (1, None, None, None, 1, 12), + (1, Some(Q::High), Some(Ar::Square), None, 1, 7), + (4, Some(Q::High), Some(Ar::Square), None, 1, 10), + (1, Some(Q::High), Some(Ar::Square), None, 4, 28), + (1, Some(Q::Low), Some(Ar::Square), None, 1, 2), + ]; + + #[test] + fn costs_through_build2() { + for &(refs, quality, aspect_ratio, resolution, batch, expected) in CASES { + assert_eq!(cost_cents(refs, quality, aspect_ratio, resolution, batch), expected, "{refs} refs {quality:?} {aspect_ratio:?} {resolution:?} x{batch}"); + } + } + + #[test] + fn estimate_flags() { + let cost = builder(0, Some(Q::High), Some(Ar::Square), None, 1).build2().unwrap().estimate_cost().unwrap(); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } + + fn cost_cents(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> u64 { + builder(refs, quality, aspect_ratio, resolution, batch) + .build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn builder(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> GenerateImageRequestBuilder { + let image_inputs = if refs == 0 { + None + } else { + Some(ImageListRef::MediaFileTokens((0..refs).map(|_| MediaFileToken::generate()).collect())) + }; + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Flare, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs, + resolution, + aspect_ratio, + quality, + image_batch_count: Some(batch), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/request.rs new file mode 100644 index 0000000000..0584b6fab9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_flare/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2p5FlareRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftGptImage2p5FlareRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/build.rs new file mode 100644 index 0000000000..2e86e306e8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/build.rs @@ -0,0 +1,114 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_sunburst::request::ArtcraftGptImage2p5SunburstRequestState; + +pub fn build_artcraft_gpt_image_2p5_sunburst( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::GptImage2p5Sunburst)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage2p5Sunburst(ArtcraftGptImage2p5SunburstRequestState { request }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; + use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum; + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + #[test] + fn build2_routes_to_omni_request_with_sunburst_model() { + let request = unwrap_request(base_builder().build2()); + assert_eq!(request.model, Some(CommonImageModelEnum::GptImage2p5Sunburst)); + assert_eq!(request.prompt.as_deref(), Some("a cat in space")); + assert_eq!(request.image_batch_count, Some(1)); + assert!(request.image_media_tokens.is_none()); + assert!(request.aspect_ratio.is_none()); + assert!(request.resolution.is_none()); + assert!(request.quality.is_none()); + assert!(request.idempotency_token.is_some()); + } + + #[test] + fn omni_request_carries_all_generation_fields() { + let request = unwrap_request(GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::TwoK), + quality: Some(RouterQuality::Medium), + image_batch_count: Some(3), + idempotency_token: Some("idem-123".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::generate(), MediaFileToken::generate()])), + ..base_builder() + }.build2()); + assert_eq!(request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + assert_eq!(request.resolution, Some(CommonResolutionEnum::TwoK)); + assert_eq!(request.quality, Some(CommonQualityEnum::Medium)); + assert_eq!(request.image_batch_count, Some(3)); + assert_eq!(request.idempotency_token.as_deref(), Some("idem-123")); + assert_eq!(request.image_media_tokens.map(|tokens| tokens.len()), Some(2)); + } + + #[test] + fn batch_count_is_validated() { + assert!(matches!( + GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }.build2(), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + GenerateImageRequestBuilder { image_batch_count: Some(5), ..base_builder() }.build2(), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + let request = unwrap_request(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..base_builder() + }.build2()); + assert_eq!(request.image_batch_count, Some(4)); + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Sunburst, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_request(result: Result) -> OmniGenImageCostAndGenerateRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftGptImage2p5Sunburst(state) + ) = result.expect("build should succeed") else { + panic!("expected Artcraft GPT Image 2.5 Sunburst request") + }; + state.request + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/cost.rs new file mode 100644 index 0000000000..74feeed5c8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/cost.rs @@ -0,0 +1,106 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_common_cost::ArtcraftGptImage2p5CostInputs; +use crate::generate::generate_image::providers::artcraft::gpt_image_2p5_sunburst::request::ArtcraftGptImage2p5SunburstRequestState; + +/// Cost state for Artcraft GPT Image 2.5 Sunburst. The price table is shared with the other +/// GPT Image 2.5 variant; see `gpt_image_2p5_common_cost`. +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2p5SunburstCostState { + inputs: ArtcraftGptImage2p5CostInputs, +} + +impl ArtcraftGptImage2p5SunburstCostState { + pub fn from_request(request: &ArtcraftGptImage2p5SunburstRequestState) -> Self { + Self { inputs: ArtcraftGptImage2p5CostInputs::from_omni_request(&request.request) } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + self.inputs.estimate_cost() + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use RouterAspectRatio as Ar; + use RouterQuality as Q; + use RouterResolution as R; + + // (reference_images, quality, aspect_ratio, resolution, batch, expected_cents) + const CASES: &[(usize, Option, Option, Option, u16, u64)] = &[ + (0, None, None, None, 1, 4), + (0, Some(Q::Low), Some(Ar::Square), None, 1, 1), + (0, Some(Q::Medium), Some(Ar::Square), None, 1, 2), + (0, Some(Q::High), Some(Ar::Square), None, 1, 6), + (0, Some(Q::High), Some(Ar::Square), None, 4, 24), + (0, Some(Q::High), Some(Ar::SquareHd), None, 1, 10), + (0, Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 10), + (0, Some(Q::High), Some(Ar::Square), Some(R::FourK), 1, 14), + (0, Some(Q::High), Some(Ar::WideFourByThree), None, 1, 4), + (0, Some(Q::High), Some(Ar::WideSixteenByNine), None, 1, 5), + (0, Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::OneK), 1, 3), + (0, Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::FourK), 1, 12), + (0, Some(Q::Medium), Some(Ar::TallNineBySixteen), Some(R::ThreeK), 2, 6), + (0, Some(Q::High), Some(Ar::Auto), None, 1, 12), + (1, None, None, None, 1, 12), + (1, Some(Q::High), Some(Ar::Square), None, 1, 7), + (4, Some(Q::High), Some(Ar::Square), None, 1, 10), + (1, Some(Q::High), Some(Ar::Square), None, 4, 28), + (1, Some(Q::Low), Some(Ar::Square), None, 1, 2), + ]; + + #[test] + fn costs_through_build2() { + for &(refs, quality, aspect_ratio, resolution, batch, expected) in CASES { + assert_eq!(cost_cents(refs, quality, aspect_ratio, resolution, batch), expected, "{refs} refs {quality:?} {aspect_ratio:?} {resolution:?} x{batch}"); + } + } + + #[test] + fn estimate_flags() { + let cost = builder(0, Some(Q::High), Some(Ar::Square), None, 1).build2().unwrap().estimate_cost().unwrap(); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } + + fn cost_cents(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> u64 { + builder(refs, quality, aspect_ratio, resolution, batch) + .build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn builder(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> GenerateImageRequestBuilder { + let image_inputs = if refs == 0 { + None + } else { + Some(ImageListRef::MediaFileTokens((0..refs).map(|_| MediaFileToken::generate()).collect())) + }; + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Sunburst, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs, + resolution, + aspect_ratio, + quality, + image_batch_count: Some(batch), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/request.rs new file mode 100644 index 0000000000..4da9eb772c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_sunburst/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGptImage2p5SunburstRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftGptImage2p5SunburstRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs new file mode 100644 index 0000000000..aea16c0779 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState; + +pub fn build_artcraft_midjourney_7( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney7)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftMidjourney7(ArtcraftMidjourney7RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs new file mode 100644 index 0000000000..f43dd52966 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs @@ -0,0 +1,156 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState; + +/// Cost state for Artcraft Midjourney 7. Pricing mirrors the Kinovi +/// midjourney_7 provider 1:1 in USD cents — ArtCraft credits map cent-for-cent. +/// +/// | batch | credits | USD cents | +/// |-------|---------|-----------| +/// | 1 | 6 | 6¢ | +/// | 2 | 12 | 12¢ | +/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3) +/// | 4 | 25 | 25¢ | +/// +/// Kinovi-side math (historical, 22,000-credit package at 193 credits/$1): +/// 12 Kinovi credits per Midjourney task → batches 1/2/4 land on 6/12/25¢; +/// batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. Under the current +/// 525,000-credit package (243 credits/$1) Kinovi costs only 5/10/20¢ — +/// user pricing is intentionally kept at the 193-derived values. +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7CostState { + pub num_images: u16, +} + +impl ArtcraftMidjourney7CostState { + pub fn from_request(request: &ArtcraftMidjourney7RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Returns the customer-facing midjourney cost in USD cents for a given +/// batch count. Shared between v7, v7-niji, and v8 (all three Midjourney +/// models have identical pricing on Kinovi). +pub(crate) fn midjourney_cost_for_batch(batch_count: u16) -> u64 { + match batch_count { + 0 => 0, // Defensive; the builder rejects zero before reaching cost. + 1 => 6, + 2 => 12, + 3 => 19, + 4 => 25, + // Should never happen — `plan_batch_count` clamps to 1..=4. + _ => 25, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Artcraft, + prompt: Some("a corgi astronaut".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn cost_credits(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Artcraft, + prompt: Some("a corgi astronaut".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_credits.unwrap() + } + + // ── Spot checks: each supported batch size ── + + #[test] + fn one_image_is_six_cents() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn two_images_is_twelve_cents() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn three_images_is_nineteen_cents() { assert_eq!(cost_cents(3), 19); } + + #[test] + fn four_images_is_twentyfive_cents() { assert_eq!(cost_cents(4), 25); } + + // ── Credits track USD cents exactly (1 credit = 1 cent) ── + + #[test] + fn credits_equal_usd_cents() { + for batch in 1..=4u16 { + assert_eq!(cost_credits(batch), cost_cents(batch), + "credits should equal usd cents at batch {}", batch); + } + } + + // ── Monotonicity ── + + #[test] + fn cost_is_monotonically_increasing() { + let c1 = cost_cents(1); + let c2 = cost_cents(2); + let c3 = cost_cents(3); + let c4 = cost_cents(4); + assert!(c1 < c2, "{} < {}", c1, c2); + assert!(c2 < c3, "{} < {}", c2, c3); + assert!(c3 < c4, "{} < {}", c3, c4); + } + + // ── Flags ── + + #[test] + fn cost_flags_are_correct() { + let cost = ArtcraftMidjourney7CostState { num_images: 1 }.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.failures_are_refunded, None); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs new file mode 100644 index 0000000000..ab97947edc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftMidjourney7RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs new file mode 100644 index 0000000000..d5d3e0e945 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState; + +pub fn build_artcraft_midjourney_7_niji( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney7Niji)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftMidjourney7Niji(ArtcraftMidjourney7NijiRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs new file mode 100644 index 0000000000..19dfcffe72 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs @@ -0,0 +1,85 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::midjourney_cost_for_batch; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState; + +/// Cost state for Artcraft Midjourney 7 Niji. Pricing is identical to v7 +/// and v8 on Kinovi, so we share the per-batch table via +/// `midjourney_cost_for_batch`. +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7NijiCostState { + pub num_images: u16, +} + +impl ArtcraftMidjourney7NijiCostState { + pub fn from_request(request: &ArtcraftMidjourney7NijiRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7Niji, + provider: RouterProvider::Artcraft, + prompt: Some("anime forest".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn batch_1_is_6() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn batch_2_is_12() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn batch_3_is_19() { assert_eq!(cost_cents(3), 19); } + + #[test] + fn batch_4_is_25() { assert_eq!(cost_cents(4), 25); } + + #[test] + fn niji_pricing_matches_v7() { + use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState; + for batch in 1..=4u16 { + let v7 = ArtcraftMidjourney7CostState { num_images: batch }.estimate_cost(); + let niji = ArtcraftMidjourney7NijiCostState { num_images: batch }.estimate_cost(); + assert_eq!(v7.cost_in_usd_cents, niji.cost_in_usd_cents, "batch {}", batch); + assert_eq!(v7.cost_in_credits, niji.cost_in_credits, "batch {}", batch); + } + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs new file mode 100644 index 0000000000..d56f3291ce --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7NijiRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftMidjourney7NijiRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs new file mode 100644 index 0000000000..825ecced6a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState; + +pub fn build_artcraft_midjourney_8( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney8)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftMidjourney8(ArtcraftMidjourney8RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs new file mode 100644 index 0000000000..09cbaadbc8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs @@ -0,0 +1,85 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::midjourney_cost_for_batch; +use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState; + +/// Cost state for Artcraft Midjourney 8. Pricing is identical to v7 and +/// v7-niji on Kinovi; shares the per-batch table via +/// `midjourney_cost_for_batch`. +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney8CostState { + pub num_images: u16, +} + +impl ArtcraftMidjourney8CostState { + pub fn from_request(request: &ArtcraftMidjourney8RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney8, + provider: RouterProvider::Artcraft, + prompt: Some("a haunted mansion".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn batch_1_is_6() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn batch_2_is_12() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn batch_3_is_19() { assert_eq!(cost_cents(3), 19); } + + #[test] + fn batch_4_is_25() { assert_eq!(cost_cents(4), 25); } + + #[test] + fn v8_pricing_matches_v7() { + use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState; + for batch in 1..=4u16 { + let v7 = ArtcraftMidjourney7CostState { num_images: batch }.estimate_cost(); + let v8 = ArtcraftMidjourney8CostState { num_images: batch }.estimate_cost(); + assert_eq!(v7.cost_in_usd_cents, v8.cost_in_usd_cents, "batch {}", batch); + assert_eq!(v7.cost_in_credits, v8.cost_in_credits, "batch {}", batch); + } + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs new file mode 100644 index 0000000000..1f0bd730bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney8RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftMidjourney8RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs new file mode 100644 index 0000000000..b5334f4aae --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs @@ -0,0 +1,26 @@ +pub(crate) mod angle_validation; +pub(crate) mod build_common; +pub mod flux_1_dev; +pub mod flux_1_schnell; +pub mod flux_2_lora_angles; +pub mod flux_pro_1p1; +pub mod flux_pro_1p1_ultra; +pub mod gpt_image_1; +pub mod gpt_image_1p5; +pub mod gpt_image_2; +pub(crate) mod gpt_image_2p5_common_cost; +pub mod gpt_image_2p5_flare; +pub mod gpt_image_2p5_sunburst; +pub mod midjourney_7; +pub mod midjourney_7_niji; +pub mod midjourney_8; +pub mod nano_banana; +pub mod nano_banana_2; +pub mod nano_banana_pro; +pub mod qwen_edit_2511_angles; +pub(crate) mod request_common; +pub mod seedream_4; +pub mod seedream_4p5; +pub mod seedream_5_lite; +pub mod seedream_5p0_pro; +pub mod seedream_5p0_pro_ultra; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/build.rs new file mode 100644 index 0000000000..5e6395a14b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::nano_banana::request::ArtcraftNanoBananaRequestState; + +pub fn build_artcraft_nano_banana( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::NanoBanana)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftNanoBanana(ArtcraftNanoBananaRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/cost.rs new file mode 100644 index 0000000000..d2b2d12ee0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/cost.rs @@ -0,0 +1,70 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::nano_banana::request::ArtcraftNanoBananaRequestState; + +/// Cost state for Artcraft Nano Banana (Gemini 2.5 Flash). Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_nano_banana`): 4¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftNanoBananaCostState { + pub num_images: u16, +} + +impl ArtcraftNanoBananaCostState { + pub fn from_request(request: &ArtcraftNanoBananaRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 4u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::NanoBanana, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); } + + #[test] + fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); } + + #[test] + fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); } + + #[test] + fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/request.rs new file mode 100644 index 0000000000..2a1b2f2ea5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftNanoBananaRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftNanoBananaRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/build.rs new file mode 100644 index 0000000000..c74dab5efe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::nano_banana_2::request::ArtcraftNanoBanana2RequestState; + +pub fn build_artcraft_nano_banana_2( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::NanoBanana2)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftNanoBanana2(ArtcraftNanoBanana2RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/cost.rs new file mode 100644 index 0000000000..23abebe59c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/cost.rs @@ -0,0 +1,123 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::nano_banana_2::request::ArtcraftNanoBanana2RequestState; + +/// Cost state for Artcraft Nano Banana 2. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_nano_banana_2`): +/// +/// 0.5K → 6¢, 1K (default) → 8¢, 2K → 12¢, 4K → 16¢. 3K falls back to 2K +/// pricing; legacy video resolutions (480p/720p/1080p) fall back to 1K. +#[derive(Clone, Debug)] +pub struct ArtcraftNanoBanana2CostState { + pub resolution: Option, + pub num_images: u16, +} + +impl ArtcraftNanoBanana2CostState { + pub fn from_request(request: &ArtcraftNanoBanana2RequestState) -> Self { + Self { + resolution: request.request.resolution, + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_per_image: u64 = cost_per_image_in_cents(self.resolution); + let cost_in_usd_cents = cost_per_image * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn cost_per_image_in_cents(resolution: Option) -> u64 { + use CommonResolutionEnum as R; + match resolution { + Some(R::HalfK) => 6, + None + | Some(R::OneK) + | Some(R::FourEightyP) + | Some(R::SevenTwentyP) + | Some(R::TenEightyP) => 8, + Some(R::TwoK) | Some(R::ThreeK) => 12, + Some(R::FourK) => 16, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(resolution: Option, image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::NanoBanana2, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // ── Default / 1K (8¢) ────────────────────────────────────────────────────── + + #[test] + fn default_resolution_one_image_is_8c() { assert_eq!(cost_cents(None, 1), 8); } + + #[test] + fn default_resolution_four_images_is_32c() { assert_eq!(cost_cents(None, 4), 32); } + + #[test] + fn one_k_one_image_is_8c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 8); } + + // ── 0.5K (6¢) ────────────────────────────────────────────────────────────── + + #[test] + fn half_k_one_image_is_6c() { assert_eq!(cost_cents(Some(RouterResolution::HalfK), 1), 6); } + + #[test] + fn half_k_four_images_is_24c() { assert_eq!(cost_cents(Some(RouterResolution::HalfK), 4), 24); } + + // ── 2K (12¢) ─────────────────────────────────────────────────────────────── + + #[test] + fn two_k_one_image_is_12c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 12); } + + #[test] + fn two_k_four_images_is_48c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 48); } + + // ── 3K falls back to 2K pricing ──────────────────────────────────────────── + + #[test] + fn three_k_one_image_falls_back_to_two_k_pricing() { + assert_eq!(cost_cents(Some(RouterResolution::ThreeK), 1), 12); + } + + // ── 4K (16¢) ─────────────────────────────────────────────────────────────── + + #[test] + fn four_k_one_image_is_16c() { assert_eq!(cost_cents(Some(RouterResolution::FourK), 1), 16); } + + #[test] + fn four_k_four_images_is_64c() { assert_eq!(cost_cents(Some(RouterResolution::FourK), 4), 64); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/request.rs new file mode 100644 index 0000000000..4bc7a1fe00 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_2/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftNanoBanana2RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftNanoBanana2RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/build.rs new file mode 100644 index 0000000000..ec7753e2af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::nano_banana_pro::request::ArtcraftNanoBananaProRequestState; + +pub fn build_artcraft_nano_banana_pro( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::NanoBananaPro)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftNanoBananaPro(ArtcraftNanoBananaProRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/cost.rs new file mode 100644 index 0000000000..9d9a660306 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/cost.rs @@ -0,0 +1,109 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::nano_banana_pro::request::ArtcraftNanoBananaProRequestState; + +/// Cost state for Artcraft Nano Banana Pro. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_nano_banana_pro`): +/// +/// ≤2K (default 1K) → 15¢, 4K → 30¢. 3K falls back to 2K pricing; legacy +/// video resolutions and 0.5K fall back to 1K pricing. +#[derive(Clone, Debug)] +pub struct ArtcraftNanoBananaProCostState { + pub resolution: Option, + pub num_images: u16, +} + +impl ArtcraftNanoBananaProCostState { + pub fn from_request(request: &ArtcraftNanoBananaProRequestState) -> Self { + Self { + resolution: request.request.resolution, + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_per_image: u64 = match self.resolution { + Some(CommonResolutionEnum::FourK) => 30, + _ => 15, + }; + let cost_in_usd_cents = cost_per_image * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(resolution: Option, image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::NanoBananaPro, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // ── ≤2K → 15¢/image ──────────────────────────────────────────────────────── + + #[test] + fn default_resolution_one_image_is_15c() { assert_eq!(cost_cents(None, 1), 15); } + + #[test] + fn one_k_one_image_is_15c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 15); } + + #[test] + fn two_k_one_image_is_15c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 15); } + + #[test] + fn one_k_two_images_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 2), 30); } + + #[test] + fn one_k_four_images_is_60c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 60); } + + // ── 4K → 30¢/image ──────────────────────────────────────────────────────── + + #[test] + fn four_k_one_image_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::FourK), 1), 30); } + + #[test] + fn four_k_two_images_is_60c() { assert_eq!(cost_cents(Some(RouterResolution::FourK), 2), 60); } + + #[test] + fn four_k_four_images_is_120c() { assert_eq!(cost_cents(Some(RouterResolution::FourK), 4), 120); } + + // ── 3K and legacy resolutions fall back to 15¢ ───────────────────────────── + + #[test] + fn three_k_falls_back_to_15c() { assert_eq!(cost_cents(Some(RouterResolution::ThreeK), 1), 15); } + + #[test] + fn half_k_falls_back_to_15c() { assert_eq!(cost_cents(Some(RouterResolution::HalfK), 1), 15); } + + #[test] + fn ten_eighty_p_falls_back_to_15c() { assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 1), 15); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/request.rs new file mode 100644 index 0000000000..4dfefcc821 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/nano_banana_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftNanoBananaProRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftNanoBananaProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/build.rs new file mode 100644 index 0000000000..f51268f0a8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/build.rs @@ -0,0 +1,19 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::angle_validation::require_at_least_one_image_input; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles::request::ArtcraftQwenEdit2511AnglesRequestState; + +pub fn build_artcraft_qwen_edit_2511_angles( + builder: GenerateImageRequestBuilder, +) -> Result { + require_at_least_one_image_input(&builder.image_inputs)?; + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::QwenEdit2511Angles)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/cost.rs new file mode 100644 index 0000000000..6635d0ad65 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/cost.rs @@ -0,0 +1,74 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles::request::ArtcraftQwenEdit2511AnglesRequestState; + +/// Cost state for Artcraft Qwen Edit 2511 Angles. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_qwen_edit_2511_angles`): 4¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftQwenEdit2511AnglesCostState { + pub num_images: u16, +} + +impl ArtcraftQwenEdit2511AnglesCostState { + pub fn from_request(request: &ArtcraftQwenEdit2511AnglesRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // 4.6¢ per output image (held in hundredths of a cent, rounded up to + // whole cents across the batch). + let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::QwenEdit2511Angles, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new_from_str("mf_test")])), + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: Some(45.0), + vertical_angle: Some(-15.0), + zoom: Some(2.0), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); } + + #[test] + fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); } + + #[test] + fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); } + + #[test] + fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/request.rs new file mode 100644 index 0000000000..de2fbcf77a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/qwen_edit_2511_angles/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftQwenEdit2511AnglesRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftQwenEdit2511AnglesRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..8b9abd661a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/request_common.rs @@ -0,0 +1,28 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + ArtcraftImageResponsePayload, GenerateImageResponse, +}; + +/// Send an image generation request to the Artcraft omni-gen image endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_image_request( + request: &OmniGenImageCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_image_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload { + inference_job_token: response.inference_job_token, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/build.rs new file mode 100644 index 0000000000..a6374d48b1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::seedream_4::request::ArtcraftSeedream4RequestState; + +pub fn build_artcraft_seedream_4( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream4)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftSeedream4(ArtcraftSeedream4RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/cost.rs new file mode 100644 index 0000000000..7a05a74c5c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/cost.rs @@ -0,0 +1,70 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::seedream_4::request::ArtcraftSeedream4RequestState; + +/// Cost state for Artcraft Seedream V4. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_seedream_4`): 3¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream4CostState { + pub num_images: u16, +} + +impl ArtcraftSeedream4CostState { + pub fn from_request(request: &ArtcraftSeedream4RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 3u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Seedream4, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_3_cents() { assert_eq!(cost_cents(1), 3); } + + #[test] + fn two_images_costs_6_cents() { assert_eq!(cost_cents(2), 6); } + + #[test] + fn three_images_costs_9_cents() { assert_eq!(cost_cents(3), 9); } + + #[test] + fn four_images_costs_12_cents() { assert_eq!(cost_cents(4), 12); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/request.rs new file mode 100644 index 0000000000..9afda05095 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream4RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftSeedream4RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/build.rs new file mode 100644 index 0000000000..e02594f506 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState; + +pub fn build_artcraft_seedream_4p5( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream4p5)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/cost.rs new file mode 100644 index 0000000000..867ad1ade7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/cost.rs @@ -0,0 +1,64 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState; + +/// Cost state for Artcraft Seedream V4.5. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_seedream_4p5`): 4¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream4p5CostState { + pub num_images: u16, +} + +impl ArtcraftSeedream4p5CostState { + pub fn from_request(request: &ArtcraftSeedream4p5RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 4u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Seedream4p5, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); } + + #[test] + fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/request.rs new file mode 100644 index 0000000000..53936140e6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_4p5/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream4p5RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftSeedream4p5RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/build.rs new file mode 100644 index 0000000000..e631523ddd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState; + +pub fn build_artcraft_seedream_5_lite( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5Lite)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/cost.rs new file mode 100644 index 0000000000..92cafae7bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/cost.rs @@ -0,0 +1,64 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState; + +/// Cost state for Artcraft Seedream 5 Lite. Pricing mirrors v1 +/// (`estimate_image_cost_artcraft_seedream_5_lite`): 4¢ per output image. +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream5LiteCostState { + pub num_images: u16, +} + +impl ArtcraftSeedream5LiteCostState { + pub fn from_request(request: &ArtcraftSeedream5LiteRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = 4u64 * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5Lite, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); } + + #[test] + fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/request.rs new file mode 100644 index 0000000000..95c69f1df1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5_lite/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream5LiteRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftSeedream5LiteRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/build.rs new file mode 100644 index 0000000000..8e08a1f5f1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState; + +pub fn build_artcraft_seedream_5p0_pro( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0Pro)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/cost.rs new file mode 100644 index 0000000000..ea2084bf62 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/cost.rs @@ -0,0 +1,187 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState; + +/// Customer-facing price per image at 1k resolution (the default), in USD cents. +const USD_CENTS_PER_IMAGE_1K: u64 = 7; + +/// Customer-facing price per image at 2k resolution, in USD cents. +const USD_CENTS_PER_IMAGE_2K: u64 = 12; + +/// Cost state for Artcraft Seedream 5.0 Pro. Pricing is per image and +/// resolution-dependent: +/// +/// 1K (default) → 7¢, 2K → 12¢. 3K/4K fall back to 2K pricing; 0.5K and +/// legacy video resolutions (480p/720p/1080p) fall back to 1K. +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream5p0ProCostState { + pub resolution: Option, + pub num_images: u16, +} + +impl ArtcraftSeedream5p0ProCostState { + pub fn from_request(request: &ArtcraftSeedream5p0ProRequestState) -> Self { + Self { + resolution: request.request.resolution, + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_per_image: u64 = cost_per_image_in_cents(self.resolution); + let cost_in_usd_cents = cost_per_image * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn cost_per_image_in_cents(resolution: Option) -> u64 { + use CommonResolutionEnum as R; + match resolution { + None + | Some(R::HalfK) + | Some(R::OneK) + | Some(R::FourEightyP) + | Some(R::SevenTwentyP) + | Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K, + Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn estimate(resolution: Option, image_batch_count: u16) -> ImageGenerationCostEstimate { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5p0Pro, + provider: RouterProvider::Artcraft, + prompt: Some("an anime girl riding a dinosaur".to_string()), + image_inputs: None, + resolution, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap() + } + + fn cost_cents(resolution: Option, image_batch_count: u16) -> u64 { + estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap() + } + + // ── Default / 1K (7¢) ── + + #[test] + fn default_resolution_one_image_is_7c() { assert_eq!(cost_cents(None, 1), 7); } + + #[test] + fn one_k_one_image_is_7c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 7); } + + #[test] + fn one_k_four_images_is_28c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 28); } + + // ── 2K (12¢) ── + + #[test] + fn two_k_one_image_is_12c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 12); } + + #[test] + fn two_k_four_images_is_48c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 48); } + + // ── Fallbacks ── + + #[test] + fn four_k_falls_back_to_two_k_pricing() { + assert_eq!( + ArtcraftSeedream5p0ProCostState { + resolution: Some(CommonResolutionEnum::FourK), + num_images: 1, + }.estimate_cost().cost_in_usd_cents, + Some(12), + ); + } + + #[test] + fn half_k_falls_back_to_one_k_pricing() { + assert_eq!( + ArtcraftSeedream5p0ProCostState { + resolution: Some(CommonResolutionEnum::HalfK), + num_images: 1, + }.estimate_cost().cost_in_usd_cents, + Some(7), + ); + } + + // ── Credits track USD cents exactly (1 credit = 1 cent) ── + + #[test] + fn credits_equal_usd_cents() { + for batch in 1..=4u16 { + let one_k = estimate(Some(RouterResolution::OneK), batch); + assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch); + let two_k = estimate(Some(RouterResolution::TwoK), batch); + assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch); + } + } + + // ── The Artcraft price must cover the Kinovi provider cost ── + + #[test] + fn price_covers_kinovi_cost() { + use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{ + KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution, + }; + use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState; + + let cases = [ + (RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One), + (RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four), + (RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One), + (RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four), + ]; + for (router_res, kinovi_res, batch, kinovi_batch) in cases { + let artcraft_cents = cost_cents(Some(router_res), batch); + let kinovi_cents = KinoviSeedream5p0ProCostState { + resolution: kinovi_res, + batch_count: kinovi_batch, + }.estimate_cost().cost_in_usd_cents.unwrap(); + assert!( + artcraft_cents > kinovi_cents, + "artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})", + artcraft_cents, kinovi_cents, router_res, batch, + ); + } + } + + // ── Flags ── + + #[test] + fn cost_flags_are_correct() { + let cost = ArtcraftSeedream5p0ProCostState { resolution: None, num_images: 1 }.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.failures_are_refunded, None); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/request.rs new file mode 100644 index 0000000000..76a0bda9b7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream5p0ProRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftSeedream5p0ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/build.rs new file mode 100644 index 0000000000..24339af5d2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState; + +pub fn build_artcraft_seedream_5p0_pro_ultra( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0ProUltra)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/cost.rs new file mode 100644 index 0000000000..9236c4205d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/cost.rs @@ -0,0 +1,208 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState; + +/// Customer-facing price per image at 1k resolution (the default), in USD cents. +const USD_CENTS_PER_IMAGE_1K: u64 = 18; + +/// Customer-facing price per image at 2k resolution, in USD cents. +const USD_CENTS_PER_IMAGE_2K: u64 = 30; + +/// Cost state for Artcraft Seedream 5.0 Pro Ultra. Pricing is per image and +/// resolution-dependent: +/// +/// 1K (default) → 18¢, 2K → 30¢. 3K/4K fall back to 2K pricing; 0.5K and +/// legacy video resolutions (480p/720p/1080p) fall back to 1K. +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream5p0ProUltraCostState { + pub resolution: Option, + pub num_images: u16, +} + +impl ArtcraftSeedream5p0ProUltraCostState { + pub fn from_request(request: &ArtcraftSeedream5p0ProUltraRequestState) -> Self { + Self { + resolution: request.request.resolution, + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_per_image: u64 = cost_per_image_in_cents(self.resolution); + let cost_in_usd_cents = cost_per_image * self.num_images as u64; + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn cost_per_image_in_cents(resolution: Option) -> u64 { + use CommonResolutionEnum as R; + match resolution { + None + | Some(R::HalfK) + | Some(R::OneK) + | Some(R::FourEightyP) + | Some(R::SevenTwentyP) + | Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K, + Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + + fn estimate(resolution: Option, image_batch_count: u16) -> ImageGenerationCostEstimate { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5p0ProUltra, + provider: RouterProvider::Artcraft, + prompt: Some("an anime girl riding a dinosaur".to_string()), + image_inputs: None, + resolution, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap() + } + + fn cost_cents(resolution: Option, image_batch_count: u16) -> u64 { + estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap() + } + + // ── Default / 1K (18¢) ── + + #[test] + fn default_resolution_one_image_is_18c() { assert_eq!(cost_cents(None, 1), 18); } + + #[test] + fn one_k_one_image_is_18c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 18); } + + #[test] + fn one_k_four_images_is_72c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 72); } + + // ── 2K (30¢) ── + + #[test] + fn two_k_one_image_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 30); } + + #[test] + fn two_k_four_images_is_120c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 120); } + + // ── Fallbacks ── + + #[test] + fn four_k_falls_back_to_two_k_pricing() { + assert_eq!( + ArtcraftSeedream5p0ProUltraCostState { + resolution: Some(CommonResolutionEnum::FourK), + num_images: 1, + }.estimate_cost().cost_in_usd_cents, + Some(30), + ); + } + + #[test] + fn half_k_falls_back_to_one_k_pricing() { + assert_eq!( + ArtcraftSeedream5p0ProUltraCostState { + resolution: Some(CommonResolutionEnum::HalfK), + num_images: 1, + }.estimate_cost().cost_in_usd_cents, + Some(18), + ); + } + + // ── Credits track USD cents exactly (1 credit = 1 cent) ── + + #[test] + fn credits_equal_usd_cents() { + for batch in 1..=4u16 { + let one_k = estimate(Some(RouterResolution::OneK), batch); + assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch); + let two_k = estimate(Some(RouterResolution::TwoK), batch); + assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch); + } + } + + // ── The price must exceed the base Seedream 5.0 Pro price ── + + #[test] + fn price_exceeds_base_seedream_5p0_pro_price() { + use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState; + + for resolution in [None, Some(CommonResolutionEnum::OneK), Some(CommonResolutionEnum::TwoK)] { + for num_images in [1u16, 4] { + let ultra_cents = ArtcraftSeedream5p0ProUltraCostState { resolution, num_images } + .estimate_cost().cost_in_usd_cents.unwrap(); + let base_cents = ArtcraftSeedream5p0ProCostState { resolution, num_images } + .estimate_cost().cost_in_usd_cents.unwrap(); + assert!( + ultra_cents > base_cents, + "ultra price {}¢ must exceed base price {}¢ ({:?} batch {})", + ultra_cents, base_cents, resolution, num_images, + ); + } + } + } + + // ── The Artcraft price must cover the Kinovi provider cost ── + + #[test] + fn price_covers_kinovi_cost() { + use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{ + KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution, + }; + use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState; + + let cases = [ + (RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One), + (RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four), + (RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One), + (RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four), + ]; + for (router_res, kinovi_res, batch, kinovi_batch) in cases { + let artcraft_cents = cost_cents(Some(router_res), batch); + let kinovi_cents = KinoviSeedream5p0ProCostState { + resolution: kinovi_res, + batch_count: kinovi_batch, + }.estimate_cost().cost_in_usd_cents.unwrap(); + assert!( + artcraft_cents > kinovi_cents, + "artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})", + artcraft_cents, kinovi_cents, router_res, batch, + ); + } + } + + // ── Flags ── + + #[test] + fn cost_flags_are_correct() { + let cost = ArtcraftSeedream5p0ProUltraCostState { resolution: None, num_images: 1 }.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.failures_are_refunded, None); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/request.rs new file mode 100644 index 0000000000..500f0aa30f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/seedream_5p0_pro_ultra/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedream5p0ProUltraRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftSeedream5p0ProUltraRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/build.rs new file mode 100644 index 0000000000..696c42ab72 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/build.rs @@ -0,0 +1,436 @@ +use fal_client::requests::api::image::edit::flux_1_dev_edit_image::api::{ + Flux1DevEditImageNumImages, Flux1DevEditImageRequest, +}; +use fal_client::requests::api::image::text::flux_1_dev_text_to_image::api::{ + Flux1DevTextToImageAspectRatio, Flux1DevTextToImageNumImages, Flux1DevTextToImageRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState; + +pub fn build_fal_flux_1_dev( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let prompt = builder.prompt.clone().unwrap_or_default(); + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let image_url = resolve_single_image_url(builder.image_inputs.clone())?; + + let state = if let Some(url) = image_url { + // Edit image (single image URL, no aspect ratio / resolution controls) + FalFlux1DevRequestState::EditImage(Flux1DevEditImageRequest { + prompt, + image_url: url, + num_images: to_edit_num_images(num_images), + }) + } else { + // Text-to-image + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio); + + FalFlux1DevRequestState::TextToImage(Flux1DevTextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + aspect_ratio, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux1Dev(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> Flux1DevTextToImageNumImages { + match n { + PlannedNumImages::One => Flux1DevTextToImageNumImages::One, + PlannedNumImages::Two => Flux1DevTextToImageNumImages::Two, + PlannedNumImages::Three => Flux1DevTextToImageNumImages::Three, + PlannedNumImages::Four => Flux1DevTextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> Flux1DevEditImageNumImages { + match n { + PlannedNumImages::One => Flux1DevEditImageNumImages::One, + PlannedNumImages::Two => Flux1DevEditImageNumImages::Two, + PlannedNumImages::Three => Flux1DevEditImageNumImages::Three, + PlannedNumImages::Four => Flux1DevEditImageNumImages::Four, + } +} + +// ── Aspect ratio ── + +fn plan_aspect_ratio( + aspect_ratio: Option, +) -> Flux1DevTextToImageAspectRatio { + use Flux1DevTextToImageAspectRatio as T; + match aspect_ratio { + None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => T::SquareHd, + Some(RouterAspectRatio::Square) => T::Square, + Some(RouterAspectRatio::SquareHd) => T::SquareHd, + Some(RouterAspectRatio::WideFourByThree) | Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::Wide) => T::LandscapeFourByThree, + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::WideTwentyOneByNine) => T::LandscapeSixteenByNine, + Some(RouterAspectRatio::TallThreeByFour) | Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallTwoByThree) | Some(RouterAspectRatio::Tall) => T::PortraitThreeByFour, + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::TallNineByTwentyOne) => T::PortraitNineBySixteen, + } +} + +// ── Image inputs ── + +/// Flux 1 Dev edit takes exactly one image URL. v1 rejects multi-URL inputs; +/// match that strictness so cost parity holds across the full sweep. +fn resolve_single_image_url( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(None), + Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None), + Some(ImageListRef::Urls(urls)) if urls.len() == 1 => Ok(Some(urls.into_iter().next().unwrap())), + Some(ImageListRef::Urls(urls)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Flux 1 Dev image-to-image supports exactly 1 image, got {}", urls.len()), + })) + } + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Flux1Dev, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> Flux1DevTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux1Dev( + FalFlux1DevRequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected TextToImage variant") + }; + req + } + + fn unwrap_edit(result: Result) -> Flux1DevEditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux1Dev( + FalFlux1DevRequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected EditImage variant") + }; + req + } + + // ── Mode detection ── + + mod mode_detection { + use super::*; + + #[test] + fn no_images_yields_text_to_image() { + let req = unwrap_t2i(build_fal_flux_1_dev(base_builder())); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn single_url_yields_edit_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_dev(builder)); + assert_eq!(req.image_url, "https://example.com/img.jpg"); + } + + #[test] + fn multiple_urls_rejected_for_parity_with_v1() { + // v1 hard-rejects >1 URL for Flux 1 Dev edit. v2 mirrors that. + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![ + "https://example.com/a.jpg".to_string(), + "https://example.com/b.jpg".to_string(), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_dev(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })) + )); + } + + #[test] + fn empty_urls_yields_text_to_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![])), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_dev(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_t2i(build_fal_flux_1_dev(base_builder())); + assert!(matches!(req.num_images, Flux1DevTextToImageNumImages::One)); + } + + #[test] + fn explicit_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.num_images, Flux1DevTextToImageNumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_dev(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_dev(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.num_images, Flux1DevTextToImageNumImages::Four)); + } + + #[test] + fn edit_mode_num_images() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(2), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_dev(builder)); + assert!(matches!(req.num_images, Flux1DevEditImageNumImages::Two)); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn default_is_square_hd() { + let req = unwrap_t2i(build_fal_flux_1_dev(base_builder())); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::SquareHd)); + } + + #[test] + fn auto_yields_square_hd() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::SquareHd)); + } + + #[test] + fn square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::Square)); + } + + #[test] + fn square_hd() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::SquareHd), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::SquareHd)); + } + + #[test] + fn wide_four_by_three() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideFourByThree), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::LandscapeFourByThree)); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn wide_twenty_one_by_nine_maps_to_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn tall_three_by_four() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallThreeByFour), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::PortraitThreeByFour)); + } + + #[test] + fn tall_nine_by_sixteen() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert!(matches!(req.aspect_ratio, Flux1DevTextToImageAspectRatio::PortraitNineBySixteen)); + } + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { + prompt: None, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_dev(builder)); + assert_eq!(req.prompt, ""); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/cost.rs new file mode 100644 index 0000000000..b28d331906 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/cost.rs @@ -0,0 +1,113 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState; + +pub struct FalFlux1DevCostState { + cost_in_usd_cents: u64, +} + +impl FalFlux1DevCostState { + pub fn from_request(request: &FalFlux1DevRequestState) -> Self { + let cost_in_usd_cents = match request { + FalFlux1DevRequestState::TextToImage(req) => req.calculate_cost_in_cents(), + FalFlux1DevRequestState::EditImage(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::edit::flux_1_dev_edit_image::api::{ + Flux1DevEditImageNumImages, Flux1DevEditImageRequest, + }; + use fal_client::requests::api::image::text::flux_1_dev_text_to_image::api::{ + Flux1DevTextToImageAspectRatio, Flux1DevTextToImageNumImages, Flux1DevTextToImageRequest, + }; + + mod text_to_image_costs { + use super::*; + + fn make_t2i(num_images: Flux1DevTextToImageNumImages) -> FalFlux1DevRequestState { + FalFlux1DevRequestState::TextToImage(Flux1DevTextToImageRequest { + prompt: "test".to_string(), + num_images, + aspect_ratio: Flux1DevTextToImageAspectRatio::Square, + }) + } + + #[test] + fn one_image() { + let cost = FalFlux1DevCostState::from_request(&make_t2i(Flux1DevTextToImageNumImages::One)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(3)); + } + + #[test] + fn two_images() { + let cost = FalFlux1DevCostState::from_request(&make_t2i(Flux1DevTextToImageNumImages::Two)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(6)); + } + + #[test] + fn four_images() { + let cost = FalFlux1DevCostState::from_request(&make_t2i(Flux1DevTextToImageNumImages::Four)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(12)); + } + } + + mod edit_image_costs { + use super::*; + + fn make_edit(num_images: Flux1DevEditImageNumImages) -> FalFlux1DevRequestState { + FalFlux1DevRequestState::EditImage(Flux1DevEditImageRequest { + prompt: "test".to_string(), + image_url: "https://example.com/img.jpg".to_string(), + num_images, + }) + } + + #[test] + fn one_image() { + let cost = FalFlux1DevCostState::from_request(&make_edit(Flux1DevEditImageNumImages::One)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(3)); + } + + #[test] + fn three_images() { + let cost = FalFlux1DevCostState::from_request(&make_edit(Flux1DevEditImageNumImages::Three)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(9)); + } + } + + #[test] + fn cost_flags_are_correct() { + let state = FalFlux1DevCostState::from_request( + &FalFlux1DevRequestState::TextToImage(Flux1DevTextToImageRequest { + prompt: "test".to_string(), + num_images: Flux1DevTextToImageNumImages::One, + aspect_ratio: Flux1DevTextToImageAspectRatio::Square, + }), + ); + let cost = state.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/request.rs new file mode 100644 index 0000000000..e14a4f1a54 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_dev/request.rs @@ -0,0 +1,155 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::flux_1_dev_edit_image::api::Flux1DevEditImageRequest; +use fal_client::requests::api::image::text::flux_1_dev_text_to_image::api::Flux1DevTextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalFlux1DevRequestState { + TextToImage(Flux1DevTextToImageRequest), + EditImage(Flux1DevEditImageRequest), +} + +impl FalFlux1DevRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + } + } +} + +// ── Helpers ── + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::flux_1_dev_edit_image::api::Flux1DevEditImageNumImages; + use fal_client::requests::api::image::text::flux_1_dev_text_to_image::api::{ + Flux1DevTextToImageAspectRatio, Flux1DevTextToImageNumImages, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + // ── Text-to-image ── + + mod text_to_image { + use super::*; + + fn t2i_request() -> FalFlux1DevRequestState { + FalFlux1DevRequestState::TextToImage(Flux1DevTextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: Flux1DevTextToImageNumImages::One, + aspect_ratio: Flux1DevTextToImageAspectRatio::LandscapeSixteenByNine, + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = t2i_request(); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + } + + // ── Edit image ── + + mod edit_image { + use super::*; + + fn edit_request() -> FalFlux1DevRequestState { + FalFlux1DevRequestState::EditImage(Flux1DevEditImageRequest { + prompt: "turn this into a watercolor painting".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::One, + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = edit_request(); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook edit — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/build.rs new file mode 100644 index 0000000000..7a5d005b5c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/build.rs @@ -0,0 +1,451 @@ +use fal_client::requests::api::image::edit::flux_1_schnell_edit_image::api::{ + Flux1SchnellEditImageNumImages, Flux1SchnellEditImageRequest, Flux1SchnellEditImageSize, +}; +use fal_client::requests::api::image::text::flux_1_schnell_text_to_image::api::{ + Flux1SchnellTextToImageAspectRatio, Flux1SchnellTextToImageNumImages, + Flux1SchnellTextToImageRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::flux_1_schnell::request::FalFlux1SchnellRequestState; + +pub fn build_fal_flux_1_schnell( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let prompt = builder.prompt.clone().unwrap_or_default(); + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let image_url = resolve_single_image_url(builder.image_inputs.clone())?; + + let state = if let Some(url) = image_url { + // Edit image (redux): single image URL, optional image_size, no prompt + let image_size = plan_edit_image_size(builder.aspect_ratio); + + FalFlux1SchnellRequestState::EditImage(Flux1SchnellEditImageRequest { + image_url: url, + num_images: to_edit_num_images(num_images), + image_size, + }) + } else { + // Text-to-image + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio); + + FalFlux1SchnellRequestState::TextToImage(Flux1SchnellTextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + aspect_ratio, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux1Schnell(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> Flux1SchnellTextToImageNumImages { + match n { + PlannedNumImages::One => Flux1SchnellTextToImageNumImages::One, + PlannedNumImages::Two => Flux1SchnellTextToImageNumImages::Two, + PlannedNumImages::Three => Flux1SchnellTextToImageNumImages::Three, + PlannedNumImages::Four => Flux1SchnellTextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> Flux1SchnellEditImageNumImages { + match n { + PlannedNumImages::One => Flux1SchnellEditImageNumImages::One, + PlannedNumImages::Two => Flux1SchnellEditImageNumImages::Two, + PlannedNumImages::Three => Flux1SchnellEditImageNumImages::Three, + PlannedNumImages::Four => Flux1SchnellEditImageNumImages::Four, + } +} + +// ── Aspect ratio ── + +fn plan_aspect_ratio( + aspect_ratio: Option, +) -> Flux1SchnellTextToImageAspectRatio { + use Flux1SchnellTextToImageAspectRatio as T; + match aspect_ratio { + None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => T::SquareHd, + Some(RouterAspectRatio::Square) => T::Square, + Some(RouterAspectRatio::SquareHd) => T::SquareHd, + Some(RouterAspectRatio::WideFourByThree) | Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::Wide) => T::LandscapeFourByThree, + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::WideTwentyOneByNine) => T::LandscapeSixteenByNine, + Some(RouterAspectRatio::TallThreeByFour) | Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallTwoByThree) | Some(RouterAspectRatio::Tall) => T::PortraitThreeByFour, + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::TallNineByTwentyOne) => T::PortraitNineBySixteen, + } +} + +fn plan_edit_image_size( + aspect_ratio: Option, +) -> Option { + use Flux1SchnellEditImageSize as S; + aspect_ratio.and_then(|ar| match ar { + RouterAspectRatio::Auto | RouterAspectRatio::Auto2k + | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k => None, + RouterAspectRatio::Square => Some(S::Square), + RouterAspectRatio::SquareHd => Some(S::SquareHd), + RouterAspectRatio::WideFourByThree | RouterAspectRatio::WideFiveByFour + | RouterAspectRatio::WideThreeByTwo | RouterAspectRatio::Wide => Some(S::LandscapeFourByThree), + RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::WideTwentyOneByNine => Some(S::LandscapeSixteenByNine), + RouterAspectRatio::TallThreeByFour | RouterAspectRatio::TallFourByFive + | RouterAspectRatio::TallTwoByThree | RouterAspectRatio::Tall => Some(S::PortraitThreeByFour), + RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::TallNineByTwentyOne => Some(S::PortraitNineBySixteen), + }) +} + +// ── Image inputs ── + +/// Flux 1 Schnell redux takes exactly one image URL. v1 rejects multi-URL +/// inputs; match that strictness so cost parity holds across the full sweep. +fn resolve_single_image_url( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(None), + Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None), + Some(ImageListRef::Urls(urls)) if urls.len() == 1 => Ok(Some(urls.into_iter().next().unwrap())), + Some(ImageListRef::Urls(urls)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("Flux 1 Schnell redux supports exactly 1 image, got {}", urls.len()), + })) + } + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Flux1Schnell, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> Flux1SchnellTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux1Schnell( + FalFlux1SchnellRequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected TextToImage variant") + }; + req + } + + fn unwrap_edit(result: Result) -> Flux1SchnellEditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux1Schnell( + FalFlux1SchnellRequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected EditImage variant") + }; + req + } + + // ── Mode detection ── + + mod mode_detection { + use super::*; + + #[test] + fn no_images_yields_text_to_image() { + let req = unwrap_t2i(build_fal_flux_1_schnell(base_builder())); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn single_url_yields_edit_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_schnell(builder)); + assert_eq!(req.image_url, "https://example.com/img.jpg"); + } + + #[test] + fn multiple_urls_rejected_for_parity_with_v1() { + // v1 hard-rejects >1 URL for Flux 1 Schnell redux. v2 mirrors that. + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![ + "https://example.com/a.jpg".to_string(), + "https://example.com/b.jpg".to_string(), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_schnell(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })) + )); + } + + #[test] + fn empty_urls_yields_text_to_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![])), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_schnell(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_t2i(build_fal_flux_1_schnell(base_builder())); + assert!(matches!(req.num_images, Flux1SchnellTextToImageNumImages::One)); + } + + #[test] + fn explicit_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.num_images, Flux1SchnellTextToImageNumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_schnell(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_1_schnell(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.num_images, Flux1SchnellTextToImageNumImages::Four)); + } + + #[test] + fn edit_mode_num_images() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(2), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.num_images, Flux1SchnellEditImageNumImages::Two)); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn default_is_square_hd() { + let req = unwrap_t2i(build_fal_flux_1_schnell(base_builder())); + assert!(matches!(req.aspect_ratio, Flux1SchnellTextToImageAspectRatio::SquareHd)); + } + + #[test] + fn square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.aspect_ratio, Flux1SchnellTextToImageAspectRatio::Square)); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.aspect_ratio, Flux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn tall_nine_by_sixteen() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.aspect_ratio, Flux1SchnellTextToImageAspectRatio::PortraitNineBySixteen)); + } + + #[test] + fn edit_auto_yields_none() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_schnell(builder)); + assert!(req.image_size.is_none()); + } + + #[test] + fn edit_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.image_size, Some(Flux1SchnellEditImageSize::Square))); + } + + #[test] + fn edit_landscape() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_schnell(builder)); + assert!(matches!(req.image_size, Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine))); + } + + #[test] + fn edit_no_aspect_ratio_yields_none() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: None, + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_flux_1_schnell(builder)); + assert!(req.image_size.is_none()); + } + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { + prompt: None, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_flux_1_schnell(builder)); + assert_eq!(req.prompt, ""); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/cost.rs new file mode 100644 index 0000000000..be5f83c33b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/cost.rs @@ -0,0 +1,114 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::flux_1_schnell::request::FalFlux1SchnellRequestState; + +pub struct FalFlux1SchnellCostState { + cost_in_usd_cents: u64, +} + +impl FalFlux1SchnellCostState { + pub fn from_request(request: &FalFlux1SchnellRequestState) -> Self { + let cost_in_usd_cents = match request { + FalFlux1SchnellRequestState::TextToImage(req) => req.calculate_cost_in_cents(), + FalFlux1SchnellRequestState::EditImage(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::edit::flux_1_schnell_edit_image::api::{ + Flux1SchnellEditImageNumImages, Flux1SchnellEditImageRequest, + }; + use fal_client::requests::api::image::text::flux_1_schnell_text_to_image::api::{ + Flux1SchnellTextToImageAspectRatio, Flux1SchnellTextToImageNumImages, + Flux1SchnellTextToImageRequest, + }; + + mod text_to_image_costs { + use super::*; + + fn make_t2i(num_images: Flux1SchnellTextToImageNumImages) -> FalFlux1SchnellRequestState { + FalFlux1SchnellRequestState::TextToImage(Flux1SchnellTextToImageRequest { + prompt: "test".to_string(), + num_images, + aspect_ratio: Flux1SchnellTextToImageAspectRatio::Square, + }) + } + + #[test] + fn one_image() { + let cost = FalFlux1SchnellCostState::from_request(&make_t2i(Flux1SchnellTextToImageNumImages::One)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(1)); + } + + #[test] + fn two_images() { + let cost = FalFlux1SchnellCostState::from_request(&make_t2i(Flux1SchnellTextToImageNumImages::Two)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(2)); + } + + #[test] + fn four_images() { + let cost = FalFlux1SchnellCostState::from_request(&make_t2i(Flux1SchnellTextToImageNumImages::Four)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(4)); + } + } + + mod edit_image_costs { + use super::*; + + fn make_edit(num_images: Flux1SchnellEditImageNumImages) -> FalFlux1SchnellRequestState { + FalFlux1SchnellRequestState::EditImage(Flux1SchnellEditImageRequest { + image_url: "https://example.com/img.jpg".to_string(), + num_images, + image_size: None, + }) + } + + #[test] + fn one_image() { + let cost = FalFlux1SchnellCostState::from_request(&make_edit(Flux1SchnellEditImageNumImages::One)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(1)); + } + + #[test] + fn three_images() { + let cost = FalFlux1SchnellCostState::from_request(&make_edit(Flux1SchnellEditImageNumImages::Three)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(3)); + } + } + + #[test] + fn cost_flags_are_correct() { + let state = FalFlux1SchnellCostState::from_request( + &FalFlux1SchnellRequestState::TextToImage(Flux1SchnellTextToImageRequest { + prompt: "test".to_string(), + num_images: Flux1SchnellTextToImageNumImages::One, + aspect_ratio: Flux1SchnellTextToImageAspectRatio::Square, + }), + ); + let cost = state.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/request.rs new file mode 100644 index 0000000000..7f23db669f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_1_schnell/request.rs @@ -0,0 +1,151 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::flux_1_schnell_edit_image::api::Flux1SchnellEditImageRequest; +use fal_client::requests::api::image::text::flux_1_schnell_text_to_image::api::Flux1SchnellTextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalFlux1SchnellRequestState { + TextToImage(Flux1SchnellTextToImageRequest), + EditImage(Flux1SchnellEditImageRequest), +} + +impl FalFlux1SchnellRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + } + } +} + +// ── Helpers ── + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::flux_1_schnell_edit_image::api::Flux1SchnellEditImageNumImages; + use fal_client::requests::api::image::text::flux_1_schnell_text_to_image::api::{ + Flux1SchnellTextToImageAspectRatio, Flux1SchnellTextToImageNumImages, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + // ── Text-to-image ── + + mod text_to_image { + use super::*; + + fn t2i_request() -> FalFlux1SchnellRequestState { + FalFlux1SchnellRequestState::TextToImage(Flux1SchnellTextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: Flux1SchnellTextToImageNumImages::One, + aspect_ratio: Flux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine, + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let response = t2i_request().send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + } + + // ── Edit image ── + + mod edit_image { + use super::*; + + fn edit_request() -> FalFlux1SchnellRequestState { + FalFlux1SchnellRequestState::EditImage(Flux1SchnellEditImageRequest { + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::One, + image_size: None, + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let response = edit_request().send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook edit — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/build.rs new file mode 100644 index 0000000000..00114b8979 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/build.rs @@ -0,0 +1,209 @@ +use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{ + Flux2LoraAngleImageSize, Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::flux_2_lora_angles::request::FalFlux2LoraAnglesRequestState; + +pub fn build_fal_flux_2_lora_angles( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + if image_urls.is_empty() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Angle models require at least one input image URL".to_string(), + })); + } + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let image_size = plan_image_size(builder.aspect_ratio, strategy)?; + + let request = Flux2LoraEditImageAngleRequest { + image_urls, + horizontal_angle: builder.horizontal_angle, + vertical_angle: builder.vertical_angle.map(|v| v.clamp(0.0, 60.0)), + zoom: builder.zoom, + num_images: Some(num_images), + image_size, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState { request }), + )) +} + +fn resolve_image_urls(image_inputs: Option) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Flux2LoraAngleNumImages as N; + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(N::One), + 2 => Ok(N::Two), + 3 => Ok(N::Three), + 4 => Ok(N::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(N::Four), + }, + } +} + +fn plan_image_size( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Flux2LoraAngleImageSize as S; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Square) => Ok(Some(S::Square)), + Some(RouterAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Wide) | Some(RouterAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(RouterAspectRatio::Tall) | Some(RouterAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::SquareHd)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + #[test] + fn passes_through_camera_params() { + let builder = GenerateImageRequestBuilder { + vertical_angle: Some(15.0), + ..base() + }; + let req = unwrap_request(build_fal_flux_2_lora_angles(builder)); + assert_eq!(req.horizontal_angle, Some(45.0)); + assert_eq!(req.vertical_angle, Some(15.0)); + assert_eq!(req.zoom, Some(2.0)); + } + + #[test] + fn negative_vertical_angle_clamps_to_zero() { + // Fal's flux-2-lora-gallery rejects vertical_angle < 0 (schema requires ge: 0.0). + let builder = GenerateImageRequestBuilder { + vertical_angle: Some(-30.0), + ..base() + }; + let req = unwrap_request(build_fal_flux_2_lora_angles(builder)); + assert_eq!(req.vertical_angle, Some(0.0)); + } + + #[test] + fn vertical_angle_above_sixty_clamps_to_sixty() { + // Fal's flux-2-lora-gallery rejects vertical_angle > 60 (schema requires le: 60.0). + let builder = GenerateImageRequestBuilder { + vertical_angle: Some(90.0), + ..base() + }; + let req = unwrap_request(build_fal_flux_2_lora_angles(builder)); + assert_eq!(req.vertical_angle, Some(60.0)); + } + + #[test] + fn missing_image_inputs_errors() { + let builder = GenerateImageRequestBuilder { image_inputs: None, ..base() }; + assert!(build_fal_flux_2_lora_angles(builder).is_err()); + } + + #[test] + fn media_file_tokens_rejected() { + use tokens::tokens::media_files::MediaFileToken; + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new_from_str("mf_test")])), + ..base() + }; + assert!(matches!( + build_fal_flux_2_lora_angles(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn batch_zero_errors() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base() }; + assert!(matches!( + build_fal_flux_2_lora_angles(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + fn base() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Flux2LoraAngles, + provider: RouterProvider::Fal, + prompt: None, + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])), + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: Some(45.0), + vertical_angle: Some(-15.0), + zoom: Some(2.0), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_request(result: Result) -> Flux2LoraEditImageAngleRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFlux2LoraAngles(state) + ) = result.expect("build should succeed") else { + panic!("expected FalFlux2LoraAngles variant") + }; + state.request + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/cost.rs new file mode 100644 index 0000000000..ae2ddf1461 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/cost.rs @@ -0,0 +1,75 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::flux_2_lora_angles::request::FalFlux2LoraAnglesRequestState; + +/// Cost state for Fal Flux 2 LoRA Angles. Delegates to the fal_client cost +/// calculator (2¢ per output image — $0.021/MP at the default ~1MP). +pub struct FalFlux2LoraAnglesCostState { + cost_in_usd_cents: u64, +} + +impl FalFlux2LoraAnglesCostState { + pub fn from_request(request: &FalFlux2LoraAnglesRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{ + Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest, + }; + + #[test] + fn one_image_is_2_cents() { + assert_eq!(cost_for(Flux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2)); + } + + #[test] + fn two_images_is_4_cents() { + assert_eq!(cost_for(Flux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4)); + } + + #[test] + fn three_images_is_6_cents() { + assert_eq!(cost_for(Flux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6)); + } + + #[test] + fn four_images_is_8_cents() { + assert_eq!(cost_for(Flux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8)); + } + + fn cost_for(n: Flux2LoraAngleNumImages) -> ImageGenerationCostEstimate { + let state = FalFlux2LoraAnglesCostState::from_request(&FalFlux2LoraAnglesRequestState { + request: Flux2LoraEditImageAngleRequest { + image_urls: vec!["https://example.com/x.jpg".to_string()], + horizontal_angle: None, + vertical_angle: None, + zoom: None, + num_images: Some(n), + image_size: None, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + }); + state.estimate_cost() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/request.rs new file mode 100644 index 0000000000..e6c184670e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_2_lora_angles/request.rs @@ -0,0 +1,111 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::Flux2LoraEditImageAngleRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalFlux2LoraAnglesRequestState { + pub request: Flux2LoraEditImageAngleRequest, +} + +impl FalFlux2LoraAnglesRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let outbound: Arc = Arc::new(self.request.clone()); + let payload = send_fal_request(&self.request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } +} + +// ── Helpers ── + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request + .send_webhook_request(&client.api_key, webhook_url) + .await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request + .send_queue_request(&client.api_key) + .await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{ + Flux2LoraAngleImageSize, Flux2LoraAngleNumImages, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = FalFlux2LoraAnglesRequestState { + request: Flux2LoraEditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(2.0), + num_images: Some(Flux2LoraAngleNumImages::One), + image_size: Some(Flux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + }; + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/build.rs new file mode 100644 index 0000000000..44a122423b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/build.rs @@ -0,0 +1,303 @@ +use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + FluxPro11AspectRatio, FluxPro11NumImages, FluxPro11Request, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::flux_pro_1p1::request::FalFluxPro1p1RequestState; + +pub fn build_fal_flux_pro_1p1( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Flux Pro 1.1 is text-to-image only. v1 hard-errors on any image_inputs + // (regardless of generation_mode_mismatch_strategy) — match that. + if builder.image_inputs.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Flux Pro 1.1 is text-to-image only".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + + let request = FluxPro11Request { + prompt: builder.prompt.clone().unwrap_or_default(), + aspect_ratio, + num_images, + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1(FalFluxPro1p1RequestState { request }), + )) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use FluxPro11AspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Square), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square), + + Some(RouterAspectRatio::Square) => Ok(Ar::Square), + Some(RouterAspectRatio::SquareHd) => Ok(Ar::SquareHd), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen), + + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::LandscapeFourByThree), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeFourByThree), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeSixteenByNine), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::PortraitThreeByFour), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::PortraitThreeByFour), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineBySixteen), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FluxPro11NumImages::One), + 2 => Ok(FluxPro11NumImages::Two), + 3 => Ok(FluxPro11NumImages::Three), + 4 => Ok(FluxPro11NumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(FluxPro11NumImages::Four), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::FluxPro11, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_request(result: Result) -> FluxPro11Request { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1(state) + ) = result.expect("build should succeed") else { + panic!("expected FalFluxPro1p1 variant") + }; + state.request + } + + // ── Mode rejection ── + + #[test] + fn image_inputs_present_yields_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })) + )); + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert_eq!(req.prompt, ""); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn default_is_square() { + let req = unwrap_request(build_fal_flux_pro_1p1(base_builder())); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::Square)); + } + + #[test] + fn auto_maps_to_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::Square)); + } + + #[test] + fn square_hd() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::SquareHd), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::SquareHd)); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn tall_nine_by_sixteen() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::PortraitNineBySixteen)); + } + + #[test] + fn wide_twenty_one_by_nine_collapses_to_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn tall_four_by_five_collapses_to_three_by_four() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallFourByFive), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::PortraitThreeByFour)); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_request(build_fal_flux_pro_1p1(base_builder())); + assert!(matches!(req.num_images, FluxPro11NumImages::One)); + } + + #[test] + fn explicit_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.num_images, FluxPro11NumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_batch_count", .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.num_images, FluxPro11NumImages::Four)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/cost.rs new file mode 100644 index 0000000000..9a49ff4364 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/cost.rs @@ -0,0 +1,86 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::flux_pro_1p1::request::FalFluxPro1p1RequestState; + +pub struct FalFluxPro1p1CostState { + cost_in_usd_cents: u64, +} + +impl FalFluxPro1p1CostState { + pub fn from_request(request: &FalFluxPro1p1RequestState) -> Self { + // Delegate to the fal_client cost calculator: $0.04 per image (1MP base). + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Matches v1: credits == cents (1:1 with USD cents). + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + FluxPro11AspectRatio, FluxPro11NumImages, FluxPro11Request, + }; + + fn cost_for(n: FluxPro11NumImages) -> ImageGenerationCostEstimate { + let state = FalFluxPro1p1CostState::from_request(&FalFluxPro1p1RequestState { + request: FluxPro11Request { + prompt: "test".to_string(), + aspect_ratio: FluxPro11AspectRatio::Square, + num_images: n, + }, + }); + state.estimate_cost() + } + + #[test] + fn one_image_is_4_cents() { + assert_eq!(cost_for(FluxPro11NumImages::One).cost_in_usd_cents, Some(4)); + } + + #[test] + fn two_images_is_8_cents() { + assert_eq!(cost_for(FluxPro11NumImages::Two).cost_in_usd_cents, Some(8)); + } + + #[test] + fn three_images_is_12_cents() { + assert_eq!(cost_for(FluxPro11NumImages::Three).cost_in_usd_cents, Some(12)); + } + + #[test] + fn four_images_is_16_cents() { + assert_eq!(cost_for(FluxPro11NumImages::Four).cost_in_usd_cents, Some(16)); + } + + #[test] + fn credits_equal_cents() { + // v1 set cost_in_credits to the same USD cents value; preserve that. + let cost = cost_for(FluxPro11NumImages::Two); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + + #[test] + fn cost_flags_match_v1() { + let cost = cost_for(FluxPro11NumImages::One); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert!(cost.failures_are_refunded.is_none()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/request.rs new file mode 100644 index 0000000000..9d5b53a260 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1/request.rs @@ -0,0 +1,85 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + enqueue_flux_pro_11_text_to_image_webhook, FluxPro11Args, FluxPro11Request, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalFluxPro1p1RequestState { + pub request: FluxPro11Request, +} + +impl FalFluxPro1p1RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let outbound: Arc = Arc::new(self.request.clone()); + let args = FluxPro11Args { + request: self.request.clone(), + webhook_url, + api_key: &client.api_key, + }; + let webhook_response = enqueue_flux_pro_11_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + FluxPro11AspectRatio, FluxPro11NumImages, + }; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + fn t2i_state() -> FalFluxPro1p1RequestState { + FalFluxPro1p1RequestState { + request: FluxPro11Request { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + aspect_ratio: FluxPro11AspectRatio::LandscapeSixteenByNine, + num_images: FluxPro11NumImages::One, + }, + } + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let response = t2i_state().send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/build.rs new file mode 100644 index 0000000000..316ebb1b20 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/build.rs @@ -0,0 +1,283 @@ +use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, FluxPro11UltraRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::flux_pro_1p1_ultra::request::FalFluxPro1p1UltraRequestState; + +pub fn build_fal_flux_pro_1p1_ultra( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Flux Pro 1.1 Ultra is text-to-image only. v1 hard-errors on any + // image_inputs (regardless of generation_mode_mismatch_strategy) — + // match that. + if builder.image_inputs.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Flux Pro 1.1 Ultra is text-to-image only".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + + let request = FluxPro11UltraRequest { + prompt: builder.prompt.clone().unwrap_or_default(), + aspect_ratio, + num_images, + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1Ultra(FalFluxPro1p1UltraRequestState { request }), + )) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use FluxPro11UltraAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Square), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeThreeByTwo), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeTwentyOneByNine), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::PortraitTwoByThree), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineByTwentyOne), + + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::LandscapeFourByThree), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::PortraitThreeByFour), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FluxPro11UltraNumImages::One), + 2 => Ok(FluxPro11UltraNumImages::Two), + 3 => Ok(FluxPro11UltraNumImages::Three), + 4 => Ok(FluxPro11UltraNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(FluxPro11UltraNumImages::Four), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::FluxPro11Ultra, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_request(result: Result) -> FluxPro11UltraRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1Ultra(state) + ) = result.expect("build should succeed") else { + panic!("expected FalFluxPro1p1Ultra variant") + }; + state.request + } + + // ── Mode rejection ── + + #[test] + fn image_inputs_present_yields_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1_ultra(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })) + )); + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert_eq!(req.prompt, ""); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn default_is_square() { + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(base_builder())); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::Square)); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn wide_three_by_two_has_direct_mapping() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideThreeByTwo), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeThreeByTwo)); + } + + #[test] + fn tall_two_by_three_has_direct_mapping() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallTwoByThree), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::PortraitTwoByThree)); + } + + #[test] + fn wide_twenty_one_by_nine_has_direct_mapping() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeTwentyOneByNine)); + } + + #[test] + fn square_hd_collapses_to_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::SquareHd), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::Square)); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(base_builder())); + assert!(matches!(req.num_images, FluxPro11UltraNumImages::One)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1_ultra(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1_ultra(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_batch_count", .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.num_images, FluxPro11UltraNumImages::Four)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/cost.rs new file mode 100644 index 0000000000..271fc22285 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/cost.rs @@ -0,0 +1,85 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::flux_pro_1p1_ultra::request::FalFluxPro1p1UltraRequestState; + +pub struct FalFluxPro1p1UltraCostState { + cost_in_usd_cents: u64, +} + +impl FalFluxPro1p1UltraCostState { + pub fn from_request(request: &FalFluxPro1p1UltraRequestState) -> Self { + // Delegate to the fal_client cost calculator: $0.06 per image. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Matches v1: credits == cents (1:1 with USD cents). + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, FluxPro11UltraRequest, + }; + + fn cost_for(n: FluxPro11UltraNumImages) -> ImageGenerationCostEstimate { + let state = FalFluxPro1p1UltraCostState::from_request(&FalFluxPro1p1UltraRequestState { + request: FluxPro11UltraRequest { + prompt: "test".to_string(), + aspect_ratio: FluxPro11UltraAspectRatio::Square, + num_images: n, + }, + }); + state.estimate_cost() + } + + #[test] + fn one_image_is_6_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::One).cost_in_usd_cents, Some(6)); + } + + #[test] + fn two_images_is_12_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::Two).cost_in_usd_cents, Some(12)); + } + + #[test] + fn three_images_is_18_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::Three).cost_in_usd_cents, Some(18)); + } + + #[test] + fn four_images_is_24_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::Four).cost_in_usd_cents, Some(24)); + } + + #[test] + fn credits_equal_cents() { + let cost = cost_for(FluxPro11UltraNumImages::Two); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + + #[test] + fn cost_flags_match_v1() { + let cost = cost_for(FluxPro11UltraNumImages::One); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert!(cost.failures_are_refunded.is_none()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/request.rs new file mode 100644 index 0000000000..98611f36dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/flux_pro_1p1_ultra/request.rs @@ -0,0 +1,85 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + enqueue_flux_pro_11_ultra_text_to_image_webhook, FluxPro11UltraArgs, FluxPro11UltraRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalFluxPro1p1UltraRequestState { + pub request: FluxPro11UltraRequest, +} + +impl FalFluxPro1p1UltraRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let outbound: Arc = Arc::new(self.request.clone()); + let args = FluxPro11UltraArgs { + request: self.request.clone(), + webhook_url, + api_key: &client.api_key, + }; + let webhook_response = enqueue_flux_pro_11_ultra_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests_old::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, + }; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + fn t2i_state() -> FalFluxPro1p1UltraRequestState { + FalFluxPro1p1UltraRequestState { + request: FluxPro11UltraRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + aspect_ratio: FluxPro11UltraAspectRatio::LandscapeSixteenByNine, + num_images: FluxPro11UltraNumImages::One, + }, + } + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let response = t2i_state().send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs new file mode 100644 index 0000000000..34f68a759b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs @@ -0,0 +1,401 @@ +use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{ + GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageRequest, + GptImage1EditImageSize, +}; +use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{ + GptImage1TextToImageNumImages, GptImage1TextToImageQuality, + GptImage1TextToImageRequest, GptImage1TextToImageSize, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::gpt_image_1::request::FalGptImage1RequestState; + +pub fn build_fal_gpt_image_1( + builder: GenerateImageRequestBuilder, +) -> Result { + let prompt = builder.prompt.clone().unwrap_or_default(); + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?; + let image_size = plan_image_size(builder.aspect_ratio); + let quality = plan_quality(builder.quality); + + let state = if image_urls.is_empty() { + FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + image_size: image_size.map(to_t2i_image_size), + quality: Some(to_t2i_quality(quality)), + background: None, + output_format: None, + }) + } else { + FalGptImage1RequestState::EditImage(GptImage1EditImageRequest { + prompt, + image_urls, + num_images: to_edit_num_images(num_images), + mask_image_url: None, + image_size: image_size.map(to_edit_image_size), + quality: Some(to_edit_quality(quality)), + input_fidelity: None, + background: None, + output_format: None, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1(state), + )) +} + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +enum PlannedQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +enum PlannedImageSize { + Square, + Horizontal, + Vertical, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn plan_quality(quality: Option) -> PlannedQuality { + match quality { + Some(RouterQuality::Low) => PlannedQuality::Low, + Some(RouterQuality::Medium) => PlannedQuality::Medium, + Some(RouterQuality::High) | None => PlannedQuality::High, + } +} + +fn plan_image_size(aspect_ratio: Option) -> Option { + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => None, + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Some(PlannedImageSize::Square) + } + Some(RouterAspectRatio::WideThreeByTwo) + | Some(RouterAspectRatio::WideFourByThree) + | Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideSixteenByNine) + | Some(RouterAspectRatio::WideTwentyOneByNine) + | Some(RouterAspectRatio::Wide) => Some(PlannedImageSize::Horizontal), + Some(RouterAspectRatio::TallTwoByThree) + | Some(RouterAspectRatio::TallThreeByFour) + | Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallNineBySixteen) + | Some(RouterAspectRatio::TallNineByTwentyOne) + | Some(RouterAspectRatio::Tall) => Some(PlannedImageSize::Vertical), + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> GptImage1TextToImageNumImages { + match n { + PlannedNumImages::One => GptImage1TextToImageNumImages::One, + PlannedNumImages::Two => GptImage1TextToImageNumImages::Two, + PlannedNumImages::Three => GptImage1TextToImageNumImages::Three, + PlannedNumImages::Four => GptImage1TextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> GptImage1EditImageNumImages { + match n { + PlannedNumImages::One => GptImage1EditImageNumImages::One, + PlannedNumImages::Two => GptImage1EditImageNumImages::Two, + PlannedNumImages::Three => GptImage1EditImageNumImages::Three, + PlannedNumImages::Four => GptImage1EditImageNumImages::Four, + } +} + +fn to_t2i_quality(q: PlannedQuality) -> GptImage1TextToImageQuality { + match q { + PlannedQuality::Low => GptImage1TextToImageQuality::Low, + PlannedQuality::Medium => GptImage1TextToImageQuality::Medium, + PlannedQuality::High => GptImage1TextToImageQuality::High, + } +} + +fn to_edit_quality(q: PlannedQuality) -> GptImage1EditImageQuality { + match q { + PlannedQuality::Low => GptImage1EditImageQuality::Low, + PlannedQuality::Medium => GptImage1EditImageQuality::Medium, + PlannedQuality::High => GptImage1EditImageQuality::High, + } +} + +fn to_t2i_image_size(s: PlannedImageSize) -> GptImage1TextToImageSize { + match s { + PlannedImageSize::Square => GptImage1TextToImageSize::Square, + PlannedImageSize::Horizontal => GptImage1TextToImageSize::Horizontal, + PlannedImageSize::Vertical => GptImage1TextToImageSize::Vertical, + } +} + +fn to_edit_image_size(s: PlannedImageSize) -> GptImage1EditImageSize { + match s { + PlannedImageSize::Square => GptImage1EditImageSize::Square, + PlannedImageSize::Horizontal => GptImage1EditImageSize::Horizontal, + PlannedImageSize::Vertical => GptImage1EditImageSize::Vertical, + } +} + +fn resolve_image_urls( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage1, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage1TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1( + FalGptImage1RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1 text-to-image request") + }; + req + } + + fn unwrap_edit(result: Result) -> GptImage1EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1( + FalGptImage1RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1 edit-image request") + }; + req + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } + + #[test] + fn build2_routes_to_gpt_image_1_request() { + let req = unwrap_t2i(base_builder().build2()); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn mode_detection_is_based_on_image_urls() { + let text_req = unwrap_t2i(build_fal_gpt_image_1(base_builder())); + assert_eq!(text_req.prompt, "a cat in space"); + + let edit_req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + })); + assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let text_cases = [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + ]; + + for (count, expected) in text_cases { + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + + for (count, expected) in text_cases { + let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(req.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(RouterQuality::Low), "Some(Low)"), + (Some(RouterQuality::Medium), "Some(Medium)"), + (Some(RouterQuality::High), "Some(High)"), + ]; + + for (quality, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + + for (quality, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(RouterAspectRatio::Auto), "None"), + (Some(RouterAspectRatio::Auto2k), "None"), + (Some(RouterAspectRatio::Auto3k), "None"), + (Some(RouterAspectRatio::Auto4k), "None"), + (Some(RouterAspectRatio::Square), "Some(Square)"), + (Some(RouterAspectRatio::SquareHd), "Some(Square)"), + (Some(RouterAspectRatio::WideThreeByTwo), "Some(Horizontal)"), + (Some(RouterAspectRatio::WideFourByThree), "Some(Horizontal)"), + (Some(RouterAspectRatio::WideFiveByFour), "Some(Horizontal)"), + (Some(RouterAspectRatio::WideSixteenByNine), "Some(Horizontal)"), + (Some(RouterAspectRatio::WideTwentyOneByNine), "Some(Horizontal)"), + (Some(RouterAspectRatio::Wide), "Some(Horizontal)"), + (Some(RouterAspectRatio::TallTwoByThree), "Some(Vertical)"), + (Some(RouterAspectRatio::TallThreeByFour), "Some(Vertical)"), + (Some(RouterAspectRatio::TallFourByFive), "Some(Vertical)"), + (Some(RouterAspectRatio::TallNineBySixteen), "Some(Vertical)"), + (Some(RouterAspectRatio::TallNineByTwentyOne), "Some(Vertical)"), + (Some(RouterAspectRatio::Tall), "Some(Vertical)"), + ]; + + for (aspect_ratio, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + + for (aspect_ratio, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/cost.rs new file mode 100644 index 0000000000..c18351a561 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/cost.rs @@ -0,0 +1,210 @@ +use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{ + GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageSize, +}; +use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{ + GptImage1TextToImageNumImages, GptImage1TextToImageQuality, GptImage1TextToImageSize, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::gpt_image_1::request::FalGptImage1RequestState; + +/// Cost state for Fal GPT Image 1. Mirrors v1 +/// (`estimate_image_cost_fal_gpt_image_1`): +/// +/// Output image cost (per output image): +/// Low: 2¢ all sizes +/// Medium: 5¢ square, 7¢ wide/tall +/// High: 17¢ square, 25¢ wide/tall +/// +/// Plus 2¢ per input image in edit mode (high-fidelity estimate). +/// Quality defaults to High when unspecified. +/// +/// The hand-rolled rates here intentionally override fal_client's trait +/// (which uses Medium as the default and omits the input-image fee). Keep +/// in sync with the v1 cost file. +#[derive(Clone, Debug)] +pub struct FalGptImage1CostState { + request: FalGptImage1RequestState, +} + +impl FalGptImage1CostState { + pub fn from_request(request: &FalGptImage1RequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = match &self.request { + FalGptImage1RequestState::TextToImage(req) => { + cost_t2i(req.quality, req.image_size, req.num_images) + } + FalGptImage1RequestState::EditImage(req) => { + let output_cost = cost_edit_output(req.quality, req.image_size, req.num_images); + let input_cost = 2 * req.image_urls.len() as u64; + output_cost + input_cost + } + }; + + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn cost_t2i( + quality: Option, + image_size: Option, + num_images: GptImage1TextToImageNumImages, +) -> u64 { + use GptImage1TextToImageQuality as Q; + use GptImage1TextToImageSize as S; + let q = quality.unwrap_or(Q::High); + let is_square = matches!(image_size, None | Some(S::Square)); + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), is_square); + per_image * t2i_num_images(num_images) +} + +fn cost_edit_output( + quality: Option, + image_size: Option, + num_images: GptImage1EditImageNumImages, +) -> u64 { + use GptImage1EditImageQuality as Q; + use GptImage1EditImageSize as S; + let q = quality.unwrap_or(Q::High); + let is_square = matches!(image_size, None | Some(S::Square)); + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), is_square); + per_image * edit_num_images(num_images) +} + +fn output_cost(is_low: bool, is_medium: bool, is_square: bool) -> u64 { + if is_low { + 2 + } else if is_medium { + if is_square { 5 } else { 7 } + } else { + // High (default) + if is_square { 17 } else { 25 } + } +} + +fn t2i_num_images(n: GptImage1TextToImageNumImages) -> u64 { + match n { + GptImage1TextToImageNumImages::One => 1, + GptImage1TextToImageNumImages::Two => 2, + GptImage1TextToImageNumImages::Three => 3, + GptImage1TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: GptImage1EditImageNumImages) -> u64 { + match n { + GptImage1EditImageNumImages::One => 1, + GptImage1EditImageNumImages::Two => 2, + GptImage1EditImageNumImages::Three => 3, + GptImage1EditImageNumImages::Four => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; + use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageRequest, GptImage1EditImageSize}; + use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{GptImage1TextToImageNumImages, GptImage1TextToImageQuality, GptImage1TextToImageRequest, GptImage1TextToImageSize}; + + #[test] + fn text_to_image_cost_table_covers_quality_size_and_count() { + for (quality, image_size, expected_by_count) in text_to_image_price_cases() { + for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) { + let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest { prompt: "test".to_string(), num_images, image_size, quality, background: None, output_format: None }); + let cost = FalGptImage1CostState::from_request(&request).estimate_cost(); + assert_standard_cost_estimate(cost, expected); + } + } + } + + #[test] + fn edit_image_cost_table_covers_quality_size_and_count() { + for (quality, image_size, expected_by_count) in edit_image_price_cases() { + for (num_images, expected) in edit_image_num_image_cases(expected_by_count) { + let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_image_url: None, image_size, quality, input_fidelity: None, background: None, output_format: None }); + let cost = FalGptImage1CostState::from_request(&request).estimate_cost(); + assert_standard_cost_estimate(cost, expected); + } + } + } + + // Tables encode v1's pricing semantics: + // - Quality defaults to High when unspecified. + // - is_square = matches!(image_size, None | Some(Square)). Auto, Horizontal, + // and Vertical are treated as non-square. + // - Low is 2¢ for every size; Medium and High differ by square vs non-square. + // Edit-mode rows additionally include a +2¢ input-image fee (one input URL + // in the make_edit helper below). + fn text_to_image_price_cases() -> [(Option, Option, [u64; 4]); 13] { + [ + // Default quality (None → High) + default size (None → square). + (None, None, [17, 34, 51, 68]), + // Low: flat 2¢ regardless of size. + (Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Auto), [2, 4, 6, 8]), + (Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Square), [2, 4, 6, 8]), + (Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Horizontal), [2, 4, 6, 8]), + (Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Vertical), [2, 4, 6, 8]), + // Medium: 5¢ square, 7¢ non-square. + (Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Auto), [7, 14, 21, 28]), + (Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Square), [5, 10, 15, 20]), + (Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Horizontal), [7, 14, 21, 28]), + (Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Vertical), [7, 14, 21, 28]), + // High: 17¢ square, 25¢ non-square. + (Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Auto), [25, 50, 75, 100]), + (Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Square), [17, 34, 51, 68]), + (Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Horizontal), [25, 50, 75, 100]), + (Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Vertical), [25, 50, 75, 100]), + ] + } + + // Each edit-mode row is t2i row + 2¢ (single input image — see make_edit + // below). Numbers are constant +2 because input fee scales with input count, + // not output count. + fn edit_image_price_cases() -> [(Option, Option, [u64; 4]); 13] { + [ + (None, None, [19, 36, 53, 70]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Auto), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Square), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Horizontal), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Vertical), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Auto), [9, 16, 23, 30]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Square), [7, 12, 17, 22]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal), [9, 16, 23, 30]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Vertical), [9, 16, 23, 30]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Auto), [27, 52, 77, 102]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square), [19, 36, 53, 70]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal), [27, 52, 77, 102]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Vertical), [27, 52, 77, 102]), + ] + } + + fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1TextToImageNumImages, u64); 4] { + [(GptImage1TextToImageNumImages::One, expected_by_count[0]), (GptImage1TextToImageNumImages::Two, expected_by_count[1]), (GptImage1TextToImageNumImages::Three, expected_by_count[2]), (GptImage1TextToImageNumImages::Four, expected_by_count[3])] + } + + fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1EditImageNumImages, u64); 4] { + [(GptImage1EditImageNumImages::One, expected_by_count[0]), (GptImage1EditImageNumImages::Two, expected_by_count[1]), (GptImage1EditImageNumImages::Three, expected_by_count[2]), (GptImage1EditImageNumImages::Four, expected_by_count[3])] + } + + fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) { + assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents)); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/request.rs new file mode 100644 index 0000000000..ec24a46c0f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/request.rs @@ -0,0 +1,128 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::GptImage1EditImageRequest; +use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::GptImage1TextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse}; + +#[derive(Clone, Debug)] +pub enum FalGptImage1RequestState { + TextToImage(GptImage1TextToImageRequest), + EditImage(GptImage1EditImageRequest), +} + +impl FalGptImage1RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => send_request(request, client).await, + Self::EditImage(request) => send_request(request, client).await, + } + } +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{ + GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{ + GptImage1TextToImageNumImages, GptImage1TextToImageQuality, + GptImage1TextToImageSize, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn client_with_webhook() -> RouterFalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalClient::new_with_webhook( + FalApiKey::from_str(secret.trim()), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_webhook() { + let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest { + prompt: "a small ceramic robot reading by a window".to_string(), + num_images: GptImage1TextToImageNumImages::One, + image_size: Some(GptImage1TextToImageSize::Square), + quality: Some(GptImage1TextToImageQuality::Low), + background: None, + output_format: None, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_webhook() { + let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest { + prompt: "make the image feel like a quiet watercolor study".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: GptImage1EditImageNumImages::One, + mask_image_url: None, + image_size: Some(GptImage1EditImageSize::Square), + quality: Some(GptImage1EditImageQuality::Low), + input_fidelity: None, + background: None, + output_format: None, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs new file mode 100644 index 0000000000..a6e9e31684 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs @@ -0,0 +1,401 @@ +use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{ + GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, + GptImage1p5EditImageRequest, GptImage1p5EditImageSize, +}; +use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{ + GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, + GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState; + +pub fn build_fal_gpt_image_1p5( + builder: GenerateImageRequestBuilder, +) -> Result { + let prompt = builder.prompt.clone().unwrap_or_default(); + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?; + let image_size = plan_image_size(builder.aspect_ratio); + let quality = plan_quality(builder.quality); + + let state = if image_urls.is_empty() { + FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + image_size: image_size.map(to_t2i_image_size), + background: None, + quality: Some(to_t2i_quality(quality)), + output_format: None, + }) + } else { + FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest { + prompt, + image_urls, + num_images: to_edit_num_images(num_images), + mask_image_url: None, + image_size: image_size.map(to_edit_image_size), + background: None, + quality: Some(to_edit_quality(quality)), + input_fidelity: None, + output_format: None, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1p5(state), + )) +} + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +enum PlannedQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +enum PlannedImageSize { + Square, + Wide, + Tall, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn plan_quality(quality: Option) -> PlannedQuality { + match quality { + Some(RouterQuality::Low) => PlannedQuality::Low, + Some(RouterQuality::Medium) => PlannedQuality::Medium, + Some(RouterQuality::High) | None => PlannedQuality::High, + } +} + +fn plan_image_size(aspect_ratio: Option) -> Option { + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => None, + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Some(PlannedImageSize::Square) + } + Some(RouterAspectRatio::WideThreeByTwo) + | Some(RouterAspectRatio::WideFourByThree) + | Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideSixteenByNine) + | Some(RouterAspectRatio::WideTwentyOneByNine) + | Some(RouterAspectRatio::Wide) => Some(PlannedImageSize::Wide), + Some(RouterAspectRatio::TallTwoByThree) + | Some(RouterAspectRatio::TallThreeByFour) + | Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallNineBySixteen) + | Some(RouterAspectRatio::TallNineByTwentyOne) + | Some(RouterAspectRatio::Tall) => Some(PlannedImageSize::Tall), + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> GptImage1p5TextToImageNumImages { + match n { + PlannedNumImages::One => GptImage1p5TextToImageNumImages::One, + PlannedNumImages::Two => GptImage1p5TextToImageNumImages::Two, + PlannedNumImages::Three => GptImage1p5TextToImageNumImages::Three, + PlannedNumImages::Four => GptImage1p5TextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> GptImage1p5EditImageNumImages { + match n { + PlannedNumImages::One => GptImage1p5EditImageNumImages::One, + PlannedNumImages::Two => GptImage1p5EditImageNumImages::Two, + PlannedNumImages::Three => GptImage1p5EditImageNumImages::Three, + PlannedNumImages::Four => GptImage1p5EditImageNumImages::Four, + } +} + +fn to_t2i_quality(q: PlannedQuality) -> GptImage1p5TextToImageQuality { + match q { + PlannedQuality::Low => GptImage1p5TextToImageQuality::Low, + PlannedQuality::Medium => GptImage1p5TextToImageQuality::Medium, + PlannedQuality::High => GptImage1p5TextToImageQuality::High, + } +} + +fn to_edit_quality(q: PlannedQuality) -> GptImage1p5EditImageQuality { + match q { + PlannedQuality::Low => GptImage1p5EditImageQuality::Low, + PlannedQuality::Medium => GptImage1p5EditImageQuality::Medium, + PlannedQuality::High => GptImage1p5EditImageQuality::High, + } +} + +fn to_t2i_image_size(s: PlannedImageSize) -> GptImage1p5TextToImageSize { + match s { + PlannedImageSize::Square => GptImage1p5TextToImageSize::Square, + PlannedImageSize::Wide => GptImage1p5TextToImageSize::Wide, + PlannedImageSize::Tall => GptImage1p5TextToImageSize::Tall, + } +} + +fn to_edit_image_size(s: PlannedImageSize) -> GptImage1p5EditImageSize { + match s { + PlannedImageSize::Square => GptImage1p5EditImageSize::Square, + PlannedImageSize::Wide => GptImage1p5EditImageSize::Wide, + PlannedImageSize::Tall => GptImage1p5EditImageSize::Tall, + } +} + +fn resolve_image_urls( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage1p5, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage1p5TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1p5( + FalGptImage1p5RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1.5 text-to-image request") + }; + req + } + + fn unwrap_edit(result: Result) -> GptImage1p5EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1p5( + FalGptImage1p5RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1.5 edit-image request") + }; + req + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } + + #[test] + fn build2_routes_to_gpt_image_1p5_request() { + let req = unwrap_t2i(base_builder().build2()); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn mode_detection_is_based_on_image_urls() { + let text_req = unwrap_t2i(build_fal_gpt_image_1p5(base_builder())); + assert_eq!(text_req.prompt, "a cat in space"); + + let edit_req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + })); + assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let cases = [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + ]; + + for (count, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + + for (count, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(req.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(RouterQuality::Low), "Some(Low)"), + (Some(RouterQuality::Medium), "Some(Medium)"), + (Some(RouterQuality::High), "Some(High)"), + ]; + + for (quality, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + + for (quality, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(RouterAspectRatio::Auto), "None"), + (Some(RouterAspectRatio::Auto2k), "None"), + (Some(RouterAspectRatio::Auto3k), "None"), + (Some(RouterAspectRatio::Auto4k), "None"), + (Some(RouterAspectRatio::Square), "Some(Square)"), + (Some(RouterAspectRatio::SquareHd), "Some(Square)"), + (Some(RouterAspectRatio::WideThreeByTwo), "Some(Wide)"), + (Some(RouterAspectRatio::WideFourByThree), "Some(Wide)"), + (Some(RouterAspectRatio::WideFiveByFour), "Some(Wide)"), + (Some(RouterAspectRatio::WideSixteenByNine), "Some(Wide)"), + (Some(RouterAspectRatio::WideTwentyOneByNine), "Some(Wide)"), + (Some(RouterAspectRatio::Wide), "Some(Wide)"), + (Some(RouterAspectRatio::TallTwoByThree), "Some(Tall)"), + (Some(RouterAspectRatio::TallThreeByFour), "Some(Tall)"), + (Some(RouterAspectRatio::TallFourByFive), "Some(Tall)"), + (Some(RouterAspectRatio::TallNineBySixteen), "Some(Tall)"), + (Some(RouterAspectRatio::TallNineByTwentyOne), "Some(Tall)"), + (Some(RouterAspectRatio::Tall), "Some(Tall)"), + ]; + + for (aspect_ratio, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + + for (aspect_ratio, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/cost.rs new file mode 100644 index 0000000000..c1542d0d7f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/cost.rs @@ -0,0 +1,206 @@ +use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{ + GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, GptImage1p5EditImageSize, +}; +use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{ + GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, GptImage1p5TextToImageSize, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState; + +/// Cost state for Fal GPT Image 1.5. Mirrors v1 +/// (`estimate_image_cost_fal_gpt_image_1p5`): +/// +/// Output image cost (per output image), rounded up to whole cents: +/// Low: 1¢ square, 2¢ wide/tall +/// Medium: 4¢ square, 5¢ wide, 6¢ tall +/// High: 14¢ square, 20¢ wide/tall +/// +/// Quality defaults to High when unspecified. +/// +/// The hand-rolled rates here intentionally override fal_client's trait, +/// which rounds $0.133→13¢ for High+Square (v1 rounds up to 14¢) and +/// flattens Medium wide/tall to a single value. Keep in sync with v1. +#[derive(Clone, Debug)] +pub struct FalGptImage1p5CostState { + request: FalGptImage1p5RequestState, +} + +impl FalGptImage1p5CostState { + pub fn from_request(request: &FalGptImage1p5RequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = match &self.request { + FalGptImage1p5RequestState::TextToImage(req) => { + cost_t2i(req.quality, req.image_size, req.num_images) + } + FalGptImage1p5RequestState::EditImage(req) => { + cost_edit(req.quality, req.image_size, req.num_images) + } + }; + + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[derive(Copy, Clone)] +enum SizeBucket { Square, Wide, Tall } + +fn cost_t2i( + quality: Option, + image_size: Option, + num_images: GptImage1p5TextToImageNumImages, +) -> u64 { + use GptImage1p5TextToImageQuality as Q; + use GptImage1p5TextToImageSize as S; + let q = quality.unwrap_or(Q::High); + let bucket = match image_size { + None | Some(S::Square) => SizeBucket::Square, + Some(S::Wide) => SizeBucket::Wide, + Some(S::Tall) => SizeBucket::Tall, + }; + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket); + per_image * t2i_num_images(num_images) +} + +fn cost_edit( + quality: Option, + image_size: Option, + num_images: GptImage1p5EditImageNumImages, +) -> u64 { + use GptImage1p5EditImageQuality as Q; + use GptImage1p5EditImageSize as S; + let q = quality.unwrap_or(Q::High); + let bucket = match image_size { + None | Some(S::Square) => SizeBucket::Square, + Some(S::Wide) => SizeBucket::Wide, + Some(S::Tall) => SizeBucket::Tall, + }; + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket); + per_image * edit_num_images(num_images) +} + +fn output_cost(is_low: bool, is_medium: bool, bucket: SizeBucket) -> u64 { + if is_low { + match bucket { SizeBucket::Square => 1, _ => 2 } + } else if is_medium { + match bucket { + SizeBucket::Square => 4, + SizeBucket::Wide => 5, + SizeBucket::Tall => 6, + } + } else { + // High (default) + match bucket { SizeBucket::Square => 14, _ => 20 } + } +} + +fn t2i_num_images(n: GptImage1p5TextToImageNumImages) -> u64 { + match n { + GptImage1p5TextToImageNumImages::One => 1, + GptImage1p5TextToImageNumImages::Two => 2, + GptImage1p5TextToImageNumImages::Three => 3, + GptImage1p5TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: GptImage1p5EditImageNumImages) -> u64 { + match n { + GptImage1p5EditImageNumImages::One => 1, + GptImage1p5EditImageNumImages::Two => 2, + GptImage1p5EditImageNumImages::Three => 3, + GptImage1p5EditImageNumImages::Four => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; + use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, GptImage1p5EditImageRequest, GptImage1p5EditImageSize}; + use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize}; + + #[test] + fn text_to_image_cost_table_covers_quality_size_and_count() { + for (quality, image_size, expected_by_count) in text_to_image_price_cases() { + for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) { + let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest { prompt: "test".to_string(), num_images, image_size, background: None, quality, output_format: None }); + let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost(); + assert_standard_cost_estimate(cost, expected); + } + } + } + + #[test] + fn edit_image_cost_table_covers_quality_size_and_count() { + for (quality, image_size, expected_by_count) in edit_image_price_cases() { + for (num_images, expected) in edit_image_num_image_cases(expected_by_count) { + let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_image_url: None, image_size, background: None, quality, input_fidelity: None, output_format: None }); + let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost(); + assert_standard_cost_estimate(cost, expected); + } + } + } + + // v1 rates: Low=1/2 (sq/wide-tall), Medium=4/5/6 (sq/wide/tall), + // High=14/20/20 (sq/wide/tall). Default quality is High. + fn text_to_image_price_cases() -> [(Option, Option, [u64; 4]); 10] { + [ + (None, None, [14, 28, 42, 56]), + (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Wide), [2, 4, 6, 8]), + (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Tall), [2, 4, 6, 8]), + (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square), [4, 8, 12, 16]), + (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Wide), [5, 10, 15, 20]), + (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Tall), [6, 12, 18, 24]), + (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square), [14, 28, 42, 56]), + (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide), [20, 40, 60, 80]), + (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Tall), [20, 40, 60, 80]), + ] + } + + // Edit-mode pricing is identical to t2i (v1 has no input-image fee for + // gpt_image_1p5 — that's only gpt_image_1). + fn edit_image_price_cases() -> [(Option, Option, [u64; 4]); 10] { + [ + (None, None, [14, 28, 42, 56]), + (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Wide), [2, 4, 6, 8]), + (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Tall), [2, 4, 6, 8]), + (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Square), [4, 8, 12, 16]), + (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide), [5, 10, 15, 20]), + (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Tall), [6, 12, 18, 24]), + (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square), [14, 28, 42, 56]), + (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Wide), [20, 40, 60, 80]), + (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Tall), [20, 40, 60, 80]), + ] + } + + fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1p5TextToImageNumImages, u64); 4] { + [(GptImage1p5TextToImageNumImages::One, expected_by_count[0]), (GptImage1p5TextToImageNumImages::Two, expected_by_count[1]), (GptImage1p5TextToImageNumImages::Three, expected_by_count[2]), (GptImage1p5TextToImageNumImages::Four, expected_by_count[3])] + } + + fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1p5EditImageNumImages, u64); 4] { + [(GptImage1p5EditImageNumImages::One, expected_by_count[0]), (GptImage1p5EditImageNumImages::Two, expected_by_count[1]), (GptImage1p5EditImageNumImages::Three, expected_by_count[2]), (GptImage1p5EditImageNumImages::Four, expected_by_count[3])] + } + + fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) { + assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents)); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/request.rs new file mode 100644 index 0000000000..4f69c13bdc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/request.rs @@ -0,0 +1,129 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::GptImage1p5EditImageRequest; +use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::GptImage1p5TextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse}; + +#[derive(Clone, Debug)] +pub enum FalGptImage1p5RequestState { + TextToImage(GptImage1p5TextToImageRequest), + EditImage(GptImage1p5EditImageRequest), +} + +impl FalGptImage1p5RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => send_request(request, client).await, + Self::EditImage(request) => send_request(request, client).await, + } + } +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{ + GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, + GptImage1p5EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{ + GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, + GptImage1p5TextToImageSize, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn client_with_webhook() -> RouterFalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalClient::new_with_webhook( + FalApiKey::from_str(secret.trim()), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_webhook() { + let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest { + prompt: "a tiny brass observatory in a snowy field".to_string(), + num_images: GptImage1p5TextToImageNumImages::One, + image_size: Some(GptImage1p5TextToImageSize::Square), + background: None, + quality: Some(GptImage1p5TextToImageQuality::Low), + output_format: None, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_webhook() { + let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest { + prompt: "turn this into a softly lit editorial photograph".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: GptImage1p5EditImageNumImages::One, + mask_image_url: None, + image_size: Some(GptImage1p5EditImageSize::Square), + background: None, + quality: Some(GptImage1p5EditImageQuality::Low), + input_fidelity: None, + output_format: None, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs new file mode 100644 index 0000000000..53b916f9fa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs @@ -0,0 +1,458 @@ +use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; +use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{ + GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest, + GptImage2EditImageSize, +}; +use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{ + GptImage2TextToImageNumImages, GptImage2TextToImageQuality, + GptImage2TextToImageRequest, GptImage2TextToImageSize, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::gpt_image_2::request::FalGptImage2RequestState; + +pub fn build_fal_gpt_image_2( + builder: GenerateImageRequestBuilder, +) -> Result { + let prompt = builder.prompt.clone().unwrap_or_default(); + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?; + let image_size = plan_image_size(builder.aspect_ratio); + let resolution = plan_resolution(builder.resolution); + let quality = plan_quality(builder.quality); + + let state = if image_urls.is_empty() { + FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + image_size: image_size.and_then(to_t2i_image_size), + resolution, + quality: Some(to_t2i_quality(quality)), + output_format: None, + }) + } else { + FalGptImage2RequestState::EditImage(GptImage2EditImageRequest { + prompt, + image_urls, + num_images: to_edit_num_images(num_images), + mask_url: None, + image_size: image_size.map(to_edit_image_size), + resolution, + quality: Some(to_edit_quality(quality)), + output_format: None, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2(state), + )) +} + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +enum PlannedQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +enum PlannedImageSize { + SquareHd, + Square, + Portrait4x3, + Portrait16x9, + Landscape4x3, + Landscape16x9, + Auto, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn plan_quality(quality: Option) -> PlannedQuality { + match quality { + Some(RouterQuality::Low) => PlannedQuality::Low, + Some(RouterQuality::Medium) => PlannedQuality::Medium, + Some(RouterQuality::High) | None => PlannedQuality::High, + } +} + +fn plan_resolution(resolution: Option) -> Option { + resolution.map(|r| match r { + RouterResolution::HalfK + | RouterResolution::FourEightyP + | RouterResolution::SevenTwentyP + | RouterResolution::OneK => GptImage2Resolution::OneK, + RouterResolution::TenEightyP | RouterResolution::TwoK => GptImage2Resolution::TwoK, + RouterResolution::ThreeK => GptImage2Resolution::ThreeK, + RouterResolution::FourK => GptImage2Resolution::FourK, + }) +} + +fn plan_image_size(aspect_ratio: Option) -> Option { + match aspect_ratio { + None => None, + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Some(PlannedImageSize::Auto), + Some(RouterAspectRatio::Square) => Some(PlannedImageSize::Square), + Some(RouterAspectRatio::SquareHd) => Some(PlannedImageSize::SquareHd), + Some(RouterAspectRatio::WideFourByThree) + | Some(RouterAspectRatio::WideFiveByFour) => Some(PlannedImageSize::Landscape4x3), + Some(RouterAspectRatio::WideThreeByTwo) + | Some(RouterAspectRatio::WideSixteenByNine) + | Some(RouterAspectRatio::WideTwentyOneByNine) + | Some(RouterAspectRatio::Wide) => Some(PlannedImageSize::Landscape16x9), + Some(RouterAspectRatio::TallThreeByFour) + | Some(RouterAspectRatio::TallFourByFive) => Some(PlannedImageSize::Portrait4x3), + Some(RouterAspectRatio::TallTwoByThree) + | Some(RouterAspectRatio::TallNineBySixteen) + | Some(RouterAspectRatio::TallNineByTwentyOne) + | Some(RouterAspectRatio::Tall) => Some(PlannedImageSize::Portrait16x9), + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> GptImage2TextToImageNumImages { + match n { + PlannedNumImages::One => GptImage2TextToImageNumImages::One, + PlannedNumImages::Two => GptImage2TextToImageNumImages::Two, + PlannedNumImages::Three => GptImage2TextToImageNumImages::Three, + PlannedNumImages::Four => GptImage2TextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> GptImage2EditImageNumImages { + match n { + PlannedNumImages::One => GptImage2EditImageNumImages::One, + PlannedNumImages::Two => GptImage2EditImageNumImages::Two, + PlannedNumImages::Three => GptImage2EditImageNumImages::Three, + PlannedNumImages::Four => GptImage2EditImageNumImages::Four, + } +} + +fn to_t2i_quality(q: PlannedQuality) -> GptImage2TextToImageQuality { + match q { + PlannedQuality::Low => GptImage2TextToImageQuality::Low, + PlannedQuality::Medium => GptImage2TextToImageQuality::Medium, + PlannedQuality::High => GptImage2TextToImageQuality::High, + } +} + +fn to_edit_quality(q: PlannedQuality) -> GptImage2EditImageQuality { + match q { + PlannedQuality::Low => GptImage2EditImageQuality::Low, + PlannedQuality::Medium => GptImage2EditImageQuality::Medium, + PlannedQuality::High => GptImage2EditImageQuality::High, + } +} + +fn to_t2i_image_size(s: PlannedImageSize) -> Option { + match s { + PlannedImageSize::SquareHd => Some(GptImage2TextToImageSize::SquareHd), + PlannedImageSize::Square => Some(GptImage2TextToImageSize::Square), + PlannedImageSize::Portrait4x3 => Some(GptImage2TextToImageSize::Portrait4x3), + PlannedImageSize::Portrait16x9 => Some(GptImage2TextToImageSize::Portrait16x9), + PlannedImageSize::Landscape4x3 => Some(GptImage2TextToImageSize::Landscape4x3), + PlannedImageSize::Landscape16x9 => Some(GptImage2TextToImageSize::Landscape16x9), + PlannedImageSize::Auto => None, + } +} + +fn to_edit_image_size(s: PlannedImageSize) -> GptImage2EditImageSize { + match s { + PlannedImageSize::SquareHd => GptImage2EditImageSize::SquareHd, + PlannedImageSize::Square => GptImage2EditImageSize::Square, + PlannedImageSize::Portrait4x3 => GptImage2EditImageSize::Portrait4x3, + PlannedImageSize::Portrait16x9 => GptImage2EditImageSize::Portrait16x9, + PlannedImageSize::Landscape4x3 => GptImage2EditImageSize::Landscape4x3, + PlannedImageSize::Landscape16x9 => GptImage2EditImageSize::Landscape16x9, + PlannedImageSize::Auto => GptImage2EditImageSize::Auto, + } +} + +fn resolve_image_urls( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage2TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2( + FalGptImage2RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2 text-to-image request") + }; + req + } + + fn unwrap_edit(result: Result) -> GptImage2EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2( + FalGptImage2RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2 edit-image request") + }; + req + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } + + #[test] + fn build2_routes_to_gpt_image_2_request() { + let req = unwrap_t2i(base_builder().build2()); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn mode_detection_is_based_on_image_urls() { + let text_req = unwrap_t2i(build_fal_gpt_image_2(base_builder())); + assert_eq!(text_req.prompt, "a cat in space"); + + let edit_req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + })); + assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let cases = [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + ]; + + for (count, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + + for (count, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(req.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(RouterQuality::Low), "Some(Low)"), + (Some(RouterQuality::Medium), "Some(Medium)"), + (Some(RouterQuality::High), "Some(High)"), + ]; + + for (quality, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + + for (quality, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + } + + #[test] + fn resolution_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(RouterResolution::HalfK), "Some(OneK)"), + (Some(RouterResolution::FourEightyP), "Some(OneK)"), + (Some(RouterResolution::SevenTwentyP), "Some(OneK)"), + (Some(RouterResolution::OneK), "Some(OneK)"), + (Some(RouterResolution::TenEightyP), "Some(TwoK)"), + (Some(RouterResolution::TwoK), "Some(TwoK)"), + (Some(RouterResolution::ThreeK), "Some(ThreeK)"), + (Some(RouterResolution::FourK), "Some(FourK)"), + ]; + + for (resolution, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + resolution, + ..base_builder() + })); + assert_debug(req.resolution, expected); + } + + for (resolution, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + resolution, + ..base_builder() + })); + assert_debug(req.resolution, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None", "None"), + (Some(RouterAspectRatio::Auto), "None", "Some(Auto)"), + (Some(RouterAspectRatio::Auto2k), "None", "Some(Auto)"), + (Some(RouterAspectRatio::Auto3k), "None", "Some(Auto)"), + (Some(RouterAspectRatio::Auto4k), "None", "Some(Auto)"), + (Some(RouterAspectRatio::Square), "Some(Square)", "Some(Square)"), + (Some(RouterAspectRatio::SquareHd), "Some(SquareHd)", "Some(SquareHd)"), + (Some(RouterAspectRatio::WideThreeByTwo), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(RouterAspectRatio::WideFourByThree), "Some(Landscape4x3)", "Some(Landscape4x3)"), + (Some(RouterAspectRatio::WideFiveByFour), "Some(Landscape4x3)", "Some(Landscape4x3)"), + (Some(RouterAspectRatio::WideSixteenByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(RouterAspectRatio::WideTwentyOneByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(RouterAspectRatio::Wide), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(RouterAspectRatio::TallTwoByThree), "Some(Portrait16x9)", "Some(Portrait16x9)"), + (Some(RouterAspectRatio::TallThreeByFour), "Some(Portrait4x3)", "Some(Portrait4x3)"), + (Some(RouterAspectRatio::TallFourByFive), "Some(Portrait4x3)", "Some(Portrait4x3)"), + (Some(RouterAspectRatio::TallNineBySixteen), "Some(Portrait16x9)", "Some(Portrait16x9)"), + (Some(RouterAspectRatio::TallNineByTwentyOne), "Some(Portrait16x9)", "Some(Portrait16x9)"), + (Some(RouterAspectRatio::Tall), "Some(Portrait16x9)", "Some(Portrait16x9)"), + ]; + + for (aspect_ratio, expected_t2i, _) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected_t2i); + } + + for (aspect_ratio, _, expected_edit) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected_edit); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/cost.rs new file mode 100644 index 0000000000..4ea794394d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/cost.rs @@ -0,0 +1,268 @@ +use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{ + GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageSize, +}; +use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{ + GptImage2TextToImageNumImages, GptImage2TextToImageQuality, GptImage2TextToImageSize, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::gpt_image_2::request::FalGptImage2RequestState; + +/// Cost state for Fal GPT Image 2. Mirrors v1 +/// (`estimate_image_cost_fal_gpt_image_2`): +/// +/// Output image cost (per output image), rounded up to whole cents: +/// Low: 1¢ all sizes +/// Medium: 6¢ square/squareHD/auto; 4¢ landscape/portrait (4:3 or 16:9) +/// High: 22¢ square; 23¢ squareHD/auto; +/// 15¢ landscape/portrait 4:3; 16¢ landscape/portrait 16:9 +/// +/// Quality defaults to High when unspecified. Resolution is NOT priced +/// separately — v1's table only varies by quality + size. +/// +/// The hand-rolled rates here intentionally override fal_client's trait, +/// which uses a base+per-megapixel formula that scales with the resolution +/// field. Keep in sync with v1. +#[derive(Clone, Debug)] +pub struct FalGptImage2CostState { + request: FalGptImage2RequestState, +} + +impl FalGptImage2CostState { + pub fn from_request(request: &FalGptImage2RequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = match &self.request { + FalGptImage2RequestState::TextToImage(req) => { + cost_t2i(req.quality, req.image_size, req.num_images) + } + FalGptImage2RequestState::EditImage(req) => { + cost_edit(req.quality, req.image_size, req.num_images) + } + }; + + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[derive(Copy, Clone)] +enum SizeBucket { + Square, + SquareHd, + Landscape4x3, + Landscape16x9, + Portrait4x3, + Portrait16x9, + Auto, +} + +fn cost_t2i( + quality: Option, + image_size: Option, + num_images: GptImage2TextToImageNumImages, +) -> u64 { + use GptImage2TextToImageQuality as Q; + use GptImage2TextToImageSize as S; + let q = quality.unwrap_or(Q::High); + // T2I size enum has no Auto variant — `None` is the only auto-equivalent. + let bucket = match image_size { + None => SizeBucket::Auto, + Some(S::Square) => SizeBucket::Square, + Some(S::SquareHd) => SizeBucket::SquareHd, + Some(S::Landscape4x3) => SizeBucket::Landscape4x3, + Some(S::Landscape16x9) => SizeBucket::Landscape16x9, + Some(S::Portrait4x3) => SizeBucket::Portrait4x3, + Some(S::Portrait16x9) => SizeBucket::Portrait16x9, + }; + output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket) * t2i_num_images(num_images) +} + +fn cost_edit( + quality: Option, + image_size: Option, + num_images: GptImage2EditImageNumImages, +) -> u64 { + use GptImage2EditImageQuality as Q; + use GptImage2EditImageSize as S; + let q = quality.unwrap_or(Q::High); + let bucket = match image_size { + None | Some(S::Auto) => SizeBucket::Auto, + Some(S::Square) => SizeBucket::Square, + Some(S::SquareHd) => SizeBucket::SquareHd, + Some(S::Landscape4x3) => SizeBucket::Landscape4x3, + Some(S::Landscape16x9) => SizeBucket::Landscape16x9, + Some(S::Portrait4x3) => SizeBucket::Portrait4x3, + Some(S::Portrait16x9) => SizeBucket::Portrait16x9, + }; + output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket) * edit_num_images(num_images) +} + +fn output_cost(is_low: bool, is_medium: bool, bucket: SizeBucket) -> u64 { + if is_low { + return 1; + } + if is_medium { + return match bucket { + SizeBucket::Square | SizeBucket::SquareHd | SizeBucket::Auto => 6, + SizeBucket::Landscape4x3 | SizeBucket::Portrait4x3 + | SizeBucket::Landscape16x9 | SizeBucket::Portrait16x9 => 4, + }; + } + // High (default) + match bucket { + SizeBucket::Square => 22, + SizeBucket::SquareHd | SizeBucket::Auto => 23, + SizeBucket::Landscape4x3 | SizeBucket::Portrait4x3 => 15, + SizeBucket::Landscape16x9 | SizeBucket::Portrait16x9 => 16, + } +} + +fn t2i_num_images(n: GptImage2TextToImageNumImages) -> u64 { + match n { + GptImage2TextToImageNumImages::One => 1, + GptImage2TextToImageNumImages::Two => 2, + GptImage2TextToImageNumImages::Three => 3, + GptImage2TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: GptImage2EditImageNumImages) -> u64 { + match n { + GptImage2EditImageNumImages::One => 1, + GptImage2EditImageNumImages::Two => 2, + GptImage2EditImageNumImages::Three => 3, + GptImage2EditImageNumImages::Four => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; + use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest, GptImage2EditImageSize}; + use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{GptImage2TextToImageNumImages, GptImage2TextToImageQuality, GptImage2TextToImageRequest, GptImage2TextToImageSize}; + + type TextPriceCase = (Option, Option, [u64; 4]); + type EditPriceCase = (Option, Option, [u64; 4]); + + // v1 doesn't price by resolution — only quality + size. The Fal request's + // resolution field is sent to Fal for actual generation but doesn't enter + // the cost calculation. + + #[test] + fn text_to_image_cost_table_covers_quality_and_size() { + for (quality, image_size, expected_by_count) in text_to_image_price_cases() { + for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) { + let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { prompt: "test".to_string(), num_images, image_size, resolution: None, quality, output_format: None }); + let cost = FalGptImage2CostState::from_request(&request).estimate_cost(); + assert_standard_cost_estimate(cost, expected); + } + } + } + + #[test] + fn edit_image_cost_table_covers_quality_and_size() { + for (quality, image_size, expected_by_count) in edit_image_price_cases() { + for (num_images, expected) in edit_image_num_image_cases(expected_by_count) { + let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_url: None, image_size, resolution: None, quality, output_format: None }); + let cost = FalGptImage2CostState::from_request(&request).estimate_cost(); + assert_standard_cost_estimate(cost, expected); + } + } + } + + // v1 rates (independent of resolution): + // Low: 1¢ all sizes + // Medium: 6¢ Square/SquareHd/Auto; 4¢ Landscape/Portrait (4:3 or 16:9) + // High: 22¢ Square; 23¢ SquareHd/Auto; 15¢ 4:3 (L/P); 16¢ 16:9 (L/P) + // Default quality is High. + fn text_to_image_price_cases() -> Vec { + vec![ + (None, None, [23, 46, 69, 92]), + // Low + // No Auto variant on t2i — None is the only auto-equivalent (tested above). + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::SquareHd), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape4x3), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape16x9), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait4x3), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait16x9), [1, 2, 3, 4]), + // Medium + // No Auto variant on t2i. + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Square), [6, 12, 18, 24]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::SquareHd), [6, 12, 18, 24]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape4x3), [4, 8, 12, 16]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape16x9), [4, 8, 12, 16]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait4x3), [4, 8, 12, 16]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait16x9), [4, 8, 12, 16]), + // High + // No Auto variant on t2i. + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Square), [22, 44, 66, 88]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd), [23, 46, 69, 92]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape4x3), [15, 30, 45, 60]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape16x9), [16, 32, 48, 64]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait4x3), [15, 30, 45, 60]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait16x9), [16, 32, 48, 64]), + ] + } + + // Edit-mode pricing is identical to t2i (v1 has no input-image fee for + // gpt_image_2 — only gpt_image_1 has that fee). + fn edit_image_price_cases() -> Vec { + vec![ + (None, None, [23, 46, 69, 92]), + // Low + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::SquareHd), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape4x3), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape16x9), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait4x3), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait16x9), [1, 2, 3, 4]), + // Medium + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), [6, 12, 18, 24]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Square), [6, 12, 18, 24]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::SquareHd), [6, 12, 18, 24]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3), [4, 8, 12, 16]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape16x9), [4, 8, 12, 16]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait4x3), [4, 8, 12, 16]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait16x9), [4, 8, 12, 16]), + // High + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), [23, 46, 69, 92]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Square), [22, 44, 66, 88]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd), [23, 46, 69, 92]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape4x3), [15, 30, 45, 60]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape16x9), [16, 32, 48, 64]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait4x3), [15, 30, 45, 60]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait16x9), [16, 32, 48, 64]), + ] + } + + fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage2TextToImageNumImages, u64); 4] { + [(GptImage2TextToImageNumImages::One, expected_by_count[0]), (GptImage2TextToImageNumImages::Two, expected_by_count[1]), (GptImage2TextToImageNumImages::Three, expected_by_count[2]), (GptImage2TextToImageNumImages::Four, expected_by_count[3])] + } + + fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage2EditImageNumImages, u64); 4] { + [(GptImage2EditImageNumImages::One, expected_by_count[0]), (GptImage2EditImageNumImages::Two, expected_by_count[1]), (GptImage2EditImageNumImages::Three, expected_by_count[2]), (GptImage2EditImageNumImages::Four, expected_by_count[3])] + } + + fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) { + assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents)); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/request.rs new file mode 100644 index 0000000000..c5846787e4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/request.rs @@ -0,0 +1,128 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::GptImage2EditImageRequest; +use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::GptImage2TextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse}; + +#[derive(Clone, Debug)] +pub enum FalGptImage2RequestState { + TextToImage(GptImage2TextToImageRequest), + EditImage(GptImage2EditImageRequest), +} + +impl FalGptImage2RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => send_request(request, client).await, + Self::EditImage(request) => send_request(request, client).await, + } + } +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{ + GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{ + GptImage2TextToImageNumImages, GptImage2TextToImageQuality, + GptImage2TextToImageSize, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn client_with_webhook() -> RouterFalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalClient::new_with_webhook( + FalApiKey::from_str(secret.trim()), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_webhook() { + let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { + prompt: "a polished product photo of a glass compass".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: Some(GptImage2TextToImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2TextToImageQuality::Low), + output_format: None, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_webhook() { + let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest { + prompt: "make this look like an elegant magazine cover image".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: GptImage2EditImageNumImages::One, + mask_url: None, + image_size: Some(GptImage2EditImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2EditImageQuality::Low), + output_format: None, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/mod.rs new file mode 100644 index 0000000000..5218ecb1e0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/mod.rs @@ -0,0 +1,5 @@ +//! Planning and transport shared by the GPT Image 2.5 Flare and Sunburst Fal providers. +//! The two models take identical parameters; only the Fal endpoint differs. + +pub mod plan; +pub mod send; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs new file mode 100644 index 0000000000..56ac929795 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs @@ -0,0 +1,330 @@ +use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; +use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; +use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; +use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; +use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; +use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + +/// Planned GPT Image 2.5 parameters. The mode is chosen by whether the builder carries +/// reference images: none means text-to-image, one or more means edit. +#[derive(Clone, Debug)] +pub enum PlannedGptImage2p5Params { + TextToImage(GptImage2p5TextToImageParams), + EditImage(GptImage2p5EditImageParams), +} + +pub fn plan_gpt_image_2p5_params( + builder: &GenerateImageRequestBuilder, +) -> Result { + let prompt = builder.prompt.clone().unwrap_or_default(); + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?; + let image_size = plan_image_size(builder.aspect_ratio); + let resolution = plan_resolution(builder.resolution); + let quality = Some(plan_quality(builder.quality)); + + if image_urls.is_empty() { + Ok(PlannedGptImage2p5Params::TextToImage(GptImage2p5TextToImageParams { + prompt, + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + })) + } else { + Ok(PlannedGptImage2p5Params::EditImage(GptImage2p5EditImageParams { + prompt, + image_urls, + mask_url: None, + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + })) + } +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(GptImage2p5NumImages::One), + 2 => Ok(GptImage2p5NumImages::Two), + 3 => Ok(GptImage2p5NumImages::Three), + 4 => Ok(GptImage2p5NumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(GptImage2p5NumImages::Four), + }, + } +} + +/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are +/// not reachable through it. Fal's default (`high`) applies when unspecified. +fn plan_quality(quality: Option) -> GptImage2p5Quality { + match quality { + Some(RouterQuality::Low) => GptImage2p5Quality::Low, + Some(RouterQuality::Medium) => GptImage2p5Quality::Medium, + Some(RouterQuality::High) | None => GptImage2p5Quality::High, + } +} + +fn plan_resolution(resolution: Option) -> Option { + resolution.map(|r| match r { + RouterResolution::HalfK + | RouterResolution::FourEightyP + | RouterResolution::SevenTwentyP + | RouterResolution::OneK => GptImage2Resolution::OneK, + RouterResolution::TenEightyP | RouterResolution::TwoK => GptImage2Resolution::TwoK, + RouterResolution::ThreeK => GptImage2Resolution::ThreeK, + RouterResolution::FourK => GptImage2Resolution::FourK, + }) +} + +/// `None` lets Fal apply its own default (`landscape_4_3` for text-to-image, `auto` for edit). +fn plan_image_size(aspect_ratio: Option) -> Option { + aspect_ratio.map(|ar| match ar { + RouterAspectRatio::Auto + | RouterAspectRatio::Auto2k + | RouterAspectRatio::Auto3k + | RouterAspectRatio::Auto4k => GptImage2p5ImageSize::Auto, + RouterAspectRatio::Square => GptImage2p5ImageSize::Square, + RouterAspectRatio::SquareHd => GptImage2p5ImageSize::SquareHd, + RouterAspectRatio::WideFourByThree + | RouterAspectRatio::WideFiveByFour => GptImage2p5ImageSize::Landscape4x3, + RouterAspectRatio::WideThreeByTwo + | RouterAspectRatio::WideSixteenByNine + | RouterAspectRatio::WideTwentyOneByNine + | RouterAspectRatio::Wide => GptImage2p5ImageSize::Landscape16x9, + RouterAspectRatio::TallThreeByFour + | RouterAspectRatio::TallFourByFive => GptImage2p5ImageSize::Portrait4x3, + RouterAspectRatio::TallTwoByThree + | RouterAspectRatio::TallNineBySixteen + | RouterAspectRatio::TallNineByTwentyOne + | RouterAspectRatio::Tall => GptImage2p5ImageSize::Portrait16x9, + }) +} + +fn resolve_image_urls( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + const IMAGE_URL: &str = "https://example.com/img.jpg"; + + #[test] + fn mode_is_based_on_image_urls() { + let text = unwrap_t2i(plan_gpt_image_2p5_params(&base_builder())); + assert_eq!(text.prompt, "a cat in space"); + + let edit = unwrap_edit(plan_gpt_image_2p5_params(&with_reference_image(base_builder()))); + assert_eq!(edit.prompt, "a cat in space"); + assert_eq!(edit.image_urls, vec![IMAGE_URL]); + assert!(edit.mask_url.is_none()); + } + + #[test] + fn optional_fal_fields_are_left_to_fal_defaults() { + let text = unwrap_t2i(plan_gpt_image_2p5_params(&base_builder())); + assert!(text.background.is_none()); + assert!(text.output_format.is_none()); + assert!(text.output_compression.is_none()); + assert!(text.image_size.is_none()); + assert!(text.resolution.is_none()); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)))); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let cases = [(1, "One"), (2, "Two"), (3, "Three"), (4, "Four")]; + for (count, expected) in cases { + let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(text.num_images, expected); + + let edit = unwrap_edit(plan_gpt_image_2p5_params(&with_reference_image(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + }))); + assert_debug(edit.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { image_batch_count: Some(5), ..base_builder() }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(text.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(RouterQuality::Low), "Some(Low)"), + (Some(RouterQuality::Medium), "Some(Medium)"), + (Some(RouterQuality::High), "Some(High)"), + ]; + for (quality, expected) in cases { + let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() })); + assert_debug(text.quality, expected); + let edit = unwrap_edit(plan_gpt_image_2p5_params(&with_reference_image(GenerateImageRequestBuilder { quality, ..base_builder() }))); + assert_debug(edit.quality, expected); + } + } + + #[test] + fn resolution_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(RouterResolution::HalfK), "Some(OneK)"), + (Some(RouterResolution::FourEightyP), "Some(OneK)"), + (Some(RouterResolution::SevenTwentyP), "Some(OneK)"), + (Some(RouterResolution::OneK), "Some(OneK)"), + (Some(RouterResolution::TenEightyP), "Some(TwoK)"), + (Some(RouterResolution::TwoK), "Some(TwoK)"), + (Some(RouterResolution::ThreeK), "Some(ThreeK)"), + (Some(RouterResolution::FourK), "Some(FourK)"), + ]; + for (resolution, expected) in cases { + let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { resolution, ..base_builder() })); + assert_debug(text.resolution, expected); + let edit = unwrap_edit(plan_gpt_image_2p5_params(&with_reference_image(GenerateImageRequestBuilder { resolution, ..base_builder() }))); + assert_debug(edit.resolution, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(RouterAspectRatio::Auto), "Some(Auto)"), + (Some(RouterAspectRatio::Auto2k), "Some(Auto)"), + (Some(RouterAspectRatio::Auto3k), "Some(Auto)"), + (Some(RouterAspectRatio::Auto4k), "Some(Auto)"), + (Some(RouterAspectRatio::Square), "Some(Square)"), + (Some(RouterAspectRatio::SquareHd), "Some(SquareHd)"), + (Some(RouterAspectRatio::WideThreeByTwo), "Some(Landscape16x9)"), + (Some(RouterAspectRatio::WideFourByThree), "Some(Landscape4x3)"), + (Some(RouterAspectRatio::WideFiveByFour), "Some(Landscape4x3)"), + (Some(RouterAspectRatio::WideSixteenByNine), "Some(Landscape16x9)"), + (Some(RouterAspectRatio::WideTwentyOneByNine), "Some(Landscape16x9)"), + (Some(RouterAspectRatio::Wide), "Some(Landscape16x9)"), + (Some(RouterAspectRatio::TallTwoByThree), "Some(Portrait16x9)"), + (Some(RouterAspectRatio::TallThreeByFour), "Some(Portrait4x3)"), + (Some(RouterAspectRatio::TallFourByFive), "Some(Portrait4x3)"), + (Some(RouterAspectRatio::TallNineBySixteen), "Some(Portrait16x9)"), + (Some(RouterAspectRatio::TallNineByTwentyOne), "Some(Portrait16x9)"), + (Some(RouterAspectRatio::Tall), "Some(Portrait16x9)"), + ]; + for (aspect_ratio, expected) in cases { + let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { aspect_ratio, ..base_builder() })); + assert_debug(text.image_size, expected); + let edit = unwrap_edit(plan_gpt_image_2p5_params(&with_reference_image(GenerateImageRequestBuilder { aspect_ratio, ..base_builder() }))); + assert_debug(edit.image_size, expected); + } + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Flare, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn with_reference_image(builder: GenerateImageRequestBuilder) -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..builder + } + } + + fn unwrap_t2i(result: Result) -> GptImage2p5TextToImageParams { + match result.expect("plan should succeed") { + PlannedGptImage2p5Params::TextToImage(params) => params, + PlannedGptImage2p5Params::EditImage(_) => panic!("expected text-to-image"), + } + } + + fn unwrap_edit(result: Result) -> GptImage2p5EditImageParams { + match result.expect("plan should succeed") { + PlannedGptImage2p5Params::EditImage(params) => params, + PlannedGptImage2p5Params::TextToImage(_) => panic!("expected edit-image"), + } + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/send.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/send.rs new file mode 100644 index 0000000000..61fce963f7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/send.rs @@ -0,0 +1,42 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse}; + +/// Send any GPT Image 2.5 request (Flare or Sunburst, text-to-image or edit) to Fal, via +/// webhook when the client has one configured and via the queue otherwise. +pub async fn send_gpt_image_2p5_request( + request: &T, + client: &RouterFalClient, +) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + + let payload = if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + FalImageResponsePayload { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + } + } else { + let response = request.send_queue_request(&client.api_key).await?; + FalImageResponsePayload { + request_id: Some(response.request_id), + gateway_request_id: None, + maybe_status_url: Some(response.status_url), + maybe_response_url: Some(response.response_url), + maybe_outbound_request: Some(outbound), + } + }; + + Ok(GenerateImageResponse::Fal(payload)) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/build.rs new file mode 100644 index 0000000000..bb0257e11d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/build.rs @@ -0,0 +1,129 @@ +use fal_client::requests::api::image::edit::gpt_image_2p5_flare_edit_image::api::GptImage2p5FlareEditImageRequest; +use fal_client::requests::api::image::text::gpt_image_2p5_flare_text_to_image::api::GptImage2p5FlareTextToImageRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_common::plan::{plan_gpt_image_2p5_params, PlannedGptImage2p5Params}; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_flare::request::FalGptImage2p5FlareRequestState; + +/// Text-to-image and edit share one model entry; the mode is chosen by whether the builder +/// carries reference images. Planning is shared with the other GPT Image 2.5 variant. +pub fn build_fal_gpt_image_2p5_flare( + builder: GenerateImageRequestBuilder, +) -> Result { + let state = match plan_gpt_image_2p5_params(&builder)? { + PlannedGptImage2p5Params::TextToImage(params) => { + FalGptImage2p5FlareRequestState::TextToImage(GptImage2p5FlareTextToImageRequest { params }) + } + PlannedGptImage2p5Params::EditImage(params) => { + FalGptImage2p5FlareRequestState::EditImage(GptImage2p5FlareEditImageRequest { params }) + } + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2p5Flare(state), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + #[test] + fn build2_routes_to_flare_text_to_image() { + let request = unwrap_t2i(base_builder().build2()); + assert_eq!(request.params.prompt, "a cat in space"); + assert_eq!(GptImage2p5FlareTextToImageRequest::ENDPOINT, "openai/gpt-image-2.5/flare/text-to-image"); + } + + #[test] + fn build2_routes_to_flare_edit_when_reference_images_present() { + let request = unwrap_edit(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }.build2()); + assert_eq!(request.params.image_urls, vec!["https://example.com/img.jpg"]); + assert_eq!(GptImage2p5FlareEditImageRequest::ENDPOINT, "openai/gpt-image-2.5/flare/edit"); + } + + #[test] + fn planned_fields_reach_the_fal_request() { + let request = unwrap_t2i(GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::TwoK), + quality: Some(RouterQuality::Medium), + image_batch_count: Some(3), + ..base_builder() + }.build2()); + assert_eq!(format!("{:?}", request.params.image_size), "Some(Landscape16x9)"); + assert_eq!(format!("{:?}", request.params.resolution), "Some(TwoK)"); + assert_eq!(format!("{:?}", request.params.quality), "Some(Medium)"); + assert_eq!(format!("{:?}", request.params.num_images), "Three"); + } + + #[test] + fn raw_wire_request_matches_fal_schema() { + let request = unwrap_t2i(GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + quality: Some(RouterQuality::High), + image_batch_count: Some(2), + ..base_builder() + }.build2()); + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "a cat in space", + "image_size": "square", + "quality": "high", + "num_images": 2, + "output_format": "png", + })); + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Flare, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage2p5FlareTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2p5Flare(FalGptImage2p5FlareRequestState::TextToImage(request)) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2.5 Flare text-to-image request") + }; + request + } + + fn unwrap_edit(result: Result) -> GptImage2p5FlareEditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2p5Flare(FalGptImage2p5FlareRequestState::EditImage(request)) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2.5 Flare edit-image request") + }; + request + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs new file mode 100644 index 0000000000..936d6a6602 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs @@ -0,0 +1,133 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_flare::request::FalGptImage2p5FlareRequestState; + +/// Cost state for Fal GPT Image 2.5 Flare. Delegates to `fal_client`'s calculator, which +/// prices by output dimensions (aspect ratio and resolution tier), quality, batch size and, +/// for edits, the number of reference images. +#[derive(Clone, Debug)] +pub struct FalGptImage2p5FlareCostState { + cost_in_usd_cents: u64, +} + +impl FalGptImage2p5FlareCostState { + pub fn from_request(request: &FalGptImage2p5FlareRequestState) -> Self { + let cost_in_usd_cents = match request { + FalGptImage2p5FlareRequestState::TextToImage(request) => request.calculate_cost_in_cents(), + FalGptImage2p5FlareRequestState::EditImage(request) => request.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use RouterAspectRatio as Ar; + use RouterQuality as Q; + use RouterResolution as R; + + // (quality, aspect_ratio, resolution, batch, expected_cents) — text-to-image + const TEXT_CASES: &[(Option, Option, Option, u16, u64)] = &[ + (None, None, None, 1, 4), // Fal default: landscape_4_3, high + (Some(Q::Low), Some(Ar::Square), None, 1, 1), + (Some(Q::Medium), Some(Ar::Square), None, 1, 2), + (Some(Q::High), Some(Ar::Square), None, 1, 6), + (Some(Q::High), Some(Ar::Square), None, 4, 24), + (Some(Q::High), Some(Ar::SquareHd), None, 1, 9), + (Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9), + (Some(Q::High), Some(Ar::Square), Some(R::FourK), 1, 13), + (Some(Q::High), Some(Ar::WideFourByThree), None, 1, 4), + (Some(Q::High), Some(Ar::TallThreeByFour), None, 1, 4), + (Some(Q::High), Some(Ar::WideSixteenByNine), None, 1, 4), + (Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::OneK), 1, 3), + (Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::FourK), 1, 11), + (Some(Q::Medium), Some(Ar::TallNineBySixteen), Some(R::ThreeK), 2, 4), + (Some(Q::High), Some(Ar::Auto), None, 1, 11), + (Some(Q::High), Some(Ar::Auto4k), None, 1, 11), + ]; + + // (reference_images, quality, aspect_ratio, resolution, batch, expected_cents) — edit + const EDIT_CASES: &[(usize, Option, Option, Option, u16, u64)] = &[ + (1, None, None, None, 1, 11), // Fal default: auto, high + (1, Some(Q::Low), Some(Ar::Square), None, 1, 2), + (1, Some(Q::High), Some(Ar::Square), None, 1, 7), + (3, Some(Q::High), Some(Ar::Square), None, 1, 8), + (1, Some(Q::High), Some(Ar::Square), None, 4, 28), + (1, Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::FourK), 1, 11), + (2, Some(Q::High), Some(Ar::Auto), None, 1, 12), + ]; + + #[test] + fn text_to_image_costs() { + for &(quality, aspect_ratio, resolution, batch, expected) in TEXT_CASES { + assert_eq!(cost_cents(0, quality, aspect_ratio, resolution, batch), expected, "{quality:?} {aspect_ratio:?} {resolution:?} x{batch}"); + } + } + + #[test] + fn edit_image_costs() { + for &(refs, quality, aspect_ratio, resolution, batch, expected) in EDIT_CASES { + assert_eq!(cost_cents(refs, quality, aspect_ratio, resolution, batch), expected, "{refs} refs {quality:?} {aspect_ratio:?} {resolution:?} x{batch}"); + } + } + + #[test] + fn estimate_flags() { + let cost = builder(0, Some(Q::High), Some(Ar::Square), None, 1).build2().unwrap().estimate_cost().unwrap(); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } + + fn cost_cents(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> u64 { + builder(refs, quality, aspect_ratio, resolution, batch) + .build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn builder(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> GenerateImageRequestBuilder { + let image_inputs = if refs == 0 { + None + } else { + Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string(); refs])) + }; + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Flare, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs, + resolution, + aspect_ratio, + quality, + image_batch_count: Some(batch), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/request.rs new file mode 100644 index 0000000000..3c64644624 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/request.rs @@ -0,0 +1,86 @@ +use fal_client::requests::api::image::edit::gpt_image_2p5_flare_edit_image::api::GptImage2p5FlareEditImageRequest; +use fal_client::requests::api::image::text::gpt_image_2p5_flare_text_to_image::api::GptImage2p5FlareTextToImageRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_common::send::send_gpt_image_2p5_request; + +#[derive(Clone, Debug)] +pub enum FalGptImage2p5FlareRequestState { + TextToImage(GptImage2p5FlareTextToImageRequest), + EditImage(GptImage2p5FlareEditImageRequest), +} + +impl FalGptImage2p5FlareRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => send_gpt_image_2p5_request(request, client).await, + Self::EditImage(request) => send_gpt_image_2p5_request(request, client).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_webhook() { + let request = FalGptImage2p5FlareRequestState::TextToImage(GptImage2p5FlareTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "a polished product photo of a glass compass".to_string(), + num_images: GptImage2p5NumImages::One, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2p5Quality::Low), + background: None, + output_format: None, + output_compression: None, + }, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_webhook() { + let request = FalGptImage2p5FlareRequestState::EditImage(GptImage2p5FlareEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "make this look like an elegant magazine cover image".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + mask_url: None, + num_images: GptImage2p5NumImages::One, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2p5Quality::Low), + background: None, + output_format: None, + output_compression: None, + }, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + fn client_with_webhook() -> RouterFalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalClient::new_with_webhook( + FalApiKey::from_str(secret.trim()), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/build.rs new file mode 100644 index 0000000000..c95e0858a5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/build.rs @@ -0,0 +1,129 @@ +use fal_client::requests::api::image::edit::gpt_image_2p5_sunburst_edit_image::api::GptImage2p5SunburstEditImageRequest; +use fal_client::requests::api::image::text::gpt_image_2p5_sunburst_text_to_image::api::GptImage2p5SunburstTextToImageRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_common::plan::{plan_gpt_image_2p5_params, PlannedGptImage2p5Params}; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_sunburst::request::FalGptImage2p5SunburstRequestState; + +/// Text-to-image and edit share one model entry; the mode is chosen by whether the builder +/// carries reference images. Planning is shared with the other GPT Image 2.5 variant. +pub fn build_fal_gpt_image_2p5_sunburst( + builder: GenerateImageRequestBuilder, +) -> Result { + let state = match plan_gpt_image_2p5_params(&builder)? { + PlannedGptImage2p5Params::TextToImage(params) => { + FalGptImage2p5SunburstRequestState::TextToImage(GptImage2p5SunburstTextToImageRequest { params }) + } + PlannedGptImage2p5Params::EditImage(params) => { + FalGptImage2p5SunburstRequestState::EditImage(GptImage2p5SunburstEditImageRequest { params }) + } + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2p5Sunburst(state), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + #[test] + fn build2_routes_to_sunburst_text_to_image() { + let request = unwrap_t2i(base_builder().build2()); + assert_eq!(request.params.prompt, "a cat in space"); + assert_eq!(GptImage2p5SunburstTextToImageRequest::ENDPOINT, "openai/gpt-image-2.5/sunburst/text-to-image"); + } + + #[test] + fn build2_routes_to_sunburst_edit_when_reference_images_present() { + let request = unwrap_edit(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }.build2()); + assert_eq!(request.params.image_urls, vec!["https://example.com/img.jpg"]); + assert_eq!(GptImage2p5SunburstEditImageRequest::ENDPOINT, "openai/gpt-image-2.5/sunburst/edit"); + } + + #[test] + fn planned_fields_reach_the_fal_request() { + let request = unwrap_t2i(GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::TwoK), + quality: Some(RouterQuality::Medium), + image_batch_count: Some(3), + ..base_builder() + }.build2()); + assert_eq!(format!("{:?}", request.params.image_size), "Some(Landscape16x9)"); + assert_eq!(format!("{:?}", request.params.resolution), "Some(TwoK)"); + assert_eq!(format!("{:?}", request.params.quality), "Some(Medium)"); + assert_eq!(format!("{:?}", request.params.num_images), "Three"); + } + + #[test] + fn raw_wire_request_matches_fal_schema() { + let request = unwrap_t2i(GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + quality: Some(RouterQuality::High), + image_batch_count: Some(2), + ..base_builder() + }.build2()); + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "a cat in space", + "image_size": "square", + "quality": "high", + "num_images": 2, + "output_format": "png", + })); + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Sunburst, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage2p5SunburstTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2p5Sunburst(FalGptImage2p5SunburstRequestState::TextToImage(request)) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2.5 Sunburst text-to-image request") + }; + request + } + + fn unwrap_edit(result: Result) -> GptImage2p5SunburstEditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2p5Sunburst(FalGptImage2p5SunburstRequestState::EditImage(request)) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2.5 Sunburst edit-image request") + }; + request + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs new file mode 100644 index 0000000000..7932ab7c38 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs @@ -0,0 +1,133 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_sunburst::request::FalGptImage2p5SunburstRequestState; + +/// Cost state for Fal GPT Image 2.5 Sunburst. Delegates to `fal_client`'s calculator, which +/// prices by output dimensions (aspect ratio and resolution tier), quality, batch size and, +/// for edits, the number of reference images. +#[derive(Clone, Debug)] +pub struct FalGptImage2p5SunburstCostState { + cost_in_usd_cents: u64, +} + +impl FalGptImage2p5SunburstCostState { + pub fn from_request(request: &FalGptImage2p5SunburstRequestState) -> Self { + let cost_in_usd_cents = match request { + FalGptImage2p5SunburstRequestState::TextToImage(request) => request.calculate_cost_in_cents(), + FalGptImage2p5SunburstRequestState::EditImage(request) => request.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use RouterAspectRatio as Ar; + use RouterQuality as Q; + use RouterResolution as R; + + // (quality, aspect_ratio, resolution, batch, expected_cents) — text-to-image + const TEXT_CASES: &[(Option, Option, Option, u16, u64)] = &[ + (None, None, None, 1, 4), // Fal default: landscape_4_3, high + (Some(Q::Low), Some(Ar::Square), None, 1, 1), + (Some(Q::Medium), Some(Ar::Square), None, 1, 2), + (Some(Q::High), Some(Ar::Square), None, 1, 6), + (Some(Q::High), Some(Ar::Square), None, 4, 24), + (Some(Q::High), Some(Ar::SquareHd), None, 1, 9), + (Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9), + (Some(Q::High), Some(Ar::Square), Some(R::FourK), 1, 13), + (Some(Q::High), Some(Ar::WideFourByThree), None, 1, 4), + (Some(Q::High), Some(Ar::TallThreeByFour), None, 1, 4), + (Some(Q::High), Some(Ar::WideSixteenByNine), None, 1, 4), + (Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::OneK), 1, 3), + (Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::FourK), 1, 11), + (Some(Q::Medium), Some(Ar::TallNineBySixteen), Some(R::ThreeK), 2, 4), + (Some(Q::High), Some(Ar::Auto), None, 1, 11), + (Some(Q::High), Some(Ar::Auto4k), None, 1, 11), + ]; + + // (reference_images, quality, aspect_ratio, resolution, batch, expected_cents) — edit + const EDIT_CASES: &[(usize, Option, Option, Option, u16, u64)] = &[ + (1, None, None, None, 1, 11), // Fal default: auto, high + (1, Some(Q::Low), Some(Ar::Square), None, 1, 2), + (1, Some(Q::High), Some(Ar::Square), None, 1, 7), + (3, Some(Q::High), Some(Ar::Square), None, 1, 8), + (1, Some(Q::High), Some(Ar::Square), None, 4, 28), + (1, Some(Q::High), Some(Ar::WideSixteenByNine), Some(R::FourK), 1, 11), + (2, Some(Q::High), Some(Ar::Auto), None, 1, 12), + ]; + + #[test] + fn text_to_image_costs() { + for &(quality, aspect_ratio, resolution, batch, expected) in TEXT_CASES { + assert_eq!(cost_cents(0, quality, aspect_ratio, resolution, batch), expected, "{quality:?} {aspect_ratio:?} {resolution:?} x{batch}"); + } + } + + #[test] + fn edit_image_costs() { + for &(refs, quality, aspect_ratio, resolution, batch, expected) in EDIT_CASES { + assert_eq!(cost_cents(refs, quality, aspect_ratio, resolution, batch), expected, "{refs} refs {quality:?} {aspect_ratio:?} {resolution:?} x{batch}"); + } + } + + #[test] + fn estimate_flags() { + let cost = builder(0, Some(Q::High), Some(Ar::Square), None, 1).build2().unwrap().estimate_cost().unwrap(); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } + + fn cost_cents(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> u64 { + builder(refs, quality, aspect_ratio, resolution, batch) + .build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn builder(refs: usize, quality: Option, aspect_ratio: Option, resolution: Option, batch: u16) -> GenerateImageRequestBuilder { + let image_inputs = if refs == 0 { + None + } else { + Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string(); refs])) + }; + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2p5Sunburst, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs, + resolution, + aspect_ratio, + quality, + image_batch_count: Some(batch), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/request.rs new file mode 100644 index 0000000000..191f92bb20 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/request.rs @@ -0,0 +1,86 @@ +use fal_client::requests::api::image::edit::gpt_image_2p5_sunburst_edit_image::api::GptImage2p5SunburstEditImageRequest; +use fal_client::requests::api::image::text::gpt_image_2p5_sunburst_text_to_image::api::GptImage2p5SunburstTextToImageRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::fal::gpt_image_2p5_common::send::send_gpt_image_2p5_request; + +#[derive(Clone, Debug)] +pub enum FalGptImage2p5SunburstRequestState { + TextToImage(GptImage2p5SunburstTextToImageRequest), + EditImage(GptImage2p5SunburstEditImageRequest), +} + +impl FalGptImage2p5SunburstRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => send_gpt_image_2p5_request(request, client).await, + Self::EditImage(request) => send_gpt_image_2p5_request(request, client).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_webhook() { + let request = FalGptImage2p5SunburstRequestState::TextToImage(GptImage2p5SunburstTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "a polished product photo of a glass compass".to_string(), + num_images: GptImage2p5NumImages::One, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2p5Quality::Low), + background: None, + output_format: None, + output_compression: None, + }, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_webhook() { + let request = FalGptImage2p5SunburstRequestState::EditImage(GptImage2p5SunburstEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "make this look like an elegant magazine cover image".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + mask_url: None, + num_images: GptImage2p5NumImages::One, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2p5Quality::Low), + background: None, + output_format: None, + output_compression: None, + }, + }); + let response = request.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + fn client_with_webhook() -> RouterFalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalClient::new_with_webhook( + FalApiKey::from_str(secret.trim()), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/mod.rs new file mode 100644 index 0000000000..5699db2340 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/mod.rs @@ -0,0 +1,18 @@ +pub mod flux_1_dev; +pub mod flux_1_schnell; +pub mod flux_2_lora_angles; +pub mod flux_pro_1p1; +pub mod flux_pro_1p1_ultra; +pub mod gpt_image_1; +pub mod gpt_image_1p5; +pub mod gpt_image_2; +pub mod gpt_image_2p5_common; +pub mod gpt_image_2p5_flare; +pub mod gpt_image_2p5_sunburst; +pub mod nano_banana; +pub mod nano_banana_2; +pub mod nano_banana_pro; +pub mod qwen_edit_2511_angles; +pub mod seedream_4; +pub mod seedream_4p5; +pub mod seedream_5_lite; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/build.rs new file mode 100644 index 0000000000..c35be42a25 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/build.rs @@ -0,0 +1,389 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{ + Gemini25FlashEditAspectRatio, Gemini25FlashEditNumImages, Gemini25FlashEditRequest, +}; +use fal_client::requests_old::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{ + Gemini25FlashTextToImageAspectRatio, Gemini25FlashTextToImageNumImages, + Gemini25FlashTextToImageRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::nano_banana::request::FalNanoBananaRequestState; + +pub fn build_fal_nano_banana( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let image_urls = resolve_image_list_ref(builder.image_inputs.clone())?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + + let state = if image_urls.is_empty() { + let aspect_ratio = plan_t2i_aspect_ratio(builder.aspect_ratio, strategy)?; + FalNanoBananaRequestState::TextToImage(Gemini25FlashTextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + aspect_ratio, + }) + } else { + let aspect_ratio = plan_edit_aspect_ratio(builder.aspect_ratio, strategy)?; + FalNanoBananaRequestState::EditImage(Gemini25FlashEditRequest { + prompt, + image_urls, + num_images: to_edit_num_images(num_images), + aspect_ratio, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBanana(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> Gemini25FlashTextToImageNumImages { + match n { + PlannedNumImages::One => Gemini25FlashTextToImageNumImages::One, + PlannedNumImages::Two => Gemini25FlashTextToImageNumImages::Two, + PlannedNumImages::Three => Gemini25FlashTextToImageNumImages::Three, + PlannedNumImages::Four => Gemini25FlashTextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> Gemini25FlashEditNumImages { + match n { + PlannedNumImages::One => Gemini25FlashEditNumImages::One, + PlannedNumImages::Two => Gemini25FlashEditNumImages::Two, + PlannedNumImages::Three => Gemini25FlashEditNumImages::Three, + PlannedNumImages::Four => Gemini25FlashEditNumImages::Four, + } +} + +// ── Aspect ratio ── + +// Gemini 2.5 Flash text-to-image supported aspect ratios: +// 1:1, 5:4, 4:3, 3:2, 16:9, 21:9, 4:5, 3:4, 2:3, 9:16 +// (no Auto for text-to-image) +fn plan_t2i_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Gemini25FlashTextToImageAspectRatio as T; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(T::OneByOne)), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(T::OneByOne)), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Some(T::FiveByFour)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(T::FourByThree)), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Some(T::ThreeByTwo)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(T::SixteenByNine)), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(T::TwentyOneByNine)), + Some(RouterAspectRatio::TallFourByFive) => Ok(Some(T::FourByFive)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(T::ThreeByFour)), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Some(T::TwoByThree)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(T::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(T::NineBySixteen)), + }, + } +} + +// Gemini 2.5 Flash image-edit supported aspect ratios: same as t2i plus Auto +fn plan_edit_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Gemini25FlashEditAspectRatio as E; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(E::Auto)), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(E::OneByOne)), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Some(E::FiveByFour)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(E::FourByThree)), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Some(E::ThreeByTwo)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(E::SixteenByNine)), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(E::TwentyOneByNine)), + Some(RouterAspectRatio::TallFourByFive) => Ok(Some(E::FourByFive)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(E::ThreeByFour)), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Some(E::TwoByThree)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(E::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(E::NineBySixteen)), + }, + } +} + +// ── Image inputs ── + +fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBanana, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> Gemini25FlashTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBanana(FalNanoBananaRequestState::TextToImage(req)) + ) = result.expect("build should succeed") else { + panic!("expected TextToImage variant") + }; + req + } + + fn unwrap_edit(result: Result) -> Gemini25FlashEditRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBanana(FalNanoBananaRequestState::EditImage(req)) + ) = result.expect("build should succeed") else { + panic!("expected EditImage variant") + }; + req + } + + // ── Mode detection ── + + mod mode_detection { + use super::*; + + #[test] + fn no_image_inputs_yields_text_to_image() { + let req = unwrap_t2i(build_fal_nano_banana(base_builder())); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn image_inputs_yields_edit() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana(builder)); + assert_eq!(req.image_urls, vec!["https://example.com/img.jpg".to_string()]); + } + + #[test] + fn multiple_image_inputs_all_passed_through() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![ + "https://example.com/a.jpg".to_string(), + "https://example.com/b.jpg".to_string(), + ])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana(builder)); + assert_eq!(req.image_urls.len(), 2); + } + + #[test] + fn empty_image_urls_yields_text_to_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![])), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana(builder)); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn t2i_default_is_none() { + let req = unwrap_t2i(build_fal_nano_banana(base_builder())); + assert!(req.aspect_ratio.is_none()); + } + + #[test] + fn t2i_auto_falls_back_to_one_by_one() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana(builder)); + assert!(matches!(req.aspect_ratio, Some(Gemini25FlashTextToImageAspectRatio::OneByOne))); + } + + #[test] + fn t2i_wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana(builder)); + assert!(matches!(req.aspect_ratio, Some(Gemini25FlashTextToImageAspectRatio::SixteenByNine))); + } + + #[test] + fn edit_auto_is_preserved() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana(builder)); + assert!(matches!(req.aspect_ratio, Some(Gemini25FlashEditAspectRatio::Auto))); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_t2i(build_fal_nano_banana(base_builder())); + assert!(matches!(req.num_images, Gemini25FlashTextToImageNumImages::One)); + } + + #[test] + fn three_is_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana(builder)); + assert!(matches!(req.num_images, Gemini25FlashTextToImageNumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(9), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_batch_count", .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(9), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana(builder)); + assert!(matches!(req.num_images, Gemini25FlashTextToImageNumImages::Four)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/cost.rs new file mode 100644 index 0000000000..a52745630a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/cost.rs @@ -0,0 +1,118 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::Gemini25FlashEditNumImages; +use fal_client::requests_old::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::Gemini25FlashTextToImageNumImages; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::nano_banana::request::FalNanoBananaRequestState; + +pub struct FalNanoBananaCostState { + num_images: u64, +} + +impl FalNanoBananaCostState { + pub fn from_request(request: &FalNanoBananaRequestState) -> Self { + let num_images = match request { + FalNanoBananaRequestState::TextToImage(req) => match req.num_images { + Gemini25FlashTextToImageNumImages::One => 1, + Gemini25FlashTextToImageNumImages::Two => 2, + Gemini25FlashTextToImageNumImages::Three => 3, + Gemini25FlashTextToImageNumImages::Four => 4, + }, + FalNanoBananaRequestState::EditImage(req) => match req.num_images { + Gemini25FlashEditNumImages::One => 1, + Gemini25FlashEditNumImages::Two => 2, + Gemini25FlashEditNumImages::Three => 3, + Gemini25FlashEditNumImages::Four => 4, + }, + }; + Self { num_images } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Pricing: $0.039/image — rounded up to 4 cents (no resolution multiplier, + // Gemini 2.5 Flash has no resolution option). Matches v1. + const COST_PER_IMAGE: u64 = 4; + let cost_in_usd_cents = COST_PER_IMAGE * self.num_images; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::Gemini25FlashEditRequest; + use fal_client::requests_old::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::Gemini25FlashTextToImageRequest; + + fn t2i_cost(n: Gemini25FlashTextToImageNumImages) -> ImageGenerationCostEstimate { + FalNanoBananaCostState::from_request(&FalNanoBananaRequestState::TextToImage( + Gemini25FlashTextToImageRequest { + prompt: "test".to_string(), + num_images: n, + aspect_ratio: None, + }, + )).estimate_cost() + } + + fn edit_cost(n: Gemini25FlashEditNumImages) -> ImageGenerationCostEstimate { + FalNanoBananaCostState::from_request(&FalNanoBananaRequestState::EditImage( + Gemini25FlashEditRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/x.jpg".to_string()], + num_images: n, + aspect_ratio: None, + }, + )).estimate_cost() + } + + #[test] + fn t2i_one_image_is_4_cents() { + assert_eq!(t2i_cost(Gemini25FlashTextToImageNumImages::One).cost_in_usd_cents, Some(4)); + } + + #[test] + fn t2i_two_images_is_8_cents() { + assert_eq!(t2i_cost(Gemini25FlashTextToImageNumImages::Two).cost_in_usd_cents, Some(8)); + } + + #[test] + fn t2i_three_images_is_12_cents() { + assert_eq!(t2i_cost(Gemini25FlashTextToImageNumImages::Three).cost_in_usd_cents, Some(12)); + } + + #[test] + fn t2i_four_images_is_16_cents() { + assert_eq!(t2i_cost(Gemini25FlashTextToImageNumImages::Four).cost_in_usd_cents, Some(16)); + } + + #[test] + fn edit_two_images_matches_t2i_two_images() { + assert_eq!( + edit_cost(Gemini25FlashEditNumImages::Two).cost_in_usd_cents, + t2i_cost(Gemini25FlashTextToImageNumImages::Two).cost_in_usd_cents, + ); + } + + #[test] + fn credits_equal_cents() { + let c = t2i_cost(Gemini25FlashTextToImageNumImages::Two); + assert_eq!(c.cost_in_credits, c.cost_in_usd_cents); + } + + #[test] + fn cost_flags_match_v1() { + let c = t2i_cost(Gemini25FlashTextToImageNumImages::One); + assert!(!c.is_free); + assert!(!c.is_unlimited); + assert!(!c.is_rate_limited); + assert!(!c.has_watermark); + assert!(c.failures_are_refunded.is_none()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/request.rs new file mode 100644 index 0000000000..9553fda357 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana/request.rs @@ -0,0 +1,117 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{ + enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs, Gemini25FlashEditRequest, +}; +use fal_client::requests_old::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{ + enqueue_gemini_25_flash_text_to_image_webhook, Gemini25FlashTextToImageArgs, + Gemini25FlashTextToImageRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalNanoBananaRequestState { + TextToImage(Gemini25FlashTextToImageRequest), + EditImage(Gemini25FlashEditRequest), +} + +impl FalNanoBananaRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound): (_, Arc) = match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = Gemini25FlashTextToImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_gemini_25_flash_text_to_image_webhook(args).await, outbound) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = Gemini25FlashEditArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_gemini_25_flash_edit_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::Gemini25FlashEditNumImages; + use fal_client::requests_old::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{ + Gemini25FlashTextToImageAspectRatio, Gemini25FlashTextToImageNumImages, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_t2i_via_webhook() { + let client = client_with_webhook(); + let state = FalNanoBananaRequestState::TextToImage(Gemini25FlashTextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: Gemini25FlashTextToImageNumImages::One, + aspect_ratio: Some(Gemini25FlashTextToImageAspectRatio::SixteenByNine), + }); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("nano_banana t2i — request_id: {:?}", payload.request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_via_webhook() { + let client = client_with_webhook(); + let state = FalNanoBananaRequestState::EditImage(Gemini25FlashEditRequest { + prompt: "add a party hat to the dog".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: Gemini25FlashEditNumImages::One, + aspect_ratio: None, + }); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("nano_banana edit — request_id: {:?}", payload.request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/build.rs new file mode 100644 index 0000000000..868c22815a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/build.rs @@ -0,0 +1,543 @@ +use fal_client::requests::api::image::edit::nano_banana_2_edit_image::api::{ + NanoBanana2EditImageAspectRatio, NanoBanana2EditImageNumImages, + NanoBanana2EditImageRequest, NanoBanana2EditImageResolution, +}; +use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::{ + NanoBanana2TextToImageAspectRatio, NanoBanana2TextToImageNumImages, + NanoBanana2TextToImageRequest, NanoBanana2TextToImageResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::nano_banana_2::request::FalNanoBanana2RequestState; + +pub fn build_fal_nano_banana_2( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let prompt = builder.prompt.clone().unwrap_or_default(); + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let resolution = plan_resolution(builder.resolution); + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + + let state = if image_urls.is_empty() { + // Text-to-image + let aspect_ratio = plan_t2i_aspect_ratio(builder.aspect_ratio); + + FalNanoBanana2RequestState::TextToImage(NanoBanana2TextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + resolution: resolution.map(to_t2i_resolution), + aspect_ratio, + }) + } else { + // Edit image + let aspect_ratio = plan_edit_aspect_ratio(builder.aspect_ratio); + + FalNanoBanana2RequestState::EditImage(NanoBanana2EditImageRequest { + prompt, + image_urls, + num_images: to_edit_num_images(num_images), + resolution: resolution.map(to_edit_resolution), + aspect_ratio, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBanana2(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> NanoBanana2TextToImageNumImages { + match n { + PlannedNumImages::One => NanoBanana2TextToImageNumImages::One, + PlannedNumImages::Two => NanoBanana2TextToImageNumImages::Two, + PlannedNumImages::Three => NanoBanana2TextToImageNumImages::Three, + PlannedNumImages::Four => NanoBanana2TextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> NanoBanana2EditImageNumImages { + match n { + PlannedNumImages::One => NanoBanana2EditImageNumImages::One, + PlannedNumImages::Two => NanoBanana2EditImageNumImages::Two, + PlannedNumImages::Three => NanoBanana2EditImageNumImages::Three, + PlannedNumImages::Four => NanoBanana2EditImageNumImages::Four, + } +} + +// ── Resolution ── + +#[derive(Copy, Clone, Debug)] +enum PlannedResolution { + HalfK, + OneK, + TwoK, + FourK, +} + +fn plan_resolution(resolution: Option) -> Option { + resolution.map(|r| match r { + RouterResolution::HalfK | RouterResolution::FourEightyP => PlannedResolution::HalfK, + RouterResolution::OneK | RouterResolution::SevenTwentyP | RouterResolution::TenEightyP => PlannedResolution::OneK, + RouterResolution::TwoK => PlannedResolution::TwoK, + // 3K isn't natively supported on Fal nano_banana_2; v1 downgrades to 2K + // pricing (and TwoK resolution). Match v1 here so cost+behavior agree. + RouterResolution::ThreeK => PlannedResolution::TwoK, + RouterResolution::FourK => PlannedResolution::FourK, + }) +} + +fn to_t2i_resolution(r: PlannedResolution) -> NanoBanana2TextToImageResolution { + match r { + PlannedResolution::HalfK => NanoBanana2TextToImageResolution::HalfK, + PlannedResolution::OneK => NanoBanana2TextToImageResolution::OneK, + PlannedResolution::TwoK => NanoBanana2TextToImageResolution::TwoK, + PlannedResolution::FourK => NanoBanana2TextToImageResolution::FourK, + } +} + +fn to_edit_resolution(r: PlannedResolution) -> NanoBanana2EditImageResolution { + match r { + PlannedResolution::HalfK => NanoBanana2EditImageResolution::HalfK, + PlannedResolution::OneK => NanoBanana2EditImageResolution::OneK, + PlannedResolution::TwoK => NanoBanana2EditImageResolution::TwoK, + PlannedResolution::FourK => NanoBanana2EditImageResolution::FourK, + } +} + +// ── Aspect ratio ── + +fn plan_t2i_aspect_ratio( + aspect_ratio: Option, +) -> Option { + use NanoBanana2TextToImageAspectRatio as T; + aspect_ratio.map(|ar| match ar { + RouterAspectRatio::Auto | RouterAspectRatio::Auto2k + | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k => T::Auto, + RouterAspectRatio::Square | RouterAspectRatio::SquareHd => T::OneByOne, + RouterAspectRatio::WideFiveByFour => T::FiveByFour, + RouterAspectRatio::WideFourByThree => T::FourByThree, + RouterAspectRatio::WideThreeByTwo | RouterAspectRatio::Wide => T::ThreeByTwo, + RouterAspectRatio::WideSixteenByNine => T::SixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => T::TwentyOneByNine, + RouterAspectRatio::TallFourByFive => T::FourByFive, + RouterAspectRatio::TallThreeByFour => T::ThreeByFour, + RouterAspectRatio::TallTwoByThree | RouterAspectRatio::Tall => T::TwoByThree, + RouterAspectRatio::TallNineBySixteen => T::NineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => T::NineBySixteen, // nearest match + }) +} + +fn plan_edit_aspect_ratio( + aspect_ratio: Option, +) -> Option { + use NanoBanana2EditImageAspectRatio as E; + aspect_ratio.map(|ar| match ar { + RouterAspectRatio::Auto | RouterAspectRatio::Auto2k + | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k => E::Auto, + RouterAspectRatio::Square | RouterAspectRatio::SquareHd => E::OneByOne, + RouterAspectRatio::WideFiveByFour => E::FiveByFour, + RouterAspectRatio::WideFourByThree => E::FourByThree, + RouterAspectRatio::WideThreeByTwo | RouterAspectRatio::Wide => E::ThreeByTwo, + RouterAspectRatio::WideSixteenByNine => E::SixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => E::TwentyOneByNine, + RouterAspectRatio::TallFourByFive => E::FourByFive, + RouterAspectRatio::TallThreeByFour => E::ThreeByFour, + RouterAspectRatio::TallTwoByThree | RouterAspectRatio::Tall => E::TwoByThree, + RouterAspectRatio::TallNineBySixteen => E::NineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => E::NineBySixteen, // nearest match + }) +} + +// ── Image inputs ── + +fn resolve_image_urls( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBanana2, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> NanoBanana2TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBanana2( + FalNanoBanana2RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected TextToImage variant") + }; + req + } + + fn unwrap_edit(result: Result) -> NanoBanana2EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBanana2( + FalNanoBanana2RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected EditImage variant") + }; + req + } + + // ── Mode detection ── + + mod mode_detection { + use super::*; + + #[test] + fn no_images_yields_text_to_image() { + let result = build_fal_nano_banana_2(base_builder()); + let req = unwrap_t2i(result); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn urls_yield_edit_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let result = build_fal_nano_banana_2(builder); + let req = unwrap_edit(result); + assert_eq!(req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + let result = build_fal_nano_banana_2(builder); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_t2i(build_fal_nano_banana_2(base_builder())); + assert!(matches!(req.num_images, NanoBanana2TextToImageNumImages::One)); + } + + #[test] + fn explicit_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.num_images, NanoBanana2TextToImageNumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana_2(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana_2(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.num_images, NanoBanana2TextToImageNumImages::Four)); + } + + #[test] + fn edit_mode_num_images() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(2), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_2(builder)); + assert!(matches!(req.num_images, NanoBanana2EditImageNumImages::Two)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn none_is_none() { + let req = unwrap_t2i(build_fal_nano_banana_2(base_builder())); + assert!(req.resolution.is_none()); + } + + #[test] + fn half_k_maps_to_half_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::HalfK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::HalfK))); + } + + #[test] + fn four_eighty_p_maps_to_half_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::FourEightyP), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::HalfK))); + } + + #[test] + fn one_k_maps_to_one_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::OneK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::OneK))); + } + + #[test] + fn two_k_maps_to_two_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::TwoK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::TwoK))); + } + + #[test] + fn four_k_maps_to_four_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::FourK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::FourK))); + } + + #[test] + fn three_k_rounds_down_to_two_k() { + // Match v1's downgrade behavior: 3K → 2K (cheaper, no 3K native support). + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::ThreeK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::TwoK))); + } + + #[test] + fn edit_mode_resolution() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + resolution: Some(RouterResolution::HalfK), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_2(builder)); + assert!(matches!(req.resolution, Some(NanoBanana2EditImageResolution::HalfK))); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn none_is_none_t2i() { + let req = unwrap_t2i(build_fal_nano_banana_2(base_builder())); + assert!(req.aspect_ratio.is_none()); + } + + #[test] + fn auto_maps_to_auto_t2i() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBanana2TextToImageAspectRatio::Auto))); + } + + #[test] + fn square_maps_to_one_by_one() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBanana2TextToImageAspectRatio::OneByOne))); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBanana2TextToImageAspectRatio::SixteenByNine))); + } + + #[test] + fn tall_nine_by_sixteen() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBanana2TextToImageAspectRatio::NineBySixteen))); + } + + #[test] + fn edit_auto() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_2(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBanana2EditImageAspectRatio::Auto))); + } + + #[test] + fn edit_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::SquareHd), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_2(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBanana2EditImageAspectRatio::OneByOne))); + } + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { + prompt: None, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_2(builder)); + assert_eq!(req.prompt, ""); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/cost.rs new file mode 100644 index 0000000000..47f7e1f8f8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/cost.rs @@ -0,0 +1,224 @@ +use fal_client::requests::api::image::edit::nano_banana_2_edit_image::api::{ + NanoBanana2EditImageNumImages, NanoBanana2EditImageResolution, +}; +use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::{ + NanoBanana2TextToImageNumImages, NanoBanana2TextToImageResolution, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::nano_banana_2::request::FalNanoBanana2RequestState; + +/// Cost state for Fal Nano Banana 2. Mirrors v1 +/// (`estimate_image_cost_fal_nano_banana_2`): +/// +/// $0.08/image base (1K). Multipliers: 0.5K = 0.75x, 2K = 1.5x, 4K = 2x. +/// 0.5K → 6¢, 1K → 8¢ (also default), 2K → 12¢, 4K → 16¢ +/// +/// The hand-rolled rates here intentionally override fal_client's trait, +/// whose rates are placeholder values from a `TODO(bt): Verify pricing` block +/// (8/15/15/30 instead of v1's 6/8/12/16). Keep this in sync with the v1 +/// cost file. +pub struct FalNanoBanana2CostState { + cost_in_usd_cents: u64, +} + +impl FalNanoBanana2CostState { + pub fn from_request(request: &FalNanoBanana2RequestState) -> Self { + let (cost_per_image, num_images) = match request { + FalNanoBanana2RequestState::TextToImage(req) => { + (cost_per_image_for_t2i_resolution(req.resolution), t2i_num_images(req.num_images)) + } + FalNanoBanana2RequestState::EditImage(req) => { + (cost_per_image_for_edit_resolution(req.resolution), edit_num_images(req.num_images)) + } + }; + Self { cost_in_usd_cents: cost_per_image * num_images } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn cost_per_image_for_t2i_resolution(r: Option) -> u64 { + match r { + Some(NanoBanana2TextToImageResolution::HalfK) => 6, + Some(NanoBanana2TextToImageResolution::OneK) | None => 8, + Some(NanoBanana2TextToImageResolution::TwoK) => 12, + Some(NanoBanana2TextToImageResolution::FourK) => 16, + } +} + +fn cost_per_image_for_edit_resolution(r: Option) -> u64 { + match r { + Some(NanoBanana2EditImageResolution::HalfK) => 6, + Some(NanoBanana2EditImageResolution::OneK) | None => 8, + Some(NanoBanana2EditImageResolution::TwoK) => 12, + Some(NanoBanana2EditImageResolution::FourK) => 16, + } +} + +fn t2i_num_images(n: NanoBanana2TextToImageNumImages) -> u64 { + match n { + NanoBanana2TextToImageNumImages::One => 1, + NanoBanana2TextToImageNumImages::Two => 2, + NanoBanana2TextToImageNumImages::Three => 3, + NanoBanana2TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: NanoBanana2EditImageNumImages) -> u64 { + match n { + NanoBanana2EditImageNumImages::One => 1, + NanoBanana2EditImageNumImages::Two => 2, + NanoBanana2EditImageNumImages::Three => 3, + NanoBanana2EditImageNumImages::Four => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::edit::nano_banana_2_edit_image::api::{ + NanoBanana2EditImageNumImages, NanoBanana2EditImageRequest, + NanoBanana2EditImageResolution, + }; + use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::{ + NanoBanana2TextToImageNumImages, NanoBanana2TextToImageRequest, + NanoBanana2TextToImageResolution, + }; + + mod text_to_image_costs { + use super::*; + + fn make_t2i( + num_images: NanoBanana2TextToImageNumImages, + resolution: Option, + ) -> FalNanoBanana2RequestState { + FalNanoBanana2RequestState::TextToImage(NanoBanana2TextToImageRequest { + prompt: "test".to_string(), + num_images, + resolution, + aspect_ratio: None, + }) + } + + #[test] + fn one_image_default_resolution() { + let state = FalNanoBanana2CostState::from_request( + &make_t2i(NanoBanana2TextToImageNumImages::One, None), + ); + // None defaults to 1K = 8¢ per v1's rate table. + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(8)); + } + + #[test] + fn one_image_half_k() { + let state = FalNanoBanana2CostState::from_request( + &make_t2i(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::HalfK)), + ); + // HalfK = 0.75x base = 6¢ per v1's rate table. + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(6)); + } + + #[test] + fn four_images_one_k() { + let state = FalNanoBanana2CostState::from_request( + &make_t2i(NanoBanana2TextToImageNumImages::Four, Some(NanoBanana2TextToImageResolution::OneK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(32)); + } + + #[test] + fn one_image_four_k() { + let state = FalNanoBanana2CostState::from_request( + &make_t2i(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::FourK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(16)); + } + + #[test] + fn two_images_four_k() { + let state = FalNanoBanana2CostState::from_request( + &make_t2i(NanoBanana2TextToImageNumImages::Two, Some(NanoBanana2TextToImageResolution::FourK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(32)); + } + } + + mod edit_image_costs { + use super::*; + + fn make_edit( + num_images: NanoBanana2EditImageNumImages, + resolution: Option, + ) -> FalNanoBanana2RequestState { + FalNanoBanana2RequestState::EditImage(NanoBanana2EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.jpg".to_string()], + num_images, + resolution, + aspect_ratio: None, + }) + } + + #[test] + fn one_image_default_resolution() { + let state = FalNanoBanana2CostState::from_request( + &make_edit(NanoBanana2EditImageNumImages::One, None), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(8)); + } + + #[test] + fn one_image_half_k() { + let state = FalNanoBanana2CostState::from_request( + &make_edit(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::HalfK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(6)); + } + + #[test] + fn three_images_two_k() { + let state = FalNanoBanana2CostState::from_request( + &make_edit(NanoBanana2EditImageNumImages::Three, Some(NanoBanana2EditImageResolution::TwoK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(36)); + } + + #[test] + fn one_image_four_k() { + let state = FalNanoBanana2CostState::from_request( + &make_edit(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::FourK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(16)); + } + } + + #[test] + fn cost_flags_are_correct() { + let state = FalNanoBanana2CostState::from_request( + &FalNanoBanana2RequestState::TextToImage(NanoBanana2TextToImageRequest { + prompt: "test".to_string(), + num_images: NanoBanana2TextToImageNumImages::One, + resolution: None, + aspect_ratio: None, + }), + ); + let cost = state.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/request.rs new file mode 100644 index 0000000000..18fa9054bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_2/request.rs @@ -0,0 +1,168 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::nano_banana_2_edit_image::api::NanoBanana2EditImageRequest; +use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::NanoBanana2TextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalNanoBanana2RequestState { + TextToImage(NanoBanana2TextToImageRequest), + EditImage(NanoBanana2EditImageRequest), +} + +impl FalNanoBanana2RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + } + } +} + +// ── Helpers ── + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request + .send_webhook_request(&client.api_key, webhook_url) + .await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request + .send_queue_request(&client.api_key) + .await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::nano_banana_2_edit_image::api::{ + NanoBanana2EditImageAspectRatio, NanoBanana2EditImageNumImages, + NanoBanana2EditImageResolution, + }; + use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::{ + NanoBanana2TextToImageAspectRatio, NanoBanana2TextToImageNumImages, + NanoBanana2TextToImageResolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + // ── Text-to-image ── + + mod text_to_image { + use super::*; + + fn t2i_request() -> FalNanoBanana2RequestState { + FalNanoBanana2RequestState::TextToImage(NanoBanana2TextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: NanoBanana2TextToImageNumImages::One, + resolution: Some(NanoBanana2TextToImageResolution::OneK), + aspect_ratio: Some(NanoBanana2TextToImageAspectRatio::SixteenByNine), + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = t2i_request(); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + } + + // ── Edit image ── + + mod edit_image { + use super::*; + use test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL; + + fn edit_request() -> FalNanoBanana2RequestState { + FalNanoBanana2RequestState::EditImage(NanoBanana2EditImageRequest { + prompt: "add a party hat to the dog, and put the dog in front of the location".to_string(), + image_urls: vec![ + JUNO_AT_LAKE_IMAGE_URL.to_string(), + WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(), + ], + num_images: NanoBanana2EditImageNumImages::One, + resolution: Some(NanoBanana2EditImageResolution::OneK), + aspect_ratio: Some(NanoBanana2EditImageAspectRatio::SixteenByNine), + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = edit_request(); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook edit — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/build.rs new file mode 100644 index 0000000000..861524861f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/build.rs @@ -0,0 +1,530 @@ +use fal_client::requests::api::image::edit::nano_banana_pro_edit_image::api::{ + NanoBananaProEditImageAspectRatio, NanoBananaProEditImageNumImages, + NanoBananaProEditImageRequest, NanoBananaProEditImageResolution, +}; +use fal_client::requests::api::image::text::nano_banana_pro_text_to_image::api::{ + NanoBananaProTextToImageAspectRatio, NanoBananaProTextToImageNumImages, + NanoBananaProTextToImageRequest, NanoBananaProTextToImageResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::nano_banana_pro::request::FalNanoBananaProRequestState; + +pub fn build_fal_nano_banana_pro( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let prompt = builder.prompt.clone().unwrap_or_default(); + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let resolution = plan_resolution(builder.resolution); + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + + let state = if image_urls.is_empty() { + // Text-to-image + let aspect_ratio = plan_t2i_aspect_ratio(builder.aspect_ratio); + + FalNanoBananaProRequestState::TextToImage(NanoBananaProTextToImageRequest { + prompt, + num_images: to_t2i_num_images(num_images), + resolution: resolution.map(to_t2i_resolution), + aspect_ratio, + }) + } else { + // Edit image + let aspect_ratio = plan_edit_aspect_ratio(builder.aspect_ratio); + + FalNanoBananaProRequestState::EditImage(NanoBananaProEditImageRequest { + prompt, + image_urls, + num_images: to_edit_num_images(num_images), + resolution: resolution.map(to_edit_resolution), + aspect_ratio, + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBananaPro(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> NanoBananaProTextToImageNumImages { + match n { + PlannedNumImages::One => NanoBananaProTextToImageNumImages::One, + PlannedNumImages::Two => NanoBananaProTextToImageNumImages::Two, + PlannedNumImages::Three => NanoBananaProTextToImageNumImages::Three, + PlannedNumImages::Four => NanoBananaProTextToImageNumImages::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> NanoBananaProEditImageNumImages { + match n { + PlannedNumImages::One => NanoBananaProEditImageNumImages::One, + PlannedNumImages::Two => NanoBananaProEditImageNumImages::Two, + PlannedNumImages::Three => NanoBananaProEditImageNumImages::Three, + PlannedNumImages::Four => NanoBananaProEditImageNumImages::Four, + } +} + +// ── Resolution ── + +#[derive(Copy, Clone, Debug)] +enum PlannedResolution { + OneK, + TwoK, + FourK, +} + +fn plan_resolution(resolution: Option) -> Option { + resolution.map(|r| match r { + RouterResolution::HalfK | RouterResolution::FourEightyP | RouterResolution::SevenTwentyP + | RouterResolution::OneK | RouterResolution::TenEightyP => PlannedResolution::OneK, + RouterResolution::TwoK => PlannedResolution::TwoK, + // 3K isn't natively supported on Fal nano_banana_pro; v1 downgrades to 2K + // pricing (and TwoK resolution). Match v1 here so cost+behavior agree. + RouterResolution::ThreeK => PlannedResolution::TwoK, + RouterResolution::FourK => PlannedResolution::FourK, + }) +} + +fn to_t2i_resolution(r: PlannedResolution) -> NanoBananaProTextToImageResolution { + match r { + PlannedResolution::OneK => NanoBananaProTextToImageResolution::OneK, + PlannedResolution::TwoK => NanoBananaProTextToImageResolution::TwoK, + PlannedResolution::FourK => NanoBananaProTextToImageResolution::FourK, + } +} + +fn to_edit_resolution(r: PlannedResolution) -> NanoBananaProEditImageResolution { + match r { + PlannedResolution::OneK => NanoBananaProEditImageResolution::OneK, + PlannedResolution::TwoK => NanoBananaProEditImageResolution::TwoK, + PlannedResolution::FourK => NanoBananaProEditImageResolution::FourK, + } +} + +// ── Aspect ratio ── + +fn plan_t2i_aspect_ratio( + aspect_ratio: Option, +) -> Option { + use NanoBananaProTextToImageAspectRatio as T; + aspect_ratio.and_then(|ar| match ar { + RouterAspectRatio::Square | RouterAspectRatio::SquareHd => Some(T::OneByOne), + RouterAspectRatio::WideFiveByFour => Some(T::FiveByFour), + RouterAspectRatio::WideFourByThree => Some(T::FourByThree), + RouterAspectRatio::WideThreeByTwo | RouterAspectRatio::Wide => Some(T::ThreeByTwo), + RouterAspectRatio::WideSixteenByNine => Some(T::SixteenByNine), + RouterAspectRatio::WideTwentyOneByNine => Some(T::TwentyOneByNine), + RouterAspectRatio::TallFourByFive => Some(T::FourByFive), + RouterAspectRatio::TallThreeByFour => Some(T::ThreeByFour), + RouterAspectRatio::TallTwoByThree | RouterAspectRatio::Tall => Some(T::TwoByThree), + RouterAspectRatio::TallNineBySixteen => Some(T::NineBySixteen), + RouterAspectRatio::TallNineByTwentyOne => Some(T::NineBySixteen), // nearest match + RouterAspectRatio::Auto | RouterAspectRatio::Auto2k + | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k => None, + }) +} + +fn plan_edit_aspect_ratio( + aspect_ratio: Option, +) -> Option { + use NanoBananaProEditImageAspectRatio as E; + aspect_ratio.map(|ar| match ar { + RouterAspectRatio::Auto | RouterAspectRatio::Auto2k + | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k => E::Auto, + RouterAspectRatio::Square | RouterAspectRatio::SquareHd => E::OneByOne, + RouterAspectRatio::WideFiveByFour => E::FiveByFour, + RouterAspectRatio::WideFourByThree => E::FourByThree, + RouterAspectRatio::WideThreeByTwo | RouterAspectRatio::Wide => E::ThreeByTwo, + RouterAspectRatio::WideSixteenByNine => E::SixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => E::TwentyOneByNine, + RouterAspectRatio::TallFourByFive => E::FourByFive, + RouterAspectRatio::TallThreeByFour => E::ThreeByFour, + RouterAspectRatio::TallTwoByThree | RouterAspectRatio::Tall => E::TwoByThree, + RouterAspectRatio::TallNineBySixteen => E::NineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => E::NineBySixteen, // nearest match + }) +} + +// ── Image inputs ── + +fn resolve_image_urls( + image_inputs: Option, +) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBananaPro, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> NanoBananaProTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBananaPro( + FalNanoBananaProRequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected TextToImage variant") + }; + req + } + + fn unwrap_edit(result: Result) -> NanoBananaProEditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalNanoBananaPro( + FalNanoBananaProRequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected EditImage variant") + }; + req + } + + // ── Mode detection ── + + mod mode_detection { + use super::*; + + #[test] + fn no_images_yields_text_to_image() { + let result = build_fal_nano_banana_pro(base_builder()); + let req = unwrap_t2i(result); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn urls_yield_edit_image() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let result = build_fal_nano_banana_pro(builder); + let req = unwrap_edit(result); + assert_eq!(req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + let result = build_fal_nano_banana_pro(builder); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_t2i(build_fal_nano_banana_pro(base_builder())); + assert!(matches!(req.num_images, NanoBananaProTextToImageNumImages::One)); + } + + #[test] + fn explicit_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.num_images, NanoBananaProTextToImageNumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana_pro(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + ..base_builder() + }; + assert!(matches!( + build_fal_nano_banana_pro(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.num_images, NanoBananaProTextToImageNumImages::Four)); + } + + #[test] + fn edit_mode_num_images() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(2), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.num_images, NanoBananaProEditImageNumImages::Two)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn none_is_none() { + let req = unwrap_t2i(build_fal_nano_banana_pro(base_builder())); + assert!(req.resolution.is_none()); + } + + #[test] + fn one_k_maps_to_one_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::OneK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::OneK))); + } + + #[test] + fn two_k_maps_to_two_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::TwoK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::TwoK))); + } + + #[test] + fn four_k_maps_to_four_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::FourK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::FourK))); + } + + #[test] + fn three_k_rounds_down_to_two_k() { + // Match v1's downgrade behavior: 3K → 2K (cheaper, no 3K native support). + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::ThreeK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::TwoK))); + } + + #[test] + fn half_k_rounds_to_one_k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::HalfK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::OneK))); + } + + #[test] + fn edit_mode_resolution() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + resolution: Some(RouterResolution::FourK), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.resolution, Some(NanoBananaProEditImageResolution::FourK))); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn none_is_none_t2i() { + let req = unwrap_t2i(build_fal_nano_banana_pro(base_builder())); + assert!(req.aspect_ratio.is_none()); + } + + #[test] + fn square_maps_to_one_by_one() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBananaProTextToImageAspectRatio::OneByOne))); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBananaProTextToImageAspectRatio::SixteenByNine))); + } + + #[test] + fn tall_nine_by_sixteen() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBananaProTextToImageAspectRatio::NineBySixteen))); + } + + #[test] + fn auto_yields_none_for_t2i() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert!(req.aspect_ratio.is_none()); + } + + #[test] + fn auto_yields_auto_for_edit() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBananaProEditImageAspectRatio::Auto))); + } + + #[test] + fn edit_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::SquareHd), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_nano_banana_pro(builder)); + assert!(matches!(req.aspect_ratio, Some(NanoBananaProEditImageAspectRatio::OneByOne))); + } + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { + prompt: None, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); + assert_eq!(req.prompt, ""); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/cost.rs new file mode 100644 index 0000000000..cf720e0e24 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/cost.rs @@ -0,0 +1,151 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::nano_banana_pro::request::FalNanoBananaProRequestState; + +pub struct FalNanoBananaProCostState { + cost_in_usd_cents: u64, +} + +impl FalNanoBananaProCostState { + pub fn from_request(request: &FalNanoBananaProRequestState) -> Self { + let cost_in_usd_cents = match request { + FalNanoBananaProRequestState::TextToImage(req) => req.calculate_cost_in_cents(), + FalNanoBananaProRequestState::EditImage(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::edit::nano_banana_pro_edit_image::api::{ + NanoBananaProEditImageNumImages, NanoBananaProEditImageRequest, + NanoBananaProEditImageResolution, + }; + use fal_client::requests::api::image::text::nano_banana_pro_text_to_image::api::{ + NanoBananaProTextToImageNumImages, NanoBananaProTextToImageRequest, + NanoBananaProTextToImageResolution, + }; + + mod text_to_image_costs { + use super::*; + + fn make_t2i( + num_images: NanoBananaProTextToImageNumImages, + resolution: Option, + ) -> FalNanoBananaProRequestState { + FalNanoBananaProRequestState::TextToImage(NanoBananaProTextToImageRequest { + prompt: "test".to_string(), + num_images, + resolution, + aspect_ratio: None, + }) + } + + #[test] + fn one_image_default_resolution() { + let state = FalNanoBananaProCostState::from_request( + &make_t2i(NanoBananaProTextToImageNumImages::One, None), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(15)); + } + + #[test] + fn four_images_one_k() { + let state = FalNanoBananaProCostState::from_request( + &make_t2i(NanoBananaProTextToImageNumImages::Four, Some(NanoBananaProTextToImageResolution::OneK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(60)); + } + + #[test] + fn one_image_four_k() { + let state = FalNanoBananaProCostState::from_request( + &make_t2i(NanoBananaProTextToImageNumImages::One, Some(NanoBananaProTextToImageResolution::FourK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(30)); + } + + #[test] + fn two_images_four_k() { + let state = FalNanoBananaProCostState::from_request( + &make_t2i(NanoBananaProTextToImageNumImages::Two, Some(NanoBananaProTextToImageResolution::FourK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(60)); + } + } + + mod edit_image_costs { + use super::*; + + fn make_edit( + num_images: NanoBananaProEditImageNumImages, + resolution: Option, + ) -> FalNanoBananaProRequestState { + FalNanoBananaProRequestState::EditImage(NanoBananaProEditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/img.jpg".to_string()], + num_images, + resolution, + aspect_ratio: None, + }) + } + + #[test] + fn one_image_default_resolution() { + let state = FalNanoBananaProCostState::from_request( + &make_edit(NanoBananaProEditImageNumImages::One, None), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(15)); + } + + #[test] + fn three_images_two_k() { + let state = FalNanoBananaProCostState::from_request( + &make_edit(NanoBananaProEditImageNumImages::Three, Some(NanoBananaProEditImageResolution::TwoK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(45)); + } + + #[test] + fn one_image_four_k() { + let state = FalNanoBananaProCostState::from_request( + &make_edit(NanoBananaProEditImageNumImages::One, Some(NanoBananaProEditImageResolution::FourK)), + ); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(30)); + } + } + + #[test] + fn cost_flags_are_correct() { + let state = FalNanoBananaProCostState::from_request( + &FalNanoBananaProRequestState::TextToImage(NanoBananaProTextToImageRequest { + prompt: "test".to_string(), + num_images: NanoBananaProTextToImageNumImages::One, + resolution: None, + aspect_ratio: None, + }), + ); + let cost = state.estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/request.rs new file mode 100644 index 0000000000..4f4318cb4c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/nano_banana_pro/request.rs @@ -0,0 +1,168 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::edit::nano_banana_pro_edit_image::api::NanoBananaProEditImageRequest; +use fal_client::requests::api::image::text::nano_banana_pro_text_to_image::api::NanoBananaProTextToImageRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalNanoBananaProRequestState { + TextToImage(NanoBananaProTextToImageRequest), + EditImage(NanoBananaProEditImageRequest), +} + +impl FalNanoBananaProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } + } + } +} + +// ── Helpers ── + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request + .send_webhook_request(&client.api_key, webhook_url) + .await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request + .send_queue_request(&client.api_key) + .await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::nano_banana_pro_edit_image::api::{ + NanoBananaProEditImageAspectRatio, NanoBananaProEditImageNumImages, + NanoBananaProEditImageResolution, + }; + use fal_client::requests::api::image::text::nano_banana_pro_text_to_image::api::{ + NanoBananaProTextToImageAspectRatio, NanoBananaProTextToImageNumImages, + NanoBananaProTextToImageResolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + // ── Text-to-image ── + + mod text_to_image { + use super::*; + + fn t2i_request() -> FalNanoBananaProRequestState { + FalNanoBananaProRequestState::TextToImage(NanoBananaProTextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: NanoBananaProTextToImageNumImages::One, + resolution: Some(NanoBananaProTextToImageResolution::OneK), + aspect_ratio: Some(NanoBananaProTextToImageAspectRatio::SixteenByNine), + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = t2i_request(); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + } + + // ── Edit image ── + + mod edit_image { + use super::*; + use test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL; + + fn edit_request() -> FalNanoBananaProRequestState { + FalNanoBananaProRequestState::EditImage(NanoBananaProEditImageRequest { + prompt: "add a party hat to the dog, and put the dog in front of the location".to_string(), + image_urls: vec![ + JUNO_AT_LAKE_IMAGE_URL.to_string(), + WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(), + ], + num_images: NanoBananaProEditImageNumImages::One, + resolution: Some(NanoBananaProEditImageResolution::OneK), + aspect_ratio: Some(NanoBananaProEditImageAspectRatio::SixteenByNine), + }) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = edit_request(); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook edit — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/build.rs new file mode 100644 index 0000000000..2421c5ab60 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/build.rs @@ -0,0 +1,201 @@ +use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{ + QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::request::FalQwenEdit2511AnglesRequestState; + +pub fn build_fal_qwen_edit_2511_angles( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let image_urls = resolve_image_urls(builder.image_inputs.clone())?; + if image_urls.is_empty() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Angle models require at least one input image URL".to_string(), + })); + } + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let image_size = plan_image_size(builder.aspect_ratio, strategy)?; + + let request = QwenEdit2511EditImageAngleRequest { + image_urls, + horizontal_angle: builder.horizontal_angle, + vertical_angle: builder.vertical_angle, + zoom: builder.zoom, + additional_prompt: builder.prompt.clone(), + num_images: Some(num_images), + image_size, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState { request }), + )) +} + +fn resolve_image_urls(image_inputs: Option) -> Result, ArtcraftRouterError> { + match image_inputs { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use QwenEdit2511AngleNumImages as N; + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(N::One), + 2 => Ok(N::Two), + 3 => Ok(N::Three), + 4 => Ok(N::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(N::Four), + }, + } +} + +fn plan_image_size( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use QwenEdit2511AngleImageSize as S; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Square) => Ok(Some(S::Square)), + Some(RouterAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Wide) | Some(RouterAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(RouterAspectRatio::Tall) | Some(RouterAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::SquareHd)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + #[test] + fn passes_through_camera_params() { + let req = unwrap_request(build_fal_qwen_edit_2511_angles(base())); + assert_eq!(req.horizontal_angle, Some(45.0)); + assert_eq!(req.vertical_angle, Some(-15.0)); + assert_eq!(req.zoom, Some(2.0)); + } + + #[test] + fn missing_image_inputs_errors() { + let builder = GenerateImageRequestBuilder { image_inputs: None, ..base() }; + assert!(build_fal_qwen_edit_2511_angles(builder).is_err()); + } + + #[test] + fn media_file_tokens_rejected() { + use tokens::tokens::media_files::MediaFileToken; + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new_from_str("mf_test")])), + ..base() + }; + assert!(matches!( + build_fal_qwen_edit_2511_angles(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn default_num_images_is_one() { + let req = unwrap_request(build_fal_qwen_edit_2511_angles(base())); + assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::One))); + } + + #[test] + fn batch_zero_errors() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base() }; + assert!(matches!( + build_fal_qwen_edit_2511_angles(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn batch_over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(9), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base() + }; + let req = unwrap_request(build_fal_qwen_edit_2511_angles(builder)); + assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::Four))); + } + + fn base() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::QwenEdit2511Angles, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])), + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: Some(45.0), + vertical_angle: Some(-15.0), + zoom: Some(2.0), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_request(result: Result) -> QwenEdit2511EditImageAngleRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalQwenEdit2511Angles(state) + ) = result.expect("build should succeed") else { + panic!("expected FalQwenEdit2511Angles variant") + }; + state.request + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/cost.rs new file mode 100644 index 0000000000..99a93ffacc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/cost.rs @@ -0,0 +1,82 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::request::FalQwenEdit2511AnglesRequestState; + +/// Cost state for Fal Qwen Edit 2511 Angles. Delegates to the fal_client +/// cost calculator (4¢ per output image — $0.035/MP at the default ~1MP). +pub struct FalQwenEdit2511AnglesCostState { + cost_in_usd_cents: u64, +} + +impl FalQwenEdit2511AnglesCostState { + pub fn from_request(request: &FalQwenEdit2511AnglesRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{ + QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest, + }; + + #[test] + fn one_image_is_4_cents() { + assert_eq!(cost_for(QwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4)); + } + + #[test] + fn two_images_is_8_cents() { + assert_eq!(cost_for(QwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8)); + } + + #[test] + fn three_images_is_12_cents() { + assert_eq!(cost_for(QwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12)); + } + + #[test] + fn four_images_is_16_cents() { + assert_eq!(cost_for(QwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16)); + } + + #[test] + fn credits_equal_cents() { + let cost = cost_for(QwenEdit2511AngleNumImages::Two); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + + fn cost_for(n: QwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate { + let state = FalQwenEdit2511AnglesCostState::from_request(&FalQwenEdit2511AnglesRequestState { + request: QwenEdit2511EditImageAngleRequest { + image_urls: vec!["https://example.com/x.jpg".to_string()], + horizontal_angle: None, + vertical_angle: None, + zoom: None, + additional_prompt: None, + num_images: Some(n), + image_size: None, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + }); + state.estimate_cost() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/request.rs new file mode 100644 index 0000000000..207e55cca7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/qwen_edit_2511_angles/request.rs @@ -0,0 +1,112 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::QwenEdit2511EditImageAngleRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalQwenEdit2511AnglesRequestState { + pub request: QwenEdit2511EditImageAngleRequest, +} + +impl FalQwenEdit2511AnglesRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let outbound: Arc = Arc::new(self.request.clone()); + let payload = send_fal_request(&self.request, client).await?; + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) + } +} + +// ── Helpers ── + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request + .send_webhook_request(&client.api_key, webhook_url) + .await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request + .send_queue_request(&client.api_key) + .await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{ + QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let state = FalQwenEdit2511AnglesRequestState { + request: QwenEdit2511EditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(-15.0), + zoom: Some(2.0), + additional_prompt: Some("cinematic".to_string()), + num_images: Some(QwenEdit2511AngleNumImages::One), + image_size: Some(QwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + }; + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/build.rs new file mode 100644 index 0000000000..8f7fb06f1d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/build.rs @@ -0,0 +1,375 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4_edit_image_webhook::{ + EnqueueBytedanceSeedreamV4EditImageNumImages, EnqueueBytedanceSeedreamV4EditImageRequest, + EnqueueBytedanceSeedreamV4EditImageSize, +}; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::{ + EnqueueBytedanceSeedreamV4TextToImageNumImages, EnqueueBytedanceSeedreamV4TextToImageRequest, + EnqueueBytedanceSeedreamV4TextToImageSize, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::seedream_4::request::FalSeedream4RequestState; + +pub fn build_fal_seedream_4( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let image_urls = resolve_image_list_ref(builder.image_inputs.clone())?; + let is_edit_mode = !image_urls.is_empty(); + let image_size = plan_image_size(builder.aspect_ratio, is_edit_mode, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + + let state = if is_edit_mode { + FalSeedream4RequestState::EditImage(EnqueueBytedanceSeedreamV4EditImageRequest { + prompt, + image_urls, + num_images: Some(to_edit_num_images(num_images)), + max_images: None, + image_size: image_size.map(to_edit_image_size), + }) + } else { + FalSeedream4RequestState::TextToImage(EnqueueBytedanceSeedreamV4TextToImageRequest { + prompt, + num_images: Some(to_t2i_num_images(num_images)), + max_images: None, + image_size: image_size.map(to_t2i_image_size), + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream4(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> EnqueueBytedanceSeedreamV4TextToImageNumImages { + use EnqueueBytedanceSeedreamV4TextToImageNumImages as T; + match n { + PlannedNumImages::One => T::One, + PlannedNumImages::Two => T::Two, + PlannedNumImages::Three => T::Three, + PlannedNumImages::Four => T::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> EnqueueBytedanceSeedreamV4EditImageNumImages { + use EnqueueBytedanceSeedreamV4EditImageNumImages as E; + match n { + PlannedNumImages::One => E::One, + PlannedNumImages::Two => E::Two, + PlannedNumImages::Three => E::Three, + PlannedNumImages::Four => E::Four, + } +} + +// ── Image size ── + +#[derive(Copy, Clone, Debug)] +enum PlannedImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, + Auto, + Auto2k, + Auto4k, +} + +fn plan_image_size( + aspect_ratio: Option, + is_edit_mode: bool, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlannedImageSize as S; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) => { + if is_edit_mode { Ok(Some(S::Auto)) } else { Ok(Some(S::Square)) } + } + Some(RouterAspectRatio::Auto2k) | Some(RouterAspectRatio::Auto3k) => Ok(Some(S::Auto2k)), + Some(RouterAspectRatio::Auto4k) => Ok(Some(S::Auto4k)), + + Some(RouterAspectRatio::Square) => Ok(Some(S::Square)), + Some(RouterAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Wide) | Some(RouterAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(unsupported @ RouterAspectRatio::WideFiveByFour) + | Some(unsupported @ RouterAspectRatio::WideThreeByTwo) + | Some(unsupported @ RouterAspectRatio::WideTwentyOneByNine) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::LandscapeSixteenNine)), + }, + + Some(RouterAspectRatio::Tall) | Some(RouterAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported @ RouterAspectRatio::TallFourByFive) + | Some(unsupported @ RouterAspectRatio::TallTwoByThree) + | Some(unsupported @ RouterAspectRatio::TallNineByTwentyOne) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::PortraitSixteenNine)), + }, + } +} + +fn to_t2i_image_size(s: PlannedImageSize) -> EnqueueBytedanceSeedreamV4TextToImageSize { + use EnqueueBytedanceSeedreamV4TextToImageSize as T; + match s { + PlannedImageSize::Square => T::Square, + PlannedImageSize::SquareHd => T::SquareHd, + PlannedImageSize::PortraitFourThree => T::PortraitFourThree, + PlannedImageSize::PortraitSixteenNine => T::PortraitSixteenNine, + PlannedImageSize::LandscapeFourThree => T::LandscapeFourThree, + PlannedImageSize::LandscapeSixteenNine => T::LandscapeSixteenNine, + PlannedImageSize::Auto => T::Auto, + PlannedImageSize::Auto2k => T::Auto2k, + PlannedImageSize::Auto4k => T::Auto4k, + } +} + +fn to_edit_image_size(s: PlannedImageSize) -> EnqueueBytedanceSeedreamV4EditImageSize { + use EnqueueBytedanceSeedreamV4EditImageSize as E; + match s { + PlannedImageSize::Square => E::Square, + PlannedImageSize::SquareHd => E::SquareHd, + PlannedImageSize::PortraitFourThree => E::PortraitFourThree, + PlannedImageSize::PortraitSixteenNine => E::PortraitSixteenNine, + PlannedImageSize::LandscapeFourThree => E::LandscapeFourThree, + PlannedImageSize::LandscapeSixteenNine => E::LandscapeSixteenNine, + PlannedImageSize::Auto => E::Auto, + PlannedImageSize::Auto2k => E::Auto2k, + PlannedImageSize::Auto4k => E::Auto4k, + } +} + +// ── Image inputs ── + +fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream4, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> EnqueueBytedanceSeedreamV4TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream4(FalSeedream4RequestState::TextToImage(req)) + ) = result.expect("build should succeed") else { panic!("expected TextToImage variant") }; + req + } + + fn unwrap_edit(result: Result) -> EnqueueBytedanceSeedreamV4EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream4(FalSeedream4RequestState::EditImage(req)) + ) = result.expect("build should succeed") else { panic!("expected EditImage variant") }; + req + } + + // ── Mode detection ── + + #[test] + fn no_image_inputs_is_t2i() { + let req = unwrap_t2i(build_fal_seedream_4(base_builder())); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn image_inputs_is_edit() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let req = unwrap_edit(build_fal_seedream_4(builder)); + assert_eq!(req.image_urls, vec!["https://example.com/img.jpg".to_string()]); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + assert!(matches!( + build_fal_seedream_4(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + // ── Image size ── + + #[test] + fn t2i_default_size_is_none() { + let req = unwrap_t2i(build_fal_seedream_4(base_builder())); + assert!(req.image_size.is_none()); + } + + #[test] + fn t2i_auto_maps_to_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_4(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV4TextToImageSize::Square))); + } + + #[test] + fn edit_auto_stays_auto() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_edit(build_fal_seedream_4(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV4EditImageSize::Auto))); + } + + #[test] + fn auto4k_maps_to_auto4k() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto4k), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_4(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV4TextToImageSize::Auto4k))); + } + + #[test] + fn wide_21_9_errors_out() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + assert!(matches!( + build_fal_seedream_4(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "aspect_ratio", .. })) + )); + } + + #[test] + fn wide_21_9_falls_back_with_upgrade() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_4(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV4TextToImageSize::LandscapeSixteenNine))); + } + + // ── Num images ── + + #[test] + fn default_num_images_is_one() { + let req = unwrap_t2i(build_fal_seedream_4(base_builder())); + assert!(matches!(req.num_images, Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::One))); + } + + #[test] + fn zero_batch_count_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_seedream_4(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(9), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_4(builder)); + assert!(matches!(req.num_images, Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Four))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/cost.rs new file mode 100644 index 0000000000..b408fdbe42 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/cost.rs @@ -0,0 +1,124 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4_edit_image_webhook::EnqueueBytedanceSeedreamV4EditImageNumImages; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::EnqueueBytedanceSeedreamV4TextToImageNumImages; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::seedream_4::request::FalSeedream4RequestState; + +pub struct FalSeedream4CostState { + num_images: u64, +} + +impl FalSeedream4CostState { + pub fn from_request(request: &FalSeedream4RequestState) -> Self { + let num_images = match request { + FalSeedream4RequestState::TextToImage(req) => match req.num_images { + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::One) => 1, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two) => 2, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Three) => 3, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Four) => 4, + None => 1, // default + }, + FalSeedream4RequestState::EditImage(req) => match req.num_images { + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::One) => 1, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Two) => 2, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Three) => 3, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Four) => 4, + None => 1, + }, + }; + Self { num_images } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Pricing: $0.03 per image. Matches v1. + const COST_PER_IMAGE: u64 = 3; + let cost_in_usd_cents = COST_PER_IMAGE * self.num_images; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4_edit_image_webhook::EnqueueBytedanceSeedreamV4EditImageRequest; + use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::EnqueueBytedanceSeedreamV4TextToImageRequest; + + fn t2i_cost(n: EnqueueBytedanceSeedreamV4TextToImageNumImages) -> ImageGenerationCostEstimate { + FalSeedream4CostState::from_request(&FalSeedream4RequestState::TextToImage( + EnqueueBytedanceSeedreamV4TextToImageRequest { + prompt: "test".to_string(), + num_images: Some(n), + max_images: None, + image_size: None, + }, + )).estimate_cost() + } + + fn edit_cost(n: EnqueueBytedanceSeedreamV4EditImageNumImages) -> ImageGenerationCostEstimate { + FalSeedream4CostState::from_request(&FalSeedream4RequestState::EditImage( + EnqueueBytedanceSeedreamV4EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/x.jpg".to_string()], + num_images: Some(n), + max_images: None, + image_size: None, + }, + )).estimate_cost() + } + + #[test] + fn t2i_one_image_is_3_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4TextToImageNumImages::One).cost_in_usd_cents, Some(3)); + } + + #[test] + fn t2i_two_images_is_6_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two).cost_in_usd_cents, Some(6)); + } + + #[test] + fn t2i_three_images_is_9_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4TextToImageNumImages::Three).cost_in_usd_cents, Some(9)); + } + + #[test] + fn t2i_four_images_is_12_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4TextToImageNumImages::Four).cost_in_usd_cents, Some(12)); + } + + #[test] + fn t2i_none_num_images_defaults_to_one() { + let cost = FalSeedream4CostState::from_request(&FalSeedream4RequestState::TextToImage( + EnqueueBytedanceSeedreamV4TextToImageRequest { + prompt: "test".to_string(), + num_images: None, + max_images: None, + image_size: None, + }, + )).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(3)); + } + + #[test] + fn edit_two_images_matches_t2i_two_images() { + assert_eq!( + edit_cost(EnqueueBytedanceSeedreamV4EditImageNumImages::Two).cost_in_usd_cents, + t2i_cost(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two).cost_in_usd_cents, + ); + } + + #[test] + fn credits_equal_cents() { + let c = t2i_cost(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two); + assert_eq!(c.cost_in_credits, c.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/request.rs new file mode 100644 index 0000000000..b0f261d029 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4/request.rs @@ -0,0 +1,118 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4_edit_image_webhook::{ + enqueue_bytedance_seedream_v4_edit_image_webhook, EnqueueBytedanceSeedreamV4EditImageArgs, + EnqueueBytedanceSeedreamV4EditImageRequest, +}; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::{ + enqueue_bytedance_seedream_v4_text_to_image_webhook, EnqueueBytedanceSeedreamV4TextToImageArgs, + EnqueueBytedanceSeedreamV4TextToImageRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalSeedream4RequestState { + TextToImage(EnqueueBytedanceSeedreamV4TextToImageRequest), + EditImage(EnqueueBytedanceSeedreamV4EditImageRequest), +} + +impl FalSeedream4RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound): (_, Arc) = match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueBytedanceSeedreamV4TextToImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_bytedance_seedream_v4_text_to_image_webhook(args).await, outbound) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueBytedanceSeedreamV4EditImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_bytedance_seedream_v4_edit_image_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4_edit_image_webhook::EnqueueBytedanceSeedreamV4EditImageNumImages; + use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::EnqueueBytedanceSeedreamV4TextToImageNumImages; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_t2i_via_webhook() { + let client = client_with_webhook(); + let state = FalSeedream4RequestState::TextToImage(EnqueueBytedanceSeedreamV4TextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::One), + max_images: None, + image_size: None, + }); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("seedream_4 t2i — request_id: {:?}", payload.request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_via_webhook() { + let client = client_with_webhook(); + let state = FalSeedream4RequestState::EditImage(EnqueueBytedanceSeedreamV4EditImageRequest { + prompt: "make it sunset".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: Some(EnqueueBytedanceSeedreamV4EditImageNumImages::One), + max_images: None, + image_size: None, + }); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("seedream_4 edit — request_id: {:?}", payload.request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/build.rs new file mode 100644 index 0000000000..efd56a43a6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/build.rs @@ -0,0 +1,286 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::{ + EnqueueBytedanceSeedreamV4p5EditImageNumImages, EnqueueBytedanceSeedreamV4p5EditImageRequest, + EnqueueBytedanceSeedreamV4p5EditImageSize, +}; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::{ + EnqueueBytedanceSeedreamV4p5TextToImageNumImages, EnqueueBytedanceSeedreamV4p5TextToImageRequest, + EnqueueBytedanceSeedreamV4p5TextToImageSize, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::seedream_4p5::request::FalSeedream4p5RequestState; + +pub fn build_fal_seedream_4p5( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let image_urls = resolve_image_list_ref(builder.image_inputs.clone())?; + let image_size = plan_image_size(builder.aspect_ratio, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + + let state = if image_urls.is_empty() { + FalSeedream4p5RequestState::TextToImage(EnqueueBytedanceSeedreamV4p5TextToImageRequest { + prompt, + num_images: Some(to_t2i_num_images(num_images)), + max_images: None, + image_size: image_size.map(to_t2i_image_size), + }) + } else { + FalSeedream4p5RequestState::EditImage(EnqueueBytedanceSeedreamV4p5EditImageRequest { + prompt, + image_urls, + num_images: Some(to_edit_num_images(num_images)), + max_images: None, + image_size: image_size.map(to_edit_image_size), + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream4p5(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> EnqueueBytedanceSeedreamV4p5TextToImageNumImages { + use EnqueueBytedanceSeedreamV4p5TextToImageNumImages as T; + match n { + PlannedNumImages::One => T::One, + PlannedNumImages::Two => T::Two, + PlannedNumImages::Three => T::Three, + PlannedNumImages::Four => T::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> EnqueueBytedanceSeedreamV4p5EditImageNumImages { + use EnqueueBytedanceSeedreamV4p5EditImageNumImages as E; + match n { + PlannedNumImages::One => E::One, + PlannedNumImages::Two => E::Two, + PlannedNumImages::Three => E::Three, + PlannedNumImages::Four => E::Four, + } +} + +// ── Image size ── +// +// Note: Seedream V4.5 lacks the bare "Auto" image_size; only Auto2k/Auto4k are supported. +// Any Auto request falls back to Auto2k. + +#[derive(Copy, Clone, Debug)] +enum PlannedImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, + Auto2k, + Auto4k, +} + +fn plan_image_size( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlannedImageSize as S; + match aspect_ratio { + None => Ok(None), + + // No bare Auto for v4.5 — fall back to Auto2k. + Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k) | Some(RouterAspectRatio::Auto3k) => Ok(Some(S::Auto2k)), + Some(RouterAspectRatio::Auto4k) => Ok(Some(S::Auto4k)), + + Some(RouterAspectRatio::Square) => Ok(Some(S::Square)), + Some(RouterAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Wide) | Some(RouterAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(unsupported @ RouterAspectRatio::WideFiveByFour) + | Some(unsupported @ RouterAspectRatio::WideThreeByTwo) + | Some(unsupported @ RouterAspectRatio::WideTwentyOneByNine) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::LandscapeSixteenNine)), + }, + + Some(RouterAspectRatio::Tall) | Some(RouterAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported @ RouterAspectRatio::TallFourByFive) + | Some(unsupported @ RouterAspectRatio::TallTwoByThree) + | Some(unsupported @ RouterAspectRatio::TallNineByTwentyOne) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::PortraitSixteenNine)), + }, + } +} + +fn to_t2i_image_size(s: PlannedImageSize) -> EnqueueBytedanceSeedreamV4p5TextToImageSize { + use EnqueueBytedanceSeedreamV4p5TextToImageSize as T; + match s { + PlannedImageSize::Square => T::Square, + PlannedImageSize::SquareHd => T::SquareHd, + PlannedImageSize::PortraitFourThree => T::PortraitFourThree, + PlannedImageSize::PortraitSixteenNine => T::PortraitSixteenNine, + PlannedImageSize::LandscapeFourThree => T::LandscapeFourThree, + PlannedImageSize::LandscapeSixteenNine => T::LandscapeSixteenNine, + PlannedImageSize::Auto2k => T::Auto2k, + PlannedImageSize::Auto4k => T::Auto4k, + } +} + +fn to_edit_image_size(s: PlannedImageSize) -> EnqueueBytedanceSeedreamV4p5EditImageSize { + use EnqueueBytedanceSeedreamV4p5EditImageSize as E; + match s { + PlannedImageSize::Square => E::Square, + PlannedImageSize::SquareHd => E::SquareHd, + PlannedImageSize::PortraitFourThree => E::PortraitFourThree, + PlannedImageSize::PortraitSixteenNine => E::PortraitSixteenNine, + PlannedImageSize::LandscapeFourThree => E::LandscapeFourThree, + PlannedImageSize::LandscapeSixteenNine => E::LandscapeSixteenNine, + PlannedImageSize::Auto2k => E::Auto2k, + PlannedImageSize::Auto4k => E::Auto4k, + } +} + +// ── Image inputs ── + +fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream4p5, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> EnqueueBytedanceSeedreamV4p5TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream4p5(FalSeedream4p5RequestState::TextToImage(req)) + ) = result.expect("build should succeed") else { panic!("expected TextToImage variant") }; + req + } + + #[test] + fn auto_falls_back_to_auto2k() { + // Seedream 4.5 has no bare Auto — request with Auto should map to Auto2k. + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_4p5(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV4p5TextToImageSize::Auto2k))); + } + + #[test] + fn auto4k_stays_auto4k() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto4k), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_4p5(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV4p5TextToImageSize::Auto4k))); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + assert!(matches!( + build_fal_seedream_4p5(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn zero_batch_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_seedream_4p5(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/cost.rs new file mode 100644 index 0000000000..8af55e2eca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/cost.rs @@ -0,0 +1,106 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::EnqueueBytedanceSeedreamV4p5EditImageNumImages; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::EnqueueBytedanceSeedreamV4p5TextToImageNumImages; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::seedream_4p5::request::FalSeedream4p5RequestState; + +pub struct FalSeedream4p5CostState { + num_images: u64, +} + +impl FalSeedream4p5CostState { + pub fn from_request(request: &FalSeedream4p5RequestState) -> Self { + let num_images = match request { + FalSeedream4p5RequestState::TextToImage(req) => match req.num_images { + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One) => 1, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two) => 2, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Three) => 3, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Four) => 4, + None => 1, + }, + FalSeedream4p5RequestState::EditImage(req) => match req.num_images { + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::One) => 1, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two) => 2, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Three) => 3, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Four) => 4, + None => 1, + }, + }; + Self { num_images } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Pricing: $0.04 per image. Matches v1. + const COST_PER_IMAGE: u64 = 4; + let cost_in_usd_cents = COST_PER_IMAGE * self.num_images; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::EnqueueBytedanceSeedreamV4p5EditImageRequest; + use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::EnqueueBytedanceSeedreamV4p5TextToImageRequest; + + fn t2i_cost(n: EnqueueBytedanceSeedreamV4p5TextToImageNumImages) -> ImageGenerationCostEstimate { + FalSeedream4p5CostState::from_request(&FalSeedream4p5RequestState::TextToImage( + EnqueueBytedanceSeedreamV4p5TextToImageRequest { + prompt: "test".to_string(), + num_images: Some(n), + max_images: None, + image_size: None, + }, + )).estimate_cost() + } + + fn edit_cost(n: EnqueueBytedanceSeedreamV4p5EditImageNumImages) -> ImageGenerationCostEstimate { + FalSeedream4p5CostState::from_request(&FalSeedream4p5RequestState::EditImage( + EnqueueBytedanceSeedreamV4p5EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/x.jpg".to_string()], + num_images: Some(n), + max_images: None, + image_size: None, + }, + )).estimate_cost() + } + + #[test] + fn t2i_one_image_is_4_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One).cost_in_usd_cents, Some(4)); + } + + #[test] + fn t2i_two_images_is_8_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two).cost_in_usd_cents, Some(8)); + } + + #[test] + fn t2i_four_images_is_16_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Four).cost_in_usd_cents, Some(16)); + } + + #[test] + fn edit_two_images_matches_t2i_two_images() { + assert_eq!( + edit_cost(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two).cost_in_usd_cents, + t2i_cost(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two).cost_in_usd_cents, + ); + } + + #[test] + fn credits_equal_cents() { + let c = t2i_cost(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two); + assert_eq!(c.cost_in_credits, c.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/request.rs new file mode 100644 index 0000000000..24620cb4a2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_4p5/request.rs @@ -0,0 +1,99 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::{ + enqueue_bytedance_seedream_v4p5_edit_image_webhook, EnqueueBytedanceSeedreamV4p5EditImageArgs, + EnqueueBytedanceSeedreamV4p5EditImageRequest, +}; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::{ + enqueue_bytedance_seedream_v4p5_text_to_image_webhook, EnqueueBytedanceSeedreamV4p5TextToImageArgs, + EnqueueBytedanceSeedreamV4p5TextToImageRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalSeedream4p5RequestState { + TextToImage(EnqueueBytedanceSeedreamV4p5TextToImageRequest), + EditImage(EnqueueBytedanceSeedreamV4p5EditImageRequest), +} + +impl FalSeedream4p5RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound): (_, Arc) = match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueBytedanceSeedreamV4p5TextToImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_bytedance_seedream_v4p5_text_to_image_webhook(args).await, outbound) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueBytedanceSeedreamV4p5EditImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_bytedance_seedream_v4p5_edit_image_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::EnqueueBytedanceSeedreamV4p5TextToImageNumImages; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_t2i_via_webhook() { + let client = client_with_webhook(); + let state = FalSeedream4p5RequestState::TextToImage(EnqueueBytedanceSeedreamV4p5TextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One), + max_images: None, + image_size: None, + }); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("seedream_4p5 t2i — request_id: {:?}", payload.request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/build.rs new file mode 100644 index 0000000000..ce79318f8c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/build.rs @@ -0,0 +1,309 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v5_lite_edit_image_webhook::{ + EnqueueBytedanceSeedreamV5LiteEditImageNumImages, EnqueueBytedanceSeedreamV5LiteEditImageRequest, + EnqueueBytedanceSeedreamV5LiteEditImageSize, +}; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::{ + EnqueueBytedanceSeedreamV5LiteTextToImageNumImages, EnqueueBytedanceSeedreamV5LiteTextToImageRequest, + EnqueueBytedanceSeedreamV5LiteTextToImageSize, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::fal::seedream_5_lite::request::FalSeedream5LiteRequestState; + +pub fn build_fal_seedream_5_lite( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let image_urls = resolve_image_list_ref(builder.image_inputs.clone())?; + let image_size = plan_image_size(builder.aspect_ratio, builder.resolution, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + + let state = if image_urls.is_empty() { + FalSeedream5LiteRequestState::TextToImage(EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + prompt, + num_images: Some(to_t2i_num_images(num_images)), + max_images: None, + image_size: image_size.map(to_t2i_image_size), + }) + } else { + FalSeedream5LiteRequestState::EditImage(EnqueueBytedanceSeedreamV5LiteEditImageRequest { + prompt, + image_urls, + num_images: Some(to_edit_num_images(num_images)), + max_images: None, + image_size: image_size.map(to_edit_image_size), + }) + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream5Lite(state), + )) +} + +// ── Num images ── + +#[derive(Copy, Clone, Debug)] +enum PlannedNumImages { + One, + Two, + Three, + Four, +} + +fn plan_num_images( + count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(PlannedNumImages::One), + 2 => Ok(PlannedNumImages::Two), + 3 => Ok(PlannedNumImages::Three), + 4 => Ok(PlannedNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(PlannedNumImages::Four), + }, + } +} + +fn to_t2i_num_images(n: PlannedNumImages) -> EnqueueBytedanceSeedreamV5LiteTextToImageNumImages { + use EnqueueBytedanceSeedreamV5LiteTextToImageNumImages as T; + match n { + PlannedNumImages::One => T::One, + PlannedNumImages::Two => T::Two, + PlannedNumImages::Three => T::Three, + PlannedNumImages::Four => T::Four, + } +} + +fn to_edit_num_images(n: PlannedNumImages) -> EnqueueBytedanceSeedreamV5LiteEditImageNumImages { + use EnqueueBytedanceSeedreamV5LiteEditImageNumImages as E; + match n { + PlannedNumImages::One => E::One, + PlannedNumImages::Two => E::Two, + PlannedNumImages::Three => E::Three, + PlannedNumImages::Four => E::Four, + } +} + +// ── Image size ── +// +// Note: Seedream V5 Lite supports Auto2k and Auto3k (no 4K, no bare Auto). +// Auto4k requests fall back to Auto3k. + +#[derive(Copy, Clone, Debug)] +enum PlannedImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, + Auto2k, + Auto3k, +} + +fn plan_image_size( + aspect_ratio: Option, + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlannedImageSize as S; + match aspect_ratio { + // No aspect_ratio: use resolution as a hint for Auto sizes. + // Seedream v5 lite has no 4K — clamp to 3K. + None => match resolution { + Some(RouterResolution::TwoK) => Ok(Some(S::Auto2k)), + Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => Ok(Some(S::Auto3k)), + _ => Ok(None), + }, + + Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k) => Ok(Some(S::Auto2k)), + Some(RouterAspectRatio::Auto3k) => Ok(Some(S::Auto3k)), + // No 4K — fall back to 3K. + Some(RouterAspectRatio::Auto4k) => Ok(Some(S::Auto3k)), + + Some(RouterAspectRatio::Square) => Ok(Some(S::Square)), + Some(RouterAspectRatio::SquareHd) => Ok(Some(S::SquareHd)), + + Some(RouterAspectRatio::Wide) | Some(RouterAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)), + + Some(unsupported @ RouterAspectRatio::WideFiveByFour) + | Some(unsupported @ RouterAspectRatio::WideThreeByTwo) + | Some(unsupported @ RouterAspectRatio::WideTwentyOneByNine) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::LandscapeSixteenNine)), + }, + + Some(RouterAspectRatio::Tall) | Some(RouterAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)), + + Some(unsupported @ RouterAspectRatio::TallFourByFive) + | Some(unsupported @ RouterAspectRatio::TallTwoByThree) + | Some(unsupported @ RouterAspectRatio::TallNineByTwentyOne) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(S::PortraitSixteenNine)), + }, + } +} + +fn to_t2i_image_size(s: PlannedImageSize) -> EnqueueBytedanceSeedreamV5LiteTextToImageSize { + use EnqueueBytedanceSeedreamV5LiteTextToImageSize as T; + match s { + PlannedImageSize::Square => T::Square, + PlannedImageSize::SquareHd => T::SquareHd, + PlannedImageSize::PortraitFourThree => T::PortraitFourThree, + PlannedImageSize::PortraitSixteenNine => T::PortraitSixteenNine, + PlannedImageSize::LandscapeFourThree => T::LandscapeFourThree, + PlannedImageSize::LandscapeSixteenNine => T::LandscapeSixteenNine, + PlannedImageSize::Auto2k => T::Auto2k, + PlannedImageSize::Auto3k => T::Auto3k, + } +} + +fn to_edit_image_size(s: PlannedImageSize) -> EnqueueBytedanceSeedreamV5LiteEditImageSize { + use EnqueueBytedanceSeedreamV5LiteEditImageSize as E; + match s { + PlannedImageSize::Square => E::Square, + PlannedImageSize::SquareHd => E::SquareHd, + PlannedImageSize::PortraitFourThree => E::PortraitFourThree, + PlannedImageSize::PortraitSixteenNine => E::PortraitSixteenNine, + PlannedImageSize::LandscapeFourThree => E::LandscapeFourThree, + PlannedImageSize::LandscapeSixteenNine => E::LandscapeSixteenNine, + PlannedImageSize::Auto2k => E::Auto2k, + PlannedImageSize::Auto3k => E::Auto3k, + } +} + +// ── Image inputs ── + +fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5Lite, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalSeedream5Lite(FalSeedream5LiteRequestState::TextToImage(req)) + ) = result.expect("build should succeed") else { panic!("expected TextToImage variant") }; + req + } + + #[test] + fn auto_maps_to_auto2k() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_5_lite(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV5LiteTextToImageSize::Auto2k))); + } + + #[test] + fn auto4k_falls_back_to_auto3k() { + // Seedream 5 Lite has no 4K — clamp to 3K. + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto4k), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_5_lite(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV5LiteTextToImageSize::Auto3k))); + } + + #[test] + fn no_aspect_no_resolution_yields_none() { + let req = unwrap_t2i(build_fal_seedream_5_lite(base_builder())); + assert!(req.image_size.is_none()); + } + + #[test] + fn resolution_two_k_triggers_auto2k_when_no_aspect() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::TwoK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_5_lite(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV5LiteTextToImageSize::Auto2k))); + } + + #[test] + fn resolution_four_k_clamps_to_auto3k() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::FourK), + ..base_builder() + }; + let req = unwrap_t2i(build_fal_seedream_5_lite(builder)); + assert!(matches!(req.image_size, Some(EnqueueBytedanceSeedreamV5LiteTextToImageSize::Auto3k))); + } + + #[test] + fn media_tokens_return_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }; + assert!(matches!( + build_fal_seedream_5_lite(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/cost.rs new file mode 100644 index 0000000000..ed083a763b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/cost.rs @@ -0,0 +1,106 @@ +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v5_lite_edit_image_webhook::EnqueueBytedanceSeedreamV5LiteEditImageNumImages; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::EnqueueBytedanceSeedreamV5LiteTextToImageNumImages; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::fal::seedream_5_lite::request::FalSeedream5LiteRequestState; + +pub struct FalSeedream5LiteCostState { + num_images: u64, +} + +impl FalSeedream5LiteCostState { + pub fn from_request(request: &FalSeedream5LiteRequestState) -> Self { + let num_images = match request { + FalSeedream5LiteRequestState::TextToImage(req) => match req.num_images { + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::One) => 1, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two) => 2, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Three) => 3, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Four) => 4, + None => 1, + }, + FalSeedream5LiteRequestState::EditImage(req) => match req.num_images { + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::One) => 1, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Two) => 2, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Three) => 3, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Four) => 4, + None => 1, + }, + }; + Self { num_images } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Pricing: $0.035 per image — rounded up to 4 cents. Matches v1. + const COST_PER_IMAGE: u64 = 4; + let cost_in_usd_cents = COST_PER_IMAGE * self.num_images; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v5_lite_edit_image_webhook::EnqueueBytedanceSeedreamV5LiteEditImageRequest; + use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::EnqueueBytedanceSeedreamV5LiteTextToImageRequest; + + fn t2i_cost(n: EnqueueBytedanceSeedreamV5LiteTextToImageNumImages) -> ImageGenerationCostEstimate { + FalSeedream5LiteCostState::from_request(&FalSeedream5LiteRequestState::TextToImage( + EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + prompt: "test".to_string(), + num_images: Some(n), + max_images: None, + image_size: None, + }, + )).estimate_cost() + } + + fn edit_cost(n: EnqueueBytedanceSeedreamV5LiteEditImageNumImages) -> ImageGenerationCostEstimate { + FalSeedream5LiteCostState::from_request(&FalSeedream5LiteRequestState::EditImage( + EnqueueBytedanceSeedreamV5LiteEditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/x.jpg".to_string()], + num_images: Some(n), + max_images: None, + image_size: None, + }, + )).estimate_cost() + } + + #[test] + fn t2i_one_image_is_4_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::One).cost_in_usd_cents, Some(4)); + } + + #[test] + fn t2i_two_images_is_8_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two).cost_in_usd_cents, Some(8)); + } + + #[test] + fn t2i_four_images_is_16_cents() { + assert_eq!(t2i_cost(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Four).cost_in_usd_cents, Some(16)); + } + + #[test] + fn edit_two_images_matches_t2i_two_images() { + assert_eq!( + edit_cost(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Two).cost_in_usd_cents, + t2i_cost(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two).cost_in_usd_cents, + ); + } + + #[test] + fn credits_equal_cents() { + let c = t2i_cost(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two); + assert_eq!(c.cost_in_credits, c.cost_in_usd_cents); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/request.rs new file mode 100644 index 0000000000..bc27127908 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/seedream_5_lite/request.rs @@ -0,0 +1,99 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v5_lite_edit_image_webhook::{ + enqueue_bytedance_seedream_v5_lite_edit_image_webhook, EnqueueBytedanceSeedreamV5LiteEditImageArgs, + EnqueueBytedanceSeedreamV5LiteEditImageRequest, +}; +use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::{ + enqueue_bytedance_seedream_v5_lite_text_to_image_webhook, EnqueueBytedanceSeedreamV5LiteTextToImageArgs, + EnqueueBytedanceSeedreamV5LiteTextToImageRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalSeedream5LiteRequestState { + TextToImage(EnqueueBytedanceSeedreamV5LiteTextToImageRequest), + EditImage(EnqueueBytedanceSeedreamV5LiteEditImageRequest), +} + +impl FalSeedream5LiteRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound): (_, Arc) = match self { + Self::TextToImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueBytedanceSeedreamV5LiteTextToImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_bytedance_seedream_v5_lite_text_to_image_webhook(args).await, outbound) + } + Self::EditImage(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueBytedanceSeedreamV5LiteEditImageArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_bytedance_seedream_v5_lite_edit_image_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::EnqueueBytedanceSeedreamV5LiteTextToImageNumImages; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_t2i_via_webhook() { + let client = client_with_webhook(); + let state = FalSeedream5LiteRequestState::TextToImage(EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + num_images: Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::One), + max_images: None, + image_size: None, + }); + let response = state.send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("seedream_5_lite t2i — request_id: {:?}", payload.request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs new file mode 100644 index 0000000000..2e271b2165 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs @@ -0,0 +1,474 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7::{ + GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality, + GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::{ + KinoviMidjourney7DraftState, KinoviMidjourney7RemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; + +pub fn build_kinovi_midjourney_7( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let quality = plan_quality(builder.quality.take()); + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + // No image inputs → we can build the final request immediately. No + // draft/upload phase needed. + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateMidjourneyV7Request { + prompt, + aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality, + raw_mode: false, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney7(KinoviMidjourney7RequestState { request }), + )); + } + + // Image inputs present → must go through the draft phase so we can + // upload each image to the KinoviWeb CDN before sending. + let draft = KinoviMidjourney7DraftState { + prompt, + aspect_ratio, + quality, + batch_count, + unhandled_request_state: Some(KinoviMidjourney7RemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use GenerateMidjourneyV7AspectRatio as Ar; + match aspect_ratio { + // No preference or auto → square, matching Midjourney's own default for new + // jobs in their UI. + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1), + + // Direct mappings — Midjourney supports every common ratio. + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + + // Every aspect ratio listed in `RouterAspectRatio` has a direct mapping; + // the catch-all is reserved for future variants. + #[allow(unreachable_patterns)] + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square1x1), + }, + } +} + +pub(crate) fn plan_quality(quality: Option) -> Option { + // Midjourney's quality knob is a compute-budget dial that doesn't change + // Kinovi pricing — pass through whatever the caller asked for, and let + // `None` ride Midjourney's server-side default. + match quality { + None => None, + Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter), + Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half), + Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full), + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviMidjourneyBatchCount::One), + 2 => Ok(KinoviMidjourneyBatchCount::Two), + 3 => Ok(KinoviMidjourneyBatchCount::Three), + 4 => Ok(KinoviMidjourneyBatchCount::Four), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + // Kinovi supports batches of 1–4; only counts of 5+ reach here, so both + // mitigation strategies clamp to the maximum supported batch. + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(KinoviMidjourneyBatchCount::Four) + } + }, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::KinoviWeb, + prompt: Some("a corgi astronaut".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + // ── Mode dispatch: no image inputs → Request; with → Draft ── + + mod mode_dispatch { + use super::*; + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_midjourney_7(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn empty_image_inputs_returns_request_directly() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn image_url_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn image_token_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test".to_string()), + ])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn draft_remembers_image_inputs() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ])), + ..base_builder() + }; + let draft = unwrap_draft(build_kinovi_midjourney_7(builder)); + let remaining = draft.unhandled_request_state.as_ref().unwrap(); + let urls = match remaining.reference_images.as_ref().unwrap() { + ImageListRef::Urls(urls) => urls, + _ => panic!("expected Urls"), + }; + assert_eq!(urls.len(), 2); + } + } + + // ── Field passthrough ── + + mod field_passthrough { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_direct_request(build_kinovi_midjourney_7(base_builder())); + assert_eq!(req.request.prompt, "a corgi astronaut"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() }; + let req = unwrap_direct_request(build_kinovi_midjourney_7(builder)); + assert_eq!(req.request.prompt, ""); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar; + + fn aspect_ratio_for(input: Option) -> Ar { + let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() }; + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.aspect_ratio + } + + #[test] + fn none_defaults_to_square() { + assert_eq!(aspect_ratio_for(None), Ar::Square1x1); + } + + #[test] + fn auto_defaults_to_square() { + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto2k)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto3k)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto4k)), Ar::Square1x1); + } + + #[test] + fn every_ratio_maps_directly() { + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Square)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::SquareHd)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideSixteenByNine)), Ar::Landscape16x9); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideTwentyOneByNine)), Ar::UltraWide21x9); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFourByThree)), Ar::Standard4x3); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFiveByFour)), Ar::Wide5x4); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideThreeByTwo)), Ar::Wide3x2); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Wide)), Ar::Landscape16x9); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineBySixteen)), Ar::Portrait9x16); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineByTwentyOne)), Ar::UltraTall9x21); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallThreeByFour)), Ar::Portrait3x4); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallFourByFive)), Ar::Tall4x5); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallTwoByThree)), Ar::Tall2x3); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Tall)), Ar::Portrait9x16); + } + } + + // ── Quality ── + + mod quality_tests { + use super::*; + use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q; + + fn quality_for(input: Option) -> Option { + let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() }; + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.quality + } + + #[test] + fn none_passes_through_as_none() { + assert_eq!(quality_for(None), None); + } + + #[test] + fn low_maps_to_quarter() { + assert_eq!(quality_for(Some(RouterQuality::Low)), Some(Q::Quarter)); + } + + #[test] + fn medium_maps_to_half() { + assert_eq!(quality_for(Some(RouterQuality::Medium)), Some(Q::Half)); + } + + #[test] + fn high_maps_to_full() { + assert_eq!(quality_for(Some(RouterQuality::High)), Some(Q::Full)); + } + } + + // ── Batch count ── + + mod batch_count_tests { + use super::*; + + fn batch_for(count: Option) -> KinoviMidjourneyBatchCount { + let builder = GenerateImageRequestBuilder { image_batch_count: count, ..base_builder() }; + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count + } + + #[test] + fn none_defaults_to_one() { + assert_eq!(batch_for(None), KinoviMidjourneyBatchCount::One); + } + + #[test] + fn one_two_three_four_map_directly() { + assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One); + assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two); + assert_eq!(batch_for(Some(3)), KinoviMidjourneyBatchCount::Three); + assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four); + } + + #[test] + fn zero_is_rejected() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }; + assert!(matches!( + build_kinovi_midjourney_7(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + )); + } + + /// Kinovi now supports batches of 3, so 3 is a first-class value and the + /// mitigation strategy no longer applies to it. + #[test] + fn three_maps_directly_regardless_of_strategy() { + for strategy in [ + RequestMismatchMitigationStrategy::ErrorOut, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + request_mismatch_mitigation_strategy: strategy, + ..base_builder() + }; + assert_eq!( + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count, + KinoviMidjourneyBatchCount::Three, + "strategy={:?}", strategy, + ); + } + } + + /// Counts above 4 are still unsupported: strict errors out, and both + /// pay-more/pay-less strategies clamp to the maximum supported batch (4). + #[test] + fn five_errors_under_strict_and_clamps_to_four_otherwise() { + let strict = GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(matches!( + build_kinovi_midjourney_7(strict), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })), + )); + + for strategy in [ + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: strategy, + ..base_builder() + }; + assert_eq!( + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count, + KinoviMidjourneyBatchCount::Four, + "strategy={:?}", strategy, + ); + } + } + } + + // ── Helpers ── + + fn unwrap_direct_request( + result: Result, + ) -> KinoviMidjourney7RequestState { + match result.expect("build should succeed") { + ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney7(req), + ) => req, + other => panic!("expected KinoviMidjourney7 Request, got {:?}", other), + } + } + + fn unwrap_draft( + result: Result, + ) -> KinoviMidjourney7DraftState { + match result.expect("build should succeed") { + ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7(draft), + ) => draft, + other => panic!("expected KinoviMidjourney7 Draft, got {:?}", other), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs new file mode 100644 index 0000000000..9b5612902d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs @@ -0,0 +1,212 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7::{ + GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; + +/// Midjourney v7 (via Kinovi) cost state. Only `batch_count` matters for +/// pricing — Midjourney pricing is flat per task regardless of aspect +/// ratio, quality, or whether reference images were supplied. +pub struct KinoviMidjourney7CostState { + pub batch_count: KinoviMidjourneyBatchCount, +} + +impl KinoviMidjourney7CostState { + pub fn from_request(request: &KinoviMidjourney7RequestState) -> Self { + Self { batch_count: request.request.batch_count } + } + + pub fn from_draft(draft: &KinoviMidjourney7DraftState) -> Self { + Self { batch_count: draft.batch_count } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateMidjourneyV7Request { + batch_count: self.batch_count, + // Cost-irrelevant placeholders below — only `batch_count` matters. + prompt: String::new(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + reference_image_urls: None, + }; + + let costs = pricing_request.calculate_costs(); + let cost_in_credits = costs.kinovi_credits; + let cost_in_usd_cents = costs.usd_cents_rounded_up; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + // ── Credit pricing (12 per task × batch) ── + + mod credits { + use super::*; + + #[test] + fn batch_one_is_twelve_credits() { + assert_eq!(credits_for(KinoviMidjourneyBatchCount::One), 12); + } + + #[test] + fn batch_two_is_twentyfour_credits() { + assert_eq!(credits_for(KinoviMidjourneyBatchCount::Two), 24); + } + + #[test] + fn batch_four_is_fortyeight_credits() { + assert_eq!(credits_for(KinoviMidjourneyBatchCount::Four), 48); + } + } + + // ── USD pricing (243 credits per dollar; rounded up) ── + + mod usd { + use super::*; + + #[test] + fn batch_one_is_five_cents() { + // 12/243 × 100 = 4.94 → 5¢ + assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP + } + + #[test] + fn batch_two_is_ten_cents() { + // 24/243 × 100 = 9.88 → 10¢ + assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP + } + + #[test] + fn batch_four_is_twenty_cents() { + // 48/243 × 100 = 19.75 → 20¢ + assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20); + } + } + + // ── Flags ── + + #[test] + fn flags_are_correct() { + let cost = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.failures_are_refunded, None); + } + + // ── from_request / from_draft ── + + mod source_state_round_trip { + use super::*; + + #[test] + fn from_request_picks_up_batch_count() { + let req = build_request_no_image_inputs(2); + let state = KinoviMidjourney7CostState::from_request(&req); + assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Two); + assert_eq!(state.estimate_cost().cost_in_credits, Some(24)); + } + + #[test] + fn from_draft_picks_up_batch_count() { + let draft = build_draft_with_image_inputs(4); + let state = KinoviMidjourney7CostState::from_draft(&draft); + assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Four); + assert_eq!(state.estimate_cost().cost_in_credits, Some(48)); + } + + #[test] + fn draft_and_request_produce_same_cost() { + // Same batch count → same cost, regardless of draft vs request path. + let req_cost = KinoviMidjourney7CostState::from_request(&build_request_no_image_inputs(2)) + .estimate_cost(); + let draft_cost = KinoviMidjourney7CostState::from_draft(&build_draft_with_image_inputs(2)) + .estimate_cost(); + assert_eq!(req_cost.cost_in_credits, draft_cost.cost_in_credits); + assert_eq!(req_cost.cost_in_usd_cents, draft_cost.cost_in_usd_cents); + } + } + + // ── Helpers ── + + fn credits_for(batch: KinoviMidjourneyBatchCount) -> u64 { + KinoviMidjourney7CostState { batch_count: batch } + .estimate_cost().cost_in_credits.unwrap() + } + + fn usd_cents_for(batch: KinoviMidjourneyBatchCount) -> u64 { + KinoviMidjourney7CostState { batch_count: batch } + .estimate_cost().cost_in_usd_cents.unwrap() + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::KinoviWeb, + prompt: Some("test".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn build_request_no_image_inputs(batch_count: u16) -> KinoviMidjourney7RequestState { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(batch_count), + ..base_builder() + }; + match builder.build2().expect("build2") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r, + _ => panic!("expected Request"), + } + } + + fn build_draft_with_image_inputs(batch_count: u16) -> KinoviMidjourney7DraftState { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(batch_count), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + match builder.build2().expect("build2") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d, + _ => panic!("expected Draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs new file mode 100644 index 0000000000..adb995728e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs @@ -0,0 +1,94 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7::{ + GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality, + GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7DraftState { + // Materialized fields — all enums already resolved. + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV7AspectRatio, + pub quality: Option, + pub batch_count: KinoviMidjourneyBatchCount, + + /// Image references that still need to be uploaded to KinoviWeb CDN + /// before the request is sendable. `None` once `to_request()` has run. + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7RemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney7DraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateMidjourneyV7Request { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: self.quality, + raw_mode: false, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviMidjourney7RequestState { request }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio; + + // Note: most behavioral tests live in `build.rs` and `cost.rs`. The + // `to_request()` method requires a live KinoviWeb client to upload + // images, so its end-to-end behavior is exercised by the `request.rs` + // #[ignore]'d integration tests rather than unit-tested here. + + #[test] + fn draft_with_no_image_inputs_finalises_to_empty_reference_image_urls() { + // Even when the draft *has* an unhandled state, an empty `ImageListRef` + // shouldn't trigger an upload. This is technically not the path the + // builder takes (it short-circuits to Request), but we exercise it + // here for robustness. + let mut draft = KinoviMidjourney7DraftState { + prompt: "test".to_string(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + quality: None, + batch_count: KinoviMidjourneyBatchCount::One, + unhandled_request_state: Some(KinoviMidjourney7RemainingItems { + reference_images: Some(ImageListRef::Urls(vec![])), + }), + }; + // We can't actually call `to_request()` without a client, but the + // resolve path returns `None` for empty inputs. Smoke-test the resolve + // helper directly via the test_resolve helper. + let inputs = draft.unhandled_request_state.take().unwrap().reference_images; + assert!(matches!(inputs, Some(ImageListRef::Urls(ref v)) if v.is_empty())); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs new file mode 100644 index 0000000000..9caf8f9b3c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs @@ -0,0 +1,142 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7::{ + generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, KinoviWebImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7RequestState { + /// Final materialized request; ready to fire against Kinovi. + pub request: GenerateMidjourneyV7Request, +} + +impl KinoviMidjourney7RequestState { + pub async fn send( + &self, + client: &RouterKinoviWebClient, + ) -> Result { + let args = GenerateMidjourneyV7Args { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_midjourney_v7(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::generate_image_response::GenerateImageResponse; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::KinoviWeb, + prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn get_kinovi_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn text_to_image_round_trip() { + let client = get_kinovi_client(); + let result = base_builder().build2().expect("build2"); + + let request = match result { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r, + _ => panic!("expected direct Request for text-to-image"), + }; + + let response = request.send(client.get_kinovi_web_client_ref().unwrap()) + .await.expect("send should succeed"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn image_reference_round_trip_via_draft() { + let client = get_kinovi_client(); + let token = MediaFileToken::new("mf_test_juno".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("a corgi in the style of @1".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])), + ..base_builder() + }; + + let draft_or_request = builder.build2().expect("build2"); + let draft = match draft_or_request { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d, + _ => panic!("expected Draft for image-input flow"), + }; + + let ctx = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&map), + }; + + let request = ImageGenerationDraftRequest::KinoviMidjourney7(draft) + .finalize(ctx).await.expect("finalize"); + + let response = request.send_request(&client).await.expect("send_request"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs new file mode 100644 index 0000000000..e32e2cc32b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs @@ -0,0 +1,278 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{ + GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality, + GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::{ + KinoviMidjourney7NijiDraftState, KinoviMidjourney7NijiRemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; + +pub fn build_kinovi_midjourney_7_niji( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let quality = plan_quality(builder.quality.take()); + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateMidjourneyV7NijiRequest { + prompt, + aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality, + raw_mode: false, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState { request }), + )); + } + + let draft = KinoviMidjourney7NijiDraftState { + prompt, + aspect_ratio, + quality, + batch_count, + unhandled_request_state: Some(KinoviMidjourney7NijiRemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7Niji(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use GenerateMidjourneyV7NijiAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + + #[allow(unreachable_patterns)] + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square1x1), + }, + } +} + +pub(crate) fn plan_quality(quality: Option) -> Option { + match quality { + None => None, + Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter), + Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half), + Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full), + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviMidjourneyBatchCount::One), + 2 => Ok(KinoviMidjourneyBatchCount::Two), + 3 => Ok(KinoviMidjourneyBatchCount::Three), + 4 => Ok(KinoviMidjourneyBatchCount::Four), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + // Kinovi supports batches of 1–4; only counts of 5+ reach here, so both + // mitigation strategies clamp to the maximum supported batch. + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(KinoviMidjourneyBatchCount::Four) + } + }, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7Niji, + provider: RouterProvider::KinoviWeb, + prompt: Some("a magical anime forest scene".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_midjourney_7_niji(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(_)), + )); + } + + #[test] + fn image_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7_niji(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(_)), + )); + } + + #[test] + fn batch_count_zero_errors() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }; + assert!(matches!( + build_kinovi_midjourney_7_niji(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + )); + } + + #[test] + fn batch_count_three_is_accepted() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three); + } + + #[test] + fn batch_count_four_is_accepted() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four); + } + + #[test] + fn aspect_ratio_passes_through_to_niji_enum() { + use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar; + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.aspect_ratio, Ar::UltraWide21x9); + } + + #[test] + fn quality_high_maps_to_full() { + use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q; + let builder = GenerateImageRequestBuilder { + quality: Some(RouterQuality::High), + ..base_builder() + }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.quality, Some(Q::Full)); + } + + #[test] + fn media_file_tokens_route_through_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_x".to_string()), + ])), + ..base_builder() + }; + let draft = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(d)) => d, + _ => panic!("expected Draft"), + }; + assert!(draft.unhandled_request_state.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs new file mode 100644 index 0000000000..70cec419d2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs @@ -0,0 +1,91 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{ + GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; + +/// Midjourney v7 Niji (via Kinovi) cost state. Pricing is flat per task +/// regardless of aspect ratio or quality, identical to v7 / v8. +pub struct KinoviMidjourney7NijiCostState { + pub batch_count: KinoviMidjourneyBatchCount, +} + +impl KinoviMidjourney7NijiCostState { + pub fn from_request(request: &KinoviMidjourney7NijiRequestState) -> Self { + Self { batch_count: request.request.batch_count } + } + + pub fn from_draft(draft: &KinoviMidjourney7NijiDraftState) -> Self { + Self { batch_count: draft.batch_count } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateMidjourneyV7NijiRequest { + batch_count: self.batch_count, + prompt: String::new(), + aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + reference_image_urls: None, + }; + + let costs = pricing_request.calculate_costs(); + let cost_in_credits = costs.kinovi_credits; + let cost_in_usd_cents = costs.usd_cents_rounded_up; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits_and_five_cents() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(12)); + assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP + } + + #[test] + fn batch_two_doubles() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(24)); + assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP + } + + #[test] + fn batch_four_quadruples() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(48)); + assert_eq!(cost.cost_in_usd_cents, Some(20)); + } + + #[test] + fn flags_are_correct() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs new file mode 100644 index 0000000000..5bb9cc2858 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs @@ -0,0 +1,58 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{ + GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality, + GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiDraftState { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio, + pub quality: Option, + pub batch_count: KinoviMidjourneyBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiRemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney7NijiDraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateMidjourneyV7NijiRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: self.quality, + raw_mode: false, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviMidjourney7NijiRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs new file mode 100644 index 0000000000..f003e9e0e7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs @@ -0,0 +1,140 @@ +use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{ + generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, KinoviWebImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiRequestState { + pub request: GenerateMidjourneyV7NijiRequest, +} + +impl KinoviMidjourney7NijiRequestState { + pub async fn send( + &self, + client: &RouterKinoviWebClient, + ) -> Result { + let args = GenerateMidjourneyV7NijiArgs { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_midjourney_v7_niji(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7Niji, + provider: RouterProvider::KinoviWeb, + prompt: Some("a magical anime fox spirit in a moonlit bamboo forest".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn get_kinovi_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn text_to_image_round_trip() { + let client = get_kinovi_client(); + let result = base_builder().build2().expect("build2"); + + let request = match result { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected direct Request for text-to-image"), + }; + + let response = request.send(client.get_kinovi_web_client_ref().unwrap()) + .await.expect("send should succeed"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("v7-niji t2i — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn image_reference_round_trip_via_draft() { + let client = get_kinovi_client(); + let token = MediaFileToken::new("mf_test_juno_niji".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("an anime corgi sorcerer inspired by @1".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])), + ..base_builder() + }; + + let draft_or_request = builder.build2().expect("build2"); + let draft = match draft_or_request { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(d)) => d, + _ => panic!("expected Draft for image-input flow"), + }; + + let ctx = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&map), + }; + + let request = ImageGenerationDraftRequest::KinoviMidjourney7Niji(draft) + .finalize(ctx).await.expect("finalize"); + + let response = request.send_request(&client).await.expect("send_request"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("v7-niji with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs new file mode 100644 index 0000000000..6274c0d527 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs @@ -0,0 +1,239 @@ +use kinovi_web_client::generate::image::generate_midjourney_v8::{ + GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality, + GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::{ + KinoviMidjourney8DraftState, KinoviMidjourney8RemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; + +pub fn build_kinovi_midjourney_8( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let quality = plan_quality(builder.quality.take()); + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateMidjourneyV8Request { + prompt, + aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality, + raw_mode: false, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney8(KinoviMidjourney8RequestState { request }), + )); + } + + let draft = KinoviMidjourney8DraftState { + prompt, + aspect_ratio, + quality, + batch_count, + unhandled_request_state: Some(KinoviMidjourney8RemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney8(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use GenerateMidjourneyV8AspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + + #[allow(unreachable_patterns)] + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square1x1), + }, + } +} + +pub(crate) fn plan_quality(quality: Option) -> Option { + match quality { + None => None, + Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter), + Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half), + Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full), + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviMidjourneyBatchCount::One), + 2 => Ok(KinoviMidjourneyBatchCount::Two), + 3 => Ok(KinoviMidjourneyBatchCount::Three), + 4 => Ok(KinoviMidjourneyBatchCount::Four), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + // Kinovi supports batches of 1–4; only counts of 5+ reach here, so both + // mitigation strategies clamp to the maximum supported batch. + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(KinoviMidjourneyBatchCount::Four) + } + }, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney8, + provider: RouterProvider::KinoviWeb, + prompt: Some("a spooky horror scene".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_midjourney_8(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(_)), + )); + } + + #[test] + fn image_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_midjourney_8(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(_)), + )); + } + + #[test] + fn batch_count_three_is_accepted() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() }; + let req = match build_kinovi_midjourney_8(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three); + } + + #[test] + fn batch_count_four_is_accepted() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() }; + let req = match build_kinovi_midjourney_8(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four); + } + + #[test] + fn media_file_token_route_through_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_x".to_string()), + ])), + ..base_builder() + }; + let draft = match build_kinovi_midjourney_8(builder).expect("build") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(d)) => d, + _ => panic!("expected Draft"), + }; + assert!(draft.unhandled_request_state.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs new file mode 100644 index 0000000000..6af3dbfd70 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs @@ -0,0 +1,81 @@ +use kinovi_web_client::generate::image::generate_midjourney_v8::{ + GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; + +/// Midjourney v8 (via Kinovi) cost state. Pricing is flat per task, +/// identical to v7 / v7 niji. +pub struct KinoviMidjourney8CostState { + pub batch_count: KinoviMidjourneyBatchCount, +} + +impl KinoviMidjourney8CostState { + pub fn from_request(request: &KinoviMidjourney8RequestState) -> Self { + Self { batch_count: request.request.batch_count } + } + + pub fn from_draft(draft: &KinoviMidjourney8DraftState) -> Self { + Self { batch_count: draft.batch_count } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateMidjourneyV8Request { + batch_count: self.batch_count, + prompt: String::new(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + reference_image_urls: None, + }; + + let costs = pricing_request.calculate_costs(); + let cost_in_credits = costs.kinovi_credits; + let cost_in_usd_cents = costs.usd_cents_rounded_up; + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits_and_five_cents() { + let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(12)); + assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP + } + + #[test] + fn batch_two_doubles() { + let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(24)); + assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP + } + + #[test] + fn batch_four_quadruples() { + let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(48)); + assert_eq!(cost.cost_in_usd_cents, Some(20)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs new file mode 100644 index 0000000000..bc37c26ddb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs @@ -0,0 +1,58 @@ +use kinovi_web_client::generate::image::generate_midjourney_v8::{ + GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality, + GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8DraftState { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV8AspectRatio, + pub quality: Option, + pub batch_count: KinoviMidjourneyBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8RemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney8DraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateMidjourneyV8Request { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: self.quality, + raw_mode: false, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviMidjourney8RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs new file mode 100644 index 0000000000..6675fbd671 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs @@ -0,0 +1,140 @@ +use kinovi_web_client::generate::image::generate_midjourney_v8::{ + generate_midjourney_v8, GenerateMidjourneyV8Args, GenerateMidjourneyV8Request, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, KinoviWebImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8RequestState { + pub request: GenerateMidjourneyV8Request, +} + +impl KinoviMidjourney8RequestState { + pub async fn send( + &self, + client: &RouterKinoviWebClient, + ) -> Result { + let args = GenerateMidjourneyV8Args { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_midjourney_v8(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney8, + provider: RouterProvider::KinoviWeb, + prompt: Some("a spooky horror scene with ghosts in an abandoned mansion".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn get_kinovi_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn text_to_image_round_trip() { + let client = get_kinovi_client(); + let result = base_builder().build2().expect("build2"); + + let request = match result { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r, + _ => panic!("expected direct Request for text-to-image"), + }; + + let response = request.send(client.get_kinovi_web_client_ref().unwrap()) + .await.expect("send should succeed"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("v8 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn image_reference_round_trip_via_draft() { + let client = get_kinovi_client(); + let token = MediaFileToken::new("mf_test_juno_v8".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("a dramatic painterly portrait inspired by @1".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])), + ..base_builder() + }; + + let draft_or_request = builder.build2().expect("build2"); + let draft = match draft_or_request { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(d)) => d, + _ => panic!("expected Draft for image-input flow"), + }; + + let ctx = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&map), + }; + + let request = ImageGenerationDraftRequest::KinoviMidjourney8(draft) + .finalize(ctx).await.expect("finalize"); + + let response = request.send_request(&client).await.expect("send_request"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("v8 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs new file mode 100644 index 0000000000..47387c76fa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs @@ -0,0 +1,7 @@ +pub mod midjourney_7; +pub mod midjourney_7_niji; +pub mod midjourney_8; +pub mod seedream_5p0_pro; + +pub(crate) mod resolve; +pub(crate) mod upload; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs new file mode 100644 index 0000000000..58f2911bb1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs @@ -0,0 +1,51 @@ +use std::collections::HashMap; + +use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::providers::kinovi::upload::upload_to_kinovi_web; + +/// Resolve an [`ImageListRef`] to a list of public KinoviWeb CDN URLs, +/// uploading each image one at a time. Preserves order. +/// +/// Returns `Ok(None)` when the input is `None` or an empty list — the +/// caller can then omit the field from the wire request entirely. (The +/// downstream Midjourney API rejects empty arrays.) +pub(crate) async fn resolve_and_upload_image_list( + session: &KinoviWebSession, + list: Option, + maybe_map: Option<&HashMap>, +) -> Result>, ArtcraftRouterError> { + let source_urls = match list { + None => return Ok(None), + Some(ImageListRef::Urls(urls)) if urls.is_empty() => return Ok(None), + Some(ImageListRef::Urls(urls)) => urls, + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => resolve_tokens(maybe_map, &tokens)?, + }; + + let mut uploaded = Vec::with_capacity(source_urls.len()); + for url in &source_urls { + uploaded.push(upload_to_kinovi_web(session, url).await?); + } + Ok(Some(uploaded)) +} + +fn resolve_tokens( + maybe_map: Option<&HashMap>, + tokens: &[MediaFileToken], +) -> Result, ArtcraftRouterError> { + let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + tokens.iter() + .map(|token| { + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + }) + }) + .collect() +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/build.rs new file mode 100644 index 0000000000..e9d86b3e3b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/build.rs @@ -0,0 +1,408 @@ +use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{ + GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount, + KinoviSeedream5p0ProResolution, MAX_REFERENCE_IMAGES, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::{ + KinoviSeedream5p0ProDraftState, KinoviSeedream5p0ProRemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState; + +pub fn build_kinovi_seedream_5p0_pro( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + validate_reference_image_count(image_inputs.as_ref())?; + + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateSeedream5p0ProRequest { + prompt, + aspect_ratio, + resolution, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState { request }), + )); + } + + let draft = KinoviSeedream5p0ProDraftState { + prompt, + aspect_ratio, + resolution, + batch_count, + unhandled_request_state: Some(KinoviSeedream5p0ProRemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +/// Reject over-limit reference lists before any uploads happen — the +/// KinoviWeb client would reject them anyway, but only after the draft +/// stage has already uploaded every image to the Kinovi CDN. +fn validate_reference_image_count( + image_inputs: Option<&ImageListRef>, +) -> Result<(), ArtcraftRouterError> { + let count = match image_inputs { + None => 0, + Some(ImageListRef::Urls(urls)) => urls.len(), + Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(), + }; + if count > MAX_REFERENCE_IMAGES { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: format!("{} reference images (max {})", count, MAX_REFERENCE_IMAGES), + })); + } + Ok(()) +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use KinoviSeedream5p0ProAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Auto), + }, + } +} + +pub(crate) fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use KinoviSeedream5p0ProResolution as Res; + match resolution { + None | Some(RouterResolution::OneK) => Ok(Res::OneK), + Some(RouterResolution::TwoK) => Ok(Res::TwoK), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + // Snap to the nearest supported tier: sub-1k inputs (0.5K, legacy + // video resolutions) land on 1k; 3K/4K land on 2k. + _ => match unsupported { + RouterResolution::ThreeK | RouterResolution::FourK => Ok(Res::TwoK), + _ => Ok(Res::OneK), + }, + }, + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviSeedream5p0ProBatchCount::One), + 2 => Ok(KinoviSeedream5p0ProBatchCount::Two), + 3 => Ok(KinoviSeedream5p0ProBatchCount::Three), + 4 => Ok(KinoviSeedream5p0ProBatchCount::Four), + 5 => Ok(KinoviSeedream5p0ProBatchCount::Five), + 6 => Ok(KinoviSeedream5p0ProBatchCount::Six), + 7 => Ok(KinoviSeedream5p0ProBatchCount::Seven), + 8 => Ok(KinoviSeedream5p0ProBatchCount::Eight), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + // Kinovi supports batches of 1–8; only counts of 9+ reach here, so both + // mitigation strategies clamp to the maximum supported batch. + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(KinoviSeedream5p0ProBatchCount::Eight) + } + }, + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5p0Pro, + provider: RouterProvider::KinoviWeb, + prompt: Some("an anime girl riding a dinosaur".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn build_request(builder: GenerateImageRequestBuilder) -> KinoviSeedream5p0ProRequestState { + match build_kinovi_seedream_5p0_pro(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r, + _ => panic!("expected Request"), + } + } + + mod routing_tests { + use super::*; + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_seedream_5p0_pro(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(_)), + )); + } + + #[test] + fn image_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_seedream_5p0_pro(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(_)), + )); + } + + #[test] + fn media_file_tokens_route_through_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_x".to_string()), + ])), + ..base_builder() + }; + let draft = match build_kinovi_seedream_5p0_pro(builder).expect("build") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d, + _ => panic!("expected Draft"), + }; + assert!(draft.unhandled_request_state.is_some()); + } + + #[test] + fn fourteen_reference_images_is_accepted() { + let urls: Vec = (0..MAX_REFERENCE_IMAGES) + .map(|i| format!("https://example.com/ref{i}.png")) + .collect(); + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(urls)), + ..base_builder() + }; + assert!(build_kinovi_seedream_5p0_pro(builder).is_ok()); + } + + #[test] + fn fifteen_reference_images_is_rejected() { + let urls: Vec = (0..MAX_REFERENCE_IMAGES + 1) + .map(|i| format!("https://example.com/ref{i}.png")) + .collect(); + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(urls)), + ..base_builder() + }; + assert!(build_kinovi_seedream_5p0_pro(builder).is_err()); + } + } + + mod plan_tests { + use super::*; + + #[test] + fn default_aspect_ratio_is_auto() { + let req = build_request(base_builder()); + assert_eq!(req.request.aspect_ratio, KinoviSeedream5p0ProAspectRatio::Auto); + } + + #[test] + fn every_supported_aspect_ratio_maps() { + let cases = [ + (RouterAspectRatio::Auto, KinoviSeedream5p0ProAspectRatio::Auto), + (RouterAspectRatio::Square, KinoviSeedream5p0ProAspectRatio::Square1x1), + (RouterAspectRatio::WideFourByThree, KinoviSeedream5p0ProAspectRatio::Standard4x3), + (RouterAspectRatio::TallThreeByFour, KinoviSeedream5p0ProAspectRatio::Portrait3x4), + (RouterAspectRatio::WideSixteenByNine, KinoviSeedream5p0ProAspectRatio::Landscape16x9), + (RouterAspectRatio::TallNineBySixteen, KinoviSeedream5p0ProAspectRatio::Portrait9x16), + (RouterAspectRatio::WideThreeByTwo, KinoviSeedream5p0ProAspectRatio::Wide3x2), + (RouterAspectRatio::TallTwoByThree, KinoviSeedream5p0ProAspectRatio::Tall2x3), + (RouterAspectRatio::WideTwentyOneByNine, KinoviSeedream5p0ProAspectRatio::UltraWide21x9), + ]; + for (input, expected) in cases { + let planned = plan_aspect_ratio(Some(input), RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(planned, expected, "input={:?}", input); + } + } + + #[test] + fn unsupported_aspect_ratio_errors_out() { + let result = plan_aspect_ratio( + Some(RouterAspectRatio::TallNineByTwentyOne), + RequestMismatchMitigationStrategy::ErrorOut, + ); + assert!(result.is_err()); + } + + #[test] + fn unsupported_aspect_ratio_falls_back_to_auto() { + let planned = plan_aspect_ratio( + Some(RouterAspectRatio::TallNineByTwentyOne), + RequestMismatchMitigationStrategy::PayLessDowngrade, + ).expect("plan"); + assert_eq!(planned, KinoviSeedream5p0ProAspectRatio::Auto); + } + + #[test] + fn default_resolution_is_1k() { + let req = build_request(base_builder()); + assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::OneK); + } + + #[test] + fn two_k_resolution_maps() { + let builder = GenerateImageRequestBuilder { + resolution: Some(RouterResolution::TwoK), + ..base_builder() + }; + let req = build_request(builder); + assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::TwoK); + } + + #[test] + fn four_k_resolution_errors_out() { + let result = plan_resolution( + Some(RouterResolution::FourK), + RequestMismatchMitigationStrategy::ErrorOut, + ); + assert!(result.is_err()); + } + + #[test] + fn four_k_resolution_downgrades_to_2k() { + let planned = plan_resolution( + Some(RouterResolution::FourK), + RequestMismatchMitigationStrategy::PayLessDowngrade, + ).expect("plan"); + assert_eq!(planned, KinoviSeedream5p0ProResolution::TwoK); + } + + #[test] + fn half_k_resolution_snaps_to_1k() { + let planned = plan_resolution( + Some(RouterResolution::HalfK), + RequestMismatchMitigationStrategy::PayMoreUpgrade, + ).expect("plan"); + assert_eq!(planned, KinoviSeedream5p0ProResolution::OneK); + } + + #[test] + fn batch_counts_one_through_eight_are_accepted() { + let cases = [ + (1, KinoviSeedream5p0ProBatchCount::One), + (2, KinoviSeedream5p0ProBatchCount::Two), + (3, KinoviSeedream5p0ProBatchCount::Three), + (4, KinoviSeedream5p0ProBatchCount::Four), + (5, KinoviSeedream5p0ProBatchCount::Five), + (6, KinoviSeedream5p0ProBatchCount::Six), + (7, KinoviSeedream5p0ProBatchCount::Seven), + (8, KinoviSeedream5p0ProBatchCount::Eight), + ]; + for (input, expected) in cases { + let planned = plan_batch_count(Some(input), RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(planned, expected, "input={}", input); + } + } + + #[test] + fn batch_count_zero_is_rejected() { + assert!(plan_batch_count(Some(0), RequestMismatchMitigationStrategy::ErrorOut).is_err()); + } + + #[test] + fn batch_count_nine_errors_out() { + assert!(plan_batch_count(Some(9), RequestMismatchMitigationStrategy::ErrorOut).is_err()); + } + + #[test] + fn batch_count_nine_clamps_to_eight() { + let planned = plan_batch_count(Some(9), RequestMismatchMitigationStrategy::PayLessDowngrade) + .expect("plan"); + assert_eq!(planned, KinoviSeedream5p0ProBatchCount::Eight); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/cost.rs new file mode 100644 index 0000000000..313334e91e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/cost.rs @@ -0,0 +1,100 @@ +use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{ + GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount, + KinoviSeedream5p0ProResolution, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState; + +/// Seedream 5.0 Pro (via Kinovi) cost state. Pricing is per image and +/// resolution-dependent (1k vs 2k); batch count multiplies. +pub struct KinoviSeedream5p0ProCostState { + pub resolution: KinoviSeedream5p0ProResolution, + pub batch_count: KinoviSeedream5p0ProBatchCount, +} + +impl KinoviSeedream5p0ProCostState { + pub fn from_request(request: &KinoviSeedream5p0ProRequestState) -> Self { + Self { + resolution: request.request.resolution, + batch_count: request.request.batch_count, + } + } + + pub fn from_draft(draft: &KinoviSeedream5p0ProDraftState) -> Self { + Self { + resolution: draft.resolution, + batch_count: draft.batch_count, + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateSeedream5p0ProRequest { + prompt: String::new(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto, + resolution: self.resolution, + batch_count: self.batch_count, + reference_image_urls: None, + }; + + let costs = pricing_request.calculate_costs(); + + ImageGenerationCostEstimate { + cost_in_credits: Some(costs.kinovi_credits), + cost_in_usd_cents: Some(costs.usd_cents_rounded_up), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_1k_image_is_fourteen_credits_and_six_cents() { + let cost = KinoviSeedream5p0ProCostState { + resolution: KinoviSeedream5p0ProResolution::OneK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + }.estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(14)); + assert_eq!(cost.cost_in_usd_cents, Some(6)); // 1400/243 = 5.76 -> rounds UP + } + + #[test] + fn one_2k_image_is_twentyfour_credits_and_ten_cents() { + let cost = KinoviSeedream5p0ProCostState { + resolution: KinoviSeedream5p0ProResolution::TwoK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + }.estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(24)); + assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP + } + + /// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits. + #[test] + fn four_2k_images_is_ninetysix_credits() { + let cost = KinoviSeedream5p0ProCostState { + resolution: KinoviSeedream5p0ProResolution::TwoK, + batch_count: KinoviSeedream5p0ProBatchCount::Four, + }.estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(96)); + assert_eq!(cost.cost_in_usd_cents, Some(40)); // 9600/243 = 39.51 -> rounds UP + } + + /// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits. + #[test] + fn eight_1k_images_is_one_hundred_twelve_credits() { + let cost = KinoviSeedream5p0ProCostState { + resolution: KinoviSeedream5p0ProResolution::OneK, + batch_count: KinoviSeedream5p0ProBatchCount::Eight, + }.estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(112)); + assert_eq!(cost.cost_in_usd_cents, Some(47)); // 11200/243 = 46.09 -> rounds UP + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/draft.rs new file mode 100644 index 0000000000..6ee8dfeb6f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/draft.rs @@ -0,0 +1,53 @@ +use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{ + GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount, + KinoviSeedream5p0ProResolution, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; +use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState; + +#[derive(Debug, Clone)] +pub struct KinoviSeedream5p0ProDraftState { + pub prompt: String, + pub aspect_ratio: KinoviSeedream5p0ProAspectRatio, + pub resolution: KinoviSeedream5p0ProResolution, + pub batch_count: KinoviSeedream5p0ProBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviSeedream5p0ProRemainingItems { + pub reference_images: Option, +} + +impl KinoviSeedream5p0ProDraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateSeedream5p0ProRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + resolution: self.resolution, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviSeedream5p0ProRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/request.rs new file mode 100644 index 0000000000..fd354f851d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/seedream_5p0_pro/request.rs @@ -0,0 +1,141 @@ +use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{ + generate_seedream_5p0_pro, GenerateSeedream5p0ProArgs, GenerateSeedream5p0ProRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, KinoviWebImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedream5p0ProRequestState { + pub request: GenerateSeedream5p0ProRequest, +} + +impl KinoviSeedream5p0ProRequestState { + pub async fn send( + &self, + client: &RouterKinoviWebClient, + ) -> Result { + let args = GenerateSeedream5p0ProArgs { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_seedream_5p0_pro(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5p0Pro, + provider: RouterProvider::KinoviWeb, + prompt: Some("an anime girl riding a dinosaur through a neon city".to_string()), + image_inputs: None, + resolution: Some(RouterResolution::OneK), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn get_kinovi_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn text_to_image_round_trip() { + let client = get_kinovi_client(); + let result = base_builder().build2().expect("build2"); + + let request = match result { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r, + _ => panic!("expected direct Request for text-to-image"), + }; + + let response = request.send(client.get_kinovi_web_client_ref().unwrap()) + .await.expect("send should succeed"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("seedream 5.0 pro t2i — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn image_reference_round_trip_via_draft() { + let client = get_kinovi_client(); + let token = MediaFileToken::new("mf_test_juno_seedream5".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("The dog in @Image1 wearing a tiny crown in a royal palace".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])), + ..base_builder() + }; + + let draft_or_request = builder.build2().expect("build2"); + let draft = match draft_or_request { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d, + _ => panic!("expected Draft for image-input flow"), + }; + + let ctx = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&map), + }; + + let request = ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft) + .finalize(ctx).await.expect("finalize"); + + let response = request.send_request(&client).await.expect("send_request"); + let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload"); + println!("seedream 5.0 pro with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs new file mode 100644 index 0000000000..3f046f6085 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs @@ -0,0 +1,44 @@ +use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; +use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; +use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; +use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::utils::download_file::download_file; + +/// Downloads an image from a source URL and re-uploads it to the +/// KinoviWeb/Kinovi CDN. Returns the public URL. +/// +/// This is the image-side twin of `generate_video::providers::kinovi::upload`. +/// The actual upload path doesn't care whether the file is a video or an +/// image, but keeping the two helpers separate lets each side evolve +/// independently and makes intent clear at call sites. +pub(crate) async fn upload_to_kinovi_web( + session: &KinoviWebSession, + source_url: &str, +) -> Result { + let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All) + .map(|ext| ext.without_period().to_string()) + .unwrap_or_else(|| "jpg".to_string()); + + let file_bytes = download_file(source_url).await?; + + let prepare_response = prepare_file_upload(PrepareFileUploadArgs { + session, + extension, + host_override: None, + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + let upload_response = upload_file(UploadFileArgs { + upload_url: prepare_response.upload_url, + file_bytes, + host_override: None, + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(upload_response.public_url) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs new file mode 100644 index 0000000000..1adb32b1fa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod artcraft; +pub mod fal; +pub mod kinovi; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_request_builder.rs new file mode 100644 index 0000000000..30cd6a5874 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_request_builder.rs @@ -0,0 +1,313 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_mesh_quality::CommonMeshQuality; +use enums::common::generation::common_polygon_type::CommonPolygonType; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::mesh_ref::MeshRef; +use crate::api::router_mesh_model::RouterMeshModel; +use crate::api::router_provider::RouterProvider; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::build::build_artcraft_hunyuan3d_3; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::build::build_artcraft_hunyuan3d_3_sketch; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::build::build_artcraft_hunyuan_3d_2p0; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::build::build_artcraft_hunyuan_3d_2p1; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::build::build_artcraft_hunyuan_3d_3p1_part; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::build::build_artcraft_hunyuan_3d_3p1_pro; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::build::build_artcraft_hunyuan_3d_3p1_rapid; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::build::build_artcraft_hunyuan_3d_3p1_smart_topology; +use crate::generate::generate_mesh::providers::artcraft::meshy_v6::build::build_artcraft_meshy_v6; +use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::build::build_artcraft_rodin_2p5_fast; +use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::build::build_artcraft_tripo3d_h3p1; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::build::build_fal_hunyuan3d_3; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::build::build_fal_hunyuan3d_3_sketch; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::build::build_fal_hunyuan_3d_2p0; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::build::build_fal_hunyuan_3d_2p1; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::build::build_fal_hunyuan_3d_3p1_part; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::build::build_fal_hunyuan_3d_3p1_pro; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::build::build_fal_hunyuan_3d_3p1_rapid; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::build::build_fal_hunyuan_3d_3p1_smart_topology; +use crate::generate::generate_mesh::providers::fal::meshy_v6::build::build_fal_meshy_v6; +use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::build::build_fal_rodin_2p5_fast; +use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::build::build_fal_tripo3d_h3p1; + +/// RouterProvider-agnostic mesh (3D object) generation request. Distilled by +/// `build2()` into a `MeshGenerationDraftOrRequest` for the selected +/// (provider, model) pair. +#[derive(Clone, Debug)] +pub struct GenerateMeshRequestBuilder { + /// Which model to use. + pub model: RouterMeshModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The text prompt (text-to-3D, or the sketch description for sketch-to-3D). + pub prompt: Option, + + /// Reference images (optional). + /// The primary/front input image, or the sketch image for sketch-to-3D. + pub reference_images: Option, + + /// Front view image (optional; multi-view input). + /// Can be used instead of `reference_images[0]` when the other side views + /// are supplied. + pub front_image: Option, + + /// Back view image (optional; multi-view input). + pub back_image: Option, + + /// Left view image (optional; multi-view input). + pub left_image: Option, + + /// Right view image (optional; multi-view input). + pub right_image: Option, + + /// Input mesh file for mesh-to-mesh models (part splitting, retopology). + pub input_mesh: Option, + + /// The mesh output type to generate (normal, low-poly, or geometry-only). + pub mesh_output_type: Option, + + /// The polygon type to use for the generated mesh (triangle or quad). + pub polygon_type: Option, + + /// Target face count for the generated mesh. + pub face_count: Option, + + /// Whether to generate PBR (physically based rendering) materials. + pub enable_pbr: Option, + + /// Whether to generate textures. Some models (Tripo3D) can skip texturing + /// for a cheaper, faster generation. + pub enable_texture: Option, + + /// Texture quality. `Detailed` may cost extra (e.g. Tripo3D's HD tier). + pub texture_quality: Option, + + /// Geometry quality. `Detailed` may cost extra (e.g. Tripo3D). + pub geometry_quality: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl Default for GenerateMeshRequestBuilder { + fn default() -> Self { + Self { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + prompt: None, + reference_images: None, + front_image: None, + back_image: None, + left_image: None, + right_image: None, + input_mesh: None, + mesh_output_type: None, + polygon_type: None, + face_count: None, + enable_pbr: None, + enable_texture: None, + texture_quality: None, + geometry_quality: None, + idempotency_token: None, + } + } +} + +impl GenerateMeshRequestBuilder { + + pub fn build2(self) -> Result { + match (self.provider, self.model) { + // Artcraft + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d2p0) => build_artcraft_hunyuan_3d_2p0(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d2p1) => build_artcraft_hunyuan_3d_2p1(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3) => build_artcraft_hunyuan3d_3(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3Sketch) => build_artcraft_hunyuan3d_3_sketch(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1Pro) => build_artcraft_hunyuan_3d_3p1_pro(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1Rapid) => build_artcraft_hunyuan_3d_3p1_rapid(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1Part) => build_artcraft_hunyuan_3d_3p1_part(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1SmartTopology) => build_artcraft_hunyuan_3d_3p1_smart_topology(self), + (RouterProvider::Artcraft, RouterMeshModel::Tripo3dH3p1) => build_artcraft_tripo3d_h3p1(self), + (RouterProvider::Artcraft, RouterMeshModel::MeshyV6) => build_artcraft_meshy_v6(self), + (RouterProvider::Artcraft, RouterMeshModel::Rodin2p5Fast) => build_artcraft_rodin_2p5_fast(self), + // Fal + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p0) => build_fal_hunyuan_3d_2p0(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p1) => build_fal_hunyuan_3d_2p1(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3) => build_fal_hunyuan3d_3(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3Sketch) => build_fal_hunyuan3d_3_sketch(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1Pro) => build_fal_hunyuan_3d_3p1_pro(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1Rapid) => build_fal_hunyuan_3d_3p1_rapid(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1Part) => build_fal_hunyuan_3d_3p1_part(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1SmartTopology) => build_fal_hunyuan_3d_3p1_smart_topology(self), + (RouterProvider::Fal, RouterMeshModel::Tripo3dH3p1) => build_fal_tripo3d_h3p1(self), + (RouterProvider::Fal, RouterMeshModel::MeshyV6) => build_fal_meshy_v6(self), + (RouterProvider::Fal, RouterMeshModel::Rodin2p5Fast) => build_fal_rodin_2p5_fast(self), + _ => self.unsupported_provider_and_model(), + } + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Mesh generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/front.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn artcraft_models_dispatch() { + let cases = [ + RouterMeshModel::Hunyuan3d2p0, + RouterMeshModel::Hunyuan3d2p1, + RouterMeshModel::Hunyuan3d3, + RouterMeshModel::Hunyuan3d3Sketch, + ]; + for model in cases { + let result = builder_with_token_image(RouterProvider::Artcraft, model).build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let request = expect_request(result); + let matches = matches!( + (model, &request), + (RouterMeshModel::Hunyuan3d2p0, MeshGenerationRequest::ArtcraftHunyuan3d2p0(_)) + | (RouterMeshModel::Hunyuan3d2p1, MeshGenerationRequest::ArtcraftHunyuan3d2p1(_)) + | (RouterMeshModel::Hunyuan3d3, MeshGenerationRequest::ArtcraftHunyuan3d3(_)) + | (RouterMeshModel::Hunyuan3d3Sketch, MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {request:?}"); + } + } + + #[test] + fn fal_hunyuan3d_3_with_image_dispatches_to_image_endpoint() { + let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3) + .build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d3Image(_) + )); + } + + #[test] + fn fal_hunyuan3d_3_with_prompt_only_dispatches_to_text_endpoint() { + let builder = GenerateMeshRequestBuilder { + provider: RouterProvider::Fal, + model: RouterMeshModel::Hunyuan3d3, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + }; + let result = builder.build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d3Text(_) + )); + } + + #[test] + fn fal_hunyuan3d_3_sketch_dispatches() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3Sketch) + }; + let result = builder.build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d3Sketch(_) + )); + } + + #[test] + fn fal_hunyuan_3d_2p0_dispatches() { + let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p0) + .build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d2p0(_) + )); + } + + #[test] + fn fal_hunyuan_3d_2p1_dispatches() { + let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p1) + .build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d2p1(_) + )); + } + } + + mod unsupported_combo_tests { + use super::*; + + #[test] + fn non_mesh_providers_are_unsupported() { + for provider in [ + RouterProvider::GmiCloud, + RouterProvider::GrokApi, + RouterProvider::KinoviWeb, + RouterProvider::WorldLabs, + ] { + let result = builder_with_url_image(provider, RouterMeshModel::Hunyuan3d3).build2(); + assert!( + matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))), + "expected unsupported error for {provider:?}", + ); + } + } + } + + // ── Helpers ── + + fn builder_with_url_image(provider: RouterProvider, model: RouterMeshModel) -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + provider, + model, + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..Default::default() + } + } + + fn builder_with_token_image(provider: RouterProvider, model: RouterMeshModel) -> GenerateMeshRequestBuilder { + use tokens::tokens::media_files::MediaFileToken; + GenerateMeshRequestBuilder { + provider, + model, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..Default::default() + } + } + + fn expect_request(result: MeshGenerationDraftOrRequest) -> MeshGenerationRequest { + match result { + MeshGenerationDraftOrRequest::Request(request) => request, + MeshGenerationDraftOrRequest::Draft(draft) => panic!("expected Request, got Draft: {draft:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_response.rs new file mode 100644 index 0000000000..5d45cbb555 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_response.rs @@ -0,0 +1,51 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +#[derive(Clone, Debug)] +pub struct ArtcraftMeshResponsePayload { + pub inference_job_token: InferenceJobToken, + pub all_inference_job_tokens: Vec, +} + +#[derive(Clone, Debug)] +pub struct FalMeshResponsePayload { + pub request_id: Option, + pub gateway_request_id: Option, + + /// Queue-mode status URL (fal's queue/polling flow). `None` for webhook + /// dispatch — the webhook callback drives status updates instead. + pub maybe_status_url: Option, + + /// Queue-mode response URL (fal's queue/polling flow). `None` for webhook + /// dispatch. + pub maybe_response_url: Option, + + /// The outbound request that was sent to Fal. + /// Stored as a trait object so any Request type can be captured. + /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. + pub maybe_outbound_request: Option>, +} + +#[derive(Clone, Debug)] +pub enum GenerateMeshResponse { + Artcraft(ArtcraftMeshResponsePayload), + Fal(FalMeshResponsePayload), +} + +impl GenerateMeshResponse { + pub fn get_artcraft_payload(&self) -> Option { + match self { + Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_fal_payload(&self) -> Option { + match self { + Self::Fal(p) => Some(p.clone()), + _ => None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_cost_estimate.rs new file mode 100644 index 0000000000..0187cf09a5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_cost_estimate.rs @@ -0,0 +1,16 @@ +/// The result of estimating the cost of a mesh generation plan. +#[derive(Clone, Debug)] +pub struct MeshGenerationCostEstimate { + pub cost_in_credits: Option, + pub cost_in_usd_cents: Option, + pub is_free: bool, + pub is_unlimited: bool, + pub is_rate_limited: bool, + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft.rs new file mode 100644 index 0000000000..0eef23b75c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft.rs @@ -0,0 +1,35 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::mesh_generation_draft_context::MeshGenerationDraftContext; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; + +/** + * Wrapper for all mesh generation draft requests. + * + * No mesh provider currently needs a draft phase: fal takes media URLs + * directly and Artcraft takes media tokens, so `build2()` always returns a + * `Request`. The enum exists so the mesh pipeline matches the audio/image + * draft-or-request shape and providers that need pre-upload can slot in + * later. + */ +#[derive(Clone, Debug)] +pub enum MeshGenerationDraftRequest {} + +impl MeshGenerationDraftRequest { + + pub fn get_provider(&self) -> RouterProvider { + match *self {} + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match *self {} + } + + /// Finalize the draft request before generation + /// This may involve uploading media to the provider. + pub async fn finalize(self, _draft_context: MeshGenerationDraftContext<'_>) -> Result { + match self {} + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_context.rs new file mode 100644 index 0000000000..1760d54afe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_context.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; + +#[derive(Clone, Default)] +pub struct MeshGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require authentication. + pub client: Option<&'a RouterClient>, + + /// Optional context: a map of Media File Tokens to their ArtCraft URLs + /// Only needed if we have to fetch these assets and upload them to another provider. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl Debug for MeshGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MeshGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_or_request.rs new file mode 100644 index 0000000000..e82d7e09f2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_or_request.rs @@ -0,0 +1,28 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::mesh_generation_draft::MeshGenerationDraftRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; + +#[derive(Debug, Clone)] +pub enum MeshGenerationDraftOrRequest { + Draft(MeshGenerationDraftRequest), + Request(MeshGenerationRequest), +} + +impl MeshGenerationDraftOrRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + MeshGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), + MeshGenerationDraftOrRequest::Request(request) => request.estimate_cost(), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_request.rs new file mode 100644 index 0000000000..7841247e1c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_request.rs @@ -0,0 +1,214 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::cost::ArtcraftHunyuan3d3CostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::cost::ArtcraftHunyuan3d3SketchCostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::cost::ArtcraftHunyuan3d2p0CostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::cost::ArtcraftHunyuan3d2p1CostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::cost::ArtcraftHunyuan3d3p1PartCostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::request::ArtcraftHunyuan3d3p1PartRequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::cost::ArtcraftHunyuan3d3p1ProCostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::request::ArtcraftHunyuan3d3p1ProRequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::cost::ArtcraftHunyuan3d3p1RapidCostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::request::ArtcraftHunyuan3d3p1RapidRequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::cost::ArtcraftHunyuan3d3p1SmartTopologyCostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::request::ArtcraftHunyuan3d3p1SmartTopologyRequestState; +use crate::generate::generate_mesh::providers::artcraft::meshy_v6::cost::ArtcraftMeshyV6CostState; +use crate::generate::generate_mesh::providers::artcraft::meshy_v6::request::ArtcraftMeshyV6RequestState; +use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::cost::ArtcraftRodin2p5FastCostState; +use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::request::ArtcraftRodin2p5FastRequestState; +use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::cost::ArtcraftTripo3dH3p1CostState; +use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::request::ArtcraftTripo3dH3p1RequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::{ + FalHunyuan3d3ImageCostState, FalHunyuan3d3TextCostState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{ + FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::cost::FalHunyuan3d3SketchCostState; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::cost::FalHunyuan3d2p0CostState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::cost::FalHunyuan3d2p1CostState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::cost::FalHunyuan3d3p1PartCostState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::request::FalHunyuan3d3p1PartRequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::cost::{ + FalHunyuan3d3p1ProImageCostState, FalHunyuan3d3p1ProTextCostState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::request::{ + FalHunyuan3d3p1ProImageRequestState, FalHunyuan3d3p1ProTextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::cost::{ + FalHunyuan3d3p1RapidImageCostState, FalHunyuan3d3p1RapidTextCostState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::request::{ + FalHunyuan3d3p1RapidImageRequestState, FalHunyuan3d3p1RapidTextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::cost::FalHunyuan3d3p1SmartTopologyCostState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::request::FalHunyuan3d3p1SmartTopologyRequestState; +use crate::generate::generate_mesh::providers::fal::meshy_v6::cost::{ + FalMeshyV6ImageCostState, FalMeshyV6TextCostState, +}; +use crate::generate::generate_mesh::providers::fal::meshy_v6::request::{ + FalMeshyV6ImageRequestState, FalMeshyV6TextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::cost::{ + FalRodin2p5FastImageCostState, FalRodin2p5FastTextCostState, +}; +use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::request::{ + FalRodin2p5FastImageRequestState, FalRodin2p5FastTextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::cost::{ + FalTripo3dH3p1ImageCostState, FalTripo3dH3p1MultiviewCostState, FalTripo3dH3p1TextCostState, +}; +use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::request::{ + FalTripo3dH3p1ImageRequestState, FalTripo3dH3p1MultiviewRequestState, FalTripo3dH3p1TextRequestState, +}; + +#[derive(Clone, Debug)] +pub enum MeshGenerationRequest { + ArtcraftHunyuan3d2p0(ArtcraftHunyuan3d2p0RequestState), + ArtcraftHunyuan3d2p1(ArtcraftHunyuan3d2p1RequestState), + ArtcraftHunyuan3d3(ArtcraftHunyuan3d3RequestState), + ArtcraftHunyuan3d3Sketch(ArtcraftHunyuan3d3SketchRequestState), + ArtcraftHunyuan3d3p1Pro(ArtcraftHunyuan3d3p1ProRequestState), + ArtcraftHunyuan3d3p1Rapid(ArtcraftHunyuan3d3p1RapidRequestState), + ArtcraftHunyuan3d3p1Part(ArtcraftHunyuan3d3p1PartRequestState), + ArtcraftHunyuan3d3p1SmartTopology(ArtcraftHunyuan3d3p1SmartTopologyRequestState), + ArtcraftTripo3dH3p1(ArtcraftTripo3dH3p1RequestState), + ArtcraftMeshyV6(ArtcraftMeshyV6RequestState), + ArtcraftRodin2p5Fast(ArtcraftRodin2p5FastRequestState), + FalHunyuan3d2p0(FalHunyuan3d2p0RequestState), + FalHunyuan3d2p1(FalHunyuan3d2p1RequestState), + FalHunyuan3d3Image(FalHunyuan3d3ImageRequestState), + FalHunyuan3d3Text(FalHunyuan3d3TextRequestState), + FalHunyuan3d3Sketch(FalHunyuan3d3SketchRequestState), + FalHunyuan3d3p1ProImage(FalHunyuan3d3p1ProImageRequestState), + FalHunyuan3d3p1ProText(FalHunyuan3d3p1ProTextRequestState), + FalHunyuan3d3p1RapidImage(FalHunyuan3d3p1RapidImageRequestState), + FalHunyuan3d3p1RapidText(FalHunyuan3d3p1RapidTextRequestState), + FalHunyuan3d3p1Part(FalHunyuan3d3p1PartRequestState), + FalHunyuan3d3p1SmartTopology(FalHunyuan3d3p1SmartTopologyRequestState), + FalTripo3dH3p1Text(FalTripo3dH3p1TextRequestState), + FalTripo3dH3p1Image(FalTripo3dH3p1ImageRequestState), + FalTripo3dH3p1Multiview(FalTripo3dH3p1MultiviewRequestState), + FalMeshyV6Text(FalMeshyV6TextRequestState), + FalMeshyV6Image(FalMeshyV6ImageRequestState), + FalRodin2p5FastText(FalRodin2p5FastTextRequestState), + FalRodin2p5FastImage(FalRodin2p5FastImageRequestState), +} + +impl MeshGenerationRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftHunyuan3d2p0(_) + | Self::ArtcraftHunyuan3d2p1(_) + | Self::ArtcraftHunyuan3d3(_) + | Self::ArtcraftHunyuan3d3Sketch(_) + | Self::ArtcraftHunyuan3d3p1Pro(_) + | Self::ArtcraftHunyuan3d3p1Rapid(_) + | Self::ArtcraftHunyuan3d3p1Part(_) + | Self::ArtcraftHunyuan3d3p1SmartTopology(_) + | Self::ArtcraftTripo3dH3p1(_) + | Self::ArtcraftMeshyV6(_) + | Self::ArtcraftRodin2p5Fast(_) => RouterProvider::Artcraft, + Self::FalHunyuan3d2p0(_) + | Self::FalHunyuan3d2p1(_) + | Self::FalHunyuan3d3Image(_) + | Self::FalHunyuan3d3Text(_) + | Self::FalHunyuan3d3Sketch(_) + | Self::FalHunyuan3d3p1ProImage(_) + | Self::FalHunyuan3d3p1ProText(_) + | Self::FalHunyuan3d3p1RapidImage(_) + | Self::FalHunyuan3d3p1RapidText(_) + | Self::FalHunyuan3d3p1Part(_) + | Self::FalHunyuan3d3p1SmartTopology(_) + | Self::FalTripo3dH3p1Text(_) + | Self::FalTripo3dH3p1Image(_) + | Self::FalTripo3dH3p1Multiview(_) + | Self::FalMeshyV6Text(_) + | Self::FalMeshyV6Image(_) + | Self::FalRodin2p5FastText(_) + | Self::FalRodin2p5FastImage(_) => RouterProvider::Fal, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + Self::ArtcraftHunyuan3d2p0(request) => Ok(ArtcraftHunyuan3d2p0CostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d2p1(request) => Ok(ArtcraftHunyuan3d2p1CostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d3(request) => Ok(ArtcraftHunyuan3d3CostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d3Sketch(request) => Ok(ArtcraftHunyuan3d3SketchCostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d3p1Pro(request) => Ok(ArtcraftHunyuan3d3p1ProCostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d3p1Rapid(request) => Ok(ArtcraftHunyuan3d3p1RapidCostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d3p1Part(request) => Ok(ArtcraftHunyuan3d3p1PartCostState::from_request(request).estimate_cost()), + Self::ArtcraftHunyuan3d3p1SmartTopology(request) => Ok(ArtcraftHunyuan3d3p1SmartTopologyCostState::from_request(request).estimate_cost()), + Self::ArtcraftTripo3dH3p1(request) => Ok(ArtcraftTripo3dH3p1CostState::from_request(request).estimate_cost()), + Self::ArtcraftMeshyV6(request) => Ok(ArtcraftMeshyV6CostState::from_request(request).estimate_cost()), + Self::ArtcraftRodin2p5Fast(request) => Ok(ArtcraftRodin2p5FastCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d2p0(request) => Ok(FalHunyuan3d2p0CostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d2p1(request) => Ok(FalHunyuan3d2p1CostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3Image(request) => Ok(FalHunyuan3d3ImageCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3Text(request) => Ok(FalHunyuan3d3TextCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3Sketch(request) => Ok(FalHunyuan3d3SketchCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3p1ProImage(request) => Ok(FalHunyuan3d3p1ProImageCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3p1ProText(request) => Ok(FalHunyuan3d3p1ProTextCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3p1RapidImage(request) => Ok(FalHunyuan3d3p1RapidImageCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3p1RapidText(request) => Ok(FalHunyuan3d3p1RapidTextCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3p1Part(request) => Ok(FalHunyuan3d3p1PartCostState::from_request(request).estimate_cost()), + Self::FalHunyuan3d3p1SmartTopology(request) => Ok(FalHunyuan3d3p1SmartTopologyCostState::from_request(request).estimate_cost()), + Self::FalTripo3dH3p1Text(request) => Ok(FalTripo3dH3p1TextCostState::from_request(request).estimate_cost()), + Self::FalTripo3dH3p1Image(request) => Ok(FalTripo3dH3p1ImageCostState::from_request(request).estimate_cost()), + Self::FalTripo3dH3p1Multiview(request) => Ok(FalTripo3dH3p1MultiviewCostState::from_request(request).estimate_cost()), + Self::FalMeshyV6Text(request) => Ok(FalMeshyV6TextCostState::from_request(request).estimate_cost()), + Self::FalMeshyV6Image(request) => Ok(FalMeshyV6ImageCostState::from_request(request).estimate_cost()), + Self::FalRodin2p5FastText(request) => Ok(FalRodin2p5FastTextCostState::from_request(request).estimate_cost()), + Self::FalRodin2p5FastImage(request) => Ok(FalRodin2p5FastImageCostState::from_request(request).estimate_cost()), + } + } + + /// Send the mesh generation request + /// If successful, returns the job IDs. + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + Self::ArtcraftHunyuan3d2p0(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d2p1(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d3(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d3Sketch(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d3p1Pro(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d3p1Rapid(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d3p1Part(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftHunyuan3d3p1SmartTopology(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftTripo3dH3p1(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftMeshyV6(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::ArtcraftRodin2p5Fast(request) => request.send(client.get_artcraft_client_ref()?).await, + Self::FalHunyuan3d2p0(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d2p1(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3Image(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3Text(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3Sketch(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3p1ProImage(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3p1ProText(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3p1RapidImage(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3p1RapidText(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3p1Part(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalHunyuan3d3p1SmartTopology(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalTripo3dH3p1Text(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalTripo3dH3p1Image(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalTripo3dH3p1Multiview(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalMeshyV6Text(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalMeshyV6Image(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalRodin2p5FastText(request) => request.send(client.get_fal_client_ref()?).await, + Self::FalRodin2p5FastImage(request) => request.send(client.get_fal_client_ref()?).await, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mod.rs new file mode 100644 index 0000000000..83ad68d45e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mod.rs @@ -0,0 +1,8 @@ +pub mod generate_mesh_request_builder; +pub mod generate_mesh_response; +pub mod mesh_generation_cost_estimate; +pub mod mesh_generation_draft; +pub mod mesh_generation_draft_context; +pub mod mesh_generation_draft_or_request; +pub mod mesh_generation_request; +pub mod providers; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..661a2fa5e5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/build_common.rs @@ -0,0 +1,140 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::providers::artcraft::resolve::{ + resolve_image_list_ref, resolve_image_ref, resolve_mesh_ref, +}; + +/// Build an `OmniGenMeshCostAndGenerateRequest` from the builder. The +/// Artcraft provider is a thin forwarding shim: the storyteller-web omni +/// mesh endpoint owns per-model validation and option mapping, so the +/// builder's options are passed through as-is. +pub fn build_artcraft_omni_mesh_request( + mut builder: GenerateMeshRequestBuilder, + model: CommonMeshModelEnum, +) -> Result { + let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?; + let front_image_media_token = resolve_image_ref(builder.front_image.take())?; + let back_image_media_token = resolve_image_ref(builder.back_image.take())?; + let left_image_media_token = resolve_image_ref(builder.left_image.take())?; + let right_image_media_token = resolve_image_ref(builder.right_image.take())?; + let input_mesh_media_token = resolve_mesh_ref(builder.input_mesh.take())?; + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenMeshCostAndGenerateRequest { + idempotency_token: Some(idempotency_token), + model: Some(model), + prompt: builder.prompt.take(), + reference_image_media_tokens, + front_image_media_token, + back_image_media_token, + left_image_media_token, + right_image_media_token, + input_mesh_media_token, + mesh_output_type: builder.mesh_output_type, + polygon_type: builder.polygon_type, + face_count: builder.face_count, + enable_pbr: builder.enable_pbr, + enable_texture: builder.enable_texture, + texture_quality: builder.texture_quality, + geometry_quality: builder.geometry_quality, + }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::errors::client_error::ClientError; + + use super::*; + + #[test] + fn all_fields_are_forwarded() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])), + front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))), + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))), + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + idempotency_token: Some("test-token".to_string()), + ..base_builder() + }; + + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3) + .expect("build"); + + assert_eq!(request.idempotency_token.as_deref(), Some("test-token")); + assert_eq!(request.model, Some(CommonMeshModelEnum::Hunyuan3d3)); + assert_eq!(request.prompt.as_deref(), Some("a red ceramic teapot")); + assert_eq!(request.reference_image_media_tokens.as_ref().map(|t| t.len()), Some(1)); + assert_eq!(request.front_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_front")); + assert_eq!(request.back_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_back")); + assert_eq!(request.left_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_left")); + assert_eq!(request.right_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_right")); + assert_eq!(request.mesh_output_type, Some(CommonMeshOutputType::LowPoly)); + assert_eq!(request.polygon_type, Some(CommonPolygonType::Quad)); + assert_eq!(request.face_count, Some(100_000)); + assert_eq!(request.enable_pbr, Some(true)); + } + + #[test] + fn idempotency_token_is_generated_when_missing() { + let request = build_artcraft_omni_mesh_request(base_builder(), CommonMeshModelEnum::Hunyuan3d3) + .expect("build"); + assert!(request.idempotency_token.is_some()); + assert!(!request.idempotency_token.unwrap().is_empty()); + } + + #[test] + fn reference_image_url_references_are_rejected() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])), + ..base_builder() + }; + let result = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + #[test] + fn side_image_url_references_are_rejected() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..base_builder() + }; + let result = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/build.rs new file mode 100644 index 0000000000..cd208ca6eb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState; + +pub fn build_artcraft_hunyuan3d_3(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3)?; + let state = ArtcraftHunyuan3d3RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/cost.rs new file mode 100644 index 0000000000..41e0e9f1b7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/cost.rs @@ -0,0 +1,225 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState; + +const NORMAL_COST_IN_USD_CENTS: u64 = 49; +const LOW_POLY_COST_IN_USD_CENTS: u64 = 59; +const GEOMETRY_COST_IN_USD_CENTS: u64 = 30; +const ADD_ON_COST_IN_USD_CENTS: u64 = 20; + +/// Hunyuan 3D v3 via Artcraft: flat base price by output type, plus a flat +/// add-on for each extra option (PBR materials, custom face count, and +/// multi-view side images). +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3CostState { + pub fn from_request(request: &ArtcraftHunyuan3d3RequestState) -> Self { + let request = &request.request; + + let mut cost = match request.mesh_output_type { + None | Some(CommonMeshOutputType::Normal) => NORMAL_COST_IN_USD_CENTS, + Some(CommonMeshOutputType::LowPoly) => LOW_POLY_COST_IN_USD_CENTS, + Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS, + }; + if request.enable_pbr.unwrap_or(false) { + cost += ADD_ON_COST_IN_USD_CENTS; + } + if request.face_count.is_some() { + cost += ADD_ON_COST_IN_USD_CENTS; + } + let uses_multi_view = request.back_image_media_token.is_some() + || request.left_image_media_token.is_some() + || request.right_image_media_token.is_some(); + if uses_multi_view { + cost += ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod base_costs { + use super::*; + + #[test] + fn default_output_type_is_forty_nine_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 49); + } + + #[test] + fn normal_is_forty_nine_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn low_poly_is_fifty_nine_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 59); + } + + #[test] + fn geometry_is_thirty_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + + #[test] + fn text_mode_prices_the_same_as_image_mode() { + // Text-only request (no image references). + let text_builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + }; + assert_eq!(estimate_usd_cents(text_builder), 49); + } + + #[test] + fn polygon_type_does_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + } + + mod add_ons { + use super::*; + + #[test] + fn pbr_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn pbr_false_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(false), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn face_count_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn each_side_image_adds_twenty_cents_at_most_once() { + for side in ["back", "left", "right"] { + let mut builder = base_builder(); + let image = Some(ImageRef::MediaFileToken(MediaFileToken::new(format!("mf_{side}")))); + match side { + "back" => builder.back_image = image, + "left" => builder.left_image = image, + _ => builder.right_image = image, + } + assert_eq!(estimate_usd_cents(builder), 49 + 20, "for {side} image"); + } + + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + enable_pbr: Some(true), + face_count: Some(100_000), + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 59 + 20 + 20 + 20); + } + + #[test] + fn geometry_with_all_add_ons() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + face_count: Some(10_000), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30 + 20 + 20 + 20); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/request.rs new file mode 100644 index 0000000000..6fe7df8f08 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/build.rs new file mode 100644 index 0000000000..894ded2b5f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState; + +pub fn build_artcraft_hunyuan3d_3_sketch(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3Sketch)?; + let state = ArtcraftHunyuan3d3SketchRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/cost.rs new file mode 100644 index 0000000000..0a7a4c6a05 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/cost.rs @@ -0,0 +1,115 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState; + +const BASE_COST_IN_USD_CENTS: u64 = 49; +const ADD_ON_COST_IN_USD_CENTS: u64 = 20; + +/// Hunyuan 3D v3 sketch-to-3D via Artcraft: flat base price plus a flat +/// add-on for each extra option (PBR materials, custom face count). +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3SketchCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3SketchCostState { + pub fn from_request(request: &ArtcraftHunyuan3d3SketchRequestState) -> Self { + let request = &request.request; + + let mut cost = BASE_COST_IN_USD_CENTS; + if request.enable_pbr.unwrap_or(false) { + cost += ADD_ON_COST_IN_USD_CENTS; + } + if request.face_count.is_some() { + cost += ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn base_cost_is_forty_nine_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 49); + } + + #[test] + fn pbr_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn pbr_false_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(false), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn face_count_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20 + 20); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_sketch".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/request.rs new file mode 100644 index 0000000000..5d0bc59379 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3SketchRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3SketchRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/build.rs new file mode 100644 index 0000000000..47cdb4b080 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState; + +pub fn build_artcraft_hunyuan_3d_2p0(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d2p0)?; + let state = ArtcraftHunyuan3d2p0RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d2p0(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/cost.rs new file mode 100644 index 0000000000..2772e1e6d2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/cost.rs @@ -0,0 +1,93 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState; + +const TEXTURED_COST_IN_USD_CENTS: u64 = 63; +const GEOMETRY_COST_IN_USD_CENTS: u64 = 21; + +/// Hunyuan 3D 2.0 via Artcraft: flat price by output type. Geometry-only +/// (white mesh) is cheaper; every other output type prices as textured. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p0CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d2p0CostState { + pub fn from_request(request: &ArtcraftHunyuan3d2p0RequestState) -> Self { + let cost = match request.request.mesh_output_type { + Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS, + None | Some(_) => TEXTURED_COST_IN_USD_CENTS, + }; + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + #[test] + fn default_output_type_is_sixty_three_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 63); + } + + #[test] + fn normal_is_sixty_three_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 63); + } + + #[test] + fn geometry_is_twenty_one_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 21); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/request.rs new file mode 100644 index 0000000000..942f02828a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p0RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d2p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/build.rs new file mode 100644 index 0000000000..af2f555278 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState; + +pub fn build_artcraft_hunyuan_3d_2p1(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d2p1)?; + let state = ArtcraftHunyuan3d2p1RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d2p1(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/cost.rs new file mode 100644 index 0000000000..e045ff4806 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/cost.rs @@ -0,0 +1,93 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState; + +const TEXTURED_COST_IN_USD_CENTS: u64 = 117; +const GEOMETRY_COST_IN_USD_CENTS: u64 = 39; + +/// Hunyuan 3D 2.1 via Artcraft: flat price by output type. Geometry-only +/// (white mesh) is cheaper; every other output type prices as textured. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p1CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d2p1CostState { + pub fn from_request(request: &ArtcraftHunyuan3d2p1RequestState) -> Self { + let cost = match request.request.mesh_output_type { + Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS, + None | Some(_) => TEXTURED_COST_IN_USD_CENTS, + }; + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + #[test] + fn default_output_type_is_one_seventeen_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 117); + } + + #[test] + fn normal_is_one_seventeen_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 117); + } + + #[test] + fn geometry_is_thirty_nine_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/request.rs new file mode 100644 index 0000000000..ccd980dcdb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p1RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d2p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/build.rs new file mode 100644 index 0000000000..2016e1d54b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::request::ArtcraftHunyuan3d3p1PartRequestState; + +pub fn build_artcraft_hunyuan_3d_3p1_part(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1Part)?; + let state = ArtcraftHunyuan3d3p1PartRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1Part(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/cost.rs new file mode 100644 index 0000000000..2cde0541e7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/cost.rs @@ -0,0 +1,78 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::request::ArtcraftHunyuan3d3p1PartRequestState; + +const FLAT_COST_IN_USD_CENTS: u64 = 59; + +/// Hunyuan 3D v3.1 Part via Artcraft: flat 59¢ per generation, with no +/// add-ons. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1PartCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3p1PartCostState { + pub fn from_request(_request: &ArtcraftHunyuan3d3p1PartRequestState) -> Self { + Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + mod cost_table { + use super::*; + + #[test] + fn cost_is_a_flat_fifty_nine_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 59); + } + + #[test] + fn options_do_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 59); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Part, + provider: RouterProvider::Artcraft, + input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".to_string()))), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/request.rs new file mode 100644 index 0000000000..05995d2060 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_part/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1PartRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3p1PartRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/build.rs new file mode 100644 index 0000000000..027c282a41 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::request::ArtcraftHunyuan3d3p1ProRequestState; + +pub fn build_artcraft_hunyuan_3d_3p1_pro(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1Pro)?; + let state = ArtcraftHunyuan3d3p1ProRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1Pro(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/cost.rs new file mode 100644 index 0000000000..8b114053f5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/cost.rs @@ -0,0 +1,183 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::request::ArtcraftHunyuan3d3p1ProRequestState; + +const BASE_COST_IN_USD_CENTS: u64 = 49; +const ADD_ON_COST_IN_USD_CENTS: u64 = 20; + +/// Hunyuan 3D v3.1 Pro via Artcraft: flat 49¢ base price (any output type), +/// plus a flat 20¢ add-on for each extra option (PBR materials, custom face +/// count, and multi-view side images). +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1ProCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3p1ProCostState { + pub fn from_request(request: &ArtcraftHunyuan3d3p1ProRequestState) -> Self { + let request = &request.request; + + let mut cost = BASE_COST_IN_USD_CENTS; + if request.enable_pbr.unwrap_or(false) { + cost += ADD_ON_COST_IN_USD_CENTS; + } + if request.face_count.is_some() { + cost += ADD_ON_COST_IN_USD_CENTS; + } + let uses_multi_view = request.back_image_media_token.is_some() + || request.left_image_media_token.is_some() + || request.right_image_media_token.is_some(); + if uses_multi_view { + cost += ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod base_costs { + use super::*; + + #[test] + fn default_output_type_is_forty_nine_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 49); + } + + #[test] + fn every_output_type_prices_the_same() { + let cases = [ + Some(CommonMeshOutputType::Normal), + Some(CommonMeshOutputType::Geometry), + ]; + for output_type in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49, "for {output_type:?}"); + } + } + + #[test] + fn text_mode_prices_the_same_as_image_mode() { + // Text-only request (no image references). + let text_builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + }; + assert_eq!(estimate_usd_cents(text_builder), 49); + } + } + + mod add_ons { + use super::*; + + #[test] + fn pbr_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn pbr_false_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(false), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn face_count_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn each_side_image_adds_twenty_cents_at_most_once() { + for side in ["back", "left", "right"] { + let mut builder = base_builder(); + let image = Some(ImageRef::MediaFileToken(MediaFileToken::new(format!("mf_{side}")))); + match side { + "back" => builder.back_image = image, + "left" => builder.left_image = image, + _ => builder.right_image = image, + } + assert_eq!(estimate_usd_cents(builder), 49 + 20, "for {side} image"); + } + + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(100_000), + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20 + 20 + 20); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/request.rs new file mode 100644 index 0000000000..bc8310b5a4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_pro/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1ProRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3p1ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/build.rs new file mode 100644 index 0000000000..7af585fa37 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::request::ArtcraftHunyuan3d3p1RapidRequestState; + +pub fn build_artcraft_hunyuan_3d_3p1_rapid(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1Rapid)?; + let state = ArtcraftHunyuan3d3p1RapidRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1Rapid(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/cost.rs new file mode 100644 index 0000000000..dd2432e526 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/cost.rs @@ -0,0 +1,124 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::request::ArtcraftHunyuan3d3p1RapidRequestState; + +const BASE_COST_IN_USD_CENTS: u64 = 30; +const PBR_ADD_ON_COST_IN_USD_CENTS: u64 = 20; + +/// Hunyuan 3D v3.1 Rapid via Artcraft: flat 30¢ base price, plus a flat 20¢ +/// add-on for PBR materials. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1RapidCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3p1RapidCostState { + pub fn from_request(request: &ArtcraftHunyuan3d3p1RapidRequestState) -> Self { + let request = &request.request; + + let mut cost = BASE_COST_IN_USD_CENTS; + if request.enable_pbr.unwrap_or(false) { + cost += PBR_ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod base_costs { + use super::*; + + #[test] + fn base_cost_is_thirty_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 30); + } + + #[test] + fn text_mode_prices_the_same_as_image_mode() { + // Text-only request (no image references). + let text_builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + }; + assert_eq!(estimate_usd_cents(text_builder), 30); + } + + #[test] + fn output_type_does_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + } + + mod add_ons { + use super::*; + + #[test] + fn pbr_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30 + 20); + } + + #[test] + fn pbr_false_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(false), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/request.rs new file mode 100644 index 0000000000..3d911230c3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_rapid/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1RapidRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3p1RapidRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/build.rs new file mode 100644 index 0000000000..50888e9a3a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::request::ArtcraftHunyuan3d3p1SmartTopologyRequestState; + +pub fn build_artcraft_hunyuan_3d_3p1_smart_topology(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1SmartTopology)?; + let state = ArtcraftHunyuan3d3p1SmartTopologyRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1SmartTopology(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/cost.rs new file mode 100644 index 0000000000..9d445a9c89 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/cost.rs @@ -0,0 +1,78 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::request::ArtcraftHunyuan3d3p1SmartTopologyRequestState; + +const FLAT_COST_IN_USD_CENTS: u64 = 98; + +/// Hunyuan 3D v3.1 Smart Topology via Artcraft: flat 98¢ per generation, +/// with no add-ons. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1SmartTopologyCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3p1SmartTopologyCostState { + pub fn from_request(_request: &ArtcraftHunyuan3d3p1SmartTopologyRequestState) -> Self { + Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + mod cost_table { + use super::*; + + #[test] + fn cost_is_a_flat_ninety_eight_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 98); + } + + #[test] + fn polygon_type_does_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 98); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1SmartTopology, + provider: RouterProvider::Artcraft, + input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".to_string()))), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/request.rs new file mode 100644 index 0000000000..14ce2c34fd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_3p1_smart_topology/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3p1SmartTopologyRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3p1SmartTopologyRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/build.rs new file mode 100644 index 0000000000..0137ec6fa7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::meshy_v6::request::ArtcraftMeshyV6RequestState; + +pub fn build_artcraft_meshy_v6(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::MeshyV6)?; + let state = ArtcraftMeshyV6RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftMeshyV6(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/cost.rs new file mode 100644 index 0000000000..47d8e9d785 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/cost.rs @@ -0,0 +1,100 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::meshy_v6::request::ArtcraftMeshyV6RequestState; + +const FLAT_COST_IN_USD_CENTS: u64 = 104; + +/// Meshy 6 via Artcraft: flat price per generation, regardless of options +/// or input mode (text or image). +#[derive(Clone, Debug)] +pub struct ArtcraftMeshyV6CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftMeshyV6CostState { + pub fn from_request(_request: &ArtcraftMeshyV6RequestState) -> Self { + Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod flat_pricing { + use super::*; + + #[test] + fn image_mode_is_one_hundred_four_cents() { + assert_eq!(estimate_usd_cents(image_builder()), 104); + } + + #[test] + fn text_mode_is_one_hundred_four_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 104); + } + + #[test] + fn options_do_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 104); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/request.rs new file mode 100644 index 0000000000..fa7cddcf1a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/meshy_v6/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMeshyV6RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftMeshyV6RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/mod.rs new file mode 100644 index 0000000000..19ec528051 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/mod.rs @@ -0,0 +1,14 @@ +pub(crate) mod build_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod hunyuan3d_3; +pub mod hunyuan3d_3_sketch; +pub mod hunyuan_3d_2p0; +pub mod hunyuan_3d_2p1; +pub mod hunyuan_3d_3p1_part; +pub mod hunyuan_3d_3p1_pro; +pub mod hunyuan_3d_3p1_rapid; +pub mod hunyuan_3d_3p1_smart_topology; +pub mod meshy_v6; +pub mod rodin_2p5_fast; +pub mod tripo3d_h3p1; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..fc49ab6dec --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/request_common.rs @@ -0,0 +1,29 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::mesh::omni_gen_mesh::omni_gen_mesh_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_mesh::generate_mesh_response::{ + ArtcraftMeshResponsePayload, GenerateMeshResponse, +}; + +/// Send a mesh generation request to the Artcraft omni-gen endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_mesh_request( + request: &OmniGenMeshCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_mesh_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateMeshResponse::Artcraft(ArtcraftMeshResponsePayload { + inference_job_token: response.inference_job_token, + all_inference_job_tokens: response.all_job_tokens, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..cd8314ecca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/resolve.rs @@ -0,0 +1,47 @@ +//! Shared resolve helpers for the Artcraft mesh provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::mesh_ref::MeshRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_image_ref( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::MediaFileToken(token)) => Ok(Some(token)), + Some(ImageRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_mesh_ref( + mesh_ref: Option, +) -> Result, ArtcraftRouterError> { + match mesh_ref { + None => Ok(None), + Some(MeshRef::MediaFileToken(token)) => Ok(Some(token)), + Some(MeshRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/build.rs new file mode 100644 index 0000000000..7dddeb4676 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::request::ArtcraftRodin2p5FastRequestState; + +pub fn build_artcraft_rodin_2p5_fast(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Rodin2p5Fast)?; + let state = ArtcraftRodin2p5FastRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftRodin2p5Fast(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/cost.rs new file mode 100644 index 0000000000..1327b1403b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/cost.rs @@ -0,0 +1,97 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::request::ArtcraftRodin2p5FastRequestState; + +const FLAT_COST_IN_USD_CENTS: u64 = 13; + +/// Hyper3D Rodin v2.5 Fast via Artcraft: flat price per generation, +/// regardless of options or input mode (text or image). +#[derive(Clone, Debug)] +pub struct ArtcraftRodin2p5FastCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftRodin2p5FastCostState { + pub fn from_request(_request: &ArtcraftRodin2p5FastRequestState) -> Self { + Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod flat_pricing { + use super::*; + + #[test] + fn image_mode_is_thirteen_cents() { + assert_eq!(estimate_usd_cents(image_builder()), 13); + } + + #[test] + fn text_mode_is_thirteen_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 13); + } + + #[test] + fn options_do_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 13); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/request.rs new file mode 100644 index 0000000000..f00c09b157 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/rodin_2p5_fast/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftRodin2p5FastRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftRodin2p5FastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/build.rs new file mode 100644 index 0000000000..7ba1208add --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::request::ArtcraftTripo3dH3p1RequestState; + +pub fn build_artcraft_tripo3d_h3p1(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Tripo3dH3p1)?; + let state = ArtcraftTripo3dH3p1RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftTripo3dH3p1(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/cost.rs new file mode 100644 index 0000000000..2c63892603 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/cost.rs @@ -0,0 +1,338 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_mesh_quality::CommonMeshQuality; +use enums::common::generation::common_polygon_type::CommonPolygonType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::request::ArtcraftTripo3dH3p1RequestState; + +// Text and multi-view generations share a price tier; single-image +// generations price higher. +const TEXT_OR_MULTIVIEW_UNTEXTURED_COST_IN_USD_CENTS: u64 = 13; +const TEXT_OR_MULTIVIEW_STANDARD_TEXTURE_COST_IN_USD_CENTS: u64 = 26; +const TEXT_OR_MULTIVIEW_DETAILED_TEXTURE_COST_IN_USD_CENTS: u64 = 39; +const IMAGE_UNTEXTURED_COST_IN_USD_CENTS: u64 = 26; +const IMAGE_STANDARD_TEXTURE_COST_IN_USD_CENTS: u64 = 39; +const IMAGE_DETAILED_TEXTURE_COST_IN_USD_CENTS: u64 = 52; +const DETAILED_GEOMETRY_ADD_ON_COST_IN_USD_CENTS: u64 = 26; +const QUAD_ADD_ON_COST_IN_USD_CENTS: u64 = 7; + +/// Tripo3D H3.1 via Artcraft: base price by input mode (text and multi-view +/// share a tier; single-image is higher) and texture tier (untextured, +/// standard, or detailed), plus flat add-ons for detailed geometry and quad +/// topology. +#[derive(Clone, Debug)] +pub struct ArtcraftTripo3dH3p1CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftTripo3dH3p1CostState { + pub fn from_request(request: &ArtcraftTripo3dH3p1RequestState) -> Self { + let request = &request.request; + + let uses_multi_view = request.back_image_media_token.is_some() + || request.left_image_media_token.is_some() + || request.right_image_media_token.is_some(); + let has_primary_image = request.front_image_media_token.is_some() + || request.reference_image_media_tokens.as_ref().is_some_and(|tokens| !tokens.is_empty()); + let uses_single_image = !uses_multi_view && has_primary_image; + + // The untextured tier requires that PBR isn't explicitly requested, + // since PBR implies texturing. + let texture_off = (matches!(request.mesh_output_type, Some(CommonMeshOutputType::Geometry)) + || request.enable_texture == Some(false)) + && request.enable_pbr != Some(true); + + let (untextured, standard, detailed) = if uses_single_image { + ( + IMAGE_UNTEXTURED_COST_IN_USD_CENTS, + IMAGE_STANDARD_TEXTURE_COST_IN_USD_CENTS, + IMAGE_DETAILED_TEXTURE_COST_IN_USD_CENTS, + ) + } else { + ( + TEXT_OR_MULTIVIEW_UNTEXTURED_COST_IN_USD_CENTS, + TEXT_OR_MULTIVIEW_STANDARD_TEXTURE_COST_IN_USD_CENTS, + TEXT_OR_MULTIVIEW_DETAILED_TEXTURE_COST_IN_USD_CENTS, + ) + }; + + let mut cost = if texture_off { + untextured + } else if matches!(request.texture_quality, Some(CommonMeshQuality::Detailed)) { + detailed + } else { + standard + }; + + if matches!(request.geometry_quality, Some(CommonMeshQuality::Detailed)) { + cost += DETAILED_GEOMETRY_ADD_ON_COST_IN_USD_CENTS; + } + if matches!(request.polygon_type, Some(CommonPolygonType::Quad)) { + cost += QUAD_ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod text_mode_costs { + use super::*; + + #[test] + fn default_is_standard_textures() { + assert_eq!(estimate_usd_cents(text_builder()), 26); + } + + #[test] + fn untextured_via_enable_texture_off() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 13); + } + + #[test] + fn untextured_via_geometry_output() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 13); + } + + #[test] + fn texture_off_with_pbr_keeps_textured_tier() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + enable_pbr: Some(true), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 26); + } + + #[test] + fn detailed_texture() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + } + + mod image_mode_costs { + use super::*; + + #[test] + fn default_is_standard_textures() { + assert_eq!(estimate_usd_cents(image_builder()), 39); + } + + #[test] + fn untextured_via_enable_texture_off() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 26); + } + + #[test] + fn untextured_via_geometry_output() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 26); + } + + #[test] + fn detailed_texture() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 52); + } + + #[test] + fn front_image_token_prices_as_image_mode() { + let builder = GenerateMeshRequestBuilder { + front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + } + + mod multiview_mode_costs { + use super::*; + + #[test] + fn default_is_standard_textures() { + assert_eq!(estimate_usd_cents(multiview_builder()), 26); + } + + #[test] + fn each_side_view_prices_as_multiview() { + for side in ["back", "left", "right"] { + let mut builder = image_builder(); + let image = Some(ImageRef::MediaFileToken(MediaFileToken::new(format!("mf_{side}")))); + match side { + "back" => builder.back_image = image, + "left" => builder.left_image = image, + _ => builder.right_image = image, + } + assert_eq!(estimate_usd_cents(builder), 26, "for {side} image"); + } + } + + #[test] + fn untextured_via_enable_texture_off() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..multiview_builder() + }; + assert_eq!(estimate_usd_cents(builder), 13); + } + + #[test] + fn detailed_texture() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..multiview_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + } + + mod add_ons { + use super::*; + + #[test] + fn detailed_geometry_adds_twenty_six_cents() { + let builder = GenerateMeshRequestBuilder { + geometry_quality: Some(CommonMeshQuality::Detailed), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39 + 26); + } + + #[test] + fn quad_adds_seven_cents() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39 + 7); + } + + #[test] + fn triangle_polygon_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Triangle), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + + #[test] + fn standard_qualities_add_nothing() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Standard), + geometry_quality: Some(CommonMeshQuality::Standard), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + polygon_type: Some(CommonPolygonType::Quad), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 52 + 26 + 7); + } + + #[test] + fn add_ons_stack_on_the_untextured_tier() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + geometry_quality: Some(CommonMeshQuality::Detailed), + polygon_type: Some(CommonPolygonType::Quad), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 13 + 26 + 7); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Tripo3dH3p1, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..base_builder() + } + } + + fn multiview_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + ..image_builder() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/request.rs new file mode 100644 index 0000000000..8c0a8c85c1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/tripo3d_h3p1/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftTripo3dH3p1RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftTripo3dH3p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::Tripo3dH3p1, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/build.rs new file mode 100644 index 0000000000..6f46320993 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/build.rs @@ -0,0 +1,330 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use fal_client::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::{ + Hunyuan3d3ImageToMeshGenerateType, Hunyuan3d3ImageToMeshPolygonType, Hunyuan3d3ImageToMeshRequest, +}; +use fal_client::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::{ + Hunyuan3d3TextToMeshGenerateType, Hunyuan3d3TextToMeshPolygonType, Hunyuan3d3TextToMeshRequest, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{ + FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, plan_side_image_url, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Hunyuan 3D v3 combines fal's image-to-3d and text-to-3d endpoints under a +/// single router model. The request shape picks the endpoint: any image input +/// (reference images or `front_image`) dispatches to image-to-3d; otherwise a +/// prompt dispatches to text-to-3d. +pub fn build_fal_hunyuan3d_3(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan3d_3_state(builder)?; + let request = match state { + FalHunyuan3d3State::Image(state) => MeshGenerationRequest::FalHunyuan3d3Image(state), + FalHunyuan3d3State::Text(state) => MeshGenerationRequest::FalHunyuan3d3Text(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalHunyuan3d3State { + Image(FalHunyuan3d3ImageRequestState), + Text(FalHunyuan3d3TextRequestState), +} + +pub(crate) fn build_fal_hunyuan3d_3_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let maybe_front_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + let face_count = plan_face_count(builder.face_count, strategy)?; + + match maybe_front_url { + Some(image_url) => { + // Image mode. The image endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + let request = Hunyuan3d3ImageToMeshRequest { + image_url, + back_image_url: plan_side_image_url(builder.back_image.take())?, + left_image_url: plan_side_image_url(builder.left_image.take())?, + right_image_url: plan_side_image_url(builder.right_image.take())?, + face_count, + generate_type: builder.mesh_output_type.map(to_image_generate_type), + polygon_type: builder.polygon_type.map(to_image_polygon_type), + enable_pbr: builder.enable_pbr, + }; + Ok(FalHunyuan3d3State::Image(FalHunyuan3d3ImageRequestState { request })) + } + None => { + // Text mode. Multi-view side images only make sense alongside a front + // image, so they're unsupported here. + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Hunyuan 3D v3 requires an input image or a prompt".to_string(), + ) + })?; + + let request = Hunyuan3d3TextToMeshRequest { + prompt, + face_count, + generate_type: builder.mesh_output_type.map(to_text_generate_type), + polygon_type: builder.polygon_type.map(to_text_polygon_type), + enable_pbr: builder.enable_pbr, + }; + Ok(FalHunyuan3d3State::Text(FalHunyuan3d3TextRequestState { request })) + } + } +} + +// ── Enum mapping helpers ── + +fn to_image_generate_type(output_type: CommonMeshOutputType) -> Hunyuan3d3ImageToMeshGenerateType { + match output_type { + CommonMeshOutputType::Normal => Hunyuan3d3ImageToMeshGenerateType::Normal, + CommonMeshOutputType::LowPoly => Hunyuan3d3ImageToMeshGenerateType::LowPoly, + CommonMeshOutputType::Geometry => Hunyuan3d3ImageToMeshGenerateType::Geometry, + } +} + +fn to_image_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3ImageToMeshPolygonType { + match polygon_type { + CommonPolygonType::Triangle => Hunyuan3d3ImageToMeshPolygonType::Triangle, + CommonPolygonType::Quad => Hunyuan3d3ImageToMeshPolygonType::Quadrilateral, + } +} + +fn to_text_generate_type(output_type: CommonMeshOutputType) -> Hunyuan3d3TextToMeshGenerateType { + match output_type { + CommonMeshOutputType::Normal => Hunyuan3d3TextToMeshGenerateType::Normal, + CommonMeshOutputType::LowPoly => Hunyuan3d3TextToMeshGenerateType::LowPoly, + CommonMeshOutputType::Geometry => Hunyuan3d3TextToMeshGenerateType::Geometry, + } +} + +fn to_text_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3TextToMeshPolygonType { + match polygon_type { + CommonPolygonType::Triangle => Hunyuan3d3TextToMeshPolygonType::Triangle, + CommonPolygonType::Quad => Hunyuan3d3TextToMeshPolygonType::Quadrilateral, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const BACK_URL: &str = "https://example.com/back.png"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_hunyuan3d_3_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let state = build_fal_hunyuan3d_3_state(builder).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_hunyuan3d_3_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_hunyuan3d_3_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + } + + mod image_mode { + use super::*; + + #[test] + fn side_images_map_to_multi_view_params() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())), + right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan3d_3_state(builder).expect("build")); + assert_eq!(image.request.back_image_url.as_deref(), Some(BACK_URL)); + assert_eq!(image.request.left_image_url.as_deref(), Some("https://example.com/left.png")); + assert_eq!(image.request.right_image_url.as_deref(), Some("https://example.com/right.png")); + } + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan3d_3_state(builder).expect("build")); + assert!(matches!(image.request.generate_type, Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly))); + assert!(matches!(image.request.polygon_type, Some(Hunyuan3d3ImageToMeshPolygonType::Quadrilateral))); + assert_eq!(image.request.face_count, Some(100_000)); + assert_eq!(image.request.enable_pbr, Some(true)); + } + + #[test] + fn prompt_with_image_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan3d_3_state(builder).is_err()); + } + + #[test] + fn prompt_with_image_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..image_builder() + }; + assert!(matches!( + build_fal_hunyuan3d_3_state(builder).expect("build"), + FalHunyuan3d3State::Image(_) + )); + } + + #[test] + fn media_tokens_are_rejected() { + use tokens::tokens::media_files::MediaFileToken; + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_hunyuan3d_3_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod text_mode { + use super::*; + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + polygon_type: Some(CommonPolygonType::Triangle), + face_count: Some(40_000), + enable_pbr: Some(false), + ..text_builder() + }; + let text = expect_text(build_fal_hunyuan3d_3_state(builder).expect("build")); + assert!(matches!(text.request.generate_type, Some(Hunyuan3d3TextToMeshGenerateType::Geometry))); + assert!(matches!(text.request.polygon_type, Some(Hunyuan3d3TextToMeshPolygonType::Triangle))); + assert_eq!(text.request.face_count, Some(40_000)); + assert_eq!(text.request.enable_pbr, Some(false)); + } + + #[test] + fn side_images_without_front_error_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..text_builder() + }; + assert!(build_fal_hunyuan3d_3_state(builder).is_err()); + } + + #[test] + fn side_images_without_front_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..text_builder() + }; + assert!(matches!( + build_fal_hunyuan3d_3_state(builder).expect("build"), + FalHunyuan3d3State::Text(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn expect_image(state: FalHunyuan3d3State) -> FalHunyuan3d3ImageRequestState { + match state { + FalHunyuan3d3State::Image(image) => image, + FalHunyuan3d3State::Text(text) => panic!("expected image mode, got text: {text:?}"), + } + } + + fn expect_text(state: FalHunyuan3d3State) -> FalHunyuan3d3TextRequestState { + match state { + FalHunyuan3d3State::Text(text) => text, + FalHunyuan3d3State::Image(image) => panic!("expected text mode, got image: {image:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/cost.rs new file mode 100644 index 0000000000..98568333ff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/cost.rs @@ -0,0 +1,164 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{ + FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3ImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3ImageCostState { + pub fn from_request(request: &FalHunyuan3d3ImageRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3TextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3TextCostState { + pub fn from_request(request: &FalHunyuan3d3TextRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +pub(crate) fn fal_mesh_cost_estimate(cost_in_usd_cents: u64) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + + mod image_mode_costs { + use super::*; + + #[test] + fn base_cost_table() { + let cases = [ + (None, 38), + (Some(CommonMeshOutputType::Normal), 38), + (Some(CommonMeshOutputType::LowPoly), 45), + (Some(CommonMeshOutputType::Geometry), 23), + ]; + for (output_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}"); + } + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + enable_pbr: Some(true), + face_count: Some(100_000), + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..image_builder() + }; + // LowPoly(45) + PBR(15) + face_count(15) + multi_view(15) + assert_eq!(estimate_usd_cents(builder), 90); + } + } + + mod text_mode_costs { + use super::*; + + #[test] + fn base_cost_table() { + let cases = [ + (None, 38), + (Some(CommonMeshOutputType::Normal), 38), + (Some(CommonMeshOutputType::LowPoly), 45), + (Some(CommonMeshOutputType::Geometry), 23), + ]; + for (output_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}"); + } + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + face_count: Some(40_000), + ..text_builder() + }; + // Geometry(23) + PBR(15) + face_count(15) + assert_eq!(estimate_usd_cents(builder), 53); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/request.rs new file mode 100644 index 0000000000..ed2fcc77aa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/request.rs @@ -0,0 +1,141 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::Hunyuan3d3ImageToMeshRequest; +use fal_client::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::Hunyuan3d3TextToMeshRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::{ + FalMeshResponsePayload, GenerateMeshResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3ImageRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3ImageToMeshRequest, +} + +impl FalHunyuan3d3ImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3TextRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3TextToMeshRequest, +} + +impl FalHunyuan3d3TextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +/// Send a mesh request to fal using the client's dispatch mode (webhook or +/// queue). Shared by all Fal mesh request states. +pub(crate) async fn send_fal_mesh_request( + request: &R, + client: &RouterFalClient, +) -> Result +where + R: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + + let payload = if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + FalMeshResponsePayload { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + } + } else { + let response = request.send_queue_request(&client.api_key).await?; + FalMeshResponsePayload { + request_id: Some(response.request_id), + gateway_request_id: None, + maybe_status_url: Some(response.status_url), + maybe_response_url: Some(response.response_url), + maybe_outbound_request: Some(outbound), + } + }; + + Ok(GenerateMeshResponse::Fal(payload)) +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan3d_3_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..hunyuan3d_3_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan3d_3_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..hunyuan3d_3_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn hunyuan3d_3_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + pub(crate) fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } + + pub(crate) async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/build.rs new file mode 100644 index 0000000000..61814cb4ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/build.rs @@ -0,0 +1,154 @@ +use fal_client::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +pub fn build_fal_hunyuan3d_3_sketch(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan3d_3_sketch_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3Sketch(state))) +} + +pub(crate) fn build_fal_hunyuan3d_3_sketch_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // fal's sketch-to-3d schema has no generate_type/polygon_type parameters, + // and no multi-view side images. + reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )? + .ok_or_else(|| ArtcraftRouterError::InvalidInput( + "A sketch image is required for Hunyuan 3D v3 sketch-to-3D".to_string(), + ))?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "A prompt is required for Hunyuan 3D v3 sketch-to-3D".to_string(), + ) + })?; + + let request = Hunyuan3d3SketchToMeshRequest { + image_url, + prompt, + face_count: plan_face_count(builder.face_count, strategy)?, + enable_pbr: builder.enable_pbr, + }; + + Ok(FalHunyuan3d3SketchRequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + const SKETCH_URL: &str = "https://example.com/sketch.png"; + + #[test] + fn sketch_and_prompt_pass_through() { + let state = build_fal_hunyuan3d_3_sketch_state(base_builder()).expect("build"); + assert_eq!(state.request.image_url, SKETCH_URL); + assert_eq!(state.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn missing_sketch_is_rejected() { + let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan3d_3_sketch_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateMeshRequestBuilder { prompt: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan3d_3_sketch_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn face_count_and_pbr_pass_through() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + enable_pbr: Some(true), + ..base_builder() + }; + let state = build_fal_hunyuan3d_3_sketch_state(builder).expect("build"); + assert_eq!(state.request.face_count, Some(100_000)); + assert_eq!(state.request.enable_pbr, Some(true)); + } + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }, + GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..base_builder() + }, + GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..base_builder() + }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_hunyuan3d_3_sketch_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert!(build_fal_hunyuan3d_3_sketch_state(builder).is_ok()); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::Urls(vec![SKETCH_URL.to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/cost.rs new file mode 100644 index 0000000000..cb6ab4052d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/cost.rs @@ -0,0 +1,87 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3SketchCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3SketchCostState { + pub fn from_request(request: &FalHunyuan3d3SketchRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn base_cost_is_thirty_eight_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 38); + } + + #[test] + fn pbr_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15); + } + + #[test] + fn face_count_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15 + 15); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/sketch.png".to_string()])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/request.rs new file mode 100644 index 0000000000..df83ba0c79 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/request.rs @@ -0,0 +1,68 @@ +use fal_client::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3SketchRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3SketchToMeshRequest, +} + +impl FalHunyuan3d3SketchRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_sketch_to_mesh() { + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Fal, + prompt: Some("A cartoon character, colorful, plastic material".to_string()), + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..Default::default() + }; + + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/build.rs new file mode 100644 index 0000000000..2f586db1ff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/build.rs @@ -0,0 +1,219 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_primary_image_url, plan_textured_mesh, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +pub fn build_fal_hunyuan_3d_2p0(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_2p0_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d2p0(state))) +} + +pub(crate) fn build_fal_hunyuan_3d_2p0_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Hunyuan 3D 2.0 is image-to-3D only, with a single input image and no + // extra mesh options. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )? + .ok_or_else(|| ArtcraftRouterError::InvalidInput( + "An input image is required for Hunyuan 3D 2.0".to_string(), + ))?; + + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url, + textured_mesh: plan_textured_mesh(builder.mesh_output_type, strategy)?, + // Tuning knobs stay at the endpoint's defaults. + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + Ok(FalHunyuan3d2p0RequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/front.png"; + + mod image_requirements { + use super::*; + + #[test] + fn single_image_passes_through() { + let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + } + + #[test] + fn missing_image_is_rejected() { + let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan_3d_2p0_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn two_images_error_out() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err()); + } + + #[test] + fn two_images_keep_the_first_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + } + } + + mod output_type_mapping { + use super::*; + + #[test] + fn default_maps_to_textured() { + let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn normal_maps_to_textured() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn geometry_maps_to_white_mesh() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(false)); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err()); + } + + #[test] + fn low_poly_upgrades_to_textured_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateMeshRequestBuilder { prompt: Some("a teapot".to_string()), ..base_builder() }, + GenerateMeshRequestBuilder { polygon_type: Some(CommonPolygonType::Quad), ..base_builder() }, + GenerateMeshRequestBuilder { face_count: Some(100_000), ..base_builder() }, + GenerateMeshRequestBuilder { enable_pbr: Some(true), ..base_builder() }, + GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..base_builder() + }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a teapot".to_string()), + face_count: Some(100_000), + enable_pbr: Some(true), + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_ok()); + } + + #[test] + fn tuning_knobs_stay_at_endpoint_defaults() { + let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build"); + assert!(state.request.guidance_scale.is_none()); + assert!(state.request.num_inference_steps.is_none()); + assert!(state.request.octree_resolution.is_none()); + assert!(state.request.seed.is_none()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/cost.rs new file mode 100644 index 0000000000..61a0e05972 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/cost.rs @@ -0,0 +1,75 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p0CostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d2p0CostState { + pub fn from_request(request: &FalHunyuan3d2p0RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn textured_mesh_is_forty_eight_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 48); + + let normal = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(normal), 48); + } + + #[test] + fn white_mesh_is_sixteen_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 16); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/front.png".to_string()])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/request.rs new file mode 100644 index 0000000000..276f7afe74 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/request.rs @@ -0,0 +1,67 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p0RequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d2p0ImageToMeshRequest, +} + +impl FalHunyuan3d2p0RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan_3d_2p0_image_to_mesh() { + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..Default::default() + }; + + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/build.rs new file mode 100644 index 0000000000..3000381021 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/build.rs @@ -0,0 +1,146 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_primary_image_url, plan_textured_mesh, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +pub fn build_fal_hunyuan_3d_2p1(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_2p1_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d2p1(state))) +} + +pub(crate) fn build_fal_hunyuan_3d_2p1_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Hunyuan 3D 2.1 is image-to-3D only, with a single input image and no + // extra mesh options. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )? + .ok_or_else(|| ArtcraftRouterError::InvalidInput( + "An input image is required for Hunyuan 3D 2.1".to_string(), + ))?; + + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url, + textured_mesh: plan_textured_mesh(builder.mesh_output_type, strategy)?, + // Tuning knobs stay at the endpoint's defaults. + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + Ok(FalHunyuan3d2p1RequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/front.png"; + + #[test] + fn single_image_passes_through() { + let state = build_fal_hunyuan_3d_2p1_state(base_builder()).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn missing_image_is_rejected() { + let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan_3d_2p1_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn geometry_maps_to_white_mesh() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p1_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(false)); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p1_state(builder).is_err()); + } + + #[test] + fn low_poly_upgrades_to_textured_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p1_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn unsupported_options_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p1_state(builder).is_err()); + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a teapot".to_string()), + face_count: Some(100_000), + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p1_state(builder).is_ok()); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/cost.rs new file mode 100644 index 0000000000..1de566656a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/cost.rs @@ -0,0 +1,75 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p1CostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d2p1CostState { + pub fn from_request(request: &FalHunyuan3d2p1RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn textured_mesh_is_ninety_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 90); + + let normal = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(normal), 90); + } + + #[test] + fn white_mesh_is_thirty_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/front.png".to_string()])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/request.rs new file mode 100644 index 0000000000..9e20a829af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/request.rs @@ -0,0 +1,67 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p1RequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d2p1ImageToMeshRequest, +} + +impl FalHunyuan3d2p1RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan_3d_2p1_image_to_mesh() { + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..Default::default() + }; + + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/build.rs new file mode 100644 index 0000000000..f6638ea124 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/build.rs @@ -0,0 +1,152 @@ +use fal_client::requests::api::mesh::part::hunyuan_3d_3p1_part::api::Hunyuan3d3p1PartRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::request::FalHunyuan3d3p1PartRequestState; +use crate::generate::generate_mesh::providers::fal::resolve::plan_input_mesh_url; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Hunyuan 3D v3.1 Part splits an existing mesh into semantic parts. It takes +/// only a mesh file (FBX) as input — text, images, and generation options are +/// all unsupported. +pub fn build_fal_hunyuan_3d_3p1_part(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_3p1_part_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3p1Part(state))) +} + +pub(crate) fn build_fal_hunyuan_3d_3p1_part_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let input_file_url = plan_input_mesh_url(builder.input_mesh.take())?.ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Hunyuan 3D v3.1 Part requires an input mesh file (FBX)".to_string(), + ) + })?; + + // Part splitting takes only the mesh file; every other option is + // unsupported. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("reference_images", builder.reference_images.as_ref(), strategy)?; + reject_unsupported_image_ref("front_image", builder.front_image.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?; + reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?; + reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?; + reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?; + + let request = Hunyuan3d3p1PartRequest { input_file_url }; + Ok(FalHunyuan3d3p1PartRequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_ref::ImageRef; + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + use super::*; + + const MESH_URL: &str = "https://example.com/model.fbx"; + + mod shape_dispatch { + use super::*; + + #[test] + fn input_mesh_url_builds_the_request() { + let state = build_fal_hunyuan_3d_3p1_part_state(mesh_builder()).expect("build"); + assert_eq!(state.request.input_file_url, MESH_URL); + } + + #[test] + fn missing_input_mesh_is_rejected() { + let result = build_fal_hunyuan_3d_3p1_part_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + + #[test] + fn media_token_input_mesh_is_rejected() { + let builder = GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".to_string()))), + ..base_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_part_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn each_unsupported_option_errors_out_under_error_out() { + let cases: Vec = vec![ + |b| b.prompt = Some("a red ceramic teapot".to_string()), + |b| b.front_image = Some(ImageRef::Url("https://example.com/front.png".to_string())), + |b| b.back_image = Some(ImageRef::Url("https://example.com/back.png".to_string())), + |b| b.mesh_output_type = Some(CommonMeshOutputType::Normal), + |b| b.polygon_type = Some(CommonPolygonType::Quad), + |b| b.face_count = Some(100_000), + |b| b.enable_pbr = Some(true), + |b| b.enable_texture = Some(false), + ]; + for (index, set) in cases.into_iter().enumerate() { + let mut builder = GenerateMeshRequestBuilder { + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..mesh_builder() + }; + set(&mut builder); + assert!(build_fal_hunyuan_3d_3p1_part_state(builder).is_err(), "for case {index}"); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + mesh_output_type: Some(CommonMeshOutputType::Normal), + face_count: Some(100_000), + enable_pbr: Some(true), + ..mesh_builder() + }; + let state = build_fal_hunyuan_3d_3p1_part_state(builder).expect("build"); + assert_eq!(state.request.input_file_url, MESH_URL); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Part, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn mesh_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url(MESH_URL.to_string())), + ..base_builder() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/cost.rs new file mode 100644 index 0000000000..d00fd6e115 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/cost.rs @@ -0,0 +1,60 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::request::FalHunyuan3d3p1PartRequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1PartCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3p1PartCostState { + pub fn from_request(request: &FalHunyuan3d3p1PartRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const MESH_URL: &str = "https://example.com/model.fbx"; + + #[test] + fn cost_is_a_flat_forty_five_cents() { + assert_eq!(estimate_usd_cents(mesh_builder()), 45); + } + + // ── Helpers ── + + fn mesh_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Part, + provider: RouterProvider::Fal, + input_mesh: Some(MeshRef::Url(MESH_URL.to_string())), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/request.rs new file mode 100644 index 0000000000..9be550015d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_part/request.rs @@ -0,0 +1,52 @@ +use fal_client::requests::api::mesh::part::hunyuan_3d_3p1_part::api::Hunyuan3d3p1PartRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1PartRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3p1PartRequest, +} + +impl FalHunyuan3d3p1PartRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key and a real FBX URL, incurs cost + async fn send_part_split() { + let client = get_fal_client(); + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Part, + provider: RouterProvider::Fal, + input_mesh: Some(MeshRef::Url("https://example.com/model.fbx".to_string())), + ..Default::default() + }; + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/build.rs new file mode 100644 index 0000000000..95f65c2307 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/build.rs @@ -0,0 +1,411 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::{ + Hunyuan3d3p1ProImageToMeshGenerateType, Hunyuan3d3p1ProImageToMeshRequest, +}; +use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::api::{ + Hunyuan3d3p1ProTextToMeshGenerateType, Hunyuan3d3p1ProTextToMeshRequest, +}; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::request::{ + FalHunyuan3d3p1ProImageRequestState, FalHunyuan3d3p1ProTextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, plan_side_image_url, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Hunyuan 3D v3.1 Pro combines fal's image-to-3d and text-to-3d endpoints +/// under a single router model. The request shape picks the endpoint: any +/// image input (reference images or `front_image`) dispatches to image-to-3d; +/// otherwise a prompt dispatches to text-to-3d. +pub fn build_fal_hunyuan_3d_3p1_pro(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_3p1_pro_state(builder)?; + let request = match state { + FalHunyuan3d3p1ProState::Image(state) => MeshGenerationRequest::FalHunyuan3d3p1ProImage(state), + FalHunyuan3d3p1ProState::Text(state) => MeshGenerationRequest::FalHunyuan3d3p1ProText(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalHunyuan3d3p1ProState { + Image(FalHunyuan3d3p1ProImageRequestState), + Text(FalHunyuan3d3p1ProTextRequestState), +} + +pub(crate) fn build_fal_hunyuan_3d_3p1_pro_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // v3.1 Pro has no polygon-type, texture, or input-mesh parameters. + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?; + reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?; + reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?; + reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?; + + let maybe_front_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + let face_count = plan_face_count(builder.face_count, strategy)?; + + match maybe_front_url { + Some(image_url) => { + // Image mode. The image endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + let request = Hunyuan3d3p1ProImageToMeshRequest { + image_url, + back_image_url: plan_side_image_url(builder.back_image.take())?, + left_image_url: plan_side_image_url(builder.left_image.take())?, + right_image_url: plan_side_image_url(builder.right_image.take())?, + // The builder has no fields for the v3.1-exclusive extra views. + top_image_url: None, + bottom_image_url: None, + left_front_image_url: None, + right_front_image_url: None, + generate_type: plan_image_generate_type(builder.mesh_output_type, strategy)?, + face_count, + enable_pbr: builder.enable_pbr, + }; + Ok(FalHunyuan3d3p1ProState::Image(FalHunyuan3d3p1ProImageRequestState { request })) + } + None => { + // Text mode. Multi-view side images only make sense alongside a front + // image, so they're unsupported here. + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Hunyuan 3D v3.1 Pro requires an input image or a prompt".to_string(), + ) + })?; + + let request = Hunyuan3d3p1ProTextToMeshRequest { + prompt, + generate_type: plan_text_generate_type(builder.mesh_output_type, strategy)?, + face_count, + enable_pbr: builder.enable_pbr, + }; + Ok(FalHunyuan3d3p1ProState::Text(FalHunyuan3d3p1ProTextRequestState { request })) + } + } +} + +// ── Enum mapping helpers ── + +/// v3.1 drops the v3 `LowPoly` generation type: `LowPoly` rejects under +/// `ErrorOut` and drops to the endpoint default otherwise. +fn plan_image_generate_type( + mesh_output_type: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match mesh_output_type { + None => Ok(None), + Some(CommonMeshOutputType::Normal) => Ok(Some(Hunyuan3d3p1ProImageToMeshGenerateType::Normal)), + Some(CommonMeshOutputType::Geometry) => Ok(Some(Hunyuan3d3p1ProImageToMeshGenerateType::Geometry)), + Some(CommonMeshOutputType::LowPoly) => { + reject_unsupported_option("mesh_output_type", Some(&CommonMeshOutputType::LowPoly), strategy)?; + Ok(None) + } + } +} + +fn plan_text_generate_type( + mesh_output_type: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match mesh_output_type { + None => Ok(None), + Some(CommonMeshOutputType::Normal) => Ok(Some(Hunyuan3d3p1ProTextToMeshGenerateType::Normal)), + Some(CommonMeshOutputType::Geometry) => Ok(Some(Hunyuan3d3p1ProTextToMeshGenerateType::Geometry)), + Some(CommonMeshOutputType::LowPoly) => { + reject_unsupported_option("mesh_output_type", Some(&CommonMeshOutputType::LowPoly), strategy)?; + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_quality::CommonMeshQuality; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const BACK_URL: &str = "https://example.com/back.png"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_hunyuan_3d_3p1_pro_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_hunyuan_3d_3p1_pro_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_hunyuan_3d_3p1_pro_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + } + + mod image_mode { + use super::*; + + #[test] + fn side_images_map_to_multi_view_params() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())), + right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build")); + assert_eq!(image.request.back_image_url.as_deref(), Some(BACK_URL)); + assert_eq!(image.request.left_image_url.as_deref(), Some("https://example.com/left.png")); + assert_eq!(image.request.right_image_url.as_deref(), Some("https://example.com/right.png")); + } + + #[test] + fn v3p1_exclusive_extra_views_are_left_unset() { + let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(image_builder()).expect("build")); + assert!(image.request.top_image_url.is_none()); + assert!(image.request.bottom_image_url.is_none()); + assert!(image.request.left_front_image_url.is_none()); + assert!(image.request.right_front_image_url.is_none()); + } + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + face_count: Some(100_000), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build")); + assert!(matches!(image.request.generate_type, Some(Hunyuan3d3p1ProImageToMeshGenerateType::Geometry))); + assert_eq!(image.request.face_count, Some(100_000)); + assert_eq!(image.request.enable_pbr, Some(true)); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err()); + } + + #[test] + fn low_poly_drops_to_default_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build")); + assert!(image.request.generate_type.is_none()); + } + + #[test] + fn prompt_with_image_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err()); + } + + #[test] + fn prompt_with_image_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..image_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"), + FalHunyuan3d3p1ProState::Image(_) + )); + } + } + + mod text_mode { + use super::*; + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + face_count: Some(40_000), + enable_pbr: Some(false), + ..text_builder() + }; + let text = expect_text(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build")); + assert!(matches!(text.request.generate_type, Some(Hunyuan3d3p1ProTextToMeshGenerateType::Normal))); + assert_eq!(text.request.face_count, Some(40_000)); + assert_eq!(text.request.enable_pbr, Some(false)); + } + + #[test] + fn side_images_without_front_error_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..text_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err()); + } + + #[test] + fn side_images_without_front_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..text_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"), + FalHunyuan3d3p1ProState::Text(_) + )); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn polygon_type_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err()); + } + + #[test] + fn texture_options_error_out_under_error_out() { + let cases: Vec = vec![ + |b| b.enable_texture = Some(false), + |b| b.texture_quality = Some(CommonMeshQuality::Detailed), + |b| b.geometry_quality = Some(CommonMeshQuality::Detailed), + ]; + for (index, set) in cases.into_iter().enumerate() { + let mut builder = GenerateMeshRequestBuilder { + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + set(&mut builder); + assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err(), "for case {index}"); + } + } + + #[test] + fn input_mesh_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url("https://example.com/model.glb".to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..text_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err()); + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + enable_texture: Some(false), + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + input_mesh: Some(MeshRef::Url("https://example.com/model.glb".to_string())), + ..image_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"), + FalHunyuan3d3p1ProState::Image(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn expect_image(state: FalHunyuan3d3p1ProState) -> FalHunyuan3d3p1ProImageRequestState { + match state { + FalHunyuan3d3p1ProState::Image(image) => image, + FalHunyuan3d3p1ProState::Text(text) => panic!("expected image mode, got text: {text:?}"), + } + } + + fn expect_text(state: FalHunyuan3d3p1ProState) -> FalHunyuan3d3p1ProTextRequestState { + match state { + FalHunyuan3d3p1ProState::Text(text) => text, + FalHunyuan3d3p1ProState::Image(image) => panic!("expected text mode, got image: {image:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/cost.rs new file mode 100644 index 0000000000..0773ccf535 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/cost.rs @@ -0,0 +1,159 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::request::{ + FalHunyuan3d3p1ProImageRequestState, FalHunyuan3d3p1ProTextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1ProImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3p1ProImageCostState { + pub fn from_request(request: &FalHunyuan3d3p1ProImageRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1ProTextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3p1ProTextCostState { + pub fn from_request(request: &FalHunyuan3d3p1ProTextRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + + mod image_mode_costs { + use super::*; + + #[test] + fn base_cost_table() { + let cases = [ + (None, 38), + (Some(CommonMeshOutputType::Normal), 38), + (Some(CommonMeshOutputType::Geometry), 38), + ]; + for (output_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}"); + } + } + + #[test] + fn multi_view_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15); + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + face_count: Some(100_000), + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..image_builder() + }; + // base(38) + PBR(15) + face_count(15) + multi_view(15) + assert_eq!(estimate_usd_cents(builder), 83); + } + } + + mod text_mode_costs { + use super::*; + + #[test] + fn base_cost_table() { + let cases = [ + (None, 38), + (Some(CommonMeshOutputType::Normal), 38), + (Some(CommonMeshOutputType::Geometry), 38), + ]; + for (output_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}"); + } + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(40_000), + ..text_builder() + }; + // base(38) + PBR(15) + face_count(15) + assert_eq!(estimate_usd_cents(builder), 68); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/request.rs new file mode 100644 index 0000000000..0d9f4e3723 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_pro/request.rs @@ -0,0 +1,92 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::Hunyuan3d3p1ProImageToMeshRequest; +use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::api::Hunyuan3d3p1ProTextToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1ProImageRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3p1ProImageToMeshRequest, +} + +impl FalHunyuan3d3p1ProImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1ProTextRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3p1ProTextToMeshRequest, +} + +impl FalHunyuan3d3p1ProTextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_pro_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..pro_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_pro_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..pro_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn pro_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Pro, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/build.rs new file mode 100644 index 0000000000..c9b126d614 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/build.rs @@ -0,0 +1,323 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::api::Hunyuan3d3p1RapidImageToMeshRequest; +use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_rapid_text_to_mesh::api::Hunyuan3d3p1RapidTextToMeshRequest; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::request::{ + FalHunyuan3d3p1RapidImageRequestState, FalHunyuan3d3p1RapidTextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::resolve::plan_primary_image_url; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Hunyuan 3D v3.1 Rapid combines fal's rapid image-to-3d and text-to-3d +/// endpoints under a single router model. The request shape picks the +/// endpoint: any image input (reference images or `front_image`) dispatches +/// to image-to-3d; otherwise a prompt dispatches to text-to-3d. +pub fn build_fal_hunyuan_3d_3p1_rapid(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_3p1_rapid_state(builder)?; + let request = match state { + FalHunyuan3d3p1RapidState::Image(state) => MeshGenerationRequest::FalHunyuan3d3p1RapidImage(state), + FalHunyuan3d3p1RapidState::Text(state) => MeshGenerationRequest::FalHunyuan3d3p1RapidText(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalHunyuan3d3p1RapidState { + Image(FalHunyuan3d3p1RapidImageRequestState), + Text(FalHunyuan3d3p1RapidTextRequestState), +} + +pub(crate) fn build_fal_hunyuan_3d_3p1_rapid_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // The rapid schema is minimal: no polygon-type, face-count, texture, or + // input-mesh parameters. + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?; + reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?; + reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?; + reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?; + + // Rapid is single-image only — no multi-view side images in either mode. + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let maybe_front_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + let enable_geometry = plan_enable_geometry(builder.mesh_output_type, strategy)?; + + match maybe_front_url { + Some(image_url) => { + // Image mode. The image endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + let request = Hunyuan3d3p1RapidImageToMeshRequest { + image_url, + enable_pbr: builder.enable_pbr, + enable_geometry, + }; + Ok(FalHunyuan3d3p1RapidState::Image(FalHunyuan3d3p1RapidImageRequestState { request })) + } + None => { + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Hunyuan 3D v3.1 Rapid requires an input image or a prompt".to_string(), + ) + })?; + + let request = Hunyuan3d3p1RapidTextToMeshRequest { + prompt, + enable_pbr: builder.enable_pbr, + enable_geometry, + }; + Ok(FalHunyuan3d3p1RapidState::Text(FalHunyuan3d3p1RapidTextRequestState { request })) + } + } +} + +// ── Enum mapping helpers ── + +/// Rapid has no generation-type enum — geometry-only output is a boolean. +/// `Geometry` maps to `enable_geometry: true`; `Normal` (or unset) leaves it +/// unset (fal defaults to false); `LowPoly` rejects under `ErrorOut` and +/// drops otherwise. +fn plan_enable_geometry( + mesh_output_type: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match mesh_output_type { + None | Some(CommonMeshOutputType::Normal) => Ok(None), + Some(CommonMeshOutputType::Geometry) => Ok(Some(true)), + Some(CommonMeshOutputType::LowPoly) => { + reject_unsupported_option("mesh_output_type", Some(&CommonMeshOutputType::LowPoly), strategy)?; + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const BACK_URL: &str = "https://example.com/back.png"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_hunyuan_3d_3p1_rapid_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_hunyuan_3d_3p1_rapid_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_hunyuan_3d_3p1_rapid_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + } + + mod option_mapping { + use super::*; + + #[test] + fn geometry_output_maps_to_enable_geometry() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build")); + assert_eq!(image.request.enable_geometry, Some(true)); + } + + #[test] + fn normal_output_leaves_enable_geometry_unset() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build")); + assert!(image.request.enable_geometry.is_none()); + } + + #[test] + fn enable_pbr_passes_through_in_both_modes() { + let image_builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(image_builder).expect("build")); + assert_eq!(image.request.enable_pbr, Some(true)); + + let text_builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..text_builder() + }; + let text = expect_text(build_fal_hunyuan_3d_3p1_rapid_state(text_builder).expect("build")); + assert_eq!(text.request.enable_pbr, Some(true)); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_rapid_state(builder).is_err()); + } + + #[test] + fn low_poly_drops_to_default_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build")); + assert!(image.request.enable_geometry.is_none()); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn each_unsupported_option_errors_out_under_error_out() { + let cases: Vec = vec![ + |b| b.polygon_type = Some(CommonPolygonType::Quad), + |b| b.face_count = Some(100_000), + |b| b.enable_texture = Some(false), + |b| b.input_mesh = Some(MeshRef::Url("https://example.com/model.glb".to_string())), + |b| b.back_image = Some(ImageRef::Url(BACK_URL.to_string())), + |b| b.left_image = Some(ImageRef::Url(BACK_URL.to_string())), + |b| b.right_image = Some(ImageRef::Url(BACK_URL.to_string())), + ]; + for (index, set) in cases.into_iter().enumerate() { + let mut builder = GenerateMeshRequestBuilder { + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + set(&mut builder); + assert!(build_fal_hunyuan_3d_3p1_rapid_state(builder).is_err(), "for case {index}"); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..image_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"), + FalHunyuan3d3p1RapidState::Image(_) + )); + } + + #[test] + fn prompt_with_image_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan_3d_3p1_rapid_state(builder).is_err()); + } + + #[test] + fn prompt_with_image_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..image_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"), + FalHunyuan3d3p1RapidState::Image(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn expect_image(state: FalHunyuan3d3p1RapidState) -> FalHunyuan3d3p1RapidImageRequestState { + match state { + FalHunyuan3d3p1RapidState::Image(image) => image, + FalHunyuan3d3p1RapidState::Text(text) => panic!("expected image mode, got text: {text:?}"), + } + } + + fn expect_text(state: FalHunyuan3d3p1RapidState) -> FalHunyuan3d3p1RapidTextRequestState { + match state { + FalHunyuan3d3p1RapidState::Text(text) => text, + FalHunyuan3d3p1RapidState::Image(image) => panic!("expected text mode, got image: {image:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/cost.rs new file mode 100644 index 0000000000..334a2bb4bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/cost.rs @@ -0,0 +1,130 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::request::{ + FalHunyuan3d3p1RapidImageRequestState, FalHunyuan3d3p1RapidTextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1RapidImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3p1RapidImageCostState { + pub fn from_request(request: &FalHunyuan3d3p1RapidImageRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1RapidTextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3p1RapidTextCostState { + pub fn from_request(request: &FalHunyuan3d3p1RapidTextRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + + mod image_mode_costs { + use super::*; + + #[test] + fn base_cost_is_twenty_three_cents() { + assert_eq!(estimate_usd_cents(image_builder()), 23); + } + + #[test] + fn pbr_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 23 + 15); + } + + #[test] + fn geometry_output_does_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 23); + } + } + + mod text_mode_costs { + use super::*; + + #[test] + fn base_cost_is_twenty_three_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 23); + } + + #[test] + fn pbr_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 23 + 15); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/request.rs new file mode 100644 index 0000000000..468eb59aa2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_rapid/request.rs @@ -0,0 +1,92 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::api::Hunyuan3d3p1RapidImageToMeshRequest; +use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_rapid_text_to_mesh::api::Hunyuan3d3p1RapidTextToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1RapidImageRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3p1RapidImageToMeshRequest, +} + +impl FalHunyuan3d3p1RapidImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1RapidTextRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3p1RapidTextToMeshRequest, +} + +impl FalHunyuan3d3p1RapidTextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_rapid_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..rapid_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_rapid_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..rapid_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn rapid_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1Rapid, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/build.rs new file mode 100644 index 0000000000..5e3b040550 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/build.rs @@ -0,0 +1,247 @@ +use enums::common::generation::common_polygon_type::CommonPolygonType; +use fal_client::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::api::{ + Hunyuan3d3p1SmartTopologyInputFileType, Hunyuan3d3p1SmartTopologyPolygonType, + Hunyuan3d3p1SmartTopologyRequest, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::request::FalHunyuan3d3p1SmartTopologyRequestState; +use crate::generate::generate_mesh::providers::fal::resolve::plan_input_mesh_url; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Hunyuan 3D v3.1 Smart Topology retopologizes an existing mesh. It takes a +/// mesh file (GLB or OBJ) and an optional polygon type — text, images, and +/// the other generation options are all unsupported. +pub fn build_fal_hunyuan_3d_3p1_smart_topology(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3p1SmartTopology(state))) +} + +pub(crate) fn build_fal_hunyuan_3d_3p1_smart_topology_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let input_file_url = plan_input_mesh_url(builder.input_mesh.take())?.ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Hunyuan 3D v3.1 Smart Topology requires an input mesh file (GLB or OBJ)".to_string(), + ) + })?; + + // Retopology takes only the mesh file and a polygon type; every other + // option is unsupported. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("reference_images", builder.reference_images.as_ref(), strategy)?; + reject_unsupported_image_ref("front_image", builder.front_image.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?; + reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?; + reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?; + reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?; + + let input_file_type = infer_input_file_type(&input_file_url); + let request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url, + input_file_type, + polygon_type: builder.polygon_type.map(to_polygon_type), + face_level: None, + }; + Ok(FalHunyuan3d3p1SmartTopologyRequestState { request }) +} + +// ── Mapping helpers ── + +/// Infer the input file format from the URL path: a path ending in `.obj` +/// (case-insensitive, ignoring any query string) maps to `Obj`; anything +/// else is left unset (fal defaults to GLB). +fn infer_input_file_type(url: &str) -> Option { + let path = url.split(['?', '#']).next().unwrap_or(url); + if path.to_ascii_lowercase().ends_with(".obj") { + Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj) + } else { + None + } +} + +fn to_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3p1SmartTopologyPolygonType { + match polygon_type { + CommonPolygonType::Triangle => Hunyuan3d3p1SmartTopologyPolygonType::Triangle, + CommonPolygonType::Quad => Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_ref::ImageRef; + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + use super::*; + + const GLB_URL: &str = "https://example.com/model.glb"; + const OBJ_URL: &str = "https://example.com/model.obj"; + + mod shape_dispatch { + use super::*; + + #[test] + fn input_mesh_url_builds_the_request() { + let state = build_fal_hunyuan_3d_3p1_smart_topology_state(mesh_builder(GLB_URL)) + .expect("build"); + assert_eq!(state.request.input_file_url, GLB_URL); + assert!(state.request.face_level.is_none()); + } + + #[test] + fn missing_input_mesh_is_rejected() { + let result = build_fal_hunyuan_3d_3p1_smart_topology_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + + #[test] + fn media_token_input_mesh_is_rejected() { + let builder = GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".to_string()))), + ..base_builder() + }; + assert!(matches!( + build_fal_hunyuan_3d_3p1_smart_topology_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod option_mapping { + use super::*; + + #[test] + fn obj_url_infers_obj_input_file_type() { + let state = build_fal_hunyuan_3d_3p1_smart_topology_state(mesh_builder(OBJ_URL)) + .expect("build"); + assert!(matches!( + state.request.input_file_type, + Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj) + )); + } + + #[test] + fn obj_inference_is_case_insensitive_and_ignores_the_query_string() { + let cases = [ + "https://example.com/model.OBJ", + "https://example.com/model.obj?token=abc123", + "https://example.com/model.Obj#fragment", + ]; + for url in cases { + assert!( + matches!(infer_input_file_type(url), Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj)), + "for {url}", + ); + } + } + + #[test] + fn non_obj_urls_leave_input_file_type_unset() { + let cases = [ + GLB_URL, + "https://example.com/model.fbx", + "https://example.com/model.glb?format=obj", + ]; + for url in cases { + assert!(infer_input_file_type(url).is_none(), "for {url}"); + } + } + + #[test] + fn polygon_type_maps_through() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..mesh_builder(GLB_URL) + }; + let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder).expect("build"); + assert!(matches!( + state.request.polygon_type, + Some(Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral) + )); + + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Triangle), + ..mesh_builder(GLB_URL) + }; + let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder).expect("build"); + assert!(matches!( + state.request.polygon_type, + Some(Hunyuan3d3p1SmartTopologyPolygonType::Triangle) + )); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn each_unsupported_option_errors_out_under_error_out() { + let cases: Vec = vec![ + |b| b.prompt = Some("a red ceramic teapot".to_string()), + |b| b.front_image = Some(ImageRef::Url("https://example.com/front.png".to_string())), + |b| b.back_image = Some(ImageRef::Url("https://example.com/back.png".to_string())), + |b| b.mesh_output_type = Some(CommonMeshOutputType::Normal), + |b| b.face_count = Some(100_000), + |b| b.enable_pbr = Some(true), + |b| b.enable_texture = Some(false), + ]; + for (index, set) in cases.into_iter().enumerate() { + let mut builder = GenerateMeshRequestBuilder { + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..mesh_builder(GLB_URL) + }; + set(&mut builder); + assert!(build_fal_hunyuan_3d_3p1_smart_topology_state(builder).is_err(), "for case {index}"); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + mesh_output_type: Some(CommonMeshOutputType::Normal), + face_count: Some(100_000), + enable_pbr: Some(true), + ..mesh_builder(GLB_URL) + }; + let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder).expect("build"); + assert_eq!(state.request.input_file_url, GLB_URL); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1SmartTopology, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn mesh_builder(url: &str) -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url(url.to_string())), + ..base_builder() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/cost.rs new file mode 100644 index 0000000000..12cb8e6294 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/cost.rs @@ -0,0 +1,75 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::request::FalHunyuan3d3p1SmartTopologyRequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1SmartTopologyCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3p1SmartTopologyCostState { + pub fn from_request(request: &FalHunyuan3d3p1SmartTopologyRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const MESH_URL: &str = "https://example.com/model.glb"; + + mod cost_table { + use super::*; + + #[test] + fn cost_is_a_flat_seventy_five_cents() { + assert_eq!(estimate_usd_cents(mesh_builder()), 75); + } + + #[test] + fn polygon_type_does_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..mesh_builder() + }; + assert_eq!(estimate_usd_cents(builder), 75); + } + } + + // ── Helpers ── + + fn mesh_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1SmartTopology, + provider: RouterProvider::Fal, + input_mesh: Some(MeshRef::Url(MESH_URL.to_string())), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/request.rs new file mode 100644 index 0000000000..ee2ed24b46 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_3p1_smart_topology/request.rs @@ -0,0 +1,52 @@ +use fal_client::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::api::Hunyuan3d3p1SmartTopologyRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3p1SmartTopologyRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3p1SmartTopologyRequest, +} + +impl FalHunyuan3d3p1SmartTopologyRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key and a real GLB URL, incurs cost + async fn send_smart_topology() { + let client = get_fal_client(); + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3p1SmartTopology, + provider: RouterProvider::Fal, + input_mesh: Some(MeshRef::Url("https://example.com/model.glb".to_string())), + ..Default::default() + }; + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/build.rs new file mode 100644 index 0000000000..276df58a28 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/build.rs @@ -0,0 +1,490 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use fal_client::requests::api::mesh::image::meshy_v6_image_to_mesh::api::MeshyV6ImageToMeshRequest; +use fal_client::requests::api::mesh::text::meshy_v6_text_to_mesh::api::{ + MeshyV6ModelType, MeshyV6TextToMeshRequest, MeshyV6Topology, +}; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::meshy_v6::request::{ + FalMeshyV6ImageRequestState, FalMeshyV6TextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Meshy 6 combines fal's image-to-3d and text-to-3d endpoints under a single +/// router model. The request shape picks the endpoint: any primary image +/// input dispatches to image-to-3d; otherwise a prompt dispatches to +/// text-to-3d. Neither endpoint takes side-view images or an input mesh. +pub fn build_fal_meshy_v6(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_meshy_v6_state(builder)?; + let request = match state { + FalMeshyV6State::Image(state) => MeshGenerationRequest::FalMeshyV6Image(state), + FalMeshyV6State::Text(state) => MeshGenerationRequest::FalMeshyV6Text(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalMeshyV6State { + Image(FalMeshyV6ImageRequestState), + Text(FalMeshyV6TextRequestState), +} + +pub(crate) fn build_fal_meshy_v6_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options neither Meshy 6 endpoint supports. + reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?; + reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?; + reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let maybe_image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + let target_polycount = plan_face_count(builder.face_count, strategy)?; + let topology = builder.polygon_type.map(to_topology); + + match maybe_image_url { + Some(image_url) => { + // Image mode. The image endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + let request = MeshyV6ImageToMeshRequest { + image_url, + model_type: plan_image_model_type(builder.mesh_output_type), + topology, + target_polycount, + symmetry_mode: None, + should_remesh: None, + should_texture: plan_should_texture(builder.mesh_output_type, builder.enable_texture), + enable_pbr: builder.enable_pbr, + pose_mode: None, + texture_prompt: None, + texture_image_url: None, + enable_rigging: None, + rigging_height_meters: None, + enable_animation: None, + animation_action_id: None, + enable_safety_checker: None, + }; + Ok(FalMeshyV6State::Image(FalMeshyV6ImageRequestState { request })) + } + None => { + // Text mode. The text endpoint always textures, so `enable_texture` + // has no equivalent parameter. + reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Meshy 6 requires an input image or a prompt".to_string(), + ) + })?; + + let request = MeshyV6TextToMeshRequest { + prompt, + mode: None, + seed: None, + model_type: plan_text_model_type(builder.mesh_output_type, strategy)?, + topology, + target_polycount, + should_remesh: None, + symmetry_mode: None, + enable_pbr: builder.enable_pbr, + pose_mode: None, + enable_prompt_expansion: None, + texture_prompt: None, + texture_image_url: None, + enable_rigging: None, + rigging_height_meters: None, + enable_animation: None, + animation_action_id: None, + enable_safety_checker: None, + }; + Ok(FalMeshyV6State::Text(FalMeshyV6TextRequestState { request })) + } + } +} + +// ── Option planning helpers ── + +/// Map the output type onto the image endpoint's `model_type`. `Geometry` +/// is expressed via `should_texture` instead, so only `LowPoly` maps here. +fn plan_image_model_type( + mesh_output_type: Option, +) -> Option { + match mesh_output_type { + Some(CommonMeshOutputType::LowPoly) => Some(MeshyV6ModelType::LowPoly), + None | Some(CommonMeshOutputType::Normal) | Some(CommonMeshOutputType::Geometry) => None, + } +} + +/// Map the output type onto the text endpoint's `model_type`. The text +/// endpoint cannot skip texturing, so `Geometry` rejects under `ErrorOut` +/// and is dropped otherwise. +fn plan_text_model_type( + mesh_output_type: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match mesh_output_type { + None | Some(CommonMeshOutputType::Normal) => Ok(None), + Some(CommonMeshOutputType::LowPoly) => Ok(Some(MeshyV6ModelType::LowPoly)), + Some(CommonMeshOutputType::Geometry) => { + reject_unsupported_option( + "mesh_output_type", + Some(&CommonMeshOutputType::Geometry), + strategy, + )?; + Ok(None) + } + } +} + +/// A geometry-only output type or an explicit `enable_texture: false` both +/// map to the image endpoint's `should_texture: false`. +fn plan_should_texture( + mesh_output_type: Option, + enable_texture: Option, +) -> Option { + let skip_texturing = matches!(mesh_output_type, Some(CommonMeshOutputType::Geometry)) + || enable_texture == Some(false); + if skip_texturing { + Some(false) + } else { + None + } +} + +fn to_topology(polygon_type: CommonPolygonType) -> MeshyV6Topology { + match polygon_type { + CommonPolygonType::Quad => MeshyV6Topology::Quad, + CommonPolygonType::Triangle => MeshyV6Topology::Triangle, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_quality::CommonMeshQuality; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::errors::client_error::ClientError; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const PROMPT: &str = "a red ceramic teapot"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_meshy_v6_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let state = build_fal_meshy_v6_state(builder).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_meshy_v6_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, PROMPT); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_meshy_v6_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + + #[test] + fn media_tokens_are_rejected() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_meshy_v6_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod image_mode { + use super::*; + + #[test] + fn geometry_output_sets_should_texture_false() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..image_builder() + }; + let image = expect_image(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(image.request.should_texture, Some(false)); + assert_eq!(image.request.model_type, None); + } + + #[test] + fn enable_texture_false_sets_should_texture_false() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..image_builder() + }; + let image = expect_image(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(image.request.should_texture, Some(false)); + } + + #[test] + fn should_texture_is_unset_by_default() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(image.request.should_texture, None); + } + + #[test] + fn low_poly_maps_to_model_type() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..image_builder() + }; + let image = expect_image(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(image.request.model_type, Some(MeshyV6ModelType::LowPoly)); + assert_eq!(image.request.should_texture, None); + } + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(image.request.topology, Some(MeshyV6Topology::Quad)); + assert_eq!(image.request.target_polycount, Some(100_000)); + assert_eq!(image.request.enable_pbr, Some(true)); + } + + #[test] + fn prompt_with_image_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some(PROMPT.to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn prompt_with_image_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some(PROMPT.to_string()), + ..image_builder() + }; + assert!(matches!( + build_fal_meshy_v6_state(builder).expect("build"), + FalMeshyV6State::Image(_) + )); + } + } + + mod text_mode { + use super::*; + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Triangle), + face_count: Some(40_000), + enable_pbr: Some(false), + ..text_builder() + }; + let text = expect_text(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(text.request.model_type, Some(MeshyV6ModelType::LowPoly)); + assert_eq!(text.request.topology, Some(MeshyV6Topology::Triangle)); + assert_eq!(text.request.target_polycount, Some(40_000)); + assert_eq!(text.request.enable_pbr, Some(false)); + } + + #[test] + fn geometry_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..text_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn geometry_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..text_builder() + }; + let text = expect_text(build_fal_meshy_v6_state(builder).expect("build")); + assert_eq!(text.request.model_type, None); + } + + #[test] + fn enable_texture_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..text_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn enable_texture_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..text_builder() + }; + assert!(matches!( + build_fal_meshy_v6_state(builder).expect("build"), + FalMeshyV6State::Text(_) + )); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn texture_quality_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn geometry_quality_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + geometry_quality: Some(CommonMeshQuality::Detailed), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn input_mesh_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn side_images_error_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_meshy_v6_state(builder).is_err()); + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Standard), + input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())), + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())), + right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())), + ..image_builder() + }; + assert!(matches!( + build_fal_meshy_v6_state(builder).expect("build"), + FalMeshyV6State::Image(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some(PROMPT.to_string()), + ..base_builder() + } + } + + fn expect_image(state: FalMeshyV6State) -> FalMeshyV6ImageRequestState { + match state { + FalMeshyV6State::Image(image) => image, + FalMeshyV6State::Text(text) => panic!("expected image mode, got text: {text:?}"), + } + } + + fn expect_text(state: FalMeshyV6State) -> FalMeshyV6TextRequestState { + match state { + FalMeshyV6State::Text(text) => text, + FalMeshyV6State::Image(image) => panic!("expected text mode, got image: {image:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/cost.rs new file mode 100644 index 0000000000..de6da47402 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/cost.rs @@ -0,0 +1,112 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::meshy_v6::request::{ + FalMeshyV6ImageRequestState, FalMeshyV6TextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalMeshyV6ImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalMeshyV6ImageCostState { + pub fn from_request(request: &FalMeshyV6ImageRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalMeshyV6TextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalMeshyV6TextCostState { + pub fn from_request(request: &FalMeshyV6TextRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + + mod flat_pricing { + use super::*; + + #[test] + fn image_mode_is_eighty_cents() { + assert_eq!(estimate_usd_cents(image_builder()), 80); + } + + #[test] + fn text_mode_is_eighty_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 80); + } + + #[test] + fn options_do_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 80); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/request.rs new file mode 100644 index 0000000000..cc1b63c72b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/meshy_v6/request.rs @@ -0,0 +1,102 @@ +use fal_client::requests::api::mesh::image::meshy_v6_image_to_mesh::api::MeshyV6ImageToMeshRequest; +use fal_client::requests::api::mesh::text::meshy_v6_text_to_mesh::api::MeshyV6TextToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalMeshyV6ImageRequestState { + /// Final materialized request; ready to fire. + pub request: MeshyV6ImageToMeshRequest, +} + +impl FalMeshyV6ImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalMeshyV6TextRequestState { + /// Final materialized request; ready to fire. + pub request: MeshyV6TextToMeshRequest, +} + +impl FalMeshyV6TextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_meshy_v6_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..meshy_v6_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_meshy_v6_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..meshy_v6_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn meshy_v6_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::MeshyV6, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/mod.rs new file mode 100644 index 0000000000..b83304d4c9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/mod.rs @@ -0,0 +1,12 @@ +pub(crate) mod resolve; +pub mod hunyuan3d_3; +pub mod hunyuan3d_3_sketch; +pub mod hunyuan_3d_2p0; +pub mod hunyuan_3d_2p1; +pub mod hunyuan_3d_3p1_part; +pub mod hunyuan_3d_3p1_pro; +pub mod hunyuan_3d_3p1_rapid; +pub mod hunyuan_3d_3p1_smart_topology; +pub mod meshy_v6; +pub mod rodin_2p5_fast; +pub mod tripo3d_h3p1; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/resolve.rs new file mode 100644 index 0000000000..78f8cba2d3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/resolve.rs @@ -0,0 +1,223 @@ +//! Shared plan helpers for the Fal mesh provider. +//! +//! Fal only accepts media URLs — media file tokens are rejected. Callers +//! must resolve tokens to URLs before building for the Fal provider. + +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::mesh_ref::MeshRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Resolve the primary (front) image from `reference_images` and/or +/// `front_image`. More than one candidate rejects under `ErrorOut`; the other +/// strategies keep the first (reference images take precedence over +/// `front_image`). +pub(crate) fn plan_primary_image_url( + reference_images: Option, + front_image: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let mut candidates: Vec = Vec::new(); + + match reference_images { + None => {} + Some(ImageListRef::Urls(urls)) => candidates.extend(urls), + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => {} + Some(ImageListRef::MediaFileTokens(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + } + + if let Some(front) = front_image { + candidates.push(image_ref_to_url(front)?); + } + + if candidates.len() > 1 { + if let RequestMismatchMitigationStrategy::ErrorOut = strategy { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("Expected at most 1 primary image, got {}", candidates.len()), + })); + } + } + Ok(candidates.into_iter().next()) +} + +/// Resolve an optional side-view image (back/left/right) to a URL. +pub(crate) fn plan_side_image_url( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + image_ref.map(image_ref_to_url).transpose() +} + +/// Resolve an optional input mesh file to a URL. Fal only takes URLs; +/// media file tokens are rejected. +pub(crate) fn plan_input_mesh_url( + mesh_ref: Option, +) -> Result, ArtcraftRouterError> { + match mesh_ref { + None => Ok(None), + Some(MeshRef::Url(url)) => Ok(Some(url)), + Some(MeshRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +/// Narrow a `u64` face count to the `u32` the fal bindings take. +/// Out-of-range values reject under `ErrorOut` and clamp otherwise. +pub(crate) fn plan_face_count( + face_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let Some(face_count) = face_count else { + return Ok(None); + }; + match u32::try_from(face_count) { + Ok(value) => Ok(Some(value)), + Err(_) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "face_count", + value: format!("{}", face_count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(u32::MAX)), + }, + } +} + +/// Map a mesh output type onto the Hunyuan 2.x endpoints' `textured_mesh` +/// flag. Those endpoints only produce textured or white (geometry-only) +/// meshes: `Normal` (or unset) maps to textured, `Geometry` to white, and +/// `LowPoly` rejects under `ErrorOut` or upgrades to textured otherwise. +pub(crate) fn plan_textured_mesh( + mesh_output_type: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match mesh_output_type { + None | Some(CommonMeshOutputType::Normal) => Ok(Some(true)), + Some(CommonMeshOutputType::Geometry) => Ok(Some(false)), + Some(CommonMeshOutputType::LowPoly) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "mesh_output_type", + value: format!("{:?}", CommonMeshOutputType::LowPoly), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(true)), + }, + } +} + +fn image_ref_to_url(image_ref: ImageRef) -> Result { + match image_ref { + ImageRef::Url(url) => Ok(url), + ImageRef::MediaFileToken(_) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use super::*; + + mod primary_image_tests { + use super::*; + + #[test] + fn none_resolves_to_none() { + let result = plan_primary_image_url(None, None, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert!(result.is_none()); + } + + #[test] + fn single_reference_url_is_used() { + let refs = Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(result.as_deref(), Some("https://example.com/a.png")); + } + + #[test] + fn front_image_is_used_when_no_references() { + let front = Some(ImageRef::Url("https://example.com/front.png".to_string())); + let result = plan_primary_image_url(None, front, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(result.as_deref(), Some("https://example.com/front.png")); + } + + #[test] + fn multiple_candidates_error_out() { + let refs = Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])); + let front = Some(ImageRef::Url("https://example.com/front.png".to_string())); + let result = plan_primary_image_url(refs, front, RequestMismatchMitigationStrategy::ErrorOut); + assert!(result.is_err()); + } + + #[test] + fn multiple_candidates_keep_the_first_under_lenient_strategies() { + let refs = Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::PayMoreUpgrade) + .expect("plan"); + assert_eq!(result.as_deref(), Some("https://example.com/a.png")); + } + + #[test] + fn media_tokens_are_rejected() { + let refs = Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::PayMoreUpgrade); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn empty_token_list_resolves_to_none() { + let refs = Some(ImageListRef::MediaFileTokens(vec![])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert!(result.is_none()); + } + } + + mod face_count_tests { + use super::*; + + #[test] + fn in_range_passes_through() { + let result = plan_face_count(Some(500_000), RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(result, Some(500_000)); + } + + #[test] + fn overflow_errors_out() { + let result = plan_face_count(Some(u64::MAX), RequestMismatchMitigationStrategy::ErrorOut); + assert!(result.is_err()); + } + + #[test] + fn overflow_clamps_under_lenient_strategies() { + let result = plan_face_count(Some(u64::MAX), RequestMismatchMitigationStrategy::PayLessDowngrade) + .expect("plan"); + assert_eq!(result, Some(u32::MAX)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/build.rs new file mode 100644 index 0000000000..6ddce8bddc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/build.rs @@ -0,0 +1,514 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use fal_client::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::api::Rodin2p5FastImageToMeshRequest; +use fal_client::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::{ + Rodin2p5FastMaterial, Rodin2p5FastTextToMeshRequest, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::request::{ + FalRodin2p5FastImageRequestState, FalRodin2p5FastTextRequestState, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// The image endpoint accepts at most this many input images. +const MAX_INPUT_IMAGES: usize = 5; + +/// Hyper3D Rodin v2.5 Fast combines fal's image-to-3d and text-to-3d +/// endpoints under a single router model. Any image input dispatches to +/// image-to-3d (which takes up to five views of the object, plus an optional +/// guidance prompt); otherwise a prompt dispatches to text-to-3d. +pub fn build_fal_rodin_2p5_fast(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_rodin_2p5_fast_state(builder)?; + let request = match state { + FalRodin2p5FastState::Image(state) => MeshGenerationRequest::FalRodin2p5FastImage(state), + FalRodin2p5FastState::Text(state) => MeshGenerationRequest::FalRodin2p5FastText(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalRodin2p5FastState { + Image(FalRodin2p5FastImageRequestState), + Text(FalRodin2p5FastTextRequestState), +} + +pub(crate) fn build_fal_rodin_2p5_fast_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Options neither Rodin v2.5 Fast endpoint supports. + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?; + reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?; + reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let material = plan_material( + builder.mesh_output_type, + builder.enable_pbr, + builder.enable_texture, + strategy, + )?; + let image_urls = plan_image_urls( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + + if image_urls.is_empty() { + // Text mode. + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Rodin v2.5 Fast requires an input image or a prompt".to_string(), + ) + })?; + + let request = Rodin2p5FastTextToMeshRequest { + prompt, + tier: None, + seed: None, + geometry_file_format: None, + material, + quality_mesh_option: None, + texture_mode: None, + enable_creative_mode: None, + hd_texture: None, + texture_delight: None, + is_micro: None, + ta_pose: None, + bbox_condition: None, + }; + Ok(FalRodin2p5FastState::Text(FalRodin2p5FastTextRequestState { request })) + } else { + // Image mode. The prompt is optional guidance here. + let request = Rodin2p5FastImageToMeshRequest { + image_urls, + prompt: builder.prompt.take(), + use_original_alpha: None, + tier: None, + seed: None, + geometry_file_format: None, + material, + quality_mesh_option: None, + texture_mode: None, + enable_creative_mode: None, + hd_texture: None, + texture_delight: None, + is_micro: None, + ta_pose: None, + bbox_condition: None, + preview_render: None, + }; + Ok(FalRodin2p5FastState::Image(FalRodin2p5FastImageRequestState { request })) + } +} + +// ── Option planning helpers ── + +/// Collect every input image URL: all `reference_images` URLs plus the +/// `front_image` if set. More than [`MAX_INPUT_IMAGES`] rejects under +/// `ErrorOut`; the lenient strategies keep the first five. Fal only takes +/// URLs, so media file tokens are rejected. +fn plan_image_urls( + reference_images: Option, + front_image: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let mut urls: Vec = Vec::new(); + + match reference_images { + None => {} + Some(ImageListRef::Urls(reference_urls)) => urls.extend(reference_urls), + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => {} + Some(ImageListRef::MediaFileTokens(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + } + + match front_image { + None => {} + Some(ImageRef::Url(url)) => urls.push(url), + Some(ImageRef::MediaFileToken(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + } + + if urls.len() > MAX_INPUT_IMAGES { + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("Expected at most {MAX_INPUT_IMAGES} images, got {}", urls.len()), + })); + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + urls.truncate(MAX_INPUT_IMAGES); + } + } + } + Ok(urls) +} + +/// Map the builder's texturing options onto Rodin's `material` parameter. +/// PBR wins when requested; otherwise a geometry-only output type or an +/// explicit `enable_texture: false` map to no material. Rodin has no +/// low-poly mode, so `LowPoly` rejects under `ErrorOut` and is dropped +/// otherwise. +fn plan_material( + mesh_output_type: Option, + enable_pbr: Option, + enable_texture: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + if matches!(mesh_output_type, Some(CommonMeshOutputType::LowPoly)) { + reject_unsupported_option( + "mesh_output_type", + Some(&CommonMeshOutputType::LowPoly), + strategy, + )?; + } + if enable_pbr == Some(true) { + return Ok(Some(Rodin2p5FastMaterial::Pbr)); + } + let skip_texturing = matches!(mesh_output_type, Some(CommonMeshOutputType::Geometry)) + || enable_texture == Some(false); + if skip_texturing { + return Ok(Some(Rodin2p5FastMaterial::NoMaterial)); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_quality::CommonMeshQuality; + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const SECOND_URL: &str = "https://example.com/second.png"; + const PROMPT: &str = "a red ceramic teapot"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_rodin_2p5_fast_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_urls, vec![FRONT_URL.to_string()]); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.image_urls, vec![FRONT_URL.to_string()]); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_rodin_2p5_fast_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, PROMPT); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_rodin_2p5_fast_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + } + + mod image_mode { + use super::*; + + #[test] + fn multiple_reference_urls_collect_into_image_urls() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + FRONT_URL.to_string(), + SECOND_URL.to_string(), + ])), + front_image: Some(ImageRef::Url("https://example.com/third.png".to_string())), + ..base_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.image_urls, vec![ + FRONT_URL.to_string(), + SECOND_URL.to_string(), + "https://example.com/third.png".to_string(), + ]); + } + + #[test] + fn more_than_five_images_error_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(six_urls())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(matches!( + build_fal_rodin_2p5_fast_state(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn more_than_five_images_truncate_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(six_urls())), + ..base_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.image_urls.len(), 5); + assert_eq!(image.request.image_urls, &six_urls()[..5]); + } + + #[test] + fn prompt_is_forwarded_as_guidance() { + let builder = GenerateMeshRequestBuilder { + prompt: Some(PROMPT.to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.prompt.as_deref(), Some(PROMPT)); + } + + #[test] + fn reference_media_tokens_are_rejected() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_rodin_2p5_fast_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn front_image_media_token_is_rejected() { + let builder = GenerateMeshRequestBuilder { + front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))), + ..base_builder() + }; + assert!(matches!( + build_fal_rodin_2p5_fast_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod material_mapping { + use super::*; + + #[test] + fn pbr_maps_to_pbr_material() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.material, Some(Rodin2p5FastMaterial::Pbr)); + } + + #[test] + fn geometry_maps_to_no_material() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..image_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.material, Some(Rodin2p5FastMaterial::NoMaterial)); + } + + #[test] + fn enable_texture_false_maps_to_no_material() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..text_builder() + }; + let text = expect_text(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(text.request.material, Some(Rodin2p5FastMaterial::NoMaterial)); + } + + #[test] + fn pbr_takes_precedence_over_geometry() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.material, Some(Rodin2p5FastMaterial::Pbr)); + } + + #[test] + fn material_is_unset_by_default() { + let image = expect_image(build_fal_rodin_2p5_fast_state(image_builder()).expect("build")); + assert_eq!(image.request.material, None); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_rodin_2p5_fast_state(builder).is_err()); + } + + #[test] + fn low_poly_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..image_builder() + }; + let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build")); + assert_eq!(image.request.material, None); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out_under_error_out() { + let cases: Vec<(&str, GenerateMeshRequestBuilder)> = vec![ + ("polygon_type", GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..error_out_image_builder() + }), + ("face_count", GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..error_out_image_builder() + }), + ("texture_quality", GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..error_out_image_builder() + }), + ("geometry_quality", GenerateMeshRequestBuilder { + geometry_quality: Some(CommonMeshQuality::Detailed), + ..error_out_image_builder() + }), + ("input_mesh", GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())), + ..error_out_image_builder() + }), + ("back_image", GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..error_out_image_builder() + }), + ("left_image", GenerateMeshRequestBuilder { + left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())), + ..error_out_image_builder() + }), + ("right_image", GenerateMeshRequestBuilder { + right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())), + ..error_out_image_builder() + }), + ]; + for (field, builder) in cases { + assert!( + build_fal_rodin_2p5_fast_state(builder).is_err(), + "expected error for {field}", + ); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Standard), + input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())), + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..image_builder() + }; + assert!(matches!( + build_fal_rodin_2p5_fast_state(builder).expect("build"), + FalRodin2p5FastState::Image(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn error_out_image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some(PROMPT.to_string()), + ..base_builder() + } + } + + fn six_urls() -> Vec { + (1..=6).map(|i| format!("https://example.com/view-{i}.png")).collect() + } + + fn expect_image(state: FalRodin2p5FastState) -> FalRodin2p5FastImageRequestState { + match state { + FalRodin2p5FastState::Image(image) => image, + FalRodin2p5FastState::Text(text) => panic!("expected image mode, got text: {text:?}"), + } + } + + fn expect_text(state: FalRodin2p5FastState) -> FalRodin2p5FastTextRequestState { + match state { + FalRodin2p5FastState::Text(text) => text, + FalRodin2p5FastState::Image(image) => panic!("expected text mode, got image: {image:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/cost.rs new file mode 100644 index 0000000000..ae79a1edd1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/cost.rs @@ -0,0 +1,109 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::request::{ + FalRodin2p5FastImageRequestState, FalRodin2p5FastTextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalRodin2p5FastImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalRodin2p5FastImageCostState { + pub fn from_request(request: &FalRodin2p5FastImageRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalRodin2p5FastTextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalRodin2p5FastTextCostState { + pub fn from_request(request: &FalRodin2p5FastTextRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + + mod flat_pricing { + use super::*; + + #[test] + fn image_mode_is_ten_cents() { + assert_eq!(estimate_usd_cents(image_builder()), 10); + } + + #[test] + fn text_mode_is_ten_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 10); + } + + #[test] + fn options_do_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 10); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/request.rs new file mode 100644 index 0000000000..db40ce9819 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/rodin_2p5_fast/request.rs @@ -0,0 +1,102 @@ +use fal_client::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::api::Rodin2p5FastImageToMeshRequest; +use fal_client::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::Rodin2p5FastTextToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalRodin2p5FastImageRequestState { + /// Final materialized request; ready to fire. + pub request: Rodin2p5FastImageToMeshRequest, +} + +impl FalRodin2p5FastImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalRodin2p5FastTextRequestState { + /// Final materialized request; ready to fire. + pub request: Rodin2p5FastTextToMeshRequest, +} + +impl FalRodin2p5FastTextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_rodin_2p5_fast_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..rodin_2p5_fast_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_rodin_2p5_fast_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..rodin_2p5_fast_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn rodin_2p5_fast_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Rodin2p5Fast, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/build.rs new file mode 100644 index 0000000000..44319e8037 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/build.rs @@ -0,0 +1,629 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_mesh_quality::CommonMeshQuality; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use fal_client::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{ + Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, Tripo3dH3p1ImageToMeshRequest, +}; +use fal_client::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::api::Tripo3dH3p1MultiviewToMeshRequest; +use fal_client::requests::api::mesh::text::tripo3d_h3p1_text_to_mesh::api::{ + Tripo3dH3p1GeometryQuality, Tripo3dH3p1TextToMeshRequest, Tripo3dH3p1TextureQuality, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, plan_side_image_url, +}; +use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::request::{ + FalTripo3dH3p1ImageRequestState, FalTripo3dH3p1MultiviewRequestState, + FalTripo3dH3p1TextRequestState, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::reject_unsupported_option; + +/// Tripo3D H3.1 combines fal's text-to-3d, image-to-3d, and multiview-to-3d +/// endpoints under a single router model. The request shape picks the +/// endpoint: any side-view image (back/left/right) dispatches to +/// multiview-to-3d (which also requires a front image); otherwise a primary +/// image dispatches to image-to-3d; otherwise a prompt dispatches to +/// text-to-3d. +pub fn build_fal_tripo3d_h3p1(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_tripo3d_h3p1_state(builder)?; + let request = match state { + FalTripo3dH3p1State::Multiview(state) => MeshGenerationRequest::FalTripo3dH3p1Multiview(state), + FalTripo3dH3p1State::Image(state) => MeshGenerationRequest::FalTripo3dH3p1Image(state), + FalTripo3dH3p1State::Text(state) => MeshGenerationRequest::FalTripo3dH3p1Text(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalTripo3dH3p1State { + Multiview(FalTripo3dH3p1MultiviewRequestState), + Image(FalTripo3dH3p1ImageRequestState), + Text(FalTripo3dH3p1TextRequestState), +} + +pub(crate) fn build_fal_tripo3d_h3p1_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // No Tripo3D endpoint takes a mesh input. + reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?; + + let maybe_front_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + let maybe_left_url = plan_side_image_url(builder.left_image.take())?; + let maybe_back_url = plan_side_image_url(builder.back_image.take())?; + let maybe_right_url = plan_side_image_url(builder.right_image.take())?; + + let options = plan_tripo3d_options(&mut builder)?; + + let has_side_view = maybe_left_url.is_some() + || maybe_back_url.is_some() + || maybe_right_url.is_some(); + + if has_side_view { + // Multiview mode. The endpoint reconstructs from 2-4 views, and the + // front view anchors the set, so it's required. + let front_url = maybe_front_url.ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Tripo3D multi-view input requires a front/reference image".to_string(), + ) + })?; + // The multiview endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + // fal's canonical view order is [front, left, back, right]; absent views + // are simply omitted. + let mut image_urls = vec![front_url]; + image_urls.extend(maybe_left_url); + image_urls.extend(maybe_back_url); + image_urls.extend(maybe_right_url); + + let request = Tripo3dH3p1MultiviewToMeshRequest { + image_urls, + face_limit: options.face_limit, + texture: options.texture, + pbr: options.pbr, + model_seed: None, + texture_seed: None, + texture_quality: options.texture_quality.map(to_image_texture_quality), + geometry_quality: options.geometry_quality.map(to_image_geometry_quality), + texture_alignment: None, + auto_size: None, + orientation: None, + quad: options.quad, + }; + Ok(FalTripo3dH3p1State::Multiview(FalTripo3dH3p1MultiviewRequestState { request })) + } else if let Some(image_url) = maybe_front_url { + // Image mode. The image endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + let request = Tripo3dH3p1ImageToMeshRequest { + image_url, + face_limit: options.face_limit, + texture: options.texture, + pbr: options.pbr, + model_seed: None, + texture_seed: None, + texture_quality: options.texture_quality.map(to_image_texture_quality), + geometry_quality: options.geometry_quality.map(to_image_geometry_quality), + texture_alignment: None, + auto_size: None, + orientation: None, + quad: options.quad, + }; + Ok(FalTripo3dH3p1State::Image(FalTripo3dH3p1ImageRequestState { request })) + } else { + // Text mode. + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Tripo3D H3.1 requires an input image or a prompt".to_string(), + ) + })?; + + let request = Tripo3dH3p1TextToMeshRequest { + prompt, + negative_prompt: None, + face_limit: options.face_limit, + texture: options.texture, + pbr: options.pbr, + model_seed: None, + image_seed: None, + texture_seed: None, + texture_quality: options.texture_quality.map(to_text_texture_quality), + geometry_quality: options.geometry_quality.map(to_text_geometry_quality), + auto_size: None, + quad: options.quad, + }; + Ok(FalTripo3dH3p1State::Text(FalTripo3dH3p1TextRequestState { request })) + } +} + +/// Options shared by all three Tripo3D H3.1 endpoints, resolved from the +/// builder once and mapped onto each endpoint's field types per-mode. +struct Tripo3dH3p1Options { + face_limit: Option, + texture: Option, + pbr: Option, + texture_quality: Option, + geometry_quality: Option, + quad: Option, +} + +fn plan_tripo3d_options( + builder: &mut GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Tripo3D has no low-poly output mode; `Normal` and `Geometry` map onto the + // texture flags below. + if matches!(builder.mesh_output_type, Some(CommonMeshOutputType::LowPoly)) { + reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?; + } + + // A geometry-only output (or explicitly disabled texturing) maps to the + // untextured tier — unless PBR is explicitly requested, since PBR implies + // texturing. Both flags default to true on fal's side and either one alone + // re-enables texturing, so both must be explicitly disabled together to + // reach the untextured billing tier. + let wants_untextured = matches!(builder.mesh_output_type, Some(CommonMeshOutputType::Geometry)) + || builder.enable_texture == Some(false); + let texture_off = wants_untextured && builder.enable_pbr != Some(true); + let (texture, pbr) = if texture_off { + (Some(false), Some(false)) + } else { + (builder.enable_texture, builder.enable_pbr) + }; + + let quad = builder.polygon_type.take().map(|polygon_type| match polygon_type { + CommonPolygonType::Quad => true, + CommonPolygonType::Triangle => false, + }); + + Ok(Tripo3dH3p1Options { + face_limit: plan_face_count(builder.face_count, strategy)?, + texture, + pbr, + texture_quality: builder.texture_quality.take(), + geometry_quality: builder.geometry_quality.take(), + quad, + }) +} + +// ── Enum mapping helpers ── + +fn to_text_texture_quality(quality: CommonMeshQuality) -> Tripo3dH3p1TextureQuality { + match quality { + CommonMeshQuality::Standard => Tripo3dH3p1TextureQuality::Standard, + CommonMeshQuality::Detailed => Tripo3dH3p1TextureQuality::Detailed, + } +} + +fn to_text_geometry_quality(quality: CommonMeshQuality) -> Tripo3dH3p1GeometryQuality { + match quality { + CommonMeshQuality::Standard => Tripo3dH3p1GeometryQuality::Standard, + CommonMeshQuality::Detailed => Tripo3dH3p1GeometryQuality::Detailed, + } +} + +fn to_image_texture_quality(quality: CommonMeshQuality) -> Tripo3dH3p1ImageTextureQuality { + match quality { + CommonMeshQuality::Standard => Tripo3dH3p1ImageTextureQuality::Standard, + CommonMeshQuality::Detailed => Tripo3dH3p1ImageTextureQuality::Detailed, + } +} + +fn to_image_geometry_quality(quality: CommonMeshQuality) -> Tripo3dH3p1ImageGeometryQuality { + match quality { + CommonMeshQuality::Standard => Tripo3dH3p1ImageGeometryQuality::Standard, + CommonMeshQuality::Detailed => Tripo3dH3p1ImageGeometryQuality::Detailed, + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::mesh_ref::MeshRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const LEFT_URL: &str = "https://example.com/left.png"; + const BACK_URL: &str = "https://example.com/back.png"; + const RIGHT_URL: &str = "https://example.com/right.png"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_tripo3d_h3p1_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_tripo3d_h3p1_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn side_image_dispatches_to_multiview_mode() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..image_builder() + }; + let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(multiview.request.image_urls, vec![FRONT_URL, BACK_URL]); + } + + #[test] + fn side_images_without_front_are_rejected() { + // A hard error even under the (default) lenient strategy: the endpoint + // cannot anchor the view set without a front image. + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..base_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_tripo3d_h3p1_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + } + + mod multiview_mode { + use super::*; + + #[test] + fn urls_follow_canonical_view_order() { + let builder = GenerateMeshRequestBuilder { + left_image: Some(ImageRef::Url(LEFT_URL.to_string())), + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + right_image: Some(ImageRef::Url(RIGHT_URL.to_string())), + ..image_builder() + }; + let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!( + multiview.request.image_urls, + vec![FRONT_URL, LEFT_URL, BACK_URL, RIGHT_URL], + ); + } + + #[test] + fn missing_views_are_omitted() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + right_image: Some(ImageRef::Url(RIGHT_URL.to_string())), + ..image_builder() + }; + let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(multiview.request.image_urls, vec![FRONT_URL, BACK_URL, RIGHT_URL]); + } + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + polygon_type: Some(CommonPolygonType::Quad), + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + ..multiview_builder() + }; + let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(multiview.request.face_limit, Some(100_000)); + assert_eq!(multiview.request.quad, Some(true)); + assert_eq!(multiview.request.texture_quality, Some(Tripo3dH3p1ImageTextureQuality::Detailed)); + assert_eq!(multiview.request.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Detailed)); + } + + #[test] + fn prompt_with_multiview_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..multiview_builder() + }; + assert!(build_fal_tripo3d_h3p1_state(builder).is_err()); + } + + #[test] + fn prompt_with_multiview_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..multiview_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder).expect("build"), + FalTripo3dH3p1State::Multiview(_) + )); + } + } + + mod image_mode { + use super::*; + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(50_000), + polygon_type: Some(CommonPolygonType::Quad), + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Standard), + ..image_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(image.request.face_limit, Some(50_000)); + assert_eq!(image.request.quad, Some(true)); + assert_eq!(image.request.texture_quality, Some(Tripo3dH3p1ImageTextureQuality::Detailed)); + assert_eq!(image.request.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Standard)); + } + + #[test] + fn prompt_with_image_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_tripo3d_h3p1_state(builder).is_err()); + } + + #[test] + fn prompt_with_image_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..image_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder).expect("build"), + FalTripo3dH3p1State::Image(_) + )); + } + + #[test] + fn media_tokens_are_rejected() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod text_mode { + use super::*; + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(40_000), + polygon_type: Some(CommonPolygonType::Triangle), + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + ..text_builder() + }; + let text = expect_text(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(text.request.face_limit, Some(40_000)); + assert_eq!(text.request.quad, Some(false)); + assert_eq!(text.request.texture_quality, Some(Tripo3dH3p1TextureQuality::Detailed)); + assert_eq!(text.request.geometry_quality, Some(Tripo3dH3p1GeometryQuality::Detailed)); + } + } + + mod option_mapping { + use super::*; + + #[test] + fn geometry_output_disables_texture_and_pbr() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..image_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(image.request.texture, Some(false)); + assert_eq!(image.request.pbr, Some(false)); + } + + #[test] + fn enable_texture_false_disables_texture_and_pbr() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..text_builder() + }; + let text = expect_text(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(text.request.texture, Some(false)); + assert_eq!(text.request.pbr, Some(false)); + } + + #[test] + fn enable_texture_false_with_pbr_true_keeps_textures() { + // PBR implies texturing, so an explicit PBR request wins. + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(image.request.texture, Some(false)); + assert_eq!(image.request.pbr, Some(true)); + } + + #[test] + fn texture_flags_pass_through_by_default() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(true), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(image.request.texture, Some(true)); + assert_eq!(image.request.pbr, Some(true)); + } + + #[test] + fn unset_texture_flags_stay_unset() { + let image = expect_image(build_fal_tripo3d_h3p1_state(image_builder()).expect("build")); + assert_eq!(image.request.texture, None); + assert_eq!(image.request.pbr, None); + } + + #[test] + fn polygon_type_maps_to_quad_flag() { + let cases = [ + (Some(CommonPolygonType::Quad), Some(true)), + (Some(CommonPolygonType::Triangle), Some(false)), + (None, None), + ]; + for (polygon_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + polygon_type, + ..image_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + assert_eq!(image.request.quad, expected, "for {polygon_type:?}"); + } + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn low_poly_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..image_builder() + }; + let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build")); + // Dropped: LowPoly doesn't force the untextured flags either. + assert_eq!(image.request.texture, None); + assert_eq!(image.request.pbr, None); + } + + #[test] + fn input_mesh_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + } + + #[test] + fn input_mesh_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())), + ..image_builder() + }; + assert!(matches!( + build_fal_tripo3d_h3p1_state(builder).expect("build"), + FalTripo3dH3p1State::Image(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Tripo3dH3p1, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn multiview_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + left_image: Some(ImageRef::Url(LEFT_URL.to_string())), + ..image_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn expect_multiview(state: FalTripo3dH3p1State) -> FalTripo3dH3p1MultiviewRequestState { + match state { + FalTripo3dH3p1State::Multiview(multiview) => multiview, + other => panic!("expected multiview mode, got: {other:?}"), + } + } + + fn expect_image(state: FalTripo3dH3p1State) -> FalTripo3dH3p1ImageRequestState { + match state { + FalTripo3dH3p1State::Image(image) => image, + other => panic!("expected image mode, got: {other:?}"), + } + } + + fn expect_text(state: FalTripo3dH3p1State) -> FalTripo3dH3p1TextRequestState { + match state { + FalTripo3dH3p1State::Text(text) => text, + other => panic!("expected text mode, got: {other:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/cost.rs new file mode 100644 index 0000000000..5ce3aaa936 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/cost.rs @@ -0,0 +1,263 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::request::{ + FalTripo3dH3p1ImageRequestState, FalTripo3dH3p1MultiviewRequestState, + FalTripo3dH3p1TextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalTripo3dH3p1TextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalTripo3dH3p1TextCostState { + pub fn from_request(request: &FalTripo3dH3p1TextRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalTripo3dH3p1ImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalTripo3dH3p1ImageCostState { + pub fn from_request(request: &FalTripo3dH3p1ImageRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalTripo3dH3p1MultiviewCostState { + pub cost_in_usd_cents: u64, +} + +impl FalTripo3dH3p1MultiviewCostState { + pub fn from_request(request: &FalTripo3dH3p1MultiviewRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_mesh_quality::CommonMeshQuality; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + const BACK_URL: &str = "https://example.com/back.png"; + + mod text_mode_costs { + use super::*; + + #[test] + fn default_is_standard_textures() { + assert_eq!(estimate_usd_cents(text_builder()), 20); + } + + #[test] + fn untextured_via_enable_texture_off() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 10); + } + + #[test] + fn untextured_via_geometry_output() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 10); + } + + #[test] + fn texture_off_with_pbr_keeps_textured_tier() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + enable_pbr: Some(true), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 20); + } + + #[test] + fn detailed_texture() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + polygon_type: Some(CommonPolygonType::Quad), + ..text_builder() + }; + // Detailed texture(30) + detailed geometry(20) + quad(5) + assert_eq!(estimate_usd_cents(builder), 55); + } + } + + mod image_mode_costs { + use super::*; + + #[test] + fn default_is_standard_textures() { + assert_eq!(estimate_usd_cents(image_builder()), 30); + } + + #[test] + fn untextured_via_enable_texture_off() { + let builder = GenerateMeshRequestBuilder { + enable_texture: Some(false), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 20); + } + + #[test] + fn detailed_texture() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), 40); + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + polygon_type: Some(CommonPolygonType::Quad), + ..image_builder() + }; + // Detailed texture(40) + detailed geometry(20) + quad(5) + assert_eq!(estimate_usd_cents(builder), 65); + } + } + + mod multiview_mode_costs { + use super::*; + + #[test] + fn default_is_standard_textures() { + assert_eq!(estimate_usd_cents(multiview_builder()), 20); + } + + #[test] + fn untextured_via_geometry_output() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..multiview_builder() + }; + assert_eq!(estimate_usd_cents(builder), 10); + } + + #[test] + fn detailed_texture() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + ..multiview_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + + #[test] + fn triangle_polygon_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Triangle), + ..multiview_builder() + }; + assert_eq!(estimate_usd_cents(builder), 20); + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + texture_quality: Some(CommonMeshQuality::Detailed), + geometry_quality: Some(CommonMeshQuality::Detailed), + polygon_type: Some(CommonPolygonType::Quad), + ..multiview_builder() + }; + // Detailed texture(30) + detailed geometry(20) + quad(5) + assert_eq!(estimate_usd_cents(builder), 55); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Tripo3dH3p1, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn multiview_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..image_builder() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/request.rs new file mode 100644 index 0000000000..fa37943a90 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/tripo3d_h3p1/request.rs @@ -0,0 +1,129 @@ +use fal_client::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::Tripo3dH3p1ImageToMeshRequest; +use fal_client::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::api::Tripo3dH3p1MultiviewToMeshRequest; +use fal_client::requests::api::mesh::text::tripo3d_h3p1_text_to_mesh::api::Tripo3dH3p1TextToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalTripo3dH3p1TextRequestState { + /// Final materialized request; ready to fire. + pub request: Tripo3dH3p1TextToMeshRequest, +} + +impl FalTripo3dH3p1TextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalTripo3dH3p1ImageRequestState { + /// Final materialized request; ready to fire. + pub request: Tripo3dH3p1ImageToMeshRequest, +} + +impl FalTripo3dH3p1ImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalTripo3dH3p1MultiviewRequestState { + /// Final materialized request; ready to fire. + pub request: Tripo3dH3p1MultiviewToMeshRequest, +} + +impl FalTripo3dH3p1MultiviewRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL}; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_tripo3d_h3p1_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..tripo3d_h3p1_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_tripo3d_h3p1_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..tripo3d_h3p1_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_tripo3d_h3p1_multiview_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + back_image: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + ..tripo3d_h3p1_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn tripo3d_h3p1_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Tripo3dH3p1, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/mod.rs new file mode 100644 index 0000000000..4418b658ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod artcraft; +pub mod fal; +pub(crate) mod reject_unsupported; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/reject_unsupported.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/reject_unsupported.rs new file mode 100644 index 0000000000..77dd3e347e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/reject_unsupported.rs @@ -0,0 +1,44 @@ +//! Shared helpers for rejecting builder options that a model does not support. +//! +//! Per the request-mismatch mitigation strategy: `ErrorOut` rejects the +//! request when an unsupported option is set; the other strategies silently +//! drop the option. + +use std::fmt::Debug; + +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Reject (under `ErrorOut`) or silently drop a set option the model doesn't +/// support. +pub(crate) fn reject_unsupported_option( + field: &'static str, + value: Option<&T>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let Some(value) = value else { + return Ok(()); + }; + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: format!("{:?}", value), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + } +} + +/// Reject (under `ErrorOut`) or silently drop a single side-view image for +/// models/endpoints that take none. +pub(crate) fn reject_unsupported_image_ref( + field: &'static str, + image_ref: Option<&ImageRef>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + reject_unsupported_option(field, image_ref, strategy) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs new file mode 100644 index 0000000000..2e56175b44 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs @@ -0,0 +1,78 @@ +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_mini::PlanArtcraftMarble0p1Mini; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +#[allow(deprecated)] // legacy marble 0.1 pricing intentionally uses the deprecated variants +pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_mini( + plan: &PlanArtcraftMarble0p1Mini, +) -> SplatGenerationCostEstimate { + let input_type = if plan.reference_image.is_some() { + InputType::ImageNonPanorama + } else { + InputType::Text + }; + + let cost = calculate_cost(WorldLabsModel::Marble0p1Mini, input_type); + let cost_in_usd_cents = cost.us_dollar_cents as u64; + + SplatGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest; + + use tokens::tokens::media_files::MediaFileToken; + + fn estimate_usd_cents(prompt: Option<&str>, image_tokens: Option>) -> u64 { + let request = GenerateSplatRequest { + model: RouterSplatModel::Marble0p1Mini, + provider: RouterProvider::Artcraft, + prompt: prompt.map(|s| s.to_string()), + reference_images: image_tokens.map(crate::api::image_list_ref::ImageListRef::MediaFileTokens), + idempotency_token: None, + }; + request.build() + .expect("build should succeed") + .estimate_costs() + .cost_in_usd_cents + .expect("cost_in_usd_cents should be present") + } + + #[test] + fn test_estimate_cost_text_only() { + let cost = estimate_usd_cents(Some("a room"), None); + assert_eq!(cost, 18); // 230 credits → 18 cents + } + + #[test] + fn test_estimate_cost_no_image_no_prompt() { + let cost = estimate_usd_cents(None, None); + assert_eq!(cost, 18); + } + + #[test] + fn test_estimate_cost_image() { + let tokens = vec![MediaFileToken("test_token".to_string())]; + let cost = estimate_usd_cents(None, Some(tokens.clone())); + assert_eq!(cost, 18); // 230 credits → 18 cents (same as text for mini) + } + + #[test] + fn test_estimate_cost_image_with_prompt() { + let tokens = vec![MediaFileToken("test_token".to_string())]; + let cost = estimate_usd_cents(Some("a cozy room"), Some(tokens.clone())); + assert_eq!(cost, 18); // 230 credits → 18 cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs new file mode 100644 index 0000000000..4c6090f624 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs @@ -0,0 +1,78 @@ +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_plus::PlanArtcraftMarble0p1Plus; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +#[allow(deprecated)] // legacy marble 0.1 pricing intentionally uses the deprecated variants +pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_plus( + plan: &PlanArtcraftMarble0p1Plus, +) -> SplatGenerationCostEstimate { + let input_type = if plan.reference_image.is_some() { + InputType::ImageNonPanorama + } else { + InputType::Text + }; + + let cost = calculate_cost(WorldLabsModel::Marble0p1Plus, input_type); + let cost_in_usd_cents = cost.us_dollar_cents as u64; + + SplatGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest; + + use tokens::tokens::media_files::MediaFileToken; + + fn estimate_usd_cents(prompt: Option<&str>, image_tokens: Option>) -> u64 { + let request = GenerateSplatRequest { + model: RouterSplatModel::Marble0p1Plus, + provider: RouterProvider::Artcraft, + prompt: prompt.map(|s| s.to_string()), + reference_images: image_tokens.map(crate::api::image_list_ref::ImageListRef::MediaFileTokens), + idempotency_token: None, + }; + request.build() + .expect("build should succeed") + .estimate_costs() + .cost_in_usd_cents + .expect("cost_in_usd_cents should be present") + } + + #[test] + fn test_estimate_cost_text_only() { + let cost = estimate_usd_cents(Some("a room"), None); + assert_eq!(cost, 126); // 1580 credits → 126 cents + } + + #[test] + fn test_estimate_cost_no_image_no_prompt() { + let cost = estimate_usd_cents(None, None); + assert_eq!(cost, 126); + } + + #[test] + fn test_estimate_cost_image() { + let tokens = vec![MediaFileToken("test_token".to_string())]; + let cost = estimate_usd_cents(None, Some(tokens.clone())); + assert_eq!(cost, 126); // 1580 credits → 126 cents (same as text for plus) + } + + #[test] + fn test_estimate_cost_image_with_prompt() { + let tokens = vec![MediaFileToken("test_token".to_string())]; + let cost = estimate_usd_cents(Some("a cozy room"), Some(tokens.clone())); + assert_eq!(cost, 126); // 1580 credits → 126 cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/mod.rs new file mode 100644 index 0000000000..ecccb4fce7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/mod.rs @@ -0,0 +1,2 @@ +pub mod estimate_splat_cost_artcraft_marble_0p1_mini; +pub mod estimate_splat_cost_artcraft_marble_0p1_plus; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/mod.rs new file mode 100644 index 0000000000..61bc3e70c0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/mod.rs @@ -0,0 +1 @@ +pub(crate) mod artcraft; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs new file mode 100644 index 0000000000..3f870f02fd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs @@ -0,0 +1,33 @@ +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_mini::PlanArtcraftMarble0p1Mini; +use crate::generate::generate_splat::generate_splat_response::{ + ArtcraftSplatResponsePayload, GenerateSplatResponse, +}; +use artcraft_api_defs::generate::splat::generate_worldlabs_marble_0p1_mini_splat::GenerateWorldlabsMarble0p1MiniSplatRequest; +use artcraft_client::endpoints::generate::splat::generate_worldlabs_marble_0p1_mini_splat::generate_worldlabs_marble_0p1_mini_splat; + +pub async fn execute_artcraft_marble_0p1_mini( + plan: &PlanArtcraftMarble0p1Mini, + artcraft_client: &RouterArtcraftClient, +) -> Result { + let request = GenerateWorldlabsMarble0p1MiniSplatRequest { + uuid_idempotency_token: plan.idempotency_token.clone(), + image_media_file_token: plan.reference_image.clone(), + prompt: plan.prompt.clone(), + }; + + let response = generate_worldlabs_marble_0p1_mini_splat( + &artcraft_client.api_host, + Some(&artcraft_client.credentials), + request, + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload { + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs new file mode 100644 index 0000000000..732c4e597c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs @@ -0,0 +1,33 @@ +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_plus::PlanArtcraftMarble0p1Plus; +use crate::generate::generate_splat::generate_splat_response::{ + ArtcraftSplatResponsePayload, GenerateSplatResponse, +}; +use artcraft_api_defs::generate::splat::generate_worldlabs_marble_0p1_plus_splat::GenerateWorldlabsMarble0p1PlusSplatRequest; +use artcraft_client::endpoints::generate::splat::generate_worldlabs_marble_0p1_plus_splat::generate_worldlabs_marble_0p1_plus_splat; + +pub async fn execute_artcraft_marble_0p1_plus( + plan: &PlanArtcraftMarble0p1Plus, + artcraft_client: &RouterArtcraftClient, +) -> Result { + let request = GenerateWorldlabsMarble0p1PlusSplatRequest { + uuid_idempotency_token: plan.idempotency_token.clone(), + image_media_file_token: plan.reference_image.clone(), + prompt: plan.prompt.clone(), + }; + + let response = generate_worldlabs_marble_0p1_plus_splat( + &artcraft_client.api_host, + Some(&artcraft_client.credentials), + request, + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload { + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/mod.rs new file mode 100644 index 0000000000..5f41e4d2ef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_splat_artcraft_marble_0p1_mini; +pub mod generate_splat_artcraft_marble_0p1_plus; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/mod.rs new file mode 100644 index 0000000000..e3a8f2a048 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/mod.rs @@ -0,0 +1 @@ +pub mod artcraft; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs new file mode 100644 index 0000000000..58f96e9829 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs @@ -0,0 +1,58 @@ +use crate::api::router_splat_model::RouterSplatModel; +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_mini::plan_generate_splat_artcraft_marble_0p1_mini; +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_plus::plan_generate_splat_artcraft_marble_0p1_plus; +use crate::generate::generate_splat::splat_generation_plan::SplatGenerationPlan; + +pub struct GenerateSplatRequest { + /// Which model to use. + pub model: RouterSplatModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The prompt for splat generation (optional). + pub prompt: Option, + + /// Reference images (optional). + pub reference_images: Option, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl GenerateSplatRequest { + /// Read the splat generation request, construct a plan, then yield a means to execute it. + pub fn build(&self) -> Result { + match self.provider { + RouterProvider::Artcraft => self.build_artcraft(), + _ => self.unsupported_provider(), + } + } + + fn build_artcraft(&self) -> Result { + match self.model { + RouterSplatModel::Marble0p1Mini => plan_generate_splat_artcraft_marble_0p1_mini(self), + RouterSplatModel::Marble0p1Plus => plan_generate_splat_artcraft_marble_0p1_plus(self), + // The marble 1.x models are only supported by the new + // `GenerateSplatRequestBuilder::build2()` pipeline. + _ => Err(ArtcraftRouterError::UnsupportedModel( + format!("Splat generation for model `{:?}` is not supported by the legacy plan API; use GenerateSplatRequestBuilder::build2()", self.model) + )), + } + } + + fn unsupported_provider(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedModel( + format!("Splat generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request_builder.rs new file mode 100644 index 0000000000..a935b27fc7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request_builder.rs @@ -0,0 +1,304 @@ +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_provider::RouterProvider; +use crate::api::router_splat_model::RouterSplatModel; +use crate::api::video_ref::VideoRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::build::build_artcraft_marble_1p0; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::build::build_artcraft_marble_1p0_draft; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::build::build_artcraft_marble_1p1; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::build::build_artcraft_marble_1p1_plus; +use crate::generate::generate_splat::providers::artcraft::triposplat::build::build_artcraft_triposplat; +use crate::generate::generate_splat::providers::fal::triposplat::build::build_fal_triposplat; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::build::build_worldlabs_marble_1p0; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::build::build_worldlabs_marble_1p0_draft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::build::build_worldlabs_marble_1p1; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::build::build_worldlabs_marble_1p1_plus; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + +/// RouterProvider-agnostic splat (gaussian world) generation request. +/// Distilled by `build2()` into a `SplatGenerationDraftOrRequest` for the +/// selected (provider, model) pair. +/// +/// NB: The deprecated marble 0.1 models are treated as their marble 1.x +/// successors (`Marble0p1Mini` → `Marble1p0Draft`, `Marble0p1Plus` → +/// `Marble1p0`). +#[derive(Clone, Debug)] +pub struct GenerateSplatRequestBuilder { + /// Which model to use. + pub model: RouterSplatModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The text prompt for the splat generation. + pub prompt: Option, + + /// Reference images (optional). + /// One image generates from a single view; multiple images are treated as + /// multi-view input of the same world. + pub reference_images: Option, + + /// Reference video (optional). Cannot be combined with reference images. + pub reference_video: Option, + + /// Whether the single reference image is a 360-degree panorama. + pub is_panoramic: Option, + + /// Whether to disable prompt recaptioning. + pub disable_recaption: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl Default for GenerateSplatRequestBuilder { + fn default() -> Self { + Self { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::Artcraft, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + prompt: None, + reference_images: None, + reference_video: None, + is_panoramic: None, + disable_recaption: None, + idempotency_token: None, + } + } +} + +impl GenerateSplatRequestBuilder { + + pub fn build2(self) -> Result { + match (self.provider, self.model) { + // Artcraft + (RouterProvider::Artcraft, RouterSplatModel::Marble1p0 | RouterSplatModel::Marble0p1Plus) => build_artcraft_marble_1p0(self), + (RouterProvider::Artcraft, RouterSplatModel::Marble1p0Draft | RouterSplatModel::Marble0p1Mini) => build_artcraft_marble_1p0_draft(self), + (RouterProvider::Artcraft, RouterSplatModel::Marble1p1) => build_artcraft_marble_1p1(self), + (RouterProvider::Artcraft, RouterSplatModel::Marble1p1Plus) => build_artcraft_marble_1p1_plus(self), + (RouterProvider::Artcraft, RouterSplatModel::TripoSplat) => build_artcraft_triposplat(self), + // Fal + (RouterProvider::Fal, RouterSplatModel::TripoSplat) => build_fal_triposplat(self), + // World Labs + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p0 | RouterSplatModel::Marble0p1Plus) => build_worldlabs_marble_1p0(self), + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p0Draft | RouterSplatModel::Marble0p1Mini) => build_worldlabs_marble_1p0_draft(self), + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p1) => build_worldlabs_marble_1p1(self), + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p1Plus) => build_worldlabs_marble_1p1_plus(self), + _ => self.unsupported_provider_and_model(), + } + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Splat generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; + use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/room.png"; + + mod artcraft_dispatch_tests { + use super::*; + + #[test] + fn all_models_dispatch_to_requests() { + let cases = [ + RouterSplatModel::Marble1p0, + RouterSplatModel::Marble1p0Draft, + RouterSplatModel::Marble1p1, + RouterSplatModel::Marble1p1Plus, + ]; + for model in cases { + let result = artcraft_builder(model).build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let request = expect_request(result); + let matches = matches!( + (model, &request), + (RouterSplatModel::Marble1p0, SplatGenerationRequest::ArtcraftMarble1p0(_)) + | (RouterSplatModel::Marble1p0Draft, SplatGenerationRequest::ArtcraftMarble1p0Draft(_)) + | (RouterSplatModel::Marble1p1, SplatGenerationRequest::ArtcraftMarble1p1(_)) + | (RouterSplatModel::Marble1p1Plus, SplatGenerationRequest::ArtcraftMarble1p1Plus(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {request:?}"); + } + } + + #[test] + fn legacy_marble_0p1_mini_is_treated_as_marble_1p0_draft() { + let result = artcraft_builder(RouterSplatModel::Marble0p1Mini).build2().expect("build"); + assert!(matches!( + expect_request(result), + SplatGenerationRequest::ArtcraftMarble1p0Draft(_) + )); + } + + #[test] + fn legacy_marble_0p1_plus_is_treated_as_marble_1p0() { + let result = artcraft_builder(RouterSplatModel::Marble0p1Plus).build2().expect("build"); + assert!(matches!( + expect_request(result), + SplatGenerationRequest::ArtcraftMarble1p0(_) + )); + } + } + + mod worldlabs_dispatch_tests { + use super::*; + + #[test] + fn text_prompts_dispatch_to_direct_requests() { + let cases = [ + RouterSplatModel::Marble1p0, + RouterSplatModel::Marble1p0Draft, + RouterSplatModel::Marble1p1, + RouterSplatModel::Marble1p1Plus, + ]; + for model in cases { + let result = worldlabs_text_builder(model).build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let request = expect_request(result); + let matches = matches!( + (model, &request), + (RouterSplatModel::Marble1p0, SplatGenerationRequest::WorldLabsMarble1p0(_)) + | (RouterSplatModel::Marble1p0Draft, SplatGenerationRequest::WorldLabsMarble1p0Draft(_)) + | (RouterSplatModel::Marble1p1, SplatGenerationRequest::WorldLabsMarble1p1(_)) + | (RouterSplatModel::Marble1p1Plus, SplatGenerationRequest::WorldLabsMarble1p1Plus(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {request:?}"); + } + } + + #[test] + fn media_inputs_dispatch_to_drafts() { + let cases = [ + RouterSplatModel::Marble1p0, + RouterSplatModel::Marble1p0Draft, + RouterSplatModel::Marble1p1, + RouterSplatModel::Marble1p1Plus, + ]; + for model in cases { + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..worldlabs_text_builder(model) + }; + let result = builder.build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let draft = expect_draft(result); + let matches = matches!( + (model, &draft), + (RouterSplatModel::Marble1p0, SplatGenerationDraftRequest::WorldLabsMarble1p0(_)) + | (RouterSplatModel::Marble1p0Draft, SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(_)) + | (RouterSplatModel::Marble1p1, SplatGenerationDraftRequest::WorldLabsMarble1p1(_)) + | (RouterSplatModel::Marble1p1Plus, SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {draft:?}"); + } + } + + #[test] + fn legacy_models_are_treated_as_their_successors() { + let mini = worldlabs_text_builder(RouterSplatModel::Marble0p1Mini).build2().expect("build"); + assert!(matches!( + expect_request(mini), + SplatGenerationRequest::WorldLabsMarble1p0Draft(_) + )); + + let plus = worldlabs_text_builder(RouterSplatModel::Marble0p1Plus).build2().expect("build"); + assert!(matches!( + expect_request(plus), + SplatGenerationRequest::WorldLabsMarble1p0(_) + )); + } + + #[test] + fn media_token_inputs_dispatch_to_drafts() { + let builder = GenerateSplatRequestBuilder { + prompt: None, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..worldlabs_text_builder(RouterSplatModel::Marble1p0) + }; + let result = builder.build2().expect("build"); + assert!(matches!(result, SplatGenerationDraftOrRequest::Draft(_))); + } + } + + mod unsupported_combo_tests { + use super::*; + + #[test] + fn non_splat_providers_are_unsupported() { + for provider in [ + RouterProvider::Fal, + RouterProvider::GmiCloud, + RouterProvider::GrokApi, + RouterProvider::KinoviWeb, + ] { + let result = GenerateSplatRequestBuilder { + provider, + model: RouterSplatModel::Marble1p0, + prompt: Some("a cozy cabin".to_string()), + ..Default::default() + }.build2(); + assert!( + matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))), + "expected unsupported error for {provider:?}", + ); + } + } + } + + // ── Helpers ── + + fn artcraft_builder(model: RouterSplatModel) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + provider: RouterProvider::Artcraft, + model, + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..Default::default() + } + } + + fn worldlabs_text_builder(model: RouterSplatModel) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + provider: RouterProvider::WorldLabs, + model, + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..Default::default() + } + } + + fn expect_request(result: SplatGenerationDraftOrRequest) -> SplatGenerationRequest { + match result { + SplatGenerationDraftOrRequest::Request(request) => request, + SplatGenerationDraftOrRequest::Draft(draft) => panic!("expected Request, got Draft: {draft:?}"), + } + } + + fn expect_draft(result: SplatGenerationDraftOrRequest) -> SplatGenerationDraftRequest { + match result { + SplatGenerationDraftOrRequest::Draft(draft) => draft, + SplatGenerationDraftOrRequest::Request(request) => panic!("expected Draft, got Request: {request:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs new file mode 100644 index 0000000000..97812ed71e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs @@ -0,0 +1,67 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +#[derive(Clone, Debug)] +pub struct ArtcraftSplatResponsePayload { + pub inference_job_token: InferenceJobToken, + pub all_inference_job_tokens: Vec, +} + +#[derive(Clone, Debug)] +pub struct WorldLabsSplatResponsePayload { + /// The World Labs operation ID; poll it for generation status. + pub operation_id: String, + pub done: bool, +} + +/// Fal's queue/webhook submission receipt for a splat generation. +#[derive(Clone, Debug)] +pub struct FalSplatResponsePayload { + /// Fal's ID for the queued request. Present on queue submissions and on + /// most webhook submissions. + pub request_id: Option, + + /// Gateway request ID (webhook submissions). + pub gateway_request_id: Option, + + /// Queue-mode status polling URL. + pub maybe_status_url: Option, + + /// Queue-mode response URL. + pub maybe_response_url: Option, + + /// The outbound request we sent, for debug logging. + pub maybe_outbound_request: Option>, +} + +#[derive(Clone, Debug)] +pub enum GenerateSplatResponse { + Artcraft(ArtcraftSplatResponsePayload), + Fal(FalSplatResponsePayload), + WorldLabs(WorldLabsSplatResponsePayload), +} + +impl GenerateSplatResponse { + pub fn get_artcraft_payload(&self) -> Option { + match self { + Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_fal_payload(&self) -> Option { + match self { + Self::Fal(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_worldlabs_payload(&self) -> Option { + match self { + Self::WorldLabs(p) => Some(p.clone()), + _ => None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs new file mode 100644 index 0000000000..b0acb8e9ca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs @@ -0,0 +1,18 @@ +// New audio-style pipeline (marble 1.x; use `GenerateSplatRequestBuilder::build2()`). +pub mod generate_splat_request_builder; +pub mod providers; +pub mod splat_generation_draft; +pub mod splat_generation_draft_context; +pub mod splat_generation_draft_or_request; +pub mod splat_generation_request; + +// Shared between the new and legacy pipelines. +pub mod generate_splat_response; +pub mod splat_generation_cost_estimate; + +// Legacy plan-based pipeline (marble 0.1 only; still used by the desktop app). +pub mod cost; +pub mod execute; +pub mod generate_splat_request; +pub mod plan; +pub mod splat_generation_plan; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/mod.rs new file mode 100644 index 0000000000..2f11cdbc1d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/mod.rs @@ -0,0 +1,2 @@ +pub mod plan_generate_splat_artcraft_marble_0p1_mini; +pub mod plan_generate_splat_artcraft_marble_0p1_plus; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/plan_generate_splat_artcraft_marble_0p1_mini.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/plan_generate_splat_artcraft_marble_0p1_mini.rs new file mode 100644 index 0000000000..c51e580435 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/plan_generate_splat_artcraft_marble_0p1_mini.rs @@ -0,0 +1,39 @@ +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest; +use crate::generate::generate_splat::splat_generation_plan::SplatGenerationPlan; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Debug, Clone)] +pub struct PlanArtcraftMarble0p1Mini { + pub prompt: Option, + pub reference_image: Option, + pub idempotency_token: String, +} + +pub fn plan_generate_splat_artcraft_marble_0p1_mini( + request: &GenerateSplatRequest, +) -> Result { + let reference_image = resolve_single_image_ref(request.reference_images.clone())?; + + Ok(SplatGenerationPlan::ArtcraftMarble0p1Mini(PlanArtcraftMarble0p1Mini { + prompt: request.prompt.clone(), + reference_image, + idempotency_token: request.get_or_generate_idempotency_token(), + })) +} + +fn resolve_single_image_ref( + image_list_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => { + Ok(tokens.into_iter().next()) + } + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/plan_generate_splat_artcraft_marble_0p1_plus.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/plan_generate_splat_artcraft_marble_0p1_plus.rs new file mode 100644 index 0000000000..d2ed3c3cbc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/artcraft/plan_generate_splat_artcraft_marble_0p1_plus.rs @@ -0,0 +1,39 @@ +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest; +use crate::generate::generate_splat::splat_generation_plan::SplatGenerationPlan; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Debug, Clone)] +pub struct PlanArtcraftMarble0p1Plus { + pub prompt: Option, + pub reference_image: Option, + pub idempotency_token: String, +} + +pub fn plan_generate_splat_artcraft_marble_0p1_plus( + request: &GenerateSplatRequest, +) -> Result { + let reference_image = resolve_single_image_ref(request.reference_images.clone())?; + + Ok(SplatGenerationPlan::ArtcraftMarble0p1Plus(PlanArtcraftMarble0p1Plus { + prompt: request.prompt.clone(), + reference_image, + idempotency_token: request.get_or_generate_idempotency_token(), + })) +} + +fn resolve_single_image_ref( + image_list_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => { + Ok(tokens.into_iter().next()) + } + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/mod.rs new file mode 100644 index 0000000000..e3a8f2a048 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/plan/mod.rs @@ -0,0 +1 @@ +pub mod artcraft; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..29661b4d1f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/build_common.rs @@ -0,0 +1,115 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::resolve::{ + resolve_image_list_ref, resolve_video_ref, +}; + +/// Build an `OmniGenSplatCostAndGenerateRequest` from the builder. The +/// Artcraft provider is a thin forwarding shim: the storyteller-web omni +/// splat endpoint owns per-model validation and option mapping, so the +/// builder's options are passed through as-is. +pub fn build_artcraft_omni_splat_request( + mut builder: GenerateSplatRequestBuilder, + model: CommonSplatModelEnum, +) -> Result { + let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?; + let reference_video_media_token = resolve_video_ref(builder.reference_video.take())?; + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenSplatCostAndGenerateRequest { + idempotency_token: Some(idempotency_token), + model: Some(model), + prompt: builder.prompt.take(), + reference_image_media_tokens, + reference_video_media_token, + is_panoramic: builder.is_panoramic, + disable_recaption: builder.disable_recaption, + }) +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::errors::client_error::ClientError; + + use super::*; + + #[test] + fn all_fields_are_forwarded() { + let builder = GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image".to_string()), + ])), + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + is_panoramic: Some(true), + disable_recaption: Some(true), + idempotency_token: Some("test-token".to_string()), + ..base_builder() + }; + + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0) + .expect("build"); + + assert_eq!(request.idempotency_token.as_deref(), Some("test-token")); + assert_eq!(request.model, Some(CommonSplatModelEnum::Marble1p0)); + assert_eq!(request.prompt.as_deref(), Some("a cozy cabin")); + assert_eq!(request.reference_image_media_tokens.as_ref().map(|t| t.len()), Some(1)); + assert_eq!(request.reference_video_media_token.as_ref().map(|t| t.as_str()), Some("mf_video")); + assert_eq!(request.is_panoramic, Some(true)); + assert_eq!(request.disable_recaption, Some(true)); + } + + #[test] + fn idempotency_token_is_generated_when_missing() { + let request = build_artcraft_omni_splat_request(base_builder(), CommonSplatModelEnum::Marble1p0Draft) + .expect("build"); + assert!(request.idempotency_token.is_some()); + assert!(!request.idempotency_token.unwrap().is_empty()); + } + + #[test] + fn image_url_references_are_rejected() { + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])), + ..base_builder() + }; + let result = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + #[test] + fn video_url_references_are_rejected() { + let builder = GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::Url("https://example.com/a.mp4".to_string())), + ..base_builder() + }; + let result = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0, + provider: RouterProvider::Artcraft, + prompt: Some("a cozy cabin".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/cost_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/cost_common.rs new file mode 100644 index 0000000000..26e631e483 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/cost_common.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +/// Flat prices for a splat model by input type, in USD cents. +#[derive(Copy, Clone, Debug)] +pub(crate) struct ArtcraftSplatPriceTable { + pub image_panorama: u64, + pub text: u64, + pub image_non_panorama: u64, + pub multi_image: u64, + pub video: u64, +} + +impl ArtcraftSplatPriceTable { + pub(crate) fn cost_in_usd_cents(&self, input_type: InputType) -> u64 { + match input_type { + InputType::ImagePanorama => self.image_panorama, + InputType::Text => self.text, + InputType::ImageNonPanorama => self.image_non_panorama, + InputType::MultiImage => self.multi_image, + InputType::Video => self.video, + } + } +} + +/// Derive the pricing input type from a forwarded omni splat request. +/// Mirrors the request-assembly derivation: video wins over images, two or +/// more images are multi-image, one image is panorama/non-panorama, and +/// anything else prices as text. +pub(crate) fn derive_input_type_for_pricing( + request: &OmniGenSplatCostAndGenerateRequest, +) -> InputType { + let image_count = request.reference_image_media_tokens.as_ref() + .map(|tokens| tokens.len()) + .unwrap_or(0); + + if request.reference_video_media_token.is_some() { + InputType::Video + } else if image_count >= 2 { + InputType::MultiImage + } else if image_count == 1 { + if request.is_panoramic == Some(true) { + InputType::ImagePanorama + } else { + InputType::ImageNonPanorama + } + } else { + InputType::Text + } +} + +pub(crate) fn artcraft_splat_cost_estimate(cost_in_usd_cents: u64) -> SplatGenerationCostEstimate { + SplatGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/build.rs new file mode 100644 index 0000000000..52868a60dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p0(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0)?; + let state = ArtcraftMarble1p0RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p0(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/cost.rs new file mode 100644 index 0000000000..1d498e5fc3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 156, + text: 165, + image_non_panorama: 165, + multi_image: 167, + video: 167, +}; + +/// Marble 1.0 via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0CostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p0CostState { + pub fn from_request(request: &ArtcraftMarble1p0RequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_165_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 165); + } + + #[test] + fn single_image_is_165_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 165); + } + + #[test] + fn panoramic_image_is_156_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 156); + } + + #[test] + fn multi_image_is_167_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 167); + } + + #[test] + fn video_is_167_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 167); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/request.rs new file mode 100644 index 0000000000..eab8286a2b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0RequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/build.rs new file mode 100644 index 0000000000..c1b457d77c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p0_draft(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0Draft)?; + let state = ArtcraftMarble1p0DraftRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p0Draft(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/cost.rs new file mode 100644 index 0000000000..f416506e13 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 16, + text: 24, + image_non_panorama: 24, + multi_image: 26, + video: 26, +}; + +/// Marble 1.0 Draft via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0DraftCostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p0DraftCostState { + pub fn from_request(request: &ArtcraftMarble1p0DraftRequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_24_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 24); + } + + #[test] + fn single_image_is_24_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 24); + } + + #[test] + fn panoramic_image_is_16_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 16); + } + + #[test] + fn multi_image_is_26_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 26); + } + + #[test] + fn video_is_26_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 26); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/request.rs new file mode 100644 index 0000000000..424e49ebaa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/request.rs @@ -0,0 +1,58 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0DraftRequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p0DraftRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; + use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_world() { + let builder = GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::Artcraft, + prompt: Some("A cozy cabin in the snowy mountains".to_string()), + ..Default::default() + }; + + let client = get_artcraft_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + SplatGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateSplatResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + assert!(matches!(response, GenerateSplatResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/build.rs new file mode 100644 index 0000000000..391aaa143c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p1(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p1)?; + let state = ArtcraftMarble1p1RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p1(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/cost.rs new file mode 100644 index 0000000000..8c22dc1d78 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 156, + text: 165, + image_non_panorama: 165, + multi_image: 167, + video: 167, +}; + +/// Marble 1.1 via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1CostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p1CostState { + pub fn from_request(request: &ArtcraftMarble1p1RequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_165_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 165); + } + + #[test] + fn single_image_is_165_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 165); + } + + #[test] + fn panoramic_image_is_156_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 156); + } + + #[test] + fn multi_image_is_167_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 167); + } + + #[test] + fn video_is_167_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 167); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/request.rs new file mode 100644 index 0000000000..3a311d0c71 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1RequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/build.rs new file mode 100644 index 0000000000..4b19546b41 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p1_plus(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p1Plus)?; + let state = ArtcraftMarble1p1PlusRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p1Plus(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/cost.rs new file mode 100644 index 0000000000..dd2ac330f0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 312, + text: 321, + image_non_panorama: 321, + multi_image: 323, + video: 323, +}; + +/// Marble 1.1 Plus via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1PlusCostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p1PlusCostState { + pub fn from_request(request: &ArtcraftMarble1p1PlusRequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_321_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 321); + } + + #[test] + fn single_image_is_321_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 321); + } + + #[test] + fn panoramic_image_is_312_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 312); + } + + #[test] + fn multi_image_is_323_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 323); + } + + #[test] + fn video_is_323_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 323); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1Plus, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/request.rs new file mode 100644 index 0000000000..aff347a04d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1PlusRequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p1PlusRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/mod.rs new file mode 100644 index 0000000000..7db75e463b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod build_common; +pub(crate) mod cost_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod marble_1p0; +pub mod marble_1p0_draft; +pub mod marble_1p1; +pub mod marble_1p1_plus; +pub mod triposplat; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..f44c5bdf35 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/request_common.rs @@ -0,0 +1,29 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::splat::omni_gen_splat::omni_gen_splat_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::generate_splat_response::{ + ArtcraftSplatResponsePayload, GenerateSplatResponse, +}; + +/// Send a splat generation request to the Artcraft omni-gen endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_splat_request( + request: &OmniGenSplatCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_splat_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload { + inference_job_token: response.inference_job_token, + all_inference_job_tokens: response.all_job_tokens, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..e5d6df9329 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/resolve.rs @@ -0,0 +1,34 @@ +//! Shared resolve helpers for the Artcraft splat provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::video_ref::VideoRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_video_ref( + video_ref: Option, +) -> Result, ArtcraftRouterError> { + match video_ref { + None => Ok(None), + Some(VideoRef::MediaFileToken(token)) => Ok(Some(token)), + Some(VideoRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/build.rs new file mode 100644 index 0000000000..ed53fb234a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::triposplat::request::ArtcraftTripoSplatRequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_triposplat(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::TripoSplat)?; + let state = ArtcraftTripoSplatRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftTripoSplat(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/cost.rs new file mode 100644 index 0000000000..ae3ea179a8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/cost.rs @@ -0,0 +1,51 @@ +use crate::generate::generate_splat::providers::artcraft::cost_common::artcraft_splat_cost_estimate; +use crate::generate::generate_splat::providers::artcraft::triposplat::request::ArtcraftTripoSplatRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const FLAT_COST_IN_USD_CENTS: u64 = 7; + +/// TripoSplat via Artcraft is flat priced: it takes a single input image and +/// no option affects the price. +#[derive(Clone, Debug)] +pub struct ArtcraftTripoSplatCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftTripoSplatCostState { + pub fn from_request(_request: &ArtcraftTripoSplatRequestState) -> Self { + Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn flat_seven_cents() { + let builder = GenerateSplatRequestBuilder { + model: RouterSplatModel::TripoSplat, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..Default::default() + }; + let cost = builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present"); + assert_eq!(cost, 7); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/request.rs new file mode 100644 index 0000000000..0d2b8e3faf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/triposplat/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftTripoSplatRequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftTripoSplatRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/mod.rs new file mode 100644 index 0000000000..14e263d977 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod request_common; +pub mod triposplat; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/request_common.rs new file mode 100644 index 0000000000..0fbdfe10cb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/request_common.rs @@ -0,0 +1,44 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::{ + FalSplatResponsePayload, GenerateSplatResponse, +}; + +/// Send a splat request to fal using the client's dispatch mode (webhook or +/// queue). Shared by all Fal splat request states. +pub(crate) async fn send_fal_splat_request( + request: &R, + client: &RouterFalClient, +) -> Result +where + R: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + + let payload = if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + FalSplatResponsePayload { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + } + } else { + let response = request.send_queue_request(&client.api_key).await?; + FalSplatResponsePayload { + request_id: Some(response.request_id), + gateway_request_id: None, + maybe_status_url: Some(response.status_url), + maybe_response_url: Some(response.response_url), + maybe_outbound_request: Some(outbound), + } + }; + + Ok(GenerateSplatResponse::Fal(payload)) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/build.rs new file mode 100644 index 0000000000..08f2e4a6a3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/build.rs @@ -0,0 +1,228 @@ +use fal_client::requests::api::splat::image::triposplat_image_to_splat::api::{ + TripoSplatImageToSplatRequest, TripoSplatOutputFormat, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_mesh::providers::reject_unsupported::reject_unsupported_option; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::fal::triposplat::request::FalTripoSplatRequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +/// TripoSplat takes exactly one input image and nothing else. Prompts, +/// videos, panorama and recaption flags are unsupported: rejected under +/// `ErrorOut`, dropped otherwise. +pub fn build_fal_triposplat(builder: GenerateSplatRequestBuilder) -> Result { + let state = build_fal_triposplat_state(builder)?; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::FalTripoSplat(state))) +} + +pub(crate) fn build_fal_triposplat_state( + mut builder: GenerateSplatRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("reference_video", builder.reference_video.as_ref(), strategy)?; + if builder.is_panoramic == Some(true) { + reject_unsupported_option("is_panoramic", builder.is_panoramic.as_ref(), strategy)?; + } + reject_unsupported_option("disable_recaption", builder.disable_recaption.as_ref(), strategy)?; + + let image_url = plan_single_image_url(builder.reference_images.take(), strategy)? + .ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "TripoSplat requires an input image".to_string(), + ) + })?; + + let request = TripoSplatImageToSplatRequest { + image_url, + num_gaussians: None, + num_inference_steps: None, + guidance_scale: None, + // Always request PLY output — it's the format our pipeline stores. + output_format: Some(TripoSplatOutputFormat::Ply), + seed: None, + enable_safety_checker: None, + }; + Ok(FalTripoSplatRequestState { request }) +} + +/// Resolve the single input image from `reference_images`. More than one +/// image rejects under `ErrorOut`; the other strategies keep the first. +/// Fal only takes URLs; media file tokens are rejected. +fn plan_single_image_url( + reference_images: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let urls = match reference_images { + None => return Ok(None), + Some(ImageListRef::Urls(urls)) => urls, + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(ImageListRef::MediaFileTokens(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + }; + + if urls.len() > 1 { + if let RequestMismatchMitigationStrategy::ErrorOut = strategy { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("Expected at most 1 image, got {}", urls.len()), + })); + } + } + Ok(urls.into_iter().next()) +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/object.png"; + + mod input_tests { + use super::*; + + #[test] + fn single_image_builds() { + let state = build_fal_triposplat_state(image_builder()).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + } + + #[test] + fn output_format_is_always_ply() { + let state = build_fal_triposplat_state(image_builder()).expect("build"); + assert_eq!(state.request.output_format, Some(TripoSplatOutputFormat::Ply)); + } + + #[test] + fn no_image_is_rejected() { + let result = build_fal_triposplat_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + + #[test] + fn media_tokens_are_rejected() { + use tokens::tokens::media_files::MediaFileToken; + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_triposplat_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn multiple_images_error_out_under_error_out() { + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/second.png".to_string(), + ])), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_triposplat_state(builder).is_err()); + } + + #[test] + fn multiple_images_keep_the_first_under_lenient_strategies() { + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/second.png".to_string(), + ])), + ..base_builder() + }; + let state = build_fal_triposplat_state(builder).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + } + } + + mod unsupported_option_tests { + use super::*; + + #[test] + fn unsupported_options_error_out_under_error_out() { + let cases = [ + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin".to_string()), + ..strict_image_builder() + }, + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::Url("https://example.com/v.mp4".to_string())), + ..strict_image_builder() + }, + GenerateSplatRequestBuilder { + is_panoramic: Some(true), + ..strict_image_builder() + }, + GenerateSplatRequestBuilder { + disable_recaption: Some(true), + ..strict_image_builder() + }, + ]; + for builder in cases { + assert!(build_fal_triposplat_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin".to_string()), + is_panoramic: Some(true), + disable_recaption: Some(true), + ..image_builder() + }; + assert!(build_fal_triposplat_state(builder).is_ok()); + } + + #[test] + fn is_panoramic_false_is_not_a_mismatch() { + let builder = GenerateSplatRequestBuilder { + is_panoramic: Some(false), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_triposplat_state(builder).is_ok()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::TripoSplat, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..base_builder() + } + } + + fn strict_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/cost.rs new file mode 100644 index 0000000000..aa79c56426 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/cost.rs @@ -0,0 +1,59 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_splat::providers::fal::triposplat::request::FalTripoSplatRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +/// Cost math is owned by fal_client's `FalRequestCostCalculator` — the +/// router state just forwards the result so router cost ≡ fal_client cost +/// by construction. TripoSplat is flat 5¢. +#[derive(Clone, Debug)] +pub struct FalTripoSplatCostState { + pub cost_in_usd_cents: u64, +} + +impl FalTripoSplatCostState { + pub fn from_request(request: &FalTripoSplatRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + SplatGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn flat_five_cents() { + let builder = GenerateSplatRequestBuilder { + model: RouterSplatModel::TripoSplat, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![ + "https://example.com/object.png".to_string(), + ])), + ..Default::default() + }; + let cost = builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present"); + assert_eq!(cost, 5); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/request.rs new file mode 100644 index 0000000000..3e756b1f50 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/fal/triposplat/request.rs @@ -0,0 +1,62 @@ +use fal_client::requests::api::splat::image::triposplat_image_to_splat::api::TripoSplatImageToSplatRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::fal::request_common::send_fal_splat_request; + +#[derive(Clone, Debug)] +pub struct FalTripoSplatRequestState { + /// Final materialized request; ready to fire. + pub request: TripoSplatImageToSplatRequest, +} + +impl FalTripoSplatRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_splat_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; + use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_triposplat_image_to_splat() { + let client = get_fal_client(); + + let builder = GenerateSplatRequestBuilder { + model: RouterSplatModel::TripoSplat, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![ + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ])), + ..Default::default() + }; + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + SplatGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal splat skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = match response { + GenerateSplatResponse::Fal(p) => p, + other => panic!("expected Fal payload, got {other:?}"), + }; + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/mod.rs new file mode 100644 index 0000000000..d24733020b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod artcraft; +pub mod fal; +pub mod worldlabs; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/build_common.rs new file mode 100644 index 0000000000..59fcbd0a15 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/build_common.rs @@ -0,0 +1,54 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::api::requests::generate_world::http_request::WorldPrompt; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; +use crate::generate::generate_splat::providers::worldlabs::resolve::{plan_splat_input, SplatInput}; + +/// Either a ready-to-send request (text input) or a draft that must upload +/// media assets first. Wrapped into the per-model states by each model's +/// `build.rs` shim. +#[derive(Clone, Debug)] +pub(crate) enum WorldLabsSplatDraftOrRequest { + Draft(WorldLabsSplatDraft), + Request(WorldLabsSplatRequest), +} + +/// Build a World Labs splat draft-or-request from the builder. Text-only +/// prompts go DIRECT (no upload); any media input needs the draft phase to +/// re-upload the media as World Labs media assets. +pub(crate) fn build_worldlabs_splat( + mut builder: GenerateSplatRequestBuilder, + model: WorldLabsModel, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let prompt = builder.prompt.take(); + + let input = plan_splat_input( + builder.reference_images.take(), + builder.reference_video.take(), + builder.is_panoramic, + prompt.is_some(), + strategy, + )?; + + match input { + SplatInput::Text => { + let world_prompt = WorldPrompt::Text { + text_prompt: prompt, + disable_recaption: builder.disable_recaption, + }; + Ok(WorldLabsSplatDraftOrRequest::Request(WorldLabsSplatRequest { model, world_prompt })) + } + media_input => { + Ok(WorldLabsSplatDraftOrRequest::Draft(WorldLabsSplatDraft { + model, + text_prompt: prompt, + disable_recaption: builder.disable_recaption, + input: media_input, + })) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/draft_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/draft_common.rs new file mode 100644 index 0000000000..bca7b4dc88 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/draft_common.rs @@ -0,0 +1,263 @@ +use std::time::Duration; + +use tokens::tokens::media_files::MediaFileToken; +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::api::requests::generate_world::http_request::{ + ContentReference, SphericallyLocatedContent, WorldPrompt, +}; +use worldlabs_api_client::api::requests::prepare_upload::prepare_upload::{ + prepare_upload, MediaAssetKind, PrepareUploadArgs, +}; +use worldlabs_api_client::api::requests::upload_to_signed_url::upload_to_signed_url::{ + upload_to_signed_url, UploadToSignedUrlArgs, +}; +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::api::image_ref::ImageRef; +use crate::api::video_ref::VideoRef; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; +use crate::generate::generate_splat::providers::worldlabs::resolve::SplatInput; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; +use crate::utils::download_file::download_file; + +/// How long to wait for each prepare/upload call during the draft phase. +const UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); + +/// A World Labs splat draft, shared by all marble model draft states. Holds +/// unresolved media references that `to_request()` downloads from their +/// source URLs and re-uploads as World Labs media assets. +#[derive(Clone, Debug)] +pub struct WorldLabsSplatDraft { + pub model: WorldLabsModel, + pub text_prompt: Option, + pub disable_recaption: Option, + + /// Pending media input to resolve and upload. Never `SplatInput::Text` — + /// text-only prompts skip the draft phase. + pub(crate) input: SplatInput, +} + +impl WorldLabsSplatDraft { + + pub async fn to_request( + &self, + draft_context: &SplatGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_worldlabs_client_ref()?; + + let world_prompt = match &self.input { + // Text-only prompts never enter the draft phase, but assemble the + // prompt anyway so the state machine has no dead ends. + SplatInput::Text => WorldPrompt::Text { + text_prompt: self.text_prompt.clone(), + disable_recaption: self.disable_recaption, + }, + SplatInput::Image { image, is_panoramic } => { + let source_url = resolve_image_source_url(image, draft_context)?; + let content = upload_media_asset(client, &source_url, MediaAssetKind::Image).await?; + WorldPrompt::Image { + image_prompt: content, + text_prompt: self.text_prompt.clone(), + is_pano: Some(*is_panoramic), + disable_recaption: self.disable_recaption, + } + } + SplatInput::MultiImage { images } => { + let mut multi_image_prompt = Vec::with_capacity(images.len()); + for image in images { + let source_url = resolve_image_source_url(image, draft_context)?; + let content = upload_media_asset(client, &source_url, MediaAssetKind::Image).await?; + // Spherical coordinates aren't exposed through the router yet, so + // every image is submitted without an azimuth. + multi_image_prompt.push(SphericallyLocatedContent { content, azimuth: None }); + } + WorldPrompt::MultiImage { + multi_image_prompt, + text_prompt: self.text_prompt.clone(), + reconstruct_images: None, + disable_recaption: self.disable_recaption, + } + } + SplatInput::Video { video } => { + let source_url = resolve_video_source_url(video, draft_context)?; + let content = upload_media_asset(client, &source_url, MediaAssetKind::Video).await?; + WorldPrompt::Video { + video_prompt: content, + text_prompt: self.text_prompt.clone(), + disable_recaption: self.disable_recaption, + } + } + }; + + Ok(WorldLabsSplatRequest { + model: self.model, + world_prompt, + }) + } + + /// The pricing input type of the pending input. + pub(crate) fn input_type(&self) -> InputType { + self.input.to_input_type() + } +} + +// ── Resolve/upload helpers ── + +/// Download the media from its source URL and re-upload it as a World Labs +/// media asset, returning the content reference for the generation prompt. +async fn upload_media_asset( + client: &RouterWorldLabsClient, + source_url: &str, + kind: MediaAssetKind, +) -> Result { + let file_bytes = download_file(source_url).await?; + + let file_name = file_name_for_upload(source_url, kind); + let content_type = content_type_for_file_name(&file_name, kind); + + let prepared = prepare_upload(PrepareUploadArgs { + creds: &client.creds, + file_name: &file_name, + kind, + request_timeout: Some(UPLOAD_REQUEST_TIMEOUT), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::WorldLabs(err)))?; + + upload_to_signed_url(UploadToSignedUrlArgs { + upload_url: &prepared.upload_url, + file_bytes, + required_headers: &prepared.required_headers, + content_type, + request_timeout: Some(UPLOAD_REQUEST_TIMEOUT), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::WorldLabs(err)))?; + + Ok(ContentReference::MediaAsset { + media_asset_id: prepared.media_asset_id.as_str().to_string(), + }) +} + +fn resolve_image_source_url( + image: &ImageRef, + draft_context: &SplatGenerationDraftContext<'_>, +) -> Result { + match image { + ImageRef::Url(url) => Ok(url.clone()), + ImageRef::MediaFileToken(token) => resolve_token_source_url(token, draft_context), + } +} + +fn resolve_video_source_url( + video: &VideoRef, + draft_context: &SplatGenerationDraftContext<'_>, +) -> Result { + match video { + VideoRef::Url(url) => Ok(url.clone()), + VideoRef::MediaFileToken(token) => resolve_token_source_url(token, draft_context), + } +} + +fn resolve_token_source_url( + token: &MediaFileToken, + draft_context: &SplatGenerationDraftContext<'_>, +) -> Result { + let map = draft_context.media_file_to_artcraft_url_map + .ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + }) +} + +// ── File name / content type helpers ── + +fn file_name_for_upload(source_url: &str, kind: MediaAssetKind) -> String { + let default_extension = match kind { + MediaAssetKind::Image => "png", + MediaAssetKind::Video => "mp4", + }; + let extension = extension_from_url(source_url) + .unwrap_or_else(|| default_extension.to_string()); + format!("upload.{extension}") +} + +fn extension_from_url(url: &str) -> Option { + let path = url.split(['?', '#']).next().unwrap_or(url); + let file_name = path.rsplit('/').next()?; + let (_, extension) = file_name.rsplit_once('.')?; + if extension.is_empty() || extension.len() > 5 { + return None; + } + Some(extension.to_lowercase()) +} + +fn content_type_for_file_name(file_name: &str, kind: MediaAssetKind) -> &'static str { + let extension = file_name.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); + match extension { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "webm" => "video/webm", + _ => match kind { + MediaAssetKind::Image => "image/png", + MediaAssetKind::Video => "video/mp4", + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod extension_tests { + use super::*; + + #[test] + fn extension_is_parsed_from_url_path() { + assert_eq!(extension_from_url("https://example.com/a/b/photo.JPG").as_deref(), Some("jpg")); + assert_eq!(extension_from_url("https://example.com/clip.mp4?query=1").as_deref(), Some("mp4")); + assert_eq!(extension_from_url("https://example.com/pic.png#frag").as_deref(), Some("png")); + } + + #[test] + fn missing_extension_returns_none() { + assert_eq!(extension_from_url("https://example.com/no-extension"), None); + assert_eq!(extension_from_url("https://example.com/dir/"), None); + } + + #[test] + fn file_name_falls_back_to_kind_default() { + assert_eq!(file_name_for_upload("https://example.com/x", MediaAssetKind::Image), "upload.png"); + assert_eq!(file_name_for_upload("https://example.com/x", MediaAssetKind::Video), "upload.mp4"); + assert_eq!(file_name_for_upload("https://example.com/a.webp", MediaAssetKind::Image), "upload.webp"); + } + } + + mod content_type_tests { + use super::*; + + #[test] + fn known_extensions_map_to_content_types() { + assert_eq!(content_type_for_file_name("upload.jpg", MediaAssetKind::Image), "image/jpeg"); + assert_eq!(content_type_for_file_name("upload.png", MediaAssetKind::Image), "image/png"); + assert_eq!(content_type_for_file_name("upload.mp4", MediaAssetKind::Video), "video/mp4"); + assert_eq!(content_type_for_file_name("upload.mov", MediaAssetKind::Video), "video/quicktime"); + } + + #[test] + fn unknown_extensions_fall_back_to_kind_default() { + assert_eq!(content_type_for_file_name("upload.xyz", MediaAssetKind::Image), "image/png"); + assert_eq!(content_type_for_file_name("upload.xyz", MediaAssetKind::Video), "video/mp4"); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/build.rs new file mode 100644 index 0000000000..925b7c2308 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::draft::WorldLabsMarble1p0DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p0(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p0)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p0RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p0(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p0DraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p0(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/cost.rs new file mode 100644 index 0000000000..6c0890eedf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::draft::WorldLabsMarble1p0DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p0; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0CostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p0CostState { + pub fn from_request(request: &WorldLabsMarble1p0RequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p0DraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_126_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 126); + } + + #[test] + fn single_image_is_126_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 126); + } + + #[test] + fn panoramic_image_is_120_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 120); + } + + #[test] + fn multi_image_is_128_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 128); + } + + #[test] + fn video_is_128_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 128); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(1580)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/draft.rs new file mode 100644 index 0000000000..e4dae6904f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p0DraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p0RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/request.rs new file mode 100644 index 0000000000..ea0d151c60 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/request.rs @@ -0,0 +1,17 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0RequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p0RequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/build.rs new file mode 100644 index 0000000000..1d11a6545e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::draft::WorldLabsMarble1p0DraftModelDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p0_draft(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p0Draft)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p0DraftModelRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p0Draft(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p0DraftModelDraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/cost.rs new file mode 100644 index 0000000000..0ef5810c21 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::draft::WorldLabsMarble1p0DraftModelDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p0Draft; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftModelCostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p0DraftModelCostState { + pub fn from_request(request: &WorldLabsMarble1p0DraftModelRequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p0DraftModelDraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_18_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 18); + } + + #[test] + fn single_image_is_18_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 18); + } + + #[test] + fn panoramic_image_is_12_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 12); + } + + #[test] + fn multi_image_is_20_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 20); + } + + #[test] + fn video_is_20_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 20); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(230)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/draft.rs new file mode 100644 index 0000000000..67fa389cd2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftModelDraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p0DraftModelDraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p0DraftModelRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/request.rs new file mode 100644 index 0000000000..6fbfec0b7a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/request.rs @@ -0,0 +1,102 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftModelRequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p0DraftModelRequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::client::router_client::RouterClient; + use crate::client::router_worldlabs_client::RouterWorldLabsClient; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; + use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + + const WORLDLABS_API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/world_labs_api_key.txt"; + + #[tokio::test] + #[ignore] // sends a real generation to World Labs, incurs cost + async fn text_to_world() { + let response = run_pipeline(GenerateSplatRequestBuilder { + prompt: Some("A cozy cabin in the snowy mountains".to_string()), + ..base_builder() + }).await; + let payload = response.get_worldlabs_payload().expect("expected WorldLabs payload"); + assert!(!payload.operation_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to World Labs, incurs cost; uploads media + async fn image_to_world() { + let response = run_pipeline(GenerateSplatRequestBuilder { + prompt: Some("Mountain landscape with a tree".to_string()), + reference_images: Some(ImageListRef::Urls(vec![MOUNTAIN_TREE_IMAGE_URL.to_string()])), + ..base_builder() + }).await; + let payload = response.get_worldlabs_payload().expect("expected WorldLabs payload"); + assert!(!payload.operation_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn get_worldlabs_client() -> RouterClient { + let api_key = std::fs::read_to_string(WORLDLABS_API_KEY_PATH) + .expect("Failed to read world_labs_api_key.txt"); + RouterClient::WorldLabs(RouterWorldLabsClient::new_from_raw_key(api_key.trim())) + } + + async fn run_pipeline(builder: GenerateSplatRequestBuilder) -> GenerateSplatResponse { + let client = get_worldlabs_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + SplatGenerationDraftOrRequest::Request(request) => request, + SplatGenerationDraftOrRequest::Draft(draft) => { + let draft_context = SplatGenerationDraftContext { + client: Some(&client), + ..Default::default() + }; + draft.finalize(draft_context).await.expect("finalize should succeed") + } + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateSplatResponse::WorldLabs(p) => { + println!("operation_id={}, done={}", p.operation_id, p.done); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/build.rs new file mode 100644 index 0000000000..2972c3485b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::draft::WorldLabsMarble1p1DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p1(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p1)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p1RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p1(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p1DraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p1(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/cost.rs new file mode 100644 index 0000000000..2d7bc205ad --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::draft::WorldLabsMarble1p1DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p1; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1CostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p1CostState { + pub fn from_request(request: &WorldLabsMarble1p1RequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p1DraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_126_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 126); + } + + #[test] + fn single_image_is_126_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 126); + } + + #[test] + fn panoramic_image_is_120_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 120); + } + + #[test] + fn multi_image_is_128_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 128); + } + + #[test] + fn video_is_128_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 128); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(1580)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/draft.rs new file mode 100644 index 0000000000..457e6e41a2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1DraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p1DraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p1RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/request.rs new file mode 100644 index 0000000000..8556d4aae6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/request.rs @@ -0,0 +1,17 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1RequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p1RequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/build.rs new file mode 100644 index 0000000000..899930f9e4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::draft::WorldLabsMarble1p1PlusDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p1_plus(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p1Plus)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p1PlusRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p1Plus(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p1PlusDraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/cost.rs new file mode 100644 index 0000000000..9afa2dd53a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::draft::WorldLabsMarble1p1PlusDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p1Plus; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1PlusCostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p1PlusCostState { + pub fn from_request(request: &WorldLabsMarble1p1PlusRequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p1PlusDraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_246_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 246); + } + + #[test] + fn single_image_is_246_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 246); + } + + #[test] + fn panoramic_image_is_240_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 240); + } + + #[test] + fn multi_image_is_248_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 248); + } + + #[test] + fn video_is_248_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 248); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(3080)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1Plus, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/draft.rs new file mode 100644 index 0000000000..986dddf115 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1PlusDraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p1PlusDraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p1PlusRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/request.rs new file mode 100644 index 0000000000..d1bb0a2aa1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/request.rs @@ -0,0 +1,17 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1PlusRequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p1PlusRequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/mod.rs new file mode 100644 index 0000000000..acf8c39812 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod build_common; +pub(crate) mod draft_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod marble_1p0; +pub mod marble_1p0_draft; +pub mod marble_1p1; +pub mod marble_1p1_plus; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/request_common.rs new file mode 100644 index 0000000000..c4bc907b4b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/request_common.rs @@ -0,0 +1,67 @@ +use std::time::Duration; + +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::api::requests::generate_world::generate_world::{ + generate_world, GenerateWorldArgs, +}; +use worldlabs_api_client::api::requests::generate_world::http_request::WorldPrompt; +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::generate_splat_response::{ + GenerateSplatResponse, WorldLabsSplatResponsePayload, +}; + +/// How long to wait for the World Labs generate call to accept the request. +/// The call only starts the generation; status is polled separately via the +/// returned operation ID. +const GENERATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// A fully materialized World Labs generation request, shared by all marble +/// model request states. Media references (if any) have already been +/// uploaded as World Labs media assets by the draft phase. +#[derive(Clone, Debug)] +pub struct WorldLabsSplatRequest { + pub model: WorldLabsModel, + pub world_prompt: WorldPrompt, +} + +impl WorldLabsSplatRequest { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + let response = generate_world(GenerateWorldArgs { + creds: &client.creds, + world_prompt: self.world_prompt.clone(), + display_name: None, + model: self.model, + seed: None, + tags: None, + permission: None, + request_timeout: Some(GENERATE_REQUEST_TIMEOUT), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::WorldLabs(err)))?; + + Ok(GenerateSplatResponse::WorldLabs(WorldLabsSplatResponsePayload { + operation_id: response.operation_id.as_str().to_string(), + done: response.done, + })) + } + + /// The pricing input type of the assembled prompt. + pub(crate) fn input_type(&self) -> InputType { + match &self.world_prompt { + WorldPrompt::Text { .. } => InputType::Text, + WorldPrompt::Image { is_pano, .. } => { + if is_pano.unwrap_or(false) { + InputType::ImagePanorama + } else { + InputType::ImageNonPanorama + } + } + WorldPrompt::MultiImage { .. } => InputType::MultiImage, + WorldPrompt::Video { .. } => InputType::Video, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/resolve.rs new file mode 100644 index 0000000000..528ece4a05 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/resolve.rs @@ -0,0 +1,211 @@ +//! Input planning for the World Labs splat provider. +//! +//! The derivation is shared by request assembly and pricing so the two +//! always agree on the input type. + +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_ref::VideoRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// The planned splat input, derived from the builder's media references. +#[derive(Clone, Debug)] +pub(crate) enum SplatInput { + Text, + Image { image: ImageRef, is_panoramic: bool }, + MultiImage { images: Vec }, + Video { video: VideoRef }, +} + +impl SplatInput { + pub(crate) fn to_input_type(&self) -> InputType { + match self { + SplatInput::Text => InputType::Text, + SplatInput::Image { is_panoramic: true, .. } => InputType::ImagePanorama, + SplatInput::Image { is_panoramic: false, .. } => InputType::ImageNonPanorama, + SplatInput::MultiImage { .. } => InputType::MultiImage, + SplatInput::Video { .. } => InputType::Video, + } + } +} + +/// Derive the splat input from the builder's references: +/// - a reference video wins (images alongside it reject under `ErrorOut` and +/// are dropped otherwise), +/// - two or more reference images are multi-image input, +/// - one reference image is a single panorama or non-panorama image, +/// - otherwise a prompt is required for text input. +pub(crate) fn plan_splat_input( + reference_images: Option, + reference_video: Option, + is_panoramic: Option, + has_prompt: bool, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let mut images = image_list_to_refs(reference_images); + + if let Some(video) = reference_video { + if !images.is_empty() { + if let RequestMismatchMitigationStrategy::ErrorOut = strategy { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Reference images cannot be combined with a reference video".to_string(), + })); + } + } + return Ok(SplatInput::Video { video }); + } + + match images.len() { + 0 => { + if has_prompt { + Ok(SplatInput::Text) + } else { + Err(ArtcraftRouterError::InvalidInput( + "A prompt, reference image, or reference video is required for splat generation".to_string(), + )) + } + } + 1 => Ok(SplatInput::Image { + image: images.remove(0), + is_panoramic: is_panoramic.unwrap_or(false), + }), + _ => Ok(SplatInput::MultiImage { images }), + } +} + +fn image_list_to_refs(reference_images: Option) -> Vec { + match reference_images { + None => Vec::new(), + Some(ImageListRef::Urls(urls)) => urls.into_iter().map(ImageRef::Url).collect(), + Some(ImageListRef::MediaFileTokens(tokens)) => { + tokens.into_iter().map(ImageRef::MediaFileToken).collect() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IMAGE_URL: &str = "https://example.com/room.png"; + const VIDEO_URL: &str = "https://example.com/room.mp4"; + + #[test] + fn video_wins() { + let input = plan_splat_input( + None, + Some(VideoRef::Url(VIDEO_URL.to_string())), + None, + true, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert!(matches!(input, SplatInput::Video { .. })); + assert_eq!(input.to_input_type(), InputType::Video); + } + + #[test] + fn video_with_images_errors_out_under_error_out() { + let result = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + Some(VideoRef::Url(VIDEO_URL.to_string())), + None, + false, + RequestMismatchMitigationStrategy::ErrorOut, + ); + assert!(result.is_err()); + } + + #[test] + fn video_with_images_drops_the_images_under_lenient_strategies() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + Some(VideoRef::Url(VIDEO_URL.to_string())), + None, + false, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + ).expect("plan"); + assert!(matches!(input, SplatInput::Video { .. })); + } + + #[test] + fn two_images_are_multi_image() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + None, + None, + false, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::MultiImage); + } + + #[test] + fn one_panoramic_image_is_image_panorama() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + None, + Some(true), + false, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::ImagePanorama); + } + + #[test] + fn one_image_is_image_non_panorama() { + for is_panoramic in [None, Some(false)] { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + None, + is_panoramic, + false, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::ImageNonPanorama); + } + } + + #[test] + fn prompt_only_is_text() { + let input = plan_splat_input( + None, + None, + None, + true, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::Text); + } + + #[test] + fn empty_input_is_rejected() { + let result = plan_splat_input( + None, + None, + None, + false, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + ); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + + #[test] + fn empty_image_list_counts_as_no_images() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![])), + None, + None, + true, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::Text); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs new file mode 100644 index 0000000000..a9b93682cd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs @@ -0,0 +1,16 @@ +/// The result of estimating the cost of a splat generation plan. +#[derive(Clone, Debug)] +pub struct SplatGenerationCostEstimate { + pub cost_in_credits: Option, + pub cost_in_usd_cents: Option, + pub is_free: bool, + pub is_unlimited: bool, + pub is_rate_limited: bool, + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft.rs new file mode 100644 index 0000000000..381b686779 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft.rs @@ -0,0 +1,75 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::cost::WorldLabsMarble1p0CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::draft::WorldLabsMarble1p0DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::cost::WorldLabsMarble1p0DraftModelCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::draft::WorldLabsMarble1p0DraftModelDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::cost::WorldLabsMarble1p1CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::draft::WorldLabsMarble1p1DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::cost::WorldLabsMarble1p1PlusCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::draft::WorldLabsMarble1p1PlusDraftState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +/** + * Wrapper for all splat generation draft requests. + * + * Only the World Labs media inputs need a draft phase: reference images and + * videos must be downloaded from our CDN and re-uploaded to World Labs media + * assets before the request can be sent. Text-only prompts (and the Artcraft + * provider, which takes media tokens directly) return a `Request` from + * `build2()`. + */ +#[derive(Clone, Debug)] +pub enum SplatGenerationDraftRequest { + WorldLabsMarble1p0(WorldLabsMarble1p0DraftState), + WorldLabsMarble1p0Draft(WorldLabsMarble1p0DraftModelDraftState), + WorldLabsMarble1p1(WorldLabsMarble1p1DraftState), + WorldLabsMarble1p1Plus(WorldLabsMarble1p1PlusDraftState), +} + +impl SplatGenerationDraftRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::WorldLabsMarble1p0(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p0Draft(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1Plus(_) => RouterProvider::WorldLabs, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + SplatGenerationDraftRequest::WorldLabsMarble1p0(draft) => Ok(WorldLabsMarble1p0CostState::from_draft(draft).estimate_cost()), + SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(draft) => Ok(WorldLabsMarble1p0DraftModelCostState::from_draft(draft).estimate_cost()), + SplatGenerationDraftRequest::WorldLabsMarble1p1(draft) => Ok(WorldLabsMarble1p1CostState::from_draft(draft).estimate_cost()), + SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(draft) => Ok(WorldLabsMarble1p1PlusCostState::from_draft(draft).estimate_cost()), + } + } + + /// Finalize the draft request before generation + /// This may involve uploading media to the provider. + pub async fn finalize(self, draft_context: SplatGenerationDraftContext<'_>) -> Result { + match self { + SplatGenerationDraftRequest::WorldLabsMarble1p0(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p0(result)) + }, + SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p0Draft(result)) + }, + SplatGenerationDraftRequest::WorldLabsMarble1p1(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p1(result)) + }, + SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p1Plus(result)) + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_context.rs new file mode 100644 index 0000000000..b76894036c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_context.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +#[derive(Clone, Default)] +pub struct SplatGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require authentication. + pub client: Option<&'a RouterClient>, + + /// Optional context: a map of Media File Tokens to their ArtCraft URLs + /// Only needed if we have to fetch these assets and upload them to another provider. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl <'a> SplatGenerationDraftContext<'a> { + pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ArtcraftRouterError> { + let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?; + client.get_worldlabs_client_ref() + .map_err(|err| ArtcraftRouterError::Client(err)) + } +} + +impl Debug for SplatGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SplatGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_or_request.rs new file mode 100644 index 0000000000..5046e6c255 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_or_request.rs @@ -0,0 +1,28 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +#[derive(Debug, Clone)] +pub enum SplatGenerationDraftOrRequest { + Draft(SplatGenerationDraftRequest), + Request(SplatGenerationRequest), +} + +impl SplatGenerationDraftOrRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + SplatGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), + SplatGenerationDraftOrRequest::Request(request) => request.estimate_cost(), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_plan.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_plan.rs new file mode 100644 index 0000000000..42c100ae39 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_plan.rs @@ -0,0 +1,45 @@ +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::cost::artcraft::estimate_splat_cost_artcraft_marble_0p1_mini::estimate_splat_cost_artcraft_marble_0p1_mini; +use crate::generate::generate_splat::cost::artcraft::estimate_splat_cost_artcraft_marble_0p1_plus::estimate_splat_cost_artcraft_marble_0p1_plus; +use crate::generate::generate_splat::execute::artcraft::generate_splat_artcraft_marble_0p1_mini::execute_artcraft_marble_0p1_mini; +use crate::generate::generate_splat::execute::artcraft::generate_splat_artcraft_marble_0p1_plus::execute_artcraft_marble_0p1_plus; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_mini::PlanArtcraftMarble0p1Mini; +use crate::generate::generate_splat::plan::artcraft::plan_generate_splat_artcraft_marble_0p1_plus::PlanArtcraftMarble0p1Plus; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +#[derive(Debug)] +pub enum SplatGenerationPlan { + ArtcraftMarble0p1Mini(PlanArtcraftMarble0p1Mini), + ArtcraftMarble0p1Plus(PlanArtcraftMarble0p1Plus), +} + +impl SplatGenerationPlan { + pub async fn generate_splat( + &self, + client: &RouterClient, + ) -> Result { + match self { + SplatGenerationPlan::ArtcraftMarble0p1Mini(plan) => { + let artcraft_client = client.get_artcraft_client_ref()?; + execute_artcraft_marble_0p1_mini(plan, artcraft_client).await + } + SplatGenerationPlan::ArtcraftMarble0p1Plus(plan) => { + let artcraft_client = client.get_artcraft_client_ref()?; + execute_artcraft_marble_0p1_plus(plan, artcraft_client).await + } + } + } + + pub fn estimate_costs(&self) -> SplatGenerationCostEstimate { + match self { + SplatGenerationPlan::ArtcraftMarble0p1Mini(plan) => { + estimate_splat_cost_artcraft_marble_0p1_mini(plan) + } + SplatGenerationPlan::ArtcraftMarble0p1Plus(plan) => { + estimate_splat_cost_artcraft_marble_0p1_plus(plan) + } + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_request.rs new file mode 100644 index 0000000000..769cff5d39 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_request.rs @@ -0,0 +1,120 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::cost::ArtcraftMarble1p0CostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::cost::ArtcraftMarble1p0DraftCostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::cost::ArtcraftMarble1p1CostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::cost::ArtcraftMarble1p1PlusCostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState; +use crate::generate::generate_splat::providers::artcraft::triposplat::cost::ArtcraftTripoSplatCostState; +use crate::generate::generate_splat::providers::artcraft::triposplat::request::ArtcraftTripoSplatRequestState; +use crate::generate::generate_splat::providers::fal::triposplat::cost::FalTripoSplatCostState; +use crate::generate::generate_splat::providers::fal::triposplat::request::FalTripoSplatRequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::cost::WorldLabsMarble1p0CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::cost::WorldLabsMarble1p0DraftModelCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::cost::WorldLabsMarble1p1CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::cost::WorldLabsMarble1p1PlusCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub enum SplatGenerationRequest { + ArtcraftMarble1p0(ArtcraftMarble1p0RequestState), + ArtcraftMarble1p0Draft(ArtcraftMarble1p0DraftRequestState), + ArtcraftMarble1p1(ArtcraftMarble1p1RequestState), + ArtcraftMarble1p1Plus(ArtcraftMarble1p1PlusRequestState), + ArtcraftTripoSplat(ArtcraftTripoSplatRequestState), + FalTripoSplat(FalTripoSplatRequestState), + WorldLabsMarble1p0(WorldLabsMarble1p0RequestState), + WorldLabsMarble1p0Draft(WorldLabsMarble1p0DraftModelRequestState), + WorldLabsMarble1p1(WorldLabsMarble1p1RequestState), + WorldLabsMarble1p1Plus(WorldLabsMarble1p1PlusRequestState), +} + +impl SplatGenerationRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftMarble1p0(_) => RouterProvider::Artcraft, + Self::ArtcraftMarble1p0Draft(_) => RouterProvider::Artcraft, + Self::ArtcraftMarble1p1(_) => RouterProvider::Artcraft, + Self::ArtcraftMarble1p1Plus(_) => RouterProvider::Artcraft, + Self::ArtcraftTripoSplat(_) => RouterProvider::Artcraft, + Self::FalTripoSplat(_) => RouterProvider::Fal, + Self::WorldLabsMarble1p0(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p0Draft(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1Plus(_) => RouterProvider::WorldLabs, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + SplatGenerationRequest::ArtcraftMarble1p0(request) => Ok(ArtcraftMarble1p0CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftMarble1p0Draft(request) => Ok(ArtcraftMarble1p0DraftCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftMarble1p1(request) => Ok(ArtcraftMarble1p1CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftMarble1p1Plus(request) => Ok(ArtcraftMarble1p1PlusCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftTripoSplat(request) => Ok(ArtcraftTripoSplatCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::FalTripoSplat(request) => Ok(FalTripoSplatCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p0(request) => Ok(WorldLabsMarble1p0CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p0Draft(request) => Ok(WorldLabsMarble1p0DraftModelCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p1(request) => Ok(WorldLabsMarble1p1CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p1Plus(request) => Ok(WorldLabsMarble1p1PlusCostState::from_request(request).estimate_cost()), + } + } + + /// Send the splat generation request + /// If successful, returns the job IDs. + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + SplatGenerationRequest::ArtcraftMarble1p0(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftMarble1p0Draft(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftMarble1p1(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftMarble1p1Plus(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftTripoSplat(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::FalTripoSplat(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p0(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p0Draft(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p1(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p1Plus(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/AGENTS.md b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/AGENTS.md new file mode 100644 index 0000000000..1c6f012adf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/AGENTS.md @@ -0,0 +1,127 @@ +# generate_video + +This module implements the v2 video generation pipeline. It replaces the v1 system +(in `generate_video/`) with a two-phase approach that separates request planning from +execution, allowing cost estimation and media uploads to happen at the right time. + +## Lifecycle + +``` +GenerateVideoRequestBuilder + │ + ▼ build2() +VideoGenerationDraftOrRequest + │ + ├─ Draft(VideoGenerationDraftRequest) + │ │ + │ ▼ estimate_cost() ← cost estimate from draft state + │ │ + │ ▼ finalize(context) ← uploads media, resolves tokens + │ │ + │ ▼ + │ VideoGenerationRequest + │ │ + │ ▼ estimate_cost() ← cost estimate from finalized request + │ │ + │ ▼ send_request() ← fires the API call + │ │ + │ ▼ + │ GenerateVideoResponse + │ + └─ Request(VideoGenerationRequest) ← skips draft phase (used by Artcraft provider) +``` + +### Key types + +- **`GenerateVideoRequestBuilder`** — generic builder with provider/model/prompt/resolution/etc. + Located in `generate_video/generate_video_request_builder.rs`. The `use_new_builder()` method + gates which provider+model combos use the v2 path. `build2()` dispatches to the correct + provider's build function. + +- **`VideoGenerationDraftOrRequest`** — the output of `build2()`. Either `Draft` (for providers + that need media upload/resolution, like Kinovi) or `Request` (for providers that work with + media file tokens directly, like Artcraft). + +- **`VideoGenerationDraftRequest`** — enum with one variant per provider+model. Holds + materialized settings (resolution, duration, batch count) plus unresolved media references. + Can estimate cost without network calls. + +- **`VideoGenerationDraftContext`** — context needed during `finalize()`: the provider client, + media file token-to-URL map, and character token-to-ID map. + +- **`VideoGenerationRequest`** — enum with one variant per provider+model. Holds a fully + materialized request ready to send. All media URLs are resolved and uploaded. + +### Cost estimation + +Cost can be estimated at two stages: +1. **From draft** — before `finalize()`. Uses the planned resolution/duration/batch count. + Good for showing the user a price before committing. +2. **From request** — after `finalize()`. Uses the final materialized request. Should match + the draft estimate for the same parameters. + +## Module layout + +``` +generate_video/ +├── mod.rs +├── video_generation_draft.rs ← VideoGenerationDraftRequest enum +├── video_generation_draft_context.rs ← context for finalize() +├── video_generation_draft_or_request.rs +├── video_generation_request.rs ← VideoGenerationRequest enum +└── providers/ + ├── artcraft/ ← Artcraft provider (uses media file tokens directly) + │ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly) + │ │ ├── mod.rs + │ │ ├── build.rs ← builder → Request conversion + plan helpers + │ │ ├── cost.rs ← standalone cost estimation (independent of kinovi_web_client) + │ │ └── request.rs ← request state + send() via Artcraft multi-function API + │ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only) + │ ├── mod.rs + │ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p) + │ ├── cost.rs ← standalone cost estimation with Fast-specific pricing + │ └── request.rs ← request state + send() via Artcraft omni-gen API + └── kinovi/ ← Kinovi/KinoviWeb provider + ├── mod.rs + ├── resolve.rs ← shared: media token resolution, upload helpers + ├── upload.rs ← shared: download + re-upload to KinoviWeb CDN + ├── seedance_2p0/ ← Seedance 2.0 Pro model + │ ├── mod.rs + │ ├── build.rs ← builder → draft conversion + plan helpers + │ ├── cost.rs ← cost estimation state + │ ├── draft.rs ← draft state + finalize (to_request) + │ └── request.rs ← request state + send() + └── seedance_2p0_fast/ ← Seedance 2.0 Fast model + ├── mod.rs + ├── build.rs + ├── cost.rs + ├── draft.rs + └── request.rs +``` + +## Adding a new provider or model + +1. **Create the module** under `providers/{provider}/{model}/` with these files: + - `mod.rs` — declares sub-modules + - `build.rs` — `build_{provider}_{model}(builder) -> VideoGenerationDraftOrRequest`. + Contains `plan_*` helpers for aspect ratio, resolution, batch count, duration. + - `draft.rs` — draft state struct with `to_request()` async method for finalization. + **Not needed** if the provider uses media tokens directly (no upload/resolution needed); + in that case `build.rs` returns `VideoGenerationDraftOrRequest::Request(...)` directly. + - `cost.rs` — cost state with `from_request()` and `estimate_cost()`. Add `from_draft()` + if the provider uses the draft phase. + - `request.rs` — request state with `send()` async method. + +2. **Register the module** in the parent provider's `mod.rs`. + +3. **Add enum variants** in: + - `video_generation_draft.rs` — `VideoGenerationDraftRequest` enum + `estimate_cost` + `finalize` + (skip if provider has no draft phase) + - `video_generation_request.rs` — `VideoGenerationRequest` enum + `estimate_cost` + `send_request` + +4. **Wire into the builder** in `generate_video_request_builder.rs`: + - Add the `(Provider, Model)` pair to `use_new_builder()` returning `true` + - Add the dispatch arm in `build2()` + +5. **Tests**: each `build.rs` and `cost.rs` should have comprehensive tests. `request.rs` + should have `#[ignore]` live API tests for manual verification. diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/CLAUDE.md b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs new file mode 100644 index 0000000000..c5615f153b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs @@ -0,0 +1,273 @@ +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; +use crate::api::router_resolution::RouterResolution; +use crate::api::router_video_model::RouterVideoModel; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::router_provider::RouterProvider; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video::build::build_artcraft_grok_imagine_video; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::build::build_artcraft_grok_imagine_video_1p5; +use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::build::build_artcraft_happy_horse_1p0; +use crate::generate::generate_video::providers::artcraft::kling_1_6_pro::build::build_artcraft_kling_1_6_pro; +use crate::generate::generate_video::providers::artcraft::kling_2_1_master::build::build_artcraft_kling_2_1_master; +use crate::generate::generate_video::providers::artcraft::kling_2_1_pro::build::build_artcraft_kling_2_1_pro; +use crate::generate::generate_video::providers::artcraft::kling_2_5_turbo_pro::build::build_artcraft_kling_2_5_turbo_pro; +use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::build::build_artcraft_kling_2_6_pro; +use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::build::build_artcraft_kling_3p0_pro; +use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::build::build_artcraft_kling_3p0_standard; +use crate::generate::generate_video::providers::artcraft::preview_model::build::build_artcraft_preview_model; +use crate::generate::generate_video::providers::artcraft::preview_model_fast::build::build_artcraft_preview_model_fast; +use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::build::build_artcraft_seedance_1p0_lite; +use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::build::build_artcraft_seedance_1p5_pro; +use crate::generate::generate_video::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::build::build_artcraft_seedance_2p0_bpu_fast; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::build::build_artcraft_seedance_2p0_mini; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::build::build_artcraft_seedance_2p0_bp_mini; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::build::build_artcraft_seedance_2p0_bpu_mini; +use crate::generate::generate_video::providers::artcraft::sora_2::build::build_artcraft_sora_2; +use crate::generate::generate_video::providers::artcraft::sora_2_pro::build::build_artcraft_sora_2_pro; +use crate::generate::generate_video::providers::artcraft::veo_2::build::build_artcraft_veo_2; +use crate::generate::generate_video::providers::artcraft::veo_3::build::build_artcraft_veo_3; +use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::build_artcraft_veo_3_fast; +use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1; +use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast; +use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite; +use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3; +use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft; +use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3; +use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3; +use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro; +use crate::generate::generate_video::providers::fal::kling_2_1_master::build::build_fal_kling_2_1_master; +use crate::generate::generate_video::providers::fal::kling_2_1_pro::build::build_fal_kling_2_1_pro; +use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::build::build_fal_kling_2_5_turbo_pro; +use crate::generate::generate_video::providers::fal::kling_2_6_pro::build::build_fal_kling_2_6_pro; +use crate::generate::generate_video::providers::fal::kling_3p0_pro::build::build_fal_kling_3p0_pro; +use crate::generate::generate_video::providers::fal::kling_3p0_standard::build::build_fal_kling_3p0_standard; +use crate::generate::generate_video::providers::fal::seedance_1p0_lite::build::build_fal_seedance_1p0_lite; +use crate::generate::generate_video::providers::fal::seedance_1p5_pro::build::build_fal_seedance_1p5_pro; +use crate::generate::generate_video::providers::fal::sora_2::build::build_fal_sora_2; +use crate::generate::generate_video::providers::fal::sora_2_pro::build::build_fal_sora_2_pro; +use crate::generate::generate_video::providers::fal::veo_2::build::build_fal_veo_2; +use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo_3; +use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast; +use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1; +use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast; +use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite; +use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3; +use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft; +use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3; +use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3; +use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini; +use crate::generate::generate_video::providers::artcraft::seedance_2p5::build::build_artcraft_seedance_2p5; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::build::build_artcraft_seedance_2p5_u; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::build::build_kinovi_seedance_2p5; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +/// RouterProvider-agnostic video generation request. Distilled by `build2()` into a +/// `VideoGenerationDraftOrRequest` for the selected (provider, model) pair. +#[derive(Clone, Debug)] +pub struct GenerateVideoRequestBuilder { + /// Which model to use. + pub model: RouterVideoModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The prompt for the video generation + pub prompt: Option, + + /// Some models support negative prompts + pub negative_prompt: Option, + + /// Starting keyframe (optional). + pub start_frame: Option, + + /// Ending keyframe (optional). + pub end_frame: Option, + + /// Reference images (optional). + pub reference_images: Option, + + /// Reference videos (optional). + pub reference_videos: Option, + + /// Reference audio (optional). + pub reference_audio: Option, + + /// Reference characters (optional). + pub reference_character_tokens: Option, + + /// The resolution to use + pub resolution: Option, + + /// The aspect ratio to use + pub aspect_ratio: Option, + + /// The output bitrate to use. + /// Not all models support this; models that don't simply ignore it. + pub bitrate: Option, + + /// How many seconds to generate. + pub duration_seconds: Option, + + /// How many videos to generate. + pub video_batch_count: Option, + + /// Whether to turn on/off audio. + /// Not all models support audio, not all models have a choice. + /// Some models will default this to true, others will default it to false, + /// so it's best to be explicit. + pub generate_audio: Option, + + /// CALCULATION-ONLY (never sent to any provider): the total seconds of + /// reference video input, summed across all reference videos and rounded + /// up to whole seconds per file. Models that bill reference-video input + /// seconds (e.g. Seedance 2.5) add this to the billed duration. + pub total_reference_video_input_seconds: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl Default for GenerateVideoRequestBuilder { + fn default() -> Self { + Self { + model: RouterVideoModel::Seedance2p0, + provider: RouterProvider::Artcraft, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + prompt: None, + negative_prompt: None, + start_frame: None, + end_frame: None, + reference_images: None, + reference_videos: None, + reference_audio: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + bitrate: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + total_reference_video_input_seconds: None, + idempotency_token: None, + } + } +} + +impl GenerateVideoRequestBuilder { + + pub fn build2(self) -> Result { + match (self.provider, self.model) { + // Artcraft + (RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self), + (RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self), + (RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self), + (RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self), + (RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling21Pro) => build_artcraft_kling_2_1_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling2p5TurboPro) => build_artcraft_kling_2_5_turbo_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling2p6Pro) => build_artcraft_kling_2_6_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling3p0Pro) => build_artcraft_kling_3p0_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Kling3p0Standard) => build_artcraft_kling_3p0_standard(self), + (RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraFast) => build_artcraft_seedance_2p0_bpu_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Mini) => build_artcraft_seedance_2p0_mini(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p5) => build_artcraft_seedance_2p5(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Ultra) => build_artcraft_seedance_2p5_u(self), + (RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self), + (RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance1p5Pro) => build_artcraft_seedance_1p5_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Sora2) => build_artcraft_sora_2(self), + (RouterProvider::Artcraft, RouterVideoModel::Sora2Pro) => build_artcraft_sora_2_pro(self), + (RouterProvider::Artcraft, RouterVideoModel::Veo2) => build_artcraft_veo_2(self), + (RouterProvider::Artcraft, RouterVideoModel::Veo3) => build_artcraft_veo_3(self), + (RouterProvider::Artcraft, RouterVideoModel::Veo3Fast) => build_artcraft_veo_3_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Veo3p1) => build_artcraft_veo_3p1(self), + (RouterProvider::Artcraft, RouterVideoModel::Veo3p1Fast) => build_artcraft_veo_3p1_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Veo3p1Lite) => build_artcraft_veo_3p1_lite(self), + (RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self), + (RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self), + // Fal + (RouterProvider::Fal, RouterVideoModel::Flux3) => build_fal_flux_3(self), + (RouterProvider::Fal, RouterVideoModel::Flux3Draft) => build_fal_flux_3_draft(self), + (RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self), + (RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self), + (RouterProvider::Fal, RouterVideoModel::Kling21Pro) => build_fal_kling_2_1_pro(self), + (RouterProvider::Fal, RouterVideoModel::Kling2p5TurboPro) => build_fal_kling_2_5_turbo_pro(self), + (RouterProvider::Fal, RouterVideoModel::Kling2p6Pro) => build_fal_kling_2_6_pro(self), + (RouterProvider::Fal, RouterVideoModel::Kling3p0Pro) => build_fal_kling_3p0_pro(self), + (RouterProvider::Fal, RouterVideoModel::Kling3p0Standard) => build_fal_kling_3p0_standard(self), + (RouterProvider::Fal, RouterVideoModel::MinimaxH3) => build_fal_minimax_h3(self), + (RouterProvider::Fal, RouterVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self), + (RouterProvider::Fal, RouterVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self), + (RouterProvider::Fal, RouterVideoModel::Sora2) => build_fal_sora_2(self), + (RouterProvider::Fal, RouterVideoModel::Sora2Pro) => build_fal_sora_2_pro(self), + (RouterProvider::Fal, RouterVideoModel::Veo2) => build_fal_veo_2(self), + (RouterProvider::Fal, RouterVideoModel::Veo3) => build_fal_veo_3(self), + (RouterProvider::Fal, RouterVideoModel::Veo3Fast) => build_fal_veo_3_fast(self), + (RouterProvider::Fal, RouterVideoModel::Veo3p1) => build_fal_veo_3p1(self), + (RouterProvider::Fal, RouterVideoModel::Veo3p1Fast) => build_fal_veo_3p1_fast(self), + (RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self), + (RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self), + (RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self), + // Grok + (RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self), + (RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self), + // Kinovi + (RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5Preview) => build_kinovi_seedance_2p5_preview(self), + (RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5) => build_kinovi_seedance_2p5(self), + _ => self.unsupported_provider_and_model(), + } + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Video generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs new file mode 100644 index 0000000000..b973d967c5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs @@ -0,0 +1,100 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +#[derive(Clone, Debug)] +pub struct ArtcraftVideoResponsePayload { + pub inference_job_token: InferenceJobToken, + pub all_inference_job_tokens: Vec, +} + +#[derive(Clone, Debug)] +pub struct KinoviWebVideoResponsePayload { + pub order_id: String, + pub task_id: String, + pub maybe_order_ids: Option>, + pub maybe_task_ids: Option>, +} + +#[derive(Clone, Debug)] +pub struct FalVideoResponsePayload { + pub request_id: Option, + pub gateway_request_id: Option, + + /// Queue-mode status URL (fal's queue/polling flow). `None` for webhook + /// dispatch — the webhook callback drives status updates instead. + pub maybe_status_url: Option, + + /// Queue-mode response URL (fal's queue/polling flow). `None` for webhook + /// dispatch. + pub maybe_response_url: Option, + + /// The outbound request that was sent to Fal. + /// Stored as a trait object so any Request type can be captured. + /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. + pub maybe_outbound_request: Option>, +} + +#[derive(Clone, Debug)] +pub struct GmiCloudVideoResponsePayload { + pub request_id: String, +} + +#[derive(Clone, Debug)] +pub struct GrokVideoResponsePayload { + /// xAI request_id; poll with `grok_api_client::api::requests::videos::video_status` + /// to fetch the rendered video URL once it's ready. + pub request_id: String, + + /// The outbound request that was sent to Grok (xAI). + /// Stored as a trait object so any Request type can be captured. + /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. + pub maybe_outbound_request: Option>, +} + +#[derive(Clone, Debug)] +pub enum GenerateVideoResponse { + Artcraft(ArtcraftVideoResponsePayload), + GmiCloud(GmiCloudVideoResponsePayload), + Grok(GrokVideoResponsePayload), + KinoviWeb(KinoviWebVideoResponsePayload), + Fal(FalVideoResponsePayload), +} + +impl GenerateVideoResponse { + pub fn get_artcraft_payload(&self) -> Option { + match self { + Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_kinovi_web_payload(&self) -> Option { + match self { + Self::KinoviWeb(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_gmicloud_payload(&self) -> Option { + match self { + Self::GmiCloud(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_grok_payload(&self) -> Option { + match self { + Self::Grok(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_fal_payload(&self) -> Option { + match self { + Self::Fal(p) => Some(p.clone()), + _ => None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/mod.rs new file mode 100644 index 0000000000..a0b3ddca1d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/mod.rs @@ -0,0 +1,8 @@ +pub mod generate_video_request_builder; +pub mod generate_video_response; +pub mod providers; +pub mod video_generation_cost_estimate; +pub mod video_generation_draft; +pub mod video_generation_draft_context; +pub mod video_generation_draft_or_request; +pub mod video_generation_request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..489a6b0241 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs @@ -0,0 +1,338 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; +use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::resolve::{ + resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref, + resolve_image_ref, resolve_video_list_ref, +}; + +/// Which output resolutions a model supports. +#[derive(Copy, Clone)] +pub enum SupportedResolutions { + /// 480p, 720p, 1080p + Full, + /// 480p, 720p, 1080p, 4K + FullWith4k, + /// 480p, 720p only (1080p downgrades to 720p) + Fast, + /// 720p, 1080p only (480p upgrades) + NoFourEightyP, +} + +/// Which aspect ratios a model supports for the 21:9 ultra-wide slot. +#[derive(Copy, Clone)] +pub enum UltraWideSupport { + /// 21:9 maps directly to WideTwentyOneByNine + Supported, + /// 21:9 is not a direct option; falls through to nearest_aspect_ratio + Unsupported, +} + +/// Build an `OmniGenVideoCostAndGenerateRequest` from the builder, using +/// model-specific configuration for the model enum variant, resolution +/// strategy, and ultra-wide aspect ratio support. +pub fn build_artcraft_omni_video_request( + mut builder: GenerateVideoRequestBuilder, + model: CommonVideoModelEnum, + resolutions: SupportedResolutions, + ultra_wide: UltraWideSupport, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy, ultra_wide)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy, resolutions)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); + let prompt = builder.prompt.take(); + + let start_frame = resolve_image_ref(builder.start_frame.take())?; + let end_frame = resolve_image_ref(builder.end_frame.take())?; + let reference_images = resolve_image_list_ref(builder.reference_images.take())?; + let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?; + let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?; + let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take()); + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenVideoCostAndGenerateRequest { + model: Some(model), + idempotency_token: Some(idempotency_token), + prompt, + start_frame_image_media_token: start_frame, + end_frame_image_media_token: end_frame, + reference_image_media_tokens: reference_images, + reference_video_media_tokens: reference_videos, + reference_audio_media_tokens: reference_audio, + reference_character_tokens: reference_characters, + resolution, + aspect_ratio, + bitrate, + duration_seconds: duration_seconds.map(|d| d as u16), + video_batch_count: Some(batch_count), + negative_prompt: None, + generate_audio: None, + quality: None, + estimate_only: None, + }) +} + +// ── Plan helpers ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, + ultra_wide: UltraWideSupport, +) -> Result, ArtcraftRouterError> { + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(None), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(Some(CommonAspectRatioEnum::WideSixteenByNine)) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(Some(CommonAspectRatioEnum::TallNineBySixteen)) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(Some(CommonAspectRatioEnum::Square)) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)), + + Some(RouterAspectRatio::WideTwentyOneByNine) => match ultra_wide { + UltraWideSupport::Supported => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)), + UltraWideSupport::Unsupported => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", RouterAspectRatio::WideTwentyOneByNine), + })) + } + _ => Ok(Some(nearest_aspect_ratio(RouterAspectRatio::WideTwentyOneByNine))), + }, + }, + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(nearest_aspect_ratio(unsupported))) + } + }, + } +} + +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> CommonAspectRatioEnum { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree, + RouterAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree, + RouterAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine, + RouterAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour, + RouterAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour, + RouterAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen, + _ => CommonAspectRatioEnum::Square, + } +} + +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, + supported: SupportedResolutions, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + Some(RouterResolution::FourEightyP) => match supported { + SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::Fast => { + Ok(Some(CommonResolutionEnum::FourEightyP)) + } + SupportedResolutions::NoFourEightyP => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::FourEightyP), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(CommonResolutionEnum::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + }, + }, + + Some(RouterResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + + Some(RouterResolution::TenEightyP) => match supported { + SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::NoFourEightyP => { + Ok(Some(CommonResolutionEnum::TenEightyP)) + } + SupportedResolutions::Fast => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::TenEightyP), + })) + } + _ => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + }, + }, + + // 4K is only available on models flagged `FullWith4k` (the non-Fast Seedance + // 2.0 family). Other models error out or fall back to their best resolution. + Some(RouterResolution::FourK) => match (supported, strategy) { + (SupportedResolutions::FullWith4k, _) => Ok(Some(CommonResolutionEnum::FourK)), + (_, RequestMismatchMitigationStrategy::ErrorOut) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::FourK), + })) + } + (SupportedResolutions::Fast, _) => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + (_, _) => Ok(Some(CommonResolutionEnum::TenEightyP)), + }, + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(match (unsupported, supported) { + (RouterResolution::HalfK, _) => CommonResolutionEnum::FourEightyP, + (_, SupportedResolutions::Fast) => CommonResolutionEnum::SevenTwentyP, + _ => CommonResolutionEnum::TenEightyP, + })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match (unsupported, supported) { + (RouterResolution::HalfK, _) => CommonResolutionEnum::FourEightyP, + (_, SupportedResolutions::Fast) => CommonResolutionEnum::SevenTwentyP, + _ => CommonResolutionEnum::TenEightyP, + })) + } + }, + } +} + +/// Batch counts: 1 through 4. +pub fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1..=4 => Ok(count), + // 5 and above: + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4), + }, + } +} + +/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid +/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the +/// mitigation strategy. +pub fn plan_mini_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1..=8 => Ok(count), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8), + }, + } +} + +/// Translate the router-facing bitrate into the API `CommonBitrate`. +/// +/// Bitrate does not affect cost. An unset value leaves the field `None` (the +/// API applies its default); `Normal` and `High` map to their `CommonBitrate` +/// counterparts. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + None => None, + Some(RouterBitrate::Normal) => Some(CommonBitrateEnum::Normal), + Some(RouterBitrate::High) => Some(CommonBitrateEnum::High), + } +} + +/// Duration: 4-15 seconds. +pub fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 4; + const MAX: u16 = 15; + match duration_seconds { + None => Ok(None), + Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX) as u8)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod bitrate_translation { + use super::*; + + #[test] + fn none_stays_none() { + assert_eq!(plan_bitrate(None), None); + } + + #[test] + fn normal_maps_to_common_normal() { + assert_eq!(plan_bitrate(Some(RouterBitrate::Normal)), Some(CommonBitrateEnum::Normal)); + } + + #[test] + fn high_maps_to_common_high() { + assert_eq!(plan_bitrate(Some(RouterBitrate::High)), Some(CommonBitrateEnum::High)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/build.rs new file mode 100644 index 0000000000..df2caeddb1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/build.rs @@ -0,0 +1,64 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_flux_3(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_flux_3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3(state))) +} + +pub(crate) fn build_artcraft_flux_3_state(mut builder: GenerateVideoRequestBuilder) -> Result { + // Flux 3 accepts 5–20 seconds — wider than the generic helper's window — + // so pre-plan the duration and reapply it after (the helper would clamp + // 16–20s down). Also preserve generate_audio (the helper hardcodes None on + // its output). + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Flux3, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + request.generate_audio = generate_audio; + request.duration_seconds = final_duration; + + Ok(ArtcraftFlux3RequestState { request }) +} + +/// Flux 3 durations: 5–20 seconds (unset lets the model pick). +pub(crate) fn plan_flux_3_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 5; + const MAX: u16 = 20; + match duration_seconds { + None => Ok(None), + Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(other.clamp(MIN, MAX))) + } + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/cost.rs new file mode 100644 index 0000000000..c8d8c32012 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/cost.rs @@ -0,0 +1,123 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +/// Per-second rates in hundredths of a US cent. +const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_955; +const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 3_335; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux3CostState { + pub duration_seconds: u64, + pub is_1080p: bool, +} + +impl ArtcraftFlux3CostState { + pub fn from_request(request: &ArtcraftFlux3RequestState) -> Self { + Self { + // Flux 3 defaults None → 5s (the shortest selectable duration). + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + is_1080p: is_1080p(request.request.resolution), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = if self.is_1080p { + HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC + } else { + LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC + }; + // Round up to the next whole cent. + let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Flux 3 renders 720p or 1080p; 1080p and above land on the higher tier, +/// while 720p and below render at 720p. The default (unset) resolution is +/// 720p. +fn is_1080p(resolution: Option) -> bool { + match resolution { + None => false, // defaults to 720p + Some(CommonResolutionEnum::TenEightyP) + | Some(CommonResolutionEnum::OneK) + | Some(CommonResolutionEnum::TwoK) + | Some(CommonResolutionEnum::ThreeK) + | Some(CommonResolutionEnum::FourK) => true, + Some(CommonResolutionEnum::HalfK) + | Some(CommonResolutionEnum::FourEightyP) + | Some(CommonResolutionEnum::SevenTwentyP) => false, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3_state; + use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState; + + #[test] + fn default_settings_5s_is_98() { assert_eq!(cost_cents(Some(5), None), 98); } + + #[test] + fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 98); } + + #[test] + fn low_res_20s_is_391() { assert_eq!(cost_cents(Some(20), Some(RouterResolution::SevenTwentyP)), 391); } + + #[test] + fn four_eighty_p_lands_on_low_tier() { + assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 98); + } + + #[test] + fn high_res_5s_is_167() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::TenEightyP)), 167); } + + #[test] + fn high_res_10s_is_334() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 334); } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s low res: 1955 × 7 = 13685 hundredth-cents → 137 cents. + assert_eq!(cost_cents(Some(7), None), 137); + } + + #[test] + fn resolution_classifier_defaults_720p() { + assert!(!super::is_1080p(None)); + assert!(super::is_1080p(Some(CommonResolutionEnum::TenEightyP))); + assert!(!super::is_1080p(Some(CommonResolutionEnum::SevenTwentyP))); + assert!(!super::is_1080p(Some(CommonResolutionEnum::FourEightyP))); + } + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + ) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + let state = build_artcraft_flux_3_state(b).unwrap(); + ArtcraftFlux3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/request.rs new file mode 100644 index 0000000000..593ba3b35c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3/request.rs @@ -0,0 +1,104 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux3RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftFlux3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog stands up and walks toward the lake.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/build.rs new file mode 100644 index 0000000000..cd217193af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/build.rs @@ -0,0 +1,37 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::flux_3::build::plan_flux_3_duration; +use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_flux_3_draft(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_flux_3_draft_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3Draft(state))) +} + +pub(crate) fn build_artcraft_flux_3_draft_state(mut builder: GenerateVideoRequestBuilder) -> Result { + // Same duration handling as full-quality Flux 3: 5–20 seconds is wider than + // the generic helper's window, so pre-plan and reapply after. Also preserve + // generate_audio (the helper hardcodes None on its output). + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Flux3Draft, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + request.generate_audio = generate_audio; + request.duration_seconds = final_duration; + + Ok(ArtcraftFlux3DraftRequestState { request }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/cost.rs new file mode 100644 index 0000000000..b60873a35f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/cost.rs @@ -0,0 +1,86 @@ +use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +/// Per-second rate in hundredths of a US cent. Drafts always render 720p, so +/// resolution does not affect the bill. +const GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 690; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux3DraftCostState { + pub duration_seconds: u64, +} + +impl ArtcraftFlux3DraftCostState { + pub fn from_request(request: &ArtcraftFlux3DraftRequestState) -> Self { + Self { + // Flux 3 Draft defaults None → 5s (the shortest selectable duration). + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Round up to the next whole cent. + let cost_in_usd_cents = + (GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft_state; + use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState; + + #[test] + fn default_settings_5s_is_35() { assert_eq!(cost_cents(Some(5), None), 35); } + + #[test] + fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 35); } + + #[test] + fn twenty_seconds_is_138() { assert_eq!(cost_cents(Some(20), None), 138); } + + /// Drafts always render 720p; a requested resolution does not change the + /// bill. + #[test] + fn cost_ignores_resolution() { + for resolution in [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)] { + assert_eq!(cost_cents(Some(5), resolution), 35, "resolution={resolution:?}"); + } + } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s generation: 690 × 7 = 4830 hundredth-cents → 49 cents. + assert_eq!(cost_cents(Some(7), None), 49); + } + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + ) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3Draft, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + let state = build_artcraft_flux_3_draft_state(b).unwrap(); + ArtcraftFlux3DraftCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/request.rs new file mode 100644 index 0000000000..05e278ae30 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/flux_3_draft/request.rs @@ -0,0 +1,88 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftFlux3DraftRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftFlux3DraftRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3Draft, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/build.rs new file mode 100644 index 0000000000..a2a9aee461 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/build.rs @@ -0,0 +1,180 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Build an ArtCraft-routed Grok Imagine Video request. +/// +/// xAI's grok-imagine-video supports 480p and 720p (no 1080p — see +/// VideoResolution docs in grok_api_client), and does NOT have a 21:9 +/// ultra-wide aspect ratio. Hence `Fast` resolution support and +/// `Unsupported` ultra-wide. +pub fn build_artcraft_grok_imagine_video( + builder: GenerateVideoRequestBuilder, +) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::GrokImagineVideo, + SupportedResolutions::Fast, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftGrokImagineVideoRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftGrokImagineVideo(state), + )) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_grok_imagine_video() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::GrokImagineVideo))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + // SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output). + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + // ArtCraft's omni endpoint requires media tokens — URLs are rejected + // (unlike the direct Grok provider, which is URL-only). + let result = build_artcraft_grok_imagine_video(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftGrokImagineVideoRequestState { + let result = build_artcraft_grok_imagine_video(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftGrokImagineVideo(state) + ) => state, + _ => panic!("expected ArtcraftGrokImagineVideo request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/cost.rs new file mode 100644 index 0000000000..273cb77f58 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/cost.rs @@ -0,0 +1,300 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState; + +// -- Pricing constants -- +// +// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents. +// +// We keep these as f64 because per-second rates are fractional; rounding +// happens once at the end after multiplying by duration * batch. + +const CENTS_PER_SECOND_480P: f64 = 6.5; +const CENTS_PER_SECOND_720P: f64 = 9.1; + +// Per-source-image surcharge. Kept as a separate term so it doesn't get +// lost in rounding when the per-second video cost is small. +const CENTS_PER_INPUT_IMAGE: f64 = 0.26; + +pub struct ArtcraftGrokImagineVideoCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub input_image_count: u64, +} + +impl ArtcraftGrokImagineVideoCostState { + pub fn from_request(request: &ArtcraftGrokImagineVideoRequestState) -> Self { + // Default duration is 8s when omitted, matching the upstream model's + // default — keeps cost estimates from reading 0 for under-specified + // requests. + let resolution = request.request.resolution.unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(8); + let batch_count = request.request.video_batch_count.unwrap_or(1); + + // Mirror grok_api_client's input-image counting: start_frame + reference_images. + let input_image_count = (request.request.start_frame_image_media_token.is_some() as u64) + + (request.request.reference_image_media_tokens + .as_ref() + .map(|v| v.len() as u64) + .unwrap_or(0)); + + Self { resolution, duration_seconds, batch_count, input_image_count } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + // Grok Imagine Video caps output at 720p; price anything else as 720p. + _ => CENTS_PER_SECOND_720P, + }; + + let video_cents = self.duration_seconds as f64 * cents_per_second * self.batch_count as f64; + // Input images are billed once, not per output in the batch. + let input_cents = self.input_image_count as f64 * CENTS_PER_INPUT_IMAGE; + + let usd_cents = (video_cents + input_cents).ceil() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // ── 720p pricing (9.1 ¢/s base × batch, ceil) ── + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + // 9.1 × 5 = 45.5 → 46 + // 9.1 × 10 = 91.0 → 91 + // 9.1 × 15 = 136.5 → 137 + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 46); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 91); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 137); + } + + #[test] + fn batch_2() { + // 9.1 × 5 × 2 = 91 → 91 + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 91); + // 9.1 × 15 × 2 = 273 → 273 + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 273); + } + + #[test] + fn batch_4() { + // 9.1 × 5 × 4 = 182 → 182 + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 182); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!( + cost_cents(None, 5, 1), + cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), + ); + } + } + + // ── 480p pricing (6.5 ¢/s base × batch, ceil) ── + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + // 6.5 × 5 = 32.5 → 33 + // 6.5 × 10 = 65.0 → 65 + // 6.5 × 15 = 97.5 → 98 + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 33); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 65); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 98); + } + + #[test] + fn batch_2() { + // 6.5 × 5 × 2 = 65 → 65 + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 65); + } + + #[test] + fn batch_4() { + // 6.5 × 5 × 4 = 130 → 130 + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 130); + } + } + + // ── Input-image surcharge ── + + mod input_image_surcharge { + use super::*; + + #[test] + fn single_start_frame_adds_input_charge() { + // 720p 5s batch 1 = 45.5 + 0.26 (one start_frame) = 45.76 → ceil 46 + // Identical to the no-image case (45.5 → 46) because of ceil rounding. + let with_img = cost_cents_with_images(Some(RouterResolution::SevenTwentyP), 5, 1, true, 0); + let no_img = cost_cents_with_images(Some(RouterResolution::SevenTwentyP), 5, 1, false, 0); + assert_eq!(with_img, 46); + assert_eq!(no_img, 46); + } + + #[test] + fn many_reference_images_eventually_bump_a_cent() { + // 480p 4s batch 1 = 6.5 × 4 = 26 + 0.26 × N + // N=0 → 26 + // N=1 → 26.26 → 27 + // N=4 → 27.04 → 28 + assert_eq!(cost_cents_with_images(Some(RouterResolution::FourEightyP), 4, 1, false, 0), 26); + assert_eq!(cost_cents_with_images(Some(RouterResolution::FourEightyP), 4, 1, false, 1), 27); + assert_eq!(cost_cents_with_images(Some(RouterResolution::FourEightyP), 4, 1, false, 4), 28); + } + } + + // ── Relative pricing ── + + mod relative_pricing { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1); + assert!(c480 < c720, "480p ({c480}) should be cheaper than 720p ({c720})"); + } + + #[test] + fn cost_scales_with_duration() { + let c5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let b2 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2); + let b4 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // ── Credits equal cents ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // ── Helpers ── + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } + + fn cost_cents_with_images( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + has_start_frame: bool, + extra_reference_images: usize, + ) -> u64 { + let start_frame = if has_start_frame { + Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))) + } else { None }; + let reference_images = if extra_reference_images > 0 { + Some(ImageListRef::MediaFileTokens( + (0..extra_reference_images) + .map(|i| MediaFileToken::new(format!("mf_ref_{i}"))) + .collect(), + )) + } else { None }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + start_frame, + reference_images, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/request.rs new file mode 100644 index 0000000000..7fd9143c10 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGrokImagineVideoRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftGrokImagineVideoRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/build.rs new file mode 100644 index 0000000000..251e911528 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/build.rs @@ -0,0 +1,195 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Build an ArtCraft-routed Grok Imagine Video 1.5 preview request. +/// +/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p +/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is +/// `grok-imagine-video-1.5`; the cost calculator in +/// [`super::cost`] keys off that to apply the v1.5 pricing tier. +pub fn build_artcraft_grok_imagine_video_1p5( + builder: GenerateVideoRequestBuilder, +) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::GrokImagineVideo1p5, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + + // NB: xAI's v1.5 model rejects text-to-video at the server, but that is + // NOT enforced here — the cost path builds image-less requests to price + // them while the user is still composing. The generate endpoints reject + // image-less v1.5 requests at the handler layer (validate_when_image_required). + let state = ArtcraftGrokImagineVideo1p5RequestState { request }; + + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state), + )) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_grok_imagine_video_1p5() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::GrokImagineVideo1p5))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_is_supported() { + // Grok Imagine 1.5 produces genuine 1080p output. + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn builds_without_any_image_inputs() { + // The cost path prices image-less requests while the user is still + // composing; the generate endpoints enforce the image requirement. + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.start_frame_image_media_token.is_none()); + assert!(req.request.reference_image_media_tokens.is_none()); + } + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + // ArtCraft's omni endpoint requires media tokens — URLs are rejected + // (unlike the direct Grok provider, which is URL-only). + let result = build_artcraft_grok_imagine_video_1p5(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftGrokImagineVideo1p5RequestState { + let result = build_artcraft_grok_imagine_video_1p5(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state) + ) => state, + _ => panic!("expected ArtcraftGrokImagineVideo1p5 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs new file mode 100644 index 0000000000..dff1982a65 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs @@ -0,0 +1,413 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + VideoGenerationRequest as GrokVideoGenerationRequest, + VideoImageSource as GrokVideoImageSource, +}; +use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator; +use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel; +use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState; + +// ── Markup ── +// +// The markup is applied at cent granularity with ceiling rounding. +const MARKUP_NUMERATOR: u64 = 105; +const MARKUP_DENOMINATOR: u64 = 100; + +pub struct ArtcraftGrokImagineVideo1p5CostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub input_image_count: u64, +} + +impl ArtcraftGrokImagineVideo1p5CostState { + pub fn from_request(request: &ArtcraftGrokImagineVideo1p5RequestState) -> Self { + let resolution = request.request.resolution.unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(8); + let batch_count = request.request.video_batch_count.unwrap_or(1); + + // Mirror grok_api_client's input-image counting: start_frame + reference_images. + let input_image_count = (request.request.start_frame_image_media_token.is_some() as u64) + + (request.request.reference_image_media_tokens + .as_ref() + .map(|v| v.len() as u64) + .unwrap_or(0)); + + Self { resolution, duration_seconds, batch_count, input_image_count } + } + + /// Base cost in cents for a single video at this state's resolution, + /// duration, and input-image count. Computed via grok_api_client's + /// official cost calculator (with the model pinned to the v1.5 preview), + /// so this number tracks upstream pricing automatically and never drifts + /// from what xAI bills. + /// + /// Does NOT include the per-batch multiplier and does NOT include the + /// ArtCraft markup. Used by tests to assert parity with grok_api_client. + pub fn base_cost_in_cents_for_one_video(&self) -> u64 { + self.build_equivalent_grok_request().calculate_cost_in_cents() + } + + /// Total base cost (across the batch) in cents, before markup. + /// Each video in the batch costs [`Self::base_cost_in_cents_for_one_video`]. + pub fn base_cost_in_cents_for_batch(&self) -> u64 { + self.base_cost_in_cents_for_one_video() + .saturating_mul(self.batch_count as u64) + } + + pub fn estimate_cost(&self) -> Result { + // NB: Image-less requests DO get a quote even though xAI's v1.5 rejects + // text-to-video — the cost path prices requests the user is still + // composing. The image requirement is enforced at send time (request.rs) + // and by the generate endpoints. + let base = self.base_cost_in_cents_for_batch(); + // Ceil at cent granularity. + let usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + + Ok(VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }) + } + + /// Build a `GrokVideoGenerationRequest` whose pricing-relevant fields + /// exactly mirror this cost state, so the grok_api_client calculator + /// produces the same number ArtCraft would owe for one video. Non-pricing + /// fields (prompt, aspect_ratio, user) are left empty — they don't affect + /// cost. + fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest { + let resolution = Some(match self.resolution { + CommonResolution::FourEightyP => GrokResolution::FourEightyP, + CommonResolution::TenEightyP => GrokResolution::TenEightyP, + // Grok Imagine 1.5 tops out at 1080p (the build step downgrades higher + // tiers via SupportedResolutions::Full); price anything else as 720p. + _ => GrokResolution::SevenTwentyP, + }); + + // Grok's calculator only counts the TOTAL number of input images — it + // doesn't care how they're split between `image` and `reference_images` + // — so we put one in `image` (if any) and the rest in `reference_images`. + let image = (self.input_image_count >= 1) + .then(|| GrokVideoImageSource::Url(String::new())); + let reference_images = if self.input_image_count >= 2 { + Some( + (1..self.input_image_count) + .map(|_| GrokVideoImageSource::Url(String::new())) + .collect() + ) + } else { None }; + + GrokVideoGenerationRequest { + prompt: String::new(), + model: Some(GrokVideoModel::GrokImagineVideo1p5), + image, + reference_images, + aspect_ratio: None, + duration: Some(self.duration_seconds as u32), + resolution, + user: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // ── Markup correctness ───────────────────────────────────────────────── + + mod markup { + use super::*; + + #[test] + fn estimate_cost_equals_ceil_of_base_times_1p05() { + for &(res, dur, imgs, batch) in MARKUP_CASES { + let state = artcraft_state(res, dur, imgs, batch); + let base = state.base_cost_in_cents_for_batch(); + let want = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + let got = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert_eq!( + got, want, + "res={res:?} dur={dur} imgs={imgs} batch={batch} base={base}", + ); + } + } + + #[test] + fn final_cost_is_always_at_least_base() { + for &(res, dur, imgs, batch) in MARKUP_CASES { + let state = artcraft_state(res, dur, imgs, batch); + let base = state.base_cost_in_cents_for_batch(); + let final_ = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert!( + final_ >= base, + "final {final_}¢ < base {base}¢ at res={res:?} dur={dur} imgs={imgs} batch={batch}", + ); + } + } + + #[test] + fn known_value_at_720p_5s_batch_1() { + // grok v1.5 720p × 5s + 1 image = 700 + 10 = 710 mills = 71¢ + // 71 × 1.05 = 74.55 → ceil = 75¢ + let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1); + assert_eq!(state.base_cost_in_cents_for_one_video(), 71); + assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 75); + } + + #[test] + fn known_value_at_480p_5s_batch_1() { + // grok v1.5 480p × 5s + 1 image = 400 + 10 = 410 mills = 41¢ + // 41 × 1.05 = 43.05 → ceil = 44¢ + let state = artcraft_state(Some(RouterResolution::FourEightyP), 5, 1, 1); + assert_eq!(state.base_cost_in_cents_for_one_video(), 41); + assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 44); + } + + #[test] + fn known_value_at_720p_5s_batch_2() { + // base per video at 720p 5s + 1 image = 71¢. Batch 2 = 142¢. + // 142 × 1.05 = 149.1 → ceil 150¢. + let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 2); + assert_eq!(state.base_cost_in_cents_for_batch(), 142); + assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 150); + } + + /// v1.5 doesn't support text-to-video at generation time, but the cost + /// calculator still quotes an image-less request — the cost UI prices + /// requests before the user has attached an image. The requirement is + /// enforced at send time and by the generate endpoints. + #[test] + fn estimate_cost_quotes_text_only_request() { + let state = ArtcraftGrokImagineVideo1p5CostState { + resolution: CommonResolution::SevenTwentyP, + duration_seconds: 5, + batch_count: 1, + input_image_count: 0, + }; + let base = state.base_cost_in_cents_for_batch(); + let want = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + let got = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert_eq!(got, want); + assert!(got > 0); + } + + // (resolution, duration, image_count, batch) — every case has at least + // one image since v1.5 rejects text-to-video. + const MARKUP_CASES: &[(Option, u16, u64, u16)] = &[ + (Some(RouterResolution::FourEightyP), 5, 1, 1), + (Some(RouterResolution::FourEightyP), 5, 1, 2), + (Some(RouterResolution::FourEightyP), 5, 1, 4), + (Some(RouterResolution::FourEightyP), 8, 1, 1), + (Some(RouterResolution::FourEightyP), 15, 1, 1), + (Some(RouterResolution::FourEightyP), 5, 3, 1), + (Some(RouterResolution::SevenTwentyP), 5, 1, 1), + (Some(RouterResolution::SevenTwentyP), 5, 1, 2), + (Some(RouterResolution::SevenTwentyP), 5, 1, 4), + (Some(RouterResolution::SevenTwentyP), 8, 1, 1), + (Some(RouterResolution::SevenTwentyP), 15, 1, 1), + (Some(RouterResolution::SevenTwentyP), 5, 3, 1), + (None, 5, 1, 1), + ]; + } + + // ── Relative pricing / monotonicity sanity ───────────────────────────── + + mod monotonicity { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = artcraft_state(Some(RouterResolution::FourEightyP), 5, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + let p720 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert!(p480 < p720, "480p ({p480}¢) should be < 720p ({p720}¢)"); + } + + #[test] + fn longer_duration_costs_more() { + let c5 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + let c10 = artcraft_state(Some(RouterResolution::SevenTwentyP), 10, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + let c15 = artcraft_state(Some(RouterResolution::SevenTwentyP), 15, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn more_images_cost_more_or_equal() { + // Each image is +10 mills = +1¢ before markup, so additional images + // monotonically increase cost. + let one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + let three = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 3, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert!(one < three, "one={one}¢ vs three={three}¢"); + } + + #[test] + fn more_batch_costs_more() { + let b1 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + let b4 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 4) + .estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(); + assert!(b1 < b4); + } + } + + // ── End-to-end through the public builder API ────────────────────────── + + mod end_to_end { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + // ArtCraft credits == USD cents, as in the v1 module. + let resolutions = [ + None, + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), // clamps to 720p + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + let cost = build_cost_via_builder(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents at res={res:?} dur={dur}s batch={batch}", + ); + } + } + } + } + + #[test] + fn ten_eighty_p_prices_higher_than_720p() { + // Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p). + let p1080 = build_cost_via_builder(Some(RouterResolution::TenEightyP), 10, 1) + .cost_in_usd_cents.unwrap(); + let p720 = build_cost_via_builder(Some(RouterResolution::SevenTwentyP), 10, 1) + .cost_in_usd_cents.unwrap(); + assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})"); + } + + /// Regression: an image-less v1.5 request must get a quote through the + /// full public builder path (build2 → estimate_cost). The cost UI polls + /// for a price before the user has attached an image; the image + /// requirement is enforced at send time and by the generate endpoints, + /// NOT here. + #[test] + fn image_less_request_gets_a_quote_through_the_builder() { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + let cost = builder.build2() + .expect("build2 should succeed without an image") + .estimate_cost() + .expect("estimate_cost should succeed without an image"); + assert!(cost.cost_in_usd_cents.unwrap() > 0); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + fn artcraft_state( + resolution: Option, + duration_seconds: u16, + input_image_count: u64, + batch_count: u16, + ) -> ArtcraftGrokImagineVideo1p5CostState { + // Build a state directly from a synthesized request — avoids the full + // build2() pipeline so we can construct arbitrary image counts without + // worrying about builder validation rules. + let state = unwrap_request_via_builder(resolution, duration_seconds, input_image_count, batch_count); + ArtcraftGrokImagineVideo1p5CostState::from_request(&state) + } + + fn unwrap_request_via_builder( + resolution: Option, + duration_seconds: u16, + input_image_count: u64, + batch_count: u16, + ) -> ArtcraftGrokImagineVideo1p5RequestState { + // Split input_image_count into start_frame (1 if any) + reference_images (the rest), + // mirroring how `from_request` counts source images. + let start_frame = if input_image_count >= 1 { + Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))) + } else { None }; + let reference_images = if input_image_count >= 2 { + Some(ImageListRef::MediaFileTokens( + (1..input_image_count).map(|i| MediaFileToken::new(format!("mf_ref_{i}"))).collect(), + )) + } else { None }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(batch_count), + start_frame, + reference_images, + ..Default::default() + }; + let result = builder.build2().expect("build2 should succeed"); + match result { + crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest::Request( + crate::generate::generate_video::video_generation_request::VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(s) + ) => s, + _ => panic!("expected ArtcraftGrokImagineVideo1p5 request"), + } + } + + fn build_cost_via_builder( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + // These cases price the image-to-video tier (one input image). + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string()))), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs new file mode 100644 index 0000000000..cb999d871e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs @@ -0,0 +1,85 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGrokImagineVideo1p5RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftGrokImagineVideo1p5RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + // xAI's v1.5 model rejects text-to-video at the server. This is the + // GENERATION-time guard — `build()` and `estimate_cost()` deliberately + // allow image-less states so the cost path can quote a request the user + // is still composing. Bouncing here costs nothing and avoids an upstream + // call we know will fail. + if self.request.start_frame_image_media_token.is_none() + && self.request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty()) + { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(), + })); + } + + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + + use super::*; + + /// Generation must never reach xAI with an image-less v1.5 request. The + /// build/cost layers allow that state (the cost path quotes it), so the + /// send-time guard is the router's last line of defense. + #[tokio::test] + async fn send_rejects_image_less_request_before_any_network_call() { + let state = ArtcraftGrokImagineVideo1p5RequestState { + request: OmniGenVideoCostAndGenerateRequest { + idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()), + model: Some(CommonVideoModelEnum::GrokImagineVideo1p5), + prompt: Some("a corgi running through a field".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, + bitrate: None, + quality: None, + duration_seconds: Some(5), + video_batch_count: Some(1), + generate_audio: None, + estimate_only: None, + }, + }; + + // The guard fires before the client is used, so an unroutable localhost + // client with empty credentials proves no network call happens. + let client = RouterArtcraftClient::new( + ApiHost::Localhost { port: 1 }, + StorytellerCredentialSet::empty(), + ); + + let err = state.send(&client).await.expect_err("image-less send should be rejected"); + match err { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => { + assert_eq!(field, "image_inputs"); + } + other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/build.rs new file mode 100644 index 0000000000..1f773abe0d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/build.rs @@ -0,0 +1,170 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_happy_horse_1p0(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::HappyHorse1p0, + SupportedResolutions::NoFourEightyP, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftHappyHorse1p0RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftHappyHorse1p0(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_happy_horse_1p0() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::HappyHorse1p0))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + + #[test] + fn unsupported_480p_upgrades() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_happy_horse_1p0(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: crate::api::router_video_model::RouterVideoModel::HappyHorse1p0, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftHappyHorse1p0RequestState { + let result = build_artcraft_happy_horse_1p0(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftHappyHorse1p0(state) + ) => state, + _ => panic!("expected ArtcraftHappyHorse1p0 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/cost.rs new file mode 100644 index 0000000000..548825f91f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/cost.rs @@ -0,0 +1,237 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState; + +// ── Pricing ── +// +// Happy Horse 1.0 upstream credit pricing: +// +// | Resolution | Credits/sec | +// |------------|-------------| +// | 720p | 33 | +// | 1080p | 66 | +// +// Default resolution (None) is 720p. +// Batch count multiplies the total cost. +// Credit package (historical): 22,000 credits for $114 (~193 credits/$1). +// The upstream package is now 525,000 credits for $2,159.0909 (~243 credits/$1), +// but user pricing is intentionally kept at the 193-derived rates. +// +// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents. +// We compute USD cents from upstream credits ÷ credits_per_dollar × 100. + +const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33; +const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66; +// Frozen at the historical rate so user pricing doesn't shift with the package deal. +const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0; + +pub struct ArtcraftHappyHorse1p0CostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, +} + +impl ArtcraftHappyHorse1p0CostState { + pub fn from_request(request: &ArtcraftHappyHorse1p0RequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + + Self { resolution, duration_seconds, batch_count } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let credits_per_second = match self.resolution { + CommonResolution::TenEightyP => UPSTREAM_CREDITS_PER_SECOND_1080P, + _ => UPSTREAM_CREDITS_PER_SECOND_720P, + }; + + let upstream_credits = credits_per_second as u64 + * self.duration_seconds as u64 + * self.batch_count as u64; + + let usd_cents = (upstream_credits as f64 / UPSTREAM_CREDITS_PER_DOLLAR * 100.0).round() as u64; + + // ArtCraft credits = USD cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // ── 720p pricing (33 upstream credits/sec) ── + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + // 33 credits/sec; upstream_credits / 193 * 100 + // (NB: duration 3s is clamped to 4s by the builder) + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 68); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 85); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 171); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 256); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 171); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 342); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // ── 1080p pricing (66 upstream credits/sec) ── + + mod pricing_1080p { + use super::*; + + #[test] + fn batch_1() { + // 66 credits/sec; upstream_credits / 193 * 100 + // (NB: duration 3s is clamped to 4s by the builder) + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 4, 1), 137); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 171); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 342); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 513); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 342); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 684); + } + } + + // ── Relative pricing ── + + mod relative_pricing { + use super::*; + + #[test] + fn cost_720p_cheaper_than_1080p() { + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + assert!(c720 < c1080); + } + + #[test] + fn r1080p_approximately_double_720p() { + // 1080p upstream credits are exactly 2× 720p, but USD cents may differ + // by ±1 due to rounding (each is rounded independently). + for dur in [4u16, 5, 10, 15] { + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), dur, 1); + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), dur, 1); + let expected = c720 * 2; + assert!( + c1080 >= expected - 1 && c1080 <= expected + 1, + "1080p ({}) should be ~2× 720p ({}) at {}s", c1080, c720, dur, + ); + } + } + + #[test] + fn cost_scales_with_duration() { + let c4 = cost_cents(None, 4, 1); + let c10 = cost_cents(None, 10, 1); + let c15 = cost_cents(None, 15, 1); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(None, 5, 1); + let b2 = cost_cents(None, 5, 2); + let b4 = cost_cents(None, 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // ── Credits equal cents ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + let resolutions = [ + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // ── Helpers ── + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::HappyHorse1p0, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/request.rs new file mode 100644 index 0000000000..d3690c44af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/happy_horse_1p0/request.rs @@ -0,0 +1,178 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHappyHorse1p0RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftHappyHorse1p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + mod aspect_ratio_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn landscape() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn portrait() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn square() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A hummingbird hovering near a flower.".to_string()), + aspect_ratio: Some(RouterAspectRatio::Square), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + mod resolution_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn res_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn res_1080p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A fox walking through a snowy forest.".to_string()), + resolution: Some(RouterResolution::TenEightyP), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + mod modality_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog watches the lake as the sun sets.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::HappyHorse1p0, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/build.rs new file mode 100644 index 0000000000..670e694504 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/build.rs @@ -0,0 +1,88 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_1_6_pro::request::ArtcraftKling16ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_1_6_pro(mut builder: GenerateVideoRequestBuilder) -> Result { + // Kling 1.6 Pro serves only {5, 10} seconds. Pre-plan the duration with the + // SAME normalization the dispatching provider uses, so the billed duration + // always matches the generated one. + let strategy = builder.request_mismatch_mitigation_strategy; + builder.duration_seconds = plan_kling_1_6_pro_duration(builder.duration_seconds, strategy)?; + + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling16Pro, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftKling16ProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling16Pro(state))) +} + +/// Mirrors the Fal-side `plan_duration` for Kling 1.6 Pro: unsupported +/// durations upgrade to 10s (PayMoreUpgrade) or downgrade to 5s +/// (PayLessDowngrade). +fn plan_kling_1_6_pro_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) | Some(10) => Ok(duration_seconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(10)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(5)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod duration_planning { + use super::*; + + #[test] + fn supported_durations_pass_through() { + let s = RequestMismatchMitigationStrategy::PayMoreUpgrade; + assert_eq!(plan_kling_1_6_pro_duration(None, s).unwrap(), None); + assert_eq!(plan_kling_1_6_pro_duration(Some(5), s).unwrap(), Some(5)); + assert_eq!(plan_kling_1_6_pro_duration(Some(10), s).unwrap(), Some(10)); + } + + #[test] + fn unsupported_duration_upgrades_to_ten() { + let s = RequestMismatchMitigationStrategy::PayMoreUpgrade; + assert_eq!(plan_kling_1_6_pro_duration(Some(4), s).unwrap(), Some(10)); + assert_eq!(plan_kling_1_6_pro_duration(Some(7), s).unwrap(), Some(10)); + } + + #[test] + fn unsupported_duration_downgrades_to_five() { + let s = RequestMismatchMitigationStrategy::PayLessDowngrade; + assert_eq!(plan_kling_1_6_pro_duration(Some(4), s).unwrap(), Some(5)); + } + + #[test] + fn unsupported_duration_errors_in_strict_mode() { + let s = RequestMismatchMitigationStrategy::ErrorOut; + assert!(plan_kling_1_6_pro_duration(Some(4), s).is_err()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/cost.rs new file mode 100644 index 0000000000..e4394bdff3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/cost.rs @@ -0,0 +1,61 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::kling_1_6_pro::request::ArtcraftKling16ProRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling16ProCostState { + pub is_ten_seconds: bool, +} + +impl ArtcraftKling16ProCostState { + pub fn from_request(request: &ArtcraftKling16ProRequestState) -> Self { + Self { + // Default duration is 5s (None → 5s) per v1 plan. + is_ten_seconds: request.request.duration_seconds == Some(10), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // 5s = 57¢, 10s = 113¢. + let cost_in_usd_cents: u64 = if self.is_ten_seconds { 113 } else { 57 }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling16Pro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn five_seconds_is_57() { assert_eq!(cost_cents(Some(5)), 57); } + + #[test] + fn ten_seconds_is_113() { assert_eq!(cost_cents(Some(10)), 113); } + + #[test] + fn default_duration_is_5s_priced_at_57() { assert_eq!(cost_cents(None), 57); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/request.rs new file mode 100644 index 0000000000..0690ac57a2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_1_6_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling16ProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling16ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/build.rs new file mode 100644 index 0000000000..66daddbe8d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/build.rs @@ -0,0 +1,21 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_2_1_master::request::ArtcraftKling21MasterRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_2_1_master(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling21Master, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftKling21MasterRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling21Master(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/cost.rs new file mode 100644 index 0000000000..f9abb3070b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/cost.rs @@ -0,0 +1,58 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::kling_2_1_master::request::ArtcraftKling21MasterRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling21MasterCostState { + pub is_ten_seconds: bool, +} + +impl ArtcraftKling21MasterCostState { + pub fn from_request(request: &ArtcraftKling21MasterRequestState) -> Self { + Self { is_ten_seconds: request.request.duration_seconds == Some(10) } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_v2p1_master: 5s = $1.40, 10s = $2.80. + let cost_in_usd_cents: u64 = if self.is_ten_seconds { 322 } else { 161 }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling21Master, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn five_seconds_is_161() { assert_eq!(cost_cents(Some(5)), 161); } + + #[test] + fn ten_seconds_is_322() { assert_eq!(cost_cents(Some(10)), 322); } + + #[test] + fn default_duration_is_5s_priced_at_161() { assert_eq!(cost_cents(None), 161); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/request.rs new file mode 100644 index 0000000000..b4c6141e63 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_master/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling21MasterRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling21MasterRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/build.rs new file mode 100644 index 0000000000..a2b42be051 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/build.rs @@ -0,0 +1,21 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_2_1_pro::request::ArtcraftKling21ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_2_1_pro(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling21Pro, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftKling21ProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling21Pro(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/cost.rs new file mode 100644 index 0000000000..02f47bb92a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/cost.rs @@ -0,0 +1,58 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::kling_2_1_pro::request::ArtcraftKling21ProRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling21ProCostState { + pub is_ten_seconds: bool, +} + +impl ArtcraftKling21ProCostState { + pub fn from_request(request: &ArtcraftKling21ProRequestState) -> Self { + Self { is_ten_seconds: request.request.duration_seconds == Some(10) } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_v2p1_pro: 5s = 45¢, 10s = 90¢. + let cost_in_usd_cents: u64 = if self.is_ten_seconds { 104 } else { 52 }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling21Pro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); } + + #[test] + fn ten_seconds_is_104() { assert_eq!(cost_cents(Some(10)), 104); } + + #[test] + fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/request.rs new file mode 100644 index 0000000000..97b91a948d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_1_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling21ProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling21ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/build.rs new file mode 100644 index 0000000000..081b2259be --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/build.rs @@ -0,0 +1,21 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_2_5_turbo_pro::request::ArtcraftKling2p5TurboProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_2_5_turbo_pro(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling2p5TurboPro, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftKling2p5TurboProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling2p5TurboPro(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/cost.rs new file mode 100644 index 0000000000..26875949b6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/cost.rs @@ -0,0 +1,55 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::kling_2_5_turbo_pro::request::ArtcraftKling2p5TurboProRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling2p5TurboProCostState { + pub is_ten_seconds: bool, +} + +impl ArtcraftKling2p5TurboProCostState { + pub fn from_request(request: &ArtcraftKling2p5TurboProRequestState) -> Self { + Self { is_ten_seconds: request.request.duration_seconds == Some(10) } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢. + let cost_in_usd_cents: u64 = if self.is_ten_seconds { 81 } else { 41 }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling2p5TurboPro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn five_seconds_is_41() { assert_eq!(cost_cents(Some(5)), 41); } + + #[test] + fn ten_seconds_is_81() { assert_eq!(cost_cents(Some(10)), 81); } + + #[test] + fn default_duration_is_5s_priced_at_41() { assert_eq!(cost_cents(None), 41); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/request.rs new file mode 100644 index 0000000000..c2793c6267 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_5_turbo_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling2p5TurboProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling2p5TurboProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/build.rs new file mode 100644 index 0000000000..f31bbb793d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/build.rs @@ -0,0 +1,26 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::request::ArtcraftKling2p6ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_2_6_pro(builder: GenerateVideoRequestBuilder) -> Result { + // Kling 2.6 Pro supports `generate_audio` — preserve it so cost can read it back. + // build_artcraft_omni_request hardcodes generate_audio = None on its output, + // so we extract before, build, then restore. + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling2p6Pro, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + let state = ArtcraftKling2p6ProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling2p6Pro(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/cost.rs new file mode 100644 index 0000000000..2f6424b946 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/cost.rs @@ -0,0 +1,76 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::request::ArtcraftKling2p6ProRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling2p6ProCostState { + pub is_ten_seconds: bool, + pub generate_audio: bool, +} + +impl ArtcraftKling2p6ProCostState { + pub fn from_request(request: &ArtcraftKling2p6ProRequestState) -> Self { + Self { + is_ten_seconds: request.request.duration_seconds == Some(10), + // Default per v1 plan / Fal client: audio is on when unspecified. + generate_audio: request.request.generate_audio.unwrap_or(true), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_v2p6_pro: + // audio off: $0.07/sec → 5s=35¢, 10s=70¢ + // audio on: $0.14/sec → 5s=70¢, 10s=140¢ + let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) { + (false, false) => 41, + (false, true) => 81, + (true, false) => 81, + (true, true) => 161, + }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling2p6Pro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_off_5s_is_41() { assert_eq!(cost_cents(Some(5), Some(false)), 41); } + + #[test] + fn audio_off_10s_is_81() { assert_eq!(cost_cents(Some(10), Some(false)), 81); } + + #[test] + fn audio_on_5s_is_81() { assert_eq!(cost_cents(Some(5), Some(true)), 81); } + + #[test] + fn audio_on_10s_is_161() { assert_eq!(cost_cents(Some(10), Some(true)), 161); } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(5), None), cost_cents(Some(5), Some(true))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/request.rs new file mode 100644 index 0000000000..dee980e188 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_2_6_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling2p6ProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling2p6ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/build.rs new file mode 100644 index 0000000000..549f444d33 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/build.rs @@ -0,0 +1,57 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_3p0_pro(mut builder: GenerateVideoRequestBuilder) -> Result { + // Kling 3.0 Pro supports `generate_audio` — preserve it (build_artcraft_omni_request + // hardcodes None on output). Also Kling 3.0 supports 3-15s durations, but the + // omni-gen helper only accepts 4-15s; we plan the Kling-specific duration here + // and substitute a stand-in (≥4) for the omni-gen call, then restore. + let generate_audio = builder.generate_audio; + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_kling_3p0_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration.map(|d| d.max(4)); + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling3p0Pro, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + request.duration_seconds = final_duration; + + let state = ArtcraftKling3p0ProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling3p0Pro(state))) +} + +/// Kling 3.0 (Pro and Standard) supports durations of 3-15 seconds. +pub(crate) fn plan_kling_3p0_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 3; + const MAX: u16 = 15; + match duration_seconds { + None => Ok(None), + Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX))), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/cost.rs new file mode 100644 index 0000000000..20209b8d07 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/cost.rs @@ -0,0 +1,92 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +// The markup is applied at cent granularity with ceiling rounding. +const MARKUP_NUMERATOR: u64 = 115; +const MARKUP_DENOMINATOR: u64 = 100; +use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling3p0ProCostState { + pub duration_seconds: u64, + pub generate_audio: bool, +} + +impl ArtcraftKling3p0ProCostState { + pub fn from_request(request: &ArtcraftKling3p0ProRequestState) -> Self { + Self { + // Default duration is 5s when unset (mirrors v1 plan default + Fal client default). + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + generate_audio: request.request.generate_audio.unwrap_or(true), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Base rates in tenths of a cent per second; the total is + // ceiling-divided to whole cents, then the markup applied. + let rate: u64 = if self.generate_audio { 336 } else { 224 }; + let base = (rate * self.duration_seconds + 9) / 10; + let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Pro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_on_5s_is_168() { + // rate=336, (336*5 + 9) / 10 = 1689/10 = 168. + assert_eq!(cost_cents(Some(5), Some(true)), 194); + } + + #[test] + fn audio_off_5s_is_112() { + // rate=224, (224*5 + 9) / 10 = 1129/10 = 112. + assert_eq!(cost_cents(Some(5), Some(false)), 129); + } + + #[test] + fn audio_on_10s_is_336() { + assert_eq!(cost_cents(Some(10), Some(true)), 387); + } + + #[test] + fn audio_on_15s_is_504() { + // rate=336, (336*15 + 9) / 10 = 5049/10 = 504. + assert_eq!(cost_cents(Some(15), Some(true)), 580); + } + + #[test] + fn default_duration_is_5s() { + assert_eq!(cost_cents(None, Some(true)), cost_cents(Some(5), Some(true))); + } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(5), None), cost_cents(Some(5), Some(true))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/request.rs new file mode 100644 index 0000000000..f82fcca032 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling3p0ProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling3p0ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/build.rs new file mode 100644 index 0000000000..1cc3de2e7c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/build.rs @@ -0,0 +1,31 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::build::plan_kling_3p0_duration; +use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_kling_3p0_standard(mut builder: GenerateVideoRequestBuilder) -> Result { + // Kling 3.0 Standard: same duration range as 3.0 Pro (3-15s); see kling_3p0_pro::build. + let generate_audio = builder.generate_audio; + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_kling_3p0_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration.map(|d| d.max(4)); + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Kling3p0Standard, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + request.duration_seconds = final_duration; + + let state = ArtcraftKling3p0StandardRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftKling3p0Standard(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/cost.rs new file mode 100644 index 0000000000..37d0fc66ec --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/cost.rs @@ -0,0 +1,86 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState; + +// The markup is applied at cent granularity with ceiling rounding. +const MARKUP_NUMERATOR: u64 = 115; +const MARKUP_DENOMINATOR: u64 = 100; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling3p0StandardCostState { + pub duration_seconds: u64, + pub generate_audio: bool, +} + +impl ArtcraftKling3p0StandardCostState { + pub fn from_request(request: &ArtcraftKling3p0StandardRequestState) -> Self { + Self { + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + generate_audio: request.request.generate_audio.unwrap_or(true), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Base rates in tenths of a cent per second; the total is + // ceiling-divided to whole cents, then the markup applied. + let rate: u64 = if self.generate_audio { 252 } else { 168 }; + let base = (rate * self.duration_seconds + 9) / 10; + let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Standard, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_on_5s_is_145() { + // rate=252, base (252*5 + 9) / 10 = 126, ×115/100 ceiled = 145. + assert_eq!(cost_cents(Some(5), Some(true)), 145); + } + + #[test] + fn audio_off_5s_is_97() { + // rate=168, base (168*5 + 9) / 10 = 84, ×115/100 ceiled = 97. + assert_eq!(cost_cents(Some(5), Some(false)), 97); + } + + #[test] + fn audio_on_10s_is_290() { + assert_eq!(cost_cents(Some(10), Some(true)), 290); + } + + #[test] + fn audio_on_15s_is_435() { + // base (252*15 + 9) / 10 = 378, ×115/100 ceiled = 435. + assert_eq!(cost_cents(Some(15), Some(true)), 435); + } + + #[test] + fn default_duration_is_5s() { + assert_eq!(cost_cents(None, Some(true)), cost_cents(Some(5), Some(true))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/request.rs new file mode 100644 index 0000000000..8db8cb5725 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/kling_3p0_standard/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftKling3p0StandardRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftKling3p0StandardRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/build.rs new file mode 100644 index 0000000000..2ecf0e7f49 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/build.rs @@ -0,0 +1,25 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_minimax_h3(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_minimax_h3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftMinimaxH3(state))) +} + +pub(crate) fn build_artcraft_minimax_h3_state(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::MinimaxH3, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + Ok(ArtcraftMinimaxH3RequestState { request }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/cost.rs new file mode 100644 index 0000000000..3da3ecf778 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/cost.rs @@ -0,0 +1,158 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +/// Per-second rates in hundredths of a US cent. +const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_840; +const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 2_990; + +/// The first 5 reference images are included; each additional image bills a +/// flat fee (in hundredths of a US cent). +const INCLUDED_REFERENCE_IMAGES: u64 = 5; +const EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS: u64 = 920; + +#[derive(Clone, Debug)] +pub struct ArtcraftMinimaxH3CostState { + pub duration_seconds: u64, + pub is_2k: bool, + pub reference_image_count: u64, +} + +impl ArtcraftMinimaxH3CostState { + pub fn from_request(request: &ArtcraftMinimaxH3RequestState) -> Self { + Self { + // MiniMax H3 defaults None → 5s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + is_2k: is_2k(request.request.resolution), + reference_image_count: request.request.reference_image_media_tokens + .as_ref() + .map_or(0, |tokens| tokens.len() as u64), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = if self.is_2k { + HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC + } else { + LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC + }; + let extra_images = self.reference_image_count.saturating_sub(INCLUDED_REFERENCE_IMAGES); + // Round up to the next whole cent. + let cost_in_usd_cents = + (rate * self.duration_seconds + extra_images * EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS) + .div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// MiniMax H3 offers 768P and 2K output; 1080p and above land on the 2K tier, +/// while 720p and below render at 768P. The default (unset) resolution is 2K. +fn is_2k(resolution: Option) -> bool { + match resolution { + None => true, // defaults to 2K + Some(CommonResolutionEnum::TenEightyP) + | Some(CommonResolutionEnum::OneK) + | Some(CommonResolutionEnum::TwoK) + | Some(CommonResolutionEnum::ThreeK) + | Some(CommonResolutionEnum::FourK) => true, + Some(CommonResolutionEnum::HalfK) + | Some(CommonResolutionEnum::FourEightyP) + | Some(CommonResolutionEnum::SevenTwentyP) => false, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3_state; + use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState; + + #[test] + fn default_resolution_5s_is_150() { assert_eq!(cost_cents(Some(5), None, 0), 150); } + + #[test] + fn default_duration_is_5s() { assert_eq!(cost_cents(None, None, 0), 150); } + + #[test] + fn low_res_5s_is_92() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::SevenTwentyP), 0), 92); } + + #[test] + fn four_eighty_p_lands_on_low_tier() { + assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP), 0), 92); + } + + #[test] + fn low_res_15s_is_276() { assert_eq!(cost_cents(Some(15), Some(RouterResolution::SevenTwentyP), 0), 276); } + + #[test] + fn high_res_10s_is_299() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP), 0), 299); } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents. + assert_eq!(cost_cents(Some(7), None, 0), 210); + } + + #[test] + fn five_reference_images_are_included() { + assert_eq!(cost_cents(Some(5), None, 5), cost_cents(Some(5), None, 0)); + } + + #[test] + fn nine_reference_images_add_the_extra_image_fee() { + // 2990 × 5 + 4 × 920 = 18630 hundredth-cents → 187 cents. + assert_eq!(cost_cents(Some(5), None, 9), 187); + } + + #[test] + fn resolution_classifier_defaults_2k() { + assert!(super::is_2k(None)); + assert!(super::is_2k(Some(CommonResolutionEnum::TenEightyP))); + assert!(!super::is_2k(Some(CommonResolutionEnum::SevenTwentyP))); + assert!(!super::is_2k(Some(CommonResolutionEnum::FourEightyP))); + } + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + reference_image_count: usize, + ) -> u64 { + let reference_images = if reference_image_count == 0 { + None + } else { + Some(ImageListRef::MediaFileTokens( + (0..reference_image_count) + .map(|i| MediaFileToken::new(format!("m_test_{i}"))) + .collect(), + )) + }; + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + reference_images, + ..Default::default() + }; + let state = build_artcraft_minimax_h3_state(b).unwrap(); + ArtcraftMinimaxH3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/request.rs new file mode 100644 index 0000000000..2216fbbc8f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/request.rs @@ -0,0 +1,117 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMinimaxH3RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftMinimaxH3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog stands up and walks toward the lake.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn reference_images_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("Image 1 is the dog. The dog explores a mossy forest clearing.".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs new file mode 100644 index 0000000000..87d97560c2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs @@ -0,0 +1,46 @@ +pub(crate) mod build_common; +pub(crate) mod seedance_common; +#[cfg(test)] +mod parity_tests; +pub mod grok_imagine_video; +pub mod grok_imagine_video_1p5; +pub mod flux_3; +pub mod flux_3_draft; +pub mod happy_horse_1p0; +pub mod kling_1_6_pro; +pub mod kling_2_1_master; +pub mod kling_2_1_pro; +pub mod kling_2_5_turbo_pro; +pub mod kling_2_6_pro; +pub mod kling_3p0_pro; +pub mod kling_3p0_standard; +pub mod minimax_h3; +pub mod preview_model; +pub mod preview_model_fast; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod seedance_1p0_lite; +pub mod seedance_1p5_pro; +pub mod seedance_2p0; +pub mod seedance_2p0_fast; +pub mod seedance_2p0_bp; +pub mod seedance_2p0_bp_fast; +pub mod seedance_2p0_bpu; +pub mod seedance_2p0_bpu_fast; +pub mod seedance_2p0_mini; +pub mod seedance_2p0_bp_mini; +pub mod seedance_2p0_bpu_mini; +pub mod seedance_2p5; +pub mod seedance_2p5_preview; +pub mod seedance_2p5_u; +pub mod sora_2; +pub mod sora_2_pro; +pub mod veo_2; +pub mod veo_3; +pub mod veo_3_fast; +pub mod veo_3p1; +pub mod veo_3p1_fast; +pub mod veo_3p1_lite; +pub mod vidu_q3; +pub mod vidu_q3_turbo; + diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/parity_tests.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/parity_tests.rs new file mode 100644 index 0000000000..adc1124141 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/parity_tests.rs @@ -0,0 +1,131 @@ +//! Cost-parity tests: the BPU (BytePlus Ultra) variants must price identically to +//! the BP (BytePlus) variants across every (resolution × duration × batch) combo. +//! +//! When BPU and BP pricing must diverge in the future, these tests should be +//! intentionally adjusted and split rather than relaxed. + +use crate::api::router_provider::RouterProvider; +use crate::api::router_resolution::RouterResolution; +use crate::api::router_video_model::RouterVideoModel; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +const RESOLUTIONS: [Option; 4] = [ + None, + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), +]; + +/// Durations spanning the supported window (1..=15 seconds) plus over-the-cap +/// inputs that exercise duration clamping in the builder. +const DURATIONS: [u16; 17] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 30, 99]; + +const BATCHES: [u16; 4] = [1, 2, 3, 4]; + +mod seedance_2p0_bpu_matches_seedance_2p0_bp { + use super::*; + + #[test] + fn cost_parity_across_all_combos() { + for &res in &RESOLUTIONS { + for &dur in &DURATIONS { + for &batch in &BATCHES { + let bp = cost_cents(RouterVideoModel::Seedance2p0BytePlus, res, dur, batch); + let bpu = cost_cents(RouterVideoModel::Seedance2p0BytePlusUltra, res, dur, batch); + assert_eq!( + bpu, bp, + "cost mismatch at res={:?} dur={} batch={}: bp={} bpu={}", + res, dur, batch, bp, bpu, + ); + } + } + } + } + + #[test] + fn credits_parity_across_all_combos() { + for &res in &RESOLUTIONS { + for &dur in &DURATIONS { + for &batch in &BATCHES { + let bp = build_cost(RouterVideoModel::Seedance2p0BytePlus, res, dur, batch); + let bpu = build_cost(RouterVideoModel::Seedance2p0BytePlusUltra, res, dur, batch); + assert_eq!( + bpu.cost_in_credits, bp.cost_in_credits, + "credit mismatch at res={:?} dur={} batch={}", + res, dur, batch, + ); + } + } + } + } +} + +mod seedance_2p0_bpu_fast_matches_seedance_2p0_bp_fast { + use super::*; + + #[test] + fn cost_parity_across_all_combos() { + for &res in &RESOLUTIONS { + for &dur in &DURATIONS { + for &batch in &BATCHES { + let bp = cost_cents(RouterVideoModel::Seedance2p0BytePlusFast, res, dur, batch); + let bpu = cost_cents(RouterVideoModel::Seedance2p0BytePlusUltraFast, res, dur, batch); + assert_eq!( + bpu, bp, + "cost mismatch at res={:?} dur={} batch={}: bp_fast={} bpu_fast={}", + res, dur, batch, bp, bpu, + ); + } + } + } + } + + #[test] + fn credits_parity_across_all_combos() { + for &res in &RESOLUTIONS { + for &dur in &DURATIONS { + for &batch in &BATCHES { + let bp = build_cost(RouterVideoModel::Seedance2p0BytePlusFast, res, dur, batch); + let bpu = build_cost(RouterVideoModel::Seedance2p0BytePlusUltraFast, res, dur, batch); + assert_eq!( + bpu.cost_in_credits, bp.cost_in_credits, + "credit mismatch at res={:?} dur={} batch={}", + res, dur, batch, + ); + } + } + } + } +} + +fn build_cost( + model: RouterVideoModel, + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, +) -> VideoGenerationCostEstimate { + GenerateVideoRequestBuilder { + model, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + } + .build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") +} + +fn cost_cents( + model: RouterVideoModel, + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, +) -> u64 { + build_cost(model, resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .expect("cost_in_usd_cents should be Some") +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/build.rs new file mode 100644 index 0000000000..3b41e3dace --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/build.rs @@ -0,0 +1,175 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_preview_model(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::PreviewModel, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + let state = ArtcraftPreviewModelRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftPreviewModel(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Field conversions ── + + mod field_conversions { + use super::*; + + #[test] + fn model_is_preview_model() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::PreviewModel))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Media tokens ── + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_preview_model(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftPreviewModelRequestState { + let result = build_artcraft_preview_model(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftPreviewModel(state) + ) => state, + _ => panic!("expected ArtcraftPreviewModel request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/cost.rs new file mode 100644 index 0000000000..5c44332905 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/cost.rs @@ -0,0 +1,249 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState; + +// ── Pricing constants ── +// +// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents. + +/// USD cents per second by resolution: +/// 480p: $0.10/s = 10.0 ¢/s +/// 720p: $0.25/s = 25.0 ¢/s +/// 1080p: $0.50/s = 50.0 ¢/s +const CENTS_PER_SECOND_480P: f64 = 10.0; +const CENTS_PER_SECOND_720P: f64 = 25.0; +const CENTS_PER_SECOND_1080P: f64 = 50.0; + +pub struct ArtcraftPreviewModelCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, +} + +impl ArtcraftPreviewModelCostState { + pub fn from_request(request: &ArtcraftPreviewModelRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + + Self { resolution, duration_seconds, batch_count } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P, + // Everything else (including 720p) prices at 720p. + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // -- 720p pricing -- + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 125); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 250); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 375); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 750); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 1125); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 1500); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // -- 480p pricing -- + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 40); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 50); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 150); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200); + } + } + + // -- 1080p pricing -- + + mod pricing_1080p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 4, 1), 200); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 250); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 500); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 750); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000); + } + } + + // -- Relative pricing -- + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p_cheaper_than_1080p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + assert!(c480 < c720); + assert!(c720 < c1080); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + let b2 = cost_cents(Some(RouterResolution::TenEightyP), 5, 2); + let b4 = cost_cents(Some(RouterResolution::TenEightyP), 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // -- Credits equal cents -- + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::PreviewModel, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/request.rs new file mode 100644 index 0000000000..afccda28d4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftPreviewModelRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftPreviewModelRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/build.rs new file mode 100644 index 0000000000..096ede4941 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/build.rs @@ -0,0 +1,188 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::preview_model_fast::request::ArtcraftPreviewModelFastRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_preview_model_fast(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::PreviewModelFast, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftPreviewModelFastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftPreviewModelFast(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Field conversions ── + + mod field_conversions { + use super::*; + + #[test] + fn model_is_preview_model_fast() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::PreviewModelFast))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_preview_model_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::PreviewModelFast, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Media tokens ── + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_preview_model_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::PreviewModelFast, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + // ── Helpers ── + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::PreviewModelFast, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftPreviewModelFastRequestState { + let result = build_artcraft_preview_model_fast(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftPreviewModelFast(state) + ) => state, + _ => panic!("expected ArtcraftPreviewModelFast request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/cost.rs new file mode 100644 index 0000000000..fd94fdb268 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/cost.rs @@ -0,0 +1,214 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::preview_model_fast::request::ArtcraftPreviewModelFastRequestState; + +// ── Pricing constants ── +// +// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents. + +/// USD cents per second by resolution: +/// 480p: $0.09/s = 9.0 ¢/s +/// 720p: $0.20/s = 20.0 ¢/s +const CENTS_PER_SECOND_480P: f64 = 9.0; +const CENTS_PER_SECOND_720P: f64 = 20.0; + +pub struct ArtcraftPreviewModelFastCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, +} + +impl ArtcraftPreviewModelFastCostState { + pub fn from_request(request: &ArtcraftPreviewModelFastRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + + Self { resolution, duration_seconds, batch_count } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // -- 720p pricing -- + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 80); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 200); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 300); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 600); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 900); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 1200); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // -- 480p pricing -- + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 36); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 45); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 90); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 135); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180); + } + } + + // -- Relative pricing -- + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + assert!(c480 < c720); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let b2 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2); + let b4 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // -- Credits equal cents -- + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::PreviewModelFast, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/request.rs new file mode 100644 index 0000000000..9661f095c0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/preview_model_fast/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftPreviewModelFastRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftPreviewModelFastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..d00a4cdd19 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/request_common.rs @@ -0,0 +1,29 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + ArtcraftVideoResponsePayload, GenerateVideoResponse, +}; + +/// Send a video generation request to the Artcraft omni-gen endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_video_request( + request: &OmniGenVideoCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_video_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload { + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..849ea41e2e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/resolve.rs @@ -0,0 +1,71 @@ +//! Shared resolve helpers for the Artcraft provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_image_ref( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)), + Some(ImageRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_video_list_ref( + video_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match video_list_ref { + None => Ok(None), + Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(VideoListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_audio_list_ref( + audio_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match audio_list_ref { + None => Ok(None), + Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(AudioListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_character_list_ref( + character_list_ref: Option, +) -> Option> { + match character_list_ref { + None => None, + Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens), + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/build.rs new file mode 100644 index 0000000000..7bf760aae4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/build.rs @@ -0,0 +1,49 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::request::ArtcraftSeedance10LiteRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_1p0_lite(mut builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 1.0 Lite accepts only {5, 10} seconds; pre-plan so cost parity holds. + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_seedance_1p0_lite_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance10Lite, + // The model serves 480p/720p/1080p; 1080p must plan (and price) as 1080p. + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance10LiteRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance10Lite(state))) +} + +fn plan_seedance_1p0_lite_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) | Some(10) => Ok(duration_seconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(10)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(5)), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/cost.rs new file mode 100644 index 0000000000..c6850d960f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/cost.rs @@ -0,0 +1,108 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +use fal_client::requests_old::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{ + Seedance1LiteDuration, Seedance1LiteRequest, Seedance1LiteResolution, +}; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::request::ArtcraftSeedance10LiteRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance10LiteCostState { + pub duration: Seedance1LiteDuration, + pub resolution: Seedance1LiteResolution, +} + +impl ArtcraftSeedance10LiteCostState { + pub fn from_request(request: &ArtcraftSeedance10LiteRequestState) -> Self { + // Legacy handler defaults: 5 seconds, 720p. + let duration = if request.request.duration_seconds == Some(10) { + Seedance1LiteDuration::TenSeconds + } else { + Seedance1LiteDuration::FiveSeconds + }; + let resolution = match request.request.resolution { + Some(CommonResolutionEnum::FourEightyP) => Seedance1LiteResolution::FourEightyP, + Some(CommonResolutionEnum::TenEightyP) => Seedance1LiteResolution::TenEightyP, + _ => Seedance1LiteResolution::SevenTwentyP, + }; + Self { duration, resolution } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Derived from the Fal client's cost calculator so the price tracks + // upstream pricing across every resolution and duration. + let req = Seedance1LiteRequest { + image_url: String::new(), + end_frame_image_url: None, + prompt: String::new(), + duration: self.duration, + resolution: self.resolution, + aspect_ratio: None, + camera_fixed: false, + seed: None, + }; + let cost_in_usd_cents = (req.calculate_cost_in_cents() * 21).div_ceil(20); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, resolution: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance10Lite, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn default_is_720p_5s_priced_at_19() { + assert_eq!(cost_cents(None, None), 19); + } + + #[test] + fn p480_5s_is_10() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 10); } + + #[test] + fn p480_10s_is_18() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 18); } + + #[test] + fn p720_5s_is_19() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::SevenTwentyP)), 19); } + + #[test] + fn p720_10s_is_52() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::SevenTwentyP)), 52); } + + #[test] + fn p1080_5s_is_58() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::TenEightyP)), 58); } + + #[test] + fn p1080_10s_is_116() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 116); } + + #[test] + fn p480_5s_cheaper_than_p720_5s() { + assert!( + cost_cents(Some(5), Some(RouterResolution::FourEightyP)) + < cost_cents(Some(5), Some(RouterResolution::SevenTwentyP)) + ); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/request.rs new file mode 100644 index 0000000000..67145ba644 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p0_lite/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance10LiteRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance10LiteRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/build.rs new file mode 100644 index 0000000000..e251bb3fdd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/build.rs @@ -0,0 +1,54 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_1p5_pro(mut builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 1.5 Pro supports 4-12 seconds; omni-gen helper accepts up to 15, + // so we clamp the upper bound ourselves. Preserve generate_audio (omni-gen + // hardcodes None on output). + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_seedance_1p5_pro_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance1p5Pro, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + request.generate_audio = generate_audio; + + let state = ArtcraftSeedance1p5ProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance1p5Pro(state))) +} + +fn plan_seedance_1p5_pro_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 4; + const MAX: u16 = 12; + match duration_seconds { + None => Ok(None), + Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX))), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/cost.rs new file mode 100644 index 0000000000..37337c83bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/cost.rs @@ -0,0 +1,114 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{ + EnqueueSeedance1p5ProTextToVideoDuration, EnqueueSeedance1p5ProTextToVideoRequest, + EnqueueSeedance1p5ProTextToVideoResolution, +}; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState; + +// The markup is applied at cent granularity with ceiling rounding. +const MARKUP_NUMERATOR: u64 = 115; +const MARKUP_DENOMINATOR: u64 = 100; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance1p5ProCostState { + pub duration: Option, + pub resolution: Option, + pub generate_audio: Option, +} + +impl ArtcraftSeedance1p5ProCostState { + pub fn from_request(request: &ArtcraftSeedance1p5ProRequestState) -> Self { + let duration = request.request.duration_seconds.and_then(seconds_to_duration); + let resolution = request.request.resolution.and_then(common_resolution_to_seedance); + Self { + duration, + resolution, + generate_audio: request.request.generate_audio, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Delegate to the Fal client's cost calculator. Pricing is identical for + // text-to-video and image-to-video at the same resolution+duration+audio, + // so we use the text-to-video request type as a stand-in. + let req = EnqueueSeedance1p5ProTextToVideoRequest { + prompt: String::new(), + duration: self.duration, + resolution: self.resolution, + aspect_ratio: None, + generate_audio: self.generate_audio, + }; + let base = req.calculate_cost_in_cents(); + let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn seconds_to_duration(seconds: u16) -> Option { + use EnqueueSeedance1p5ProTextToVideoDuration as D; + match seconds { + 4 => Some(D::FourSeconds), + 5 => Some(D::FiveSeconds), + 6 => Some(D::SixSeconds), + 7 => Some(D::SevenSeconds), + 8 => Some(D::EightSeconds), + 9 => Some(D::NineSeconds), + 10 => Some(D::TenSeconds), + 11 => Some(D::ElevenSeconds), + 12 => Some(D::TwelveSeconds), + _ => None, + } +} + +fn common_resolution_to_seedance(r: CommonResolutionEnum) -> Option { + match r { + CommonResolutionEnum::FourEightyP => Some(EnqueueSeedance1p5ProTextToVideoResolution::FourEightyP), + CommonResolutionEnum::SevenTwentyP => Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP), + CommonResolutionEnum::TenEightyP => Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance1p5Pro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // Numeric literals mirror the v1 Artcraft Seedance 1.5 Pro tests we deleted: + // resolution=None + generate_audio=None → fal client defaults (720p, audio on). + #[test] + fn default_5s_is_30() { assert_eq!(cost_cents(Some(5)), 30); } + + #[test] + fn default_4s_is_30() { assert_eq!(cost_cents(Some(4)), 30); } + + #[test] + fn default_10s_is_75() { assert_eq!(cost_cents(Some(10)), 75); } + + #[test] + fn default_12s_is_90() { assert_eq!(cost_cents(Some(12)), 90); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/request.rs new file mode 100644 index 0000000000..b2597ef83b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_1p5_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance1p5ProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance1p5ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs new file mode 100644 index 0000000000..32d6b4db0c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/build.rs @@ -0,0 +1,189 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0, + SupportedResolutions::FullWith4k, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Field conversions ── + + mod field_conversions { + use super::*; + + #[test] + fn model_is_seedance_2p0() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn res_4k() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Media tokens ── + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0RequestState { + let result = build_artcraft_seedance_2p0(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/cost.rs new file mode 100644 index 0000000000..7474183dd5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/cost.rs @@ -0,0 +1,553 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +// ── Pricing constants ── +// +// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents. +// +// The per-second USD price varies by resolution. + +/// USD cents per second by resolution. +/// +/// Kept as f64 because per-second rates are fractional; rounding happens +/// once at the end after multiplying by duration × batch. +const CENTS_PER_SECOND_480P: f64 = 7.772; +const CENTS_PER_SECOND_720P: f64 = 18.5; +const CENTS_PER_SECOND_1080P: f64 = 46.632; + +/// USD cents per second, in hundredths of a cent, when one or more +/// reference videos are attached. Held as integer hundredths so the math is +/// exact; rounded up to whole cents once, after multiplying by +/// duration × batch. +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 881; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_205; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_110; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_380; + +pub struct ArtcraftSeedance2p0CostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0CostState { + pub fn from_request(request: &ArtcraftSeedance2p0RequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + if self.has_video_reference { + let usd_cents = self.with_video_reference_usd_cents(); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + if self.resolution == CommonResolution::FourK { + let usd_cents = seedance_2p0_four_k_usd_cents( + self.duration_seconds, + self.batch_count, + ); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P, + // Everything else (including 720p) prices at 720p. + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Price when one or more reference videos are attached. + fn with_video_reference_usd_cents(&self) -> u64 { + let centi_cents_per_second = match self.resolution { + CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P, + CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K, + // Everything else (including 720p) prices at 720p. + _ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P, + }; + + let seconds = self.duration_seconds as u64 * self.batch_count as u64; + + // Round up to whole cents. + (centi_cents_per_second * seconds).div_ceil(100) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // -- 720p pricing -- + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 74); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 93); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 6, 1), 111); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 185); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 278); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 2), 148); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 185); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 555); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 222); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 278); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 833); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 296); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 370); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 1110); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // -- 480p pricing -- + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 31); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 39); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 78); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 117); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 78); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 117); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 155); + } + } + + // -- 1080p pricing -- + + mod pricing_1080p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 4, 1), 187); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 233); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 466); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 699); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 466); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 699); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 933); + } + } + + // -- 4K pricing -- + + mod four_k_pricing { + use enums::common::generation::common_resolution::CommonResolution; + + fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 { + super::super::ArtcraftSeedance2p0CostState { + resolution: CommonResolution::FourK, + duration_seconds, + batch_count, + has_video_reference, + } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + #[test] + fn explicit_4k_without_video_reference() { + assert_eq!(artcraft_4k_cents(4, 1, false), 370); + assert_eq!(artcraft_4k_cents(5, 1, false), 463); + assert_eq!(artcraft_4k_cents(10, 1, false), 925); + assert_eq!(artcraft_4k_cents(15, 1, false), 1388); + } + + #[test] + fn explicit_4k_with_video_reference() { + assert_eq!(artcraft_4k_cents(4, 1, true), 456); + assert_eq!(artcraft_4k_cents(5, 1, true), 569); + assert_eq!(artcraft_4k_cents(10, 1, true), 1138); + assert_eq!(artcraft_4k_cents(15, 1, true), 1707); + } + } + + // -- With-video-reference pricing -- + + mod video_reference_pricing { + use super::*; + + #[test] + fn table_480p() { + let expected = [36, 45, 53, 62, 71, 80, 89, 97, 106, 115, 124, 133]; + for (i, cents) in expected.iter().enumerate() { + let duration = (i + 4) as u16; + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1), + *cents, + "480p {duration}s", + ); + } + } + + #[test] + fn table_720p() { + let expected = [89, 111, 133, 155, 177, 199, 221, 243, 265, 287, 309, 331]; + for (i, cents) in expected.iter().enumerate() { + let duration = (i + 4) as u16; + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1), + *cents, + "720p {duration}s", + ); + } + } + + #[test] + fn table_1080p() { + let expected = [205, 256, 307, 358, 409, 460, 511, 563, 614, 665, 716, 767]; + for (i, cents) in expected.iter().enumerate() { + let duration = (i + 4) as u16; + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::TenEightyP), duration, 1), + *cents, + "1080p {duration}s", + ); + } + } + + #[test] + fn table_4k() { + let expected = [456, 569, 683, 797, 911, 1025, 1138, 1252, 1366, 1480, 1594, 1707]; + for (i, cents) in expected.iter().enumerate() { + let duration = (i + 4) as u16; + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::FourK), duration, 1), + *cents, + "4K {duration}s", + ); + } + } + + /// Batches price identically to the equivalent total seconds. + #[test] + fn batch_multiplies_total_seconds() { + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2), + cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1), + ); + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::FourK), 5, 2), + cost_cents_with_video_ref(Some(RouterResolution::FourK), 10, 1), + ); + } + + /// Image and audio references do NOT trigger the with-reference pricing — + /// only reference videos do. (The non-reference price is unchanged.) + #[test] + fn image_and_audio_references_price_as_no_reference() { + use crate::api::audio_list_ref::AudioListRef; + use crate::api::image_list_ref::ImageListRef; + + let builder = GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + video_batch_count: Some(1), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_img".to_string()), + ])), + reference_audio: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_audio".to_string()), + ])), + ..Default::default() + }; + let cents = builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap(); + assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // -- Relative pricing -- + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p_cheaper_than_1080p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + assert!(c480 < c720); + assert!(c720 < c1080); + } + + #[test] + fn cost_4k_more_expensive_than_1080p() { + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + let c4k = cost_cents(Some(RouterResolution::FourK), 5, 1); + assert!(c4k > c1080); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + let b2 = cost_cents(Some(RouterResolution::TenEightyP), 5, 2); + let b4 = cost_cents(Some(RouterResolution::TenEightyP), 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // -- Credits equal cents -- + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + Some(RouterResolution::FourK), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // -- Helpers -- + + /// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost. + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } + + /// Like [`cost_cents`], with one reference video attached. + fn cost_cents_with_video_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + let builder = GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos: Some(VideoListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } + + // ── Video reference deltas ── + // + // References price on the with-reference rate card; the delta must be positive at every resolution and duration. + + mod video_reference_deltas { + use enums::common::generation::common_resolution::CommonResolution; + + use super::super::ArtcraftSeedance2p0CostState; + + #[test] + fn refs_cost_more_at_480p() { + assert_ref_delta(CommonResolution::FourEightyP, 5, 39, 45, 6); + assert_ref_delta(CommonResolution::FourEightyP, 10, 78, 89, 11); + assert_ref_delta(CommonResolution::FourEightyP, 15, 117, 133, 16); + } + + #[test] + fn refs_cost_more_at_720p() { + assert_ref_delta(CommonResolution::SevenTwentyP, 5, 93, 111, 18); + assert_ref_delta(CommonResolution::SevenTwentyP, 10, 185, 221, 36); + assert_ref_delta(CommonResolution::SevenTwentyP, 15, 278, 331, 53); + } + + #[test] + fn refs_cost_more_at_1080p() { + assert_ref_delta(CommonResolution::TenEightyP, 5, 233, 256, 23); + assert_ref_delta(CommonResolution::TenEightyP, 10, 466, 511, 45); + assert_ref_delta(CommonResolution::TenEightyP, 15, 699, 767, 68); + } + + #[test] + fn refs_cost_more_at_4k() { + assert_ref_delta(CommonResolution::FourK, 5, 463, 569, 106); + assert_ref_delta(CommonResolution::FourK, 10, 925, 1138, 213); + assert_ref_delta(CommonResolution::FourK, 15, 1388, 1707, 319); + } + + /// Price the same generation with and without a reference video; pin + /// both prices and the delta, in USD cents AND credits (credits equal + /// cents, and both must show references costing more). + fn assert_ref_delta( + resolution: CommonResolution, + duration_seconds: u16, + expected_no_ref: u64, + expected_with_ref: u64, + expected_delta: u64, + ) { + let no_ref = ArtcraftSeedance2p0CostState { + resolution, duration_seconds, batch_count: 1, has_video_reference: false, + }.estimate_cost(); + let with_ref = ArtcraftSeedance2p0CostState { + resolution, duration_seconds, batch_count: 1, has_video_reference: true, + }.estimate_cost(); + + let no_ref_cents = no_ref.cost_in_usd_cents.unwrap(); + let with_ref_cents = with_ref.cost_in_usd_cents.unwrap(); + assert_eq!(no_ref_cents, expected_no_ref); + assert_eq!(with_ref_cents, expected_with_ref); + assert_eq!(with_ref_cents - no_ref_cents, expected_delta); + assert!(with_ref_cents > no_ref_cents, "references must cost more"); + + let no_ref_credits = no_ref.cost_in_credits.unwrap(); + let with_ref_credits = with_ref.cost_in_credits.unwrap(); + assert_eq!(no_ref_credits, expected_no_ref); + assert_eq!(with_ref_credits, expected_with_ref); + assert_eq!(with_ref_credits - no_ref_credits, expected_delta); + } + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/request.rs new file mode 100644 index 0000000000..2f98bc578f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0/request.rs @@ -0,0 +1,237 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use test_data::web::character_tokens::{JIM, KNIGHT}; + use test_data::web::image_media_tokens::{ + FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN, + JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN, + WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN, + }; + + // -- Aspect ratio tests -- + + mod aspect_ratio_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn landscape() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn portrait() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn square() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A hummingbird hovering near a flower.".to_string()), + aspect_ratio: Some(RouterAspectRatio::Square), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Resolution tests -- + + mod resolution_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn res_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn res_1080p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A fox walking through a snowy forest.".to_string()), + resolution: Some(RouterResolution::TenEightyP), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Modality tests -- + + mod modality_tests { + use super::*; + use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN; + + #[tokio::test] + #[ignore] + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn keyframe_start_and_end_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog walks from the lake to the forest.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn image_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()), + ])), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn character_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new(JIM.token.to_string()), + CharacterToken::new(KNIGHT.token.to_string()), + ])), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Helpers -- + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs new file mode 100644 index 0000000000..7c6fdc270a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/build.rs @@ -0,0 +1,181 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bp(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlus, + SupportedResolutions::FullWith4k, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0BytePlusRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_seedance_2p0_plus() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlus))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn res_4k() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_bp(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusRequestState { + let result = build_artcraft_seedance_2p0_bp(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlus request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/cost.rs new file mode 100644 index 0000000000..7d38e15d7f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/cost.rs @@ -0,0 +1,363 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +/// USD cents per second by resolution: +/// 480p: $0.10/s = 10.0 ¢/s +/// 720p: $0.25/s = 25.0 ¢/s +/// 1080p: $0.50/s = 50.0 ¢/s +const CENTS_PER_SECOND_480P: f64 = 10.0; +const CENTS_PER_SECOND_720P: f64 = 25.0; +const CENTS_PER_SECOND_1080P: f64 = 50.0; + +/// USD cents per second, in hundredths of a cent, when one or more +/// reference videos are attached. Held as integer hundredths so the math is +/// exact; rounded up to whole cents once, after multiplying by +/// duration × batch. +/// 480p: 10.20 ¢/s +/// 720p: 25.70 ¢/s +/// 1080p: 57.80 ¢/s +/// 4K: 119.00 ¢/s +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 1_020; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_570; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_780; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_900; + +/// 4K output price with no reference videos, in hundredths of a USD cent +/// per second (95.00 ¢/s), rounded up to whole cents after multiplying by +/// duration × batch. +const CENTI_CENTS_PER_SECOND_4K: u64 = 9_500; + +pub struct ArtcraftSeedance2p0BytePlusCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + if self.has_video_reference { + let usd_cents = self.with_video_reference_usd_cents(); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + if self.resolution == CommonResolution::FourK { + let total_centi_cents = CENTI_CENTS_PER_SECOND_4K + * self.duration_seconds as u64 + * self.batch_count as u64; + let usd_cents = total_centi_cents.div_ceil(100); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P, + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Price when one or more reference videos are attached. + fn with_video_reference_usd_cents(&self) -> u64 { + let centi_cents_per_second = match self.resolution { + CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P, + CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K, + // Everything else (including 720p) prices at 720p. + _ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P, + }; + + let seconds = self.duration_seconds as u64 * self.batch_count as u64; + + // Round up to whole cents. + (centi_cents_per_second * seconds).div_ceil(100) + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 125); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 250); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 375); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 40); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 50); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 150); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200); + } + } + + mod pricing_1080p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 4, 1), 200); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 250); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 500); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 750); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000); + } + } + + mod four_k_pricing { + use enums::common::generation::common_resolution::CommonResolution; + + fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 { + super::super::ArtcraftSeedance2p0BytePlusCostState { + resolution: CommonResolution::FourK, + duration_seconds, + batch_count, + has_video_reference, + } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + #[test] + fn explicit_4k_without_video_reference() { + // 95.00 ¢/s card, rounded up to whole cents. + assert_eq!(artcraft_4k_cents(4, 1, false), 380); + assert_eq!(artcraft_4k_cents(5, 1, false), 475); + assert_eq!(artcraft_4k_cents(10, 1, false), 950); + assert_eq!(artcraft_4k_cents(15, 1, false), 1425); + } + + #[test] + fn explicit_4k_with_video_reference() { + // 119.00 ¢/s with-reference card, rounded up to whole cents. + assert_eq!(artcraft_4k_cents(4, 1, true), 476); + assert_eq!(artcraft_4k_cents(5, 1, true), 595); + assert_eq!(artcraft_4k_cents(10, 1, true), 1190); + assert_eq!(artcraft_4k_cents(15, 1, true), 1785); + } + } + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p_cheaper_than_1080p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + assert!(c480 < c720); + assert!(c720 < c1080); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for res in [Some(RouterResolution::FourEightyP), Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), None] { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlus, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }.build2().expect("build2").estimate_cost().expect("estimate_cost") + } + + fn cost_cents(resolution: Option, duration_seconds: u16, video_batch_count: u16) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap() + } + + // ── Video reference deltas ── + // + // References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration. + + mod video_reference_deltas { + use enums::common::generation::common_resolution::CommonResolution; + + use super::super::ArtcraftSeedance2p0BytePlusCostState; + + #[test] + fn refs_cost_more_at_480p() { + assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1); + assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2); + assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3); + } + + #[test] + fn refs_cost_more_at_720p() { + assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4); + assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7); + assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11); + } + + #[test] + fn refs_cost_more_at_1080p() { + assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39); + assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78); + assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117); + } + + #[test] + fn refs_cost_more_at_4k() { + assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120); + assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240); + assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360); + } + + /// Price the same generation with and without a reference video; pin + /// both prices and the delta, in USD cents AND credits (credits equal + /// cents, and both must show references costing more). + fn assert_ref_delta( + resolution: CommonResolution, + duration_seconds: u16, + expected_no_ref: u64, + expected_with_ref: u64, + expected_delta: u64, + ) { + let no_ref = ArtcraftSeedance2p0BytePlusCostState { + resolution, duration_seconds, batch_count: 1, has_video_reference: false, + }.estimate_cost(); + let with_ref = ArtcraftSeedance2p0BytePlusCostState { + resolution, duration_seconds, batch_count: 1, has_video_reference: true, + }.estimate_cost(); + + let no_ref_cents = no_ref.cost_in_usd_cents.unwrap(); + let with_ref_cents = with_ref.cost_in_usd_cents.unwrap(); + assert_eq!(no_ref_cents, expected_no_ref); + assert_eq!(with_ref_cents, expected_with_ref); + assert_eq!(with_ref_cents - no_ref_cents, expected_delta); + assert!(with_ref_cents > no_ref_cents, "references must cost more"); + + let no_ref_credits = no_ref.cost_in_credits.unwrap(); + let with_ref_credits = with_ref.cost_in_credits.unwrap(); + assert_eq!(no_ref_credits, expected_no_ref); + assert_eq!(with_ref_credits, expected_with_ref); + assert_eq!(with_ref_credits - no_ref_credits, expected_delta); + } + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/request.rs new file mode 100644 index 0000000000..874fcdba37 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0BytePlusRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs new file mode 100644 index 0000000000..4b9eebf3cc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/build.rs @@ -0,0 +1,188 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bp_fast(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusFast, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0BytePlusFastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_seedance_2p0_plus_fast() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusFast))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_bp_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusFast, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_bp_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusFast, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusFast, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusFastRequestState { + let result = build_artcraft_seedance_2p0_bp_fast(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusFast request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/cost.rs new file mode 100644 index 0000000000..d87abf6378 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/cost.rs @@ -0,0 +1,198 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState; + +/// USD cents per second by resolution: +/// 480p: $0.09/s = 9.0 ¢/s +/// 720p: $0.20/s = 20.0 ¢/s +const CENTS_PER_SECOND_480P: f64 = 9.0; +const CENTS_PER_SECOND_720P: f64 = 20.0; + +/// USD cents per second, in hundredths of a cent, when one or more +/// reference videos are attached. Held as integer hundredths so the math is +/// exact; rounded up to whole cents once, after multiplying by +/// duration × batch. +/// 480p: 9.65 ¢/s +/// 720p: 20.50 ¢/s +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 965; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_050; + +pub struct ArtcraftSeedance2p0BytePlusFastCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusFastCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusFastRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + if self.has_video_reference { + let usd_cents = self.with_video_reference_usd_cents(); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Price when one or more reference videos are attached. + fn with_video_reference_usd_cents(&self) -> u64 { + let centi_cents_per_second = match self.resolution { + CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P, + // Everything else (including 720p) prices at 720p. + _ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P, + }; + + let seconds = self.duration_seconds as u64 * self.batch_count as u64; + + // Round up to whole cents. + (centi_cents_per_second * seconds).div_ceil(100) + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 80); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 200); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 300); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 36); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 45); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 90); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 135); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180); + } + } + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + assert!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1) < cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for res in [Some(RouterResolution::FourEightyP), Some(RouterResolution::SevenTwentyP), None] { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusFast, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }.build2().expect("build2").estimate_cost().expect("estimate_cost") + } + + fn cost_cents(resolution: Option, duration_seconds: u16, video_batch_count: u16) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/request.rs new file mode 100644 index 0000000000..64a98dfd77 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_fast/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0BytePlusFastRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusFastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/build.rs new file mode 100644 index 0000000000..7ae5874170 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/build.rs @@ -0,0 +1,240 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade + // to 720p), and all six aspect ratios including 21:9. + // Mini supports batches of 1-8, wider than the shared 1/2/4 planning. + // Take the batch out before the shared build and set it after. + let strategy = builder.request_mismatch_mitigation_strategy; + let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?; + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusMini, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + request.video_batch_count = Some(batch_count); + + let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state))) +} + +#[cfg(test)] +mod tests { + 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; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_bitrate::RouterBitrate; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusMini))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn batch_count_up_to_eight_passed_through() { + // Mini supports batches of 1-8; execution and billing must agree. + for batch in 1..=8u16 { + let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); })); + assert_eq!(req.request.video_batch_count, Some(batch)); + } + } + + #[test] + fn batch_count_above_eight_clamps_to_eight() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); })); + assert_eq!(req.request.video_batch_count, Some(8)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn ultra_wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(builder_with(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![ + CharacterToken::new("char_a".to_string()), + CharacterToken::new("char_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusMiniRequestState { + let result = build_artcraft_seedance_2p0_bp_mini(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusMini request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/cost.rs new file mode 100644 index 0000000000..6f245d2226 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/cost.rs @@ -0,0 +1,218 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState; + +pub struct ArtcraftSeedance2p0BytePlusMiniCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusMiniCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusMiniRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // BytePlus / BytePlus Ultra Mini share a rate set; see + // seedance_common::seedance_2p0_byteplus_mini_usd_cents. + let usd_cents = seedance_2p0_byteplus_mini_usd_cents( + self.resolution, + self.duration_seconds, + self.batch_count, + self.has_video_reference, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // Every combination, driven through the full build pipeline. + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 15); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 36); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 54); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 23); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 45); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 68); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 37); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 91); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 137); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 44); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 55); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 110); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 165); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false)); + } + } + + mod batch_pricing { + use super::*; + + /// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not + /// apply). Once, batch 8 executed in full but billed as batch 4. + #[test] + fn every_batch_size_720p_5s() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364); + } + + #[test] + fn every_batch_size_480p_5s() { + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142); + } + + #[test] + fn batch_eight_with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438); + } + + #[test] + fn batch_above_eight_clamps_to_eight() { + assert_eq!( + cents(RouterResolution::SevenTwentyP, 5, 9, false), + cents(RouterResolution::SevenTwentyP, 5, 8, false), + ); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + for has_ref in [false, true] { + let cost = build_cost(res, dur, batch, has_ref); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + } + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> VideoGenerationCostEstimate { + let reference_videos = with_video_reference.then(|| { + VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())]) + }); + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cents( + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference) + .cost_in_usd_cents + .unwrap() + } + + fn cents_no_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count, false) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/request.rs new file mode 100644 index 0000000000..74bc9b59d1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/request.rs @@ -0,0 +1,86 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0BytePlusMiniRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusMiniRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + request.send_request(&client).await.expect("send_request should succeed") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs new file mode 100644 index 0000000000..05032c7b49 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/build.rs @@ -0,0 +1,181 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bpu(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusUltra, + SupportedResolutions::FullWith4k, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0BytePlusUltraRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_seedance_2p0_bpu() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusUltra))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn res_4k() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_bpu(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusUltraRequestState { + let result = build_artcraft_seedance_2p0_bpu(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusUltra request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/cost.rs new file mode 100644 index 0000000000..a1db1b0a58 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/cost.rs @@ -0,0 +1,363 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +/// USD cents per second by resolution: +/// 480p: $0.10/s = 10.0 ¢/s +/// 720p: $0.25/s = 25.0 ¢/s +/// 1080p: $0.50/s = 50.0 ¢/s +const CENTS_PER_SECOND_480P: f64 = 10.0; +const CENTS_PER_SECOND_720P: f64 = 25.0; +const CENTS_PER_SECOND_1080P: f64 = 50.0; + +/// USD cents per second, in hundredths of a cent, when one or more +/// reference videos are attached. Held as integer hundredths so the math is +/// exact; rounded up to whole cents once, after multiplying by +/// duration × batch. +/// 480p: 10.20 ¢/s +/// 720p: 25.70 ¢/s +/// 1080p: 57.80 ¢/s +/// 4K: 119.00 ¢/s +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 1_020; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_570; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_780; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_900; + +/// 4K output price with no reference videos, in hundredths of a USD cent +/// per second (95.00 ¢/s), rounded up to whole cents after multiplying by +/// duration × batch. +const CENTI_CENTS_PER_SECOND_4K: u64 = 9_500; + +pub struct ArtcraftSeedance2p0BytePlusUltraCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusUltraCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusUltraRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + if self.has_video_reference { + let usd_cents = self.with_video_reference_usd_cents(); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + if self.resolution == CommonResolution::FourK { + let total_centi_cents = CENTI_CENTS_PER_SECOND_4K + * self.duration_seconds as u64 + * self.batch_count as u64; + let usd_cents = total_centi_cents.div_ceil(100); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P, + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Price when one or more reference videos are attached. + fn with_video_reference_usd_cents(&self) -> u64 { + let centi_cents_per_second = match self.resolution { + CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P, + CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K, + // Everything else (including 720p) prices at 720p. + _ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P, + }; + + let seconds = self.duration_seconds as u64 * self.batch_count as u64; + + // Round up to whole cents. + (centi_cents_per_second * seconds).div_ceil(100) + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 125); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 250); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 375); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 40); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 50); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 150); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200); + } + } + + mod pricing_1080p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 4, 1), 200); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 250); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 500); + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 750); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000); + } + } + + mod four_k_pricing { + use enums::common::generation::common_resolution::CommonResolution; + + fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 { + super::super::ArtcraftSeedance2p0BytePlusUltraCostState { + resolution: CommonResolution::FourK, + duration_seconds, + batch_count, + has_video_reference, + } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + #[test] + fn explicit_4k_without_video_reference() { + // 95.00 ¢/s card, rounded up to whole cents. + assert_eq!(artcraft_4k_cents(4, 1, false), 380); + assert_eq!(artcraft_4k_cents(5, 1, false), 475); + assert_eq!(artcraft_4k_cents(10, 1, false), 950); + assert_eq!(artcraft_4k_cents(15, 1, false), 1425); + } + + #[test] + fn explicit_4k_with_video_reference() { + // 119.00 ¢/s with-reference card, rounded up to whole cents. + assert_eq!(artcraft_4k_cents(4, 1, true), 476); + assert_eq!(artcraft_4k_cents(5, 1, true), 595); + assert_eq!(artcraft_4k_cents(10, 1, true), 1190); + assert_eq!(artcraft_4k_cents(15, 1, true), 1785); + } + } + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p_cheaper_than_1080p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1); + assert!(c480 < c720); + assert!(c720 < c1080); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for res in [Some(RouterResolution::FourEightyP), Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), None] { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltra, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }.build2().expect("build2").estimate_cost().expect("estimate_cost") + } + + fn cost_cents(resolution: Option, duration_seconds: u16, video_batch_count: u16) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap() + } + + // ── Video reference deltas ── + // + // References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration. + + mod video_reference_deltas { + use enums::common::generation::common_resolution::CommonResolution; + + use super::super::ArtcraftSeedance2p0BytePlusUltraCostState; + + #[test] + fn refs_cost_more_at_480p() { + assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1); + assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2); + assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3); + } + + #[test] + fn refs_cost_more_at_720p() { + assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4); + assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7); + assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11); + } + + #[test] + fn refs_cost_more_at_1080p() { + assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39); + assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78); + assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117); + } + + #[test] + fn refs_cost_more_at_4k() { + assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120); + assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240); + assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360); + } + + /// Price the same generation with and without a reference video; pin + /// both prices and the delta, in USD cents AND credits (credits equal + /// cents, and both must show references costing more). + fn assert_ref_delta( + resolution: CommonResolution, + duration_seconds: u16, + expected_no_ref: u64, + expected_with_ref: u64, + expected_delta: u64, + ) { + let no_ref = ArtcraftSeedance2p0BytePlusUltraCostState { + resolution, duration_seconds, batch_count: 1, has_video_reference: false, + }.estimate_cost(); + let with_ref = ArtcraftSeedance2p0BytePlusUltraCostState { + resolution, duration_seconds, batch_count: 1, has_video_reference: true, + }.estimate_cost(); + + let no_ref_cents = no_ref.cost_in_usd_cents.unwrap(); + let with_ref_cents = with_ref.cost_in_usd_cents.unwrap(); + assert_eq!(no_ref_cents, expected_no_ref); + assert_eq!(with_ref_cents, expected_with_ref); + assert_eq!(with_ref_cents - no_ref_cents, expected_delta); + assert!(with_ref_cents > no_ref_cents, "references must cost more"); + + let no_ref_credits = no_ref.cost_in_credits.unwrap(); + let with_ref_credits = with_ref.cost_in_credits.unwrap(); + assert_eq!(no_ref_credits, expected_no_ref); + assert_eq!(with_ref_credits, expected_with_ref); + assert_eq!(with_ref_credits - no_ref_credits, expected_delta); + } + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/request.rs new file mode 100644 index 0000000000..51645861de --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0BytePlusUltraRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusUltraRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs new file mode 100644 index 0000000000..5bf890b4ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/build.rs @@ -0,0 +1,188 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bpu_fast(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusUltraFast, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0BytePlusUltraFastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_seedance_2p0_bpu_fast() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusUltraFast))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_bpu_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraFast, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_bpu_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraFast, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraFast, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusUltraFastRequestState { + let result = build_artcraft_seedance_2p0_bpu_fast(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusUltraFast request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/cost.rs new file mode 100644 index 0000000000..66ae005f83 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/cost.rs @@ -0,0 +1,198 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState; + +/// USD cents per second by resolution: +/// 480p: $0.09/s = 9.0 ¢/s +/// 720p: $0.20/s = 20.0 ¢/s +const CENTS_PER_SECOND_480P: f64 = 9.0; +const CENTS_PER_SECOND_720P: f64 = 20.0; + +/// USD cents per second, in hundredths of a cent, when one or more +/// reference videos are attached. Held as integer hundredths so the math is +/// exact; rounded up to whole cents once, after multiplying by +/// duration × batch. +/// 480p: 9.65 ¢/s +/// 720p: 20.50 ¢/s +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 965; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_050; + +pub struct ArtcraftSeedance2p0BytePlusUltraFastCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusUltraFastCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusUltraFastRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + if self.has_video_reference { + let usd_cents = self.with_video_reference_usd_cents(); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Price when one or more reference videos are attached. + fn with_video_reference_usd_cents(&self) -> u64 { + let centi_cents_per_second = match self.resolution { + CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P, + // Everything else (including 720p) prices at 720p. + _ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P, + }; + + let seconds = self.duration_seconds as u64 * self.batch_count as u64; + + // Round up to whole cents. + (centi_cents_per_second * seconds).div_ceil(100) + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 80); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 100); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 200); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 300); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 36); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 45); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 90); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 135); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180); + } + } + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + assert!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1) < cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for res in [Some(RouterResolution::FourEightyP), Some(RouterResolution::SevenTwentyP), None] { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraFast, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }.build2().expect("build2").estimate_cost().expect("estimate_cost") + } + + fn cost_cents(resolution: Option, duration_seconds: u16, video_batch_count: u16) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/request.rs new file mode 100644 index 0000000000..96d8aae4dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_fast/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0BytePlusUltraFastRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusUltraFastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/build.rs new file mode 100644 index 0000000000..3899bfa933 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/build.rs @@ -0,0 +1,240 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade + // to 720p), and all six aspect ratios including 21:9. + // Mini supports batches of 1-8, wider than the shared 1/2/4 planning. + // Take the batch out before the shared build and set it after. + let strategy = builder.request_mismatch_mitigation_strategy; + let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?; + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusUltraMini, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + request.video_batch_count = Some(batch_count); + + let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state))) +} + +#[cfg(test)] +mod tests { + 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; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_bitrate::RouterBitrate; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusUltraMini))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn batch_count_up_to_eight_passed_through() { + // Mini supports batches of 1-8; execution and billing must agree. + for batch in 1..=8u16 { + let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); })); + assert_eq!(req.request.video_batch_count, Some(batch)); + } + } + + #[test] + fn batch_count_above_eight_clamps_to_eight() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); })); + assert_eq!(req.request.video_batch_count, Some(8)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn ultra_wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(builder_with(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![ + CharacterToken::new("char_a".to_string()), + CharacterToken::new("char_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusUltraMiniRequestState { + let result = build_artcraft_seedance_2p0_bpu_mini(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusUltraMini request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/cost.rs new file mode 100644 index 0000000000..0488a7b1f5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/cost.rs @@ -0,0 +1,218 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState; + +pub struct ArtcraftSeedance2p0BytePlusUltraMiniCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusUltraMiniCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusUltraMiniRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // BytePlus / BytePlus Ultra Mini share a rate set; see + // seedance_common::seedance_2p0_byteplus_mini_usd_cents. + let usd_cents = seedance_2p0_byteplus_mini_usd_cents( + self.resolution, + self.duration_seconds, + self.batch_count, + self.has_video_reference, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // Every combination, driven through the full build pipeline. + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 15); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 36); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 54); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 23); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 45); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 68); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 37); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 91); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 137); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 44); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 55); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 110); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 165); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false)); + } + } + + mod batch_pricing { + use super::*; + + /// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not + /// apply). Once, batch 8 executed in full but billed as batch 4. + #[test] + fn every_batch_size_720p_5s() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364); + } + + #[test] + fn every_batch_size_480p_5s() { + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142); + } + + #[test] + fn batch_eight_with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438); + } + + #[test] + fn batch_above_eight_clamps_to_eight() { + assert_eq!( + cents(RouterResolution::SevenTwentyP, 5, 9, false), + cents(RouterResolution::SevenTwentyP, 5, 8, false), + ); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + for has_ref in [false, true] { + let cost = build_cost(res, dur, batch, has_ref); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + } + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> VideoGenerationCostEstimate { + let reference_videos = with_video_reference.then(|| { + VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())]) + }); + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cents( + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference) + .cost_in_usd_cents + .unwrap() + } + + fn cents_no_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count, false) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/request.rs new file mode 100644 index 0000000000..c794885c52 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/request.rs @@ -0,0 +1,86 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0BytePlusUltraMiniRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusUltraMiniRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + request.send_request(&client).await.expect("send_request should succeed") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs new file mode 100644 index 0000000000..cd9bbba91d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/build.rs @@ -0,0 +1,253 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_fast(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0Fast, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0FastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state))) +} + +#[cfg(test)] +mod tests { + 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; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use crate::api::router_bitrate::RouterBitrate; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Field conversions ── + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn prompt_none_stays_none() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(req.request.prompt.is_none()); + } + + #[test] + fn model_is_seedance_2p0_fast() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Fast))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.aspect_ratio = Some(RouterAspectRatio::Square); + })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(req.request.aspect_ratio.is_none()); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.resolution = Some(RouterResolution::FourEightyP); + })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.resolution = Some(RouterResolution::SevenTwentyP); + })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.resolution = Some(RouterResolution::TenEightyP); + })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Media tokens ── + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ]; + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![ + CharacterToken::new("char_a".to_string()), + CharacterToken::new("char_b".to_string()), + ]; + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + // ── Helpers ── + + fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0FastRequestState { + let result = build_artcraft_seedance_2p0_fast(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0Fast(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0Fast request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/cost.rs new file mode 100644 index 0000000000..8cbf8d9959 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/cost.rs @@ -0,0 +1,460 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState; + +// -- Pricing constants -- +// +// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents. +// +// The per-second USD price varies by resolution. + +/// USD cents per second by resolution. +/// +/// Kept as f64 because per-second rates are fractional; rounding happens +/// once at the end after multiplying by duration * batch. +const CENTS_PER_SECOND_480P: f64 = 7.13; +const CENTS_PER_SECOND_720P: f64 = 12.727; + +/// USD cents per second, in hundredths of a cent, when one or more +/// reference videos are attached. Held as integer hundredths so the math is +/// exact; rounded up to whole cents once, after multiplying by +/// duration * batch. +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 841; +const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 1_560; + +pub struct ArtcraftSeedance2p0FastCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0FastCostState { + pub fn from_request(request: &ArtcraftSeedance2p0FastRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + if self.has_video_reference { + let usd_cents = self.with_video_reference_usd_cents(); + return VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }; + } + + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Price when one or more reference videos are attached. + fn with_video_reference_usd_cents(&self) -> u64 { + let centi_cents_per_second = match self.resolution { + CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P, + }; + + let seconds = self.duration_seconds as u64 * self.batch_count as u64; + + // Round up to whole cents. + (centi_cents_per_second * seconds).div_ceil(100) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // -- 720p pricing -- + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 51); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 64); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 127); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 191); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 2), 102); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 127); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 382); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 153); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 191); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 573); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 204); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 255); + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 764); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // -- 480p pricing -- + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 29); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 36); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 71); + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 107); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 71); + } + + #[test] + fn batch_3() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 107); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 143); + } + } + + // -- With-video-reference pricing -- + + mod video_reference_pricing { + use super::*; + + #[test] + fn table_480p() { + let expected = [34, 43, 51, 59, 68, 76, 85, 93, 101, 110, 118, 127]; + for (i, cents) in expected.iter().enumerate() { + let duration = (i + 4) as u16; + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1), + *cents, + "480p {duration}s", + ); + } + } + + #[test] + fn table_720p() { + let expected = [63, 78, 94, 110, 125, 141, 156, 172, 188, 203, 219, 234]; + for (i, cents) in expected.iter().enumerate() { + let duration = (i + 4) as u16; + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1), + *cents, + "720p {duration}s", + ); + } + } + + /// Batches price identically to the equivalent total seconds. + #[test] + fn batch_multiplies_total_seconds() { + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2), + cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1), + ); + assert_eq!( + cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 5, 2), + cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 10, 1), + ); + } + + /// Image and audio references do NOT trigger the with-reference pricing -- + /// only reference videos do. (The non-reference price is unchanged.) + #[test] + fn image_and_audio_references_price_as_no_reference() { + use crate::api::audio_list_ref::AudioListRef; + use crate::api::image_list_ref::ImageListRef; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + video_batch_count: Some(1), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_img".to_string()), + ])), + reference_audio: Some(AudioListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_audio".to_string()), + ])), + ..Default::default() + }; + let cents = builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap(); + assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1)); + } + } + + // -- Relative pricing -- + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = cost_cents(Some(RouterResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1); + let b2 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2); + let b4 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + + /// A video reference must never make a generation cheaper than the same + /// generation without one, at any resolution, duration, or batch size. + #[test] + fn no_reference_cheaper_than_with_reference() { + for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] { + for duration in 4..=15u16 { + for batch in [1, 2, 4] { + let no_ref = cost_cents(Some(resolution), duration, batch); + let with_ref = cost_cents_with_video_ref(Some(resolution), duration, batch); + assert!( + no_ref < with_ref, + "{resolution:?} {duration}s batch {batch}: no-ref {no_ref}¢ should be cheaper than with-ref {with_ref}¢", + ); + } + } + } + } + + /// 480p must stay cheaper than 720p in the with-reference rate card too + /// (the no-reference case is covered by [`cost_480p_cheaper_than_720p`]). + #[test] + fn cost_480p_cheaper_than_720p_with_reference() { + for duration in 4..=15u16 { + let c480 = cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1); + let c720 = cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1); + assert!(c480 < c720, "{duration}s with-ref: 480p ({c480}¢) should be cheaper than 720p ({c720}¢)"); + } + } + + /// The BytePlus variants are premium tiers: plain Fast must stay cheaper + /// at every resolution, duration, and reference configuration. + #[test] + fn fast_cheaper_than_byteplus_variants() { + let premium_models = [ + RouterVideoModel::Seedance2p0BytePlusFast, + RouterVideoModel::Seedance2p0BytePlusUltraFast, + ]; + for premium_model in premium_models { + for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] { + for with_video_reference in [false, true] { + for duration in 4..=15u16 { + let plain = model_cost_cents(RouterVideoModel::Seedance2p0Fast, resolution, duration, 1, with_video_reference); + let premium = model_cost_cents(premium_model, resolution, duration, 1, with_video_reference); + assert!( + plain < premium, + "{premium_model:?} {resolution:?} {duration}s ref={with_video_reference}: plain Fast ({plain}¢) should be cheaper than the premium tier ({premium}¢)", + ); + } + } + } + } + } + + /// BytePlus Fast and BytePlus Ultra Fast share one rate card. + #[test] + fn byteplus_fast_and_ultra_fast_price_identically() { + for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] { + for with_video_reference in [false, true] { + for duration in 4..=15u16 { + let bp = model_cost_cents(RouterVideoModel::Seedance2p0BytePlusFast, resolution, duration, 1, with_video_reference); + let bpu = model_cost_cents(RouterVideoModel::Seedance2p0BytePlusUltraFast, resolution, duration, 1, with_video_reference); + assert_eq!( + bp, bpu, + "{resolution:?} {duration}s ref={with_video_reference}: BytePlus Fast ({bp}¢) and Ultra Fast ({bpu}¢) should price identically", + ); + } + } + } + } + } + + // -- Credits equal cents -- + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } + + /// Like [`cost_cents`], with one reference video attached. + fn cost_cents_with_video_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos: Some(VideoListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } + + /// Like [`cost_cents`], for any Fast-tier model and reference configuration. + fn model_cost_cents( + model: RouterVideoModel, + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + let reference_videos = with_video_reference.then(|| VideoListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])); + let builder = GenerateVideoRequestBuilder { + model, + provider: RouterProvider::Artcraft, + resolution: Some(resolution), + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/request.rs new file mode 100644 index 0000000000..117c626692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_fast/request.rs @@ -0,0 +1,227 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0FastRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0FastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use test_data::web::character_tokens::{JIM, KNIGHT}; + use test_data::web::image_media_tokens::{ + FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN, + JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN, + WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN, + }; + + // -- Aspect ratio tests -- + + mod aspect_ratio_tests { + use super::*; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn landscape() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn portrait() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn square() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A hummingbird hovering near a flower.".to_string()), + aspect_ratio: Some(RouterAspectRatio::Square), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Resolution tests -- + + mod resolution_tests { + use super::*; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Modality tests -- + + mod modality_tests { + use super::*; + use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn keyframe_start_and_end_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog walks from the lake to the forest.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn image_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()), + ])), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn character_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new(JIM.token.to_string()), + CharacterToken::new(KNIGHT.token.to_string()), + ])), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Helpers -- + + fn artcraft_fast_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/build.rs new file mode 100644 index 0000000000..184d837816 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/build.rs @@ -0,0 +1,240 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade + // to 720p), and all six aspect ratios including 21:9. + // Mini supports batches of 1-8, wider than the shared 1/2/4 planning. + // Take the batch out before the shared build and set it after. + let strategy = builder.request_mismatch_mitigation_strategy; + let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?; + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0Mini, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + request.video_batch_count = Some(batch_count); + + let state = ArtcraftSeedance2p0MiniRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state))) +} + +#[cfg(test)] +mod tests { + 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; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_bitrate::RouterBitrate; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Mini))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn batch_count_up_to_eight_passed_through() { + // Mini supports batches of 1-8; execution and billing must agree. + for batch in 1..=8u16 { + let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); })); + assert_eq!(req.request.video_batch_count, Some(batch)); + } + } + + #[test] + fn batch_count_above_eight_clamps_to_eight() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); })); + assert_eq!(req.request.video_batch_count, Some(8)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn ultra_wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(builder_with(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![ + CharacterToken::new("char_a".to_string()), + CharacterToken::new("char_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0MiniRequestState { + let result = build_artcraft_seedance_2p0_mini(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)) => state, + _ => panic!("expected ArtcraftSeedance2p0Mini request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/cost.rs new file mode 100644 index 0000000000..536d7d79b8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/cost.rs @@ -0,0 +1,218 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_mini_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState; + +pub struct ArtcraftSeedance2p0MiniCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0MiniCostState { + pub fn from_request(request: &ArtcraftSeedance2p0MiniRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Seedance 2.0 Mini is priced uniformly across its variants; see + // seedance_common::seedance_2p0_mini_usd_cents. + let usd_cents = seedance_2p0_mini_usd_cents( + self.resolution, + self.duration_seconds, + self.batch_count, + self.has_video_reference, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // Every combination, driven through the full build pipeline. + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 35); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 52); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 22); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 44); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 66); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 36); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 89); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 134); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 43); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 54); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 108); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 161); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false)); + } + } + + mod batch_pricing { + use super::*; + + /// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not + /// apply). Once, batch 8 executed in full but billed as batch 4. + #[test] + fn every_batch_size_720p_5s() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356); + } + + #[test] + fn every_batch_size_480p_5s() { + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138); + } + + #[test] + fn batch_eight_with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429); + } + + #[test] + fn batch_above_eight_clamps_to_eight() { + assert_eq!( + cents(RouterResolution::SevenTwentyP, 5, 9, false), + cents(RouterResolution::SevenTwentyP, 5, 8, false), + ); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + for has_ref in [false, true] { + let cost = build_cost(res, dur, batch, has_ref); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + } + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> VideoGenerationCostEstimate { + let reference_videos = with_video_reference.then(|| { + VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())]) + }); + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cents( + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference) + .cost_in_usd_cents + .unwrap() + } + + fn cents_no_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count, false) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/request.rs new file mode 100644 index 0000000000..1efad69ea3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/request.rs @@ -0,0 +1,86 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0MiniRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0MiniRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + request.send_request(&client).await.expect("send_request should succeed") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/build.rs new file mode 100644 index 0000000000..e9249c2f83 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/build.rs @@ -0,0 +1,250 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Parity with the Kinovi build: keyframes and reference media are mutually + // exclusive, an end frame requires a start frame, and there is no + // character support. Erroring here keeps the billing estimate consistent + // with the execution build. + let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some(); + let has_references = builder.reference_images.is_some() + || builder.reference_videos.is_some() + || builder.reference_audio.is_some(); + + if has_keyframes && has_references { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(), + })); + } + if builder.end_frame.is_some() && builder.start_frame.is_none() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(), + })); + } + if builder.reference_character_tokens.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_character_tokens", + value: "Seedance 2.5 does not support character references".to_string(), + })); + } + + // 2.5 allows 4-30 second durations (longer than the shared 4-15 range in + // build_common) and generates a single video per request. Plan both here, + // leaving None for the shared builder, then set them after. + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + plan_batch_count(builder.video_batch_count.take(), strategy)?; + + let total_input_seconds = builder.total_reference_video_input_seconds.take(); + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p5, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + request.duration_seconds = duration_seconds; + request.video_batch_count = Some(1); + + let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state))) +} + +// Seedance 2.5 supports durations of 4-30 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 4; + const MAX: u16 = 30; + match duration_seconds { + None => Ok(None), + Some(d) if d >= MIN && d <= MAX => Ok(Some(d)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX))), + }, + } +} + +// Seedance 2.5 generates a single video per request (no batching). +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(()), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + }, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5))); + } + + #[test] + fn duration_30_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); })); + assert_eq!(req.request.duration_seconds, Some(30)); + } + + #[test] + fn duration_clamped_to_range() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(30)); + + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); })); + assert_eq!(req.request.duration_seconds, Some(4)); + } + + #[test] + fn batch_count_is_always_one() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(1)); + } + + #[test] + fn total_input_seconds_is_carried_through() { + let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); })); + assert_eq!(req.total_input_seconds, Some(14)); + } + } + + mod modality_rules { + use super::*; + + #[test] + fn keyframes_are_accepted() { + let req = unwrap_request(builder_with(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))); + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))); + })); + assert!(req.request.start_frame_image_media_token.is_some()); + assert!(req.request.end_frame_image_media_token.is_some()); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let result = build_artcraft_seedance_2p5(builder_with(|b| { + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))); + })); + assert!(result.is_err()); + } + + #[test] + fn keyframes_plus_references_error() { + let result = build_artcraft_seedance_2p5(builder_with(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))); + b.reference_images = Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])); + })); + assert!(result.is_err()); + } + + #[test] + fn character_references_error() { + let result = build_artcraft_seedance_2p5(builder_with(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + ])); + })); + assert!(result.is_err()); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_1080p_maps_directly() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn res_4k_downgrades_to_1080p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5RequestState { + let result = build_artcraft_seedance_2p5(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state, + _ => panic!("expected ArtcraftSeedance2p5 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs new file mode 100644 index 0000000000..985c8ec913 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs @@ -0,0 +1,224 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState; + +/// Seedance 2.5 pricing depends on the resolution, the output duration, and +/// — when reference videos are attached — the total seconds of reference +/// video input, which are billed on top of the output duration (at a lower +/// per-second rate). The TOTAL input duration clamps to the 4..=30 second +/// billing range. No batching. +pub struct ArtcraftSeedance2p5CostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub has_video_references: bool, + pub maybe_total_input_seconds: Option, +} + +impl ArtcraftSeedance2p5CostState { + pub fn from_request(request: &ArtcraftSeedance2p5RequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + + let has_video_references = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { + resolution, + duration_seconds, + has_video_references, + maybe_total_input_seconds: request.total_input_seconds, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Input-second clamping (the 4..=30 billing range) and the + // unknown-or-zero-input failsafe happen inside the shared pricing + // function. + let maybe_total_input_seconds = self.maybe_total_input_seconds; + + let usd_cents = seedance_2p5_usd_cents( + self.resolution, + self.duration_seconds, + self.has_video_references, + maybe_total_input_seconds, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + use tokens::tokens::media_files::MediaFileToken; + + mod pricing_without_video_references { + use super::*; + + #[test] + fn table_480p() { + // 11.76954733 ¢/s, rounded up. + assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 48); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 59); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 118); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 354); + } + + #[test] + fn table_720p() { + // 26.70781893 ¢/s, rounded up. + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 107); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 134); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 268); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 802); + } + + #[test] + fn table_1080p() { + // 45.85869386 ¢/s, rounded up. + assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 184); + assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 230); + assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 459); + assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1376); + } + + #[test] + fn four_k_downgrades_to_1080p_and_prices_accordingly() { + assert_eq!(cents(Some(RouterResolution::FourK), 10), cents(Some(RouterResolution::TenEightyP), 10)); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10)); + } + } + + mod pricing_with_video_references { + use super::*; + + #[test] + fn thirty_second_output_with_ten_input_seconds_bills_forty() { + // 7.24279835 ¢/s × 40 = 289.71 → 290¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290); + // 15.84362140 ¢/s × 40 = 633.74 → 634¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634); + // 27.39973680 ¢/s × 40 = 1095.99 → 1096¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096); + } + + #[test] + fn table_1080p_with_ten_input_seconds() { + // 27.39973680 ¢/s over (output + 10) billed seconds, rounded up. + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 411); + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 548); + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096); + } + + #[test] + fn fourteen_input_seconds_bill_forty_four() { + // 7.24279835 ¢/s × 44 = 318.68 → 319¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 319); + } + + #[test] + fn missing_or_zero_input_seconds_bill_the_thirty_second_maximum() { + // FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM, + // matching the provider client's fallback: + // 7.24279835 ¢/s × (10+30) = 289.71 → 290¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 290); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 290); + } + + #[test] + fn input_totals_under_four_seconds_clamp_to_four() { + // 1s, 3s, and 4s totals price identically; 5s bills more. + let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4)); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum); + assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum); + } + + #[test] + fn input_seconds_clamp_to_max_billed_input_seconds() { + // 200 input seconds clamp to 30: 7.24279835 ¢/s × (30+30) = 434.57 → 435¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 435); + assert_eq!( + cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), + cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)), + ); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for dur in [4u16, 10, 30] { + let estimate = estimate_with(|b| { + b.resolution = Some(RouterResolution::FourEightyP); + b.duration_seconds = Some(dur); + }); + assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents); + } + } + } + + // ── Helpers ── + + fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + } + + fn cents(resolution: Option, duration_seconds: u16) -> u64 { + estimate_with(|b| { + b.resolution = resolution; + b.duration_seconds = Some(duration_seconds); + }).cost_in_usd_cents.unwrap() + } + + fn cents_with_video_refs( + resolution: Option, + duration_seconds: u16, + total_input_seconds: Option, + ) -> u64 { + estimate_with(|b| { + b.resolution = resolution; + b.duration_seconds = Some(duration_seconds); + b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])); + b.total_reference_video_input_seconds = total_input_seconds; + }).cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/request.rs new file mode 100644 index 0000000000..fbe8e420e7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/request.rs @@ -0,0 +1,22 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p5RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, + + /// Calculation-only: total seconds of reference video input, billed on top + /// of the output duration when reference videos are attached. Not part of + /// the omni request wire shape. + pub total_input_seconds: Option, +} + +impl ArtcraftSeedance2p5RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/build.rs new file mode 100644 index 0000000000..84ff3a0212 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/build.rs @@ -0,0 +1,250 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Parity with the Kinovi build: 2.5 Preview only operates in reference mode + // (no keyframe start/end frames) and has no character support. Erroring + // here keeps the billing estimate consistent with the execution build. + if builder.start_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "start_frame", + value: "Seedance 2.5 Preview has no keyframe mode; use reference_images".to_string(), + })); + } + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 2.5 Preview has no keyframe mode; use reference_images".to_string(), + })); + } + if builder.reference_character_tokens.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_character_tokens", + value: "Seedance 2.5 Preview does not support character references".to_string(), + })); + } + + // 2.5 Preview allows 4-30 second durations (longer than the shared 4-15 + // range in build_common) and generates a single video per request. Plan + // both here, leaving None for the shared builder, then set them after. + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + plan_batch_count(builder.video_batch_count.take(), strategy)?; + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p5Preview, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + request.duration_seconds = duration_seconds; + request.video_batch_count = Some(1); + + let state = ArtcraftSeedance2p5PreviewRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state))) +} + +// Seedance 2.5 Preview supports durations of 4-30 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 4; + const MAX: u16 = 30; + match duration_seconds { + None => Ok(None), + Some(d) if d >= MIN && d <= MAX => Ok(Some(d)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX))), + }, + } +} + +// Seedance 2.5 Preview generates a single video per request (no batching). +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(()), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + }, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Preview))); + } + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_30_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); })); + assert_eq!(req.request.duration_seconds, Some(30)); + } + + #[test] + fn duration_clamped_to_max_30() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(30)); + } + + #[test] + fn duration_clamped_to_min_4() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); })); + assert_eq!(req.request.duration_seconds, Some(4)); + } + + #[test] + fn batch_count_is_always_one() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(1)); + } + + #[test] + fn batch_over_one_error_out() { + let result = build_artcraft_seedance_2p5_preview(builder_with(|b| { + b.video_batch_count = Some(4); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + } + + mod unsupported_option_errors { + use super::*; + + #[test] + fn start_frame_errors() { + let result = build_artcraft_seedance_2p5_preview(builder_with(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))); + })); + assert!(result.is_err()); + } + + #[test] + fn end_frame_errors() { + let result = build_artcraft_seedance_2p5_preview(builder_with(|b| { + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))); + })); + assert!(result.is_err()); + } + + #[test] + fn character_references_error() { + let result = build_artcraft_seedance_2p5_preview(builder_with(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + ])); + })); + assert!(result.is_err()); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ]; + let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5Preview, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5PreviewRequestState { + let result = build_artcraft_seedance_2p5_preview(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)) => state, + _ => panic!("expected ArtcraftSeedance2p5Preview request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/cost.rs new file mode 100644 index 0000000000..40bea41120 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/cost.rs @@ -0,0 +1,142 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_preview_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState; + +/// Only the resolution and duration matter — 2.5 Preview has no +/// video-reference surcharge and no batching. +pub struct ArtcraftSeedance2p5PreviewCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, +} + +impl ArtcraftSeedance2p5PreviewCostState { + pub fn from_request(request: &ArtcraftSeedance2p5PreviewRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + + Self { resolution, duration_seconds } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let usd_cents = seedance_2p5_preview_usd_cents( + self.resolution, + self.duration_seconds, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + use tokens::tokens::media_files::MediaFileToken; + + mod pricing_480p { + use super::*; + + #[test] + fn full_duration_table() { + assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 86); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 214); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 321); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 428); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 535); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 642); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn full_duration_table() { + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 172); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 428); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 642); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 856); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1070); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1283); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10)); + } + } + + mod reference_invariance_tests { + use super::*; + + /// References never affect 2.5 Preview pricing. + #[test] + fn video_reference_does_not_change_price() { + let without = cents(Some(RouterResolution::SevenTwentyP), 10); + let estimate = estimate_with(|b| { + b.resolution = Some(RouterResolution::SevenTwentyP); + b.duration_seconds = Some(10); + b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])); + }); + assert_eq!(estimate.cost_in_usd_cents, Some(without)); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for dur in [4u16, 10, 15, 20, 25, 30] { + let estimate = estimate_with(|b| { + b.resolution = Some(RouterResolution::FourEightyP); + b.duration_seconds = Some(dur); + }); + assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents); + } + } + } + + // ── Helpers ── + + fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5Preview, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + } + + fn cents(resolution: Option, duration_seconds: u16) -> u64 { + estimate_with(|b| { + b.resolution = resolution; + b.duration_seconds = Some(duration_seconds); + }).cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/request.rs new file mode 100644 index 0000000000..fa8ffc6e27 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_preview/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p5PreviewRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p5PreviewRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/build.rs new file mode 100644 index 0000000000..94a34eb7c9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/build.rs @@ -0,0 +1,250 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Parity with the Kinovi build: keyframes and reference media are mutually + // exclusive, an end frame requires a start frame, and there is no + // character support. Erroring here keeps the billing estimate consistent + // with the execution build. + let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some(); + let has_references = builder.reference_images.is_some() + || builder.reference_videos.is_some() + || builder.reference_audio.is_some(); + + if has_keyframes && has_references { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Seedance 2.5 Ultra cannot combine keyframes with reference media; send one or the other".to_string(), + })); + } + if builder.end_frame.is_some() && builder.start_frame.is_none() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 2.5 Ultra requires a start frame when an end frame is set".to_string(), + })); + } + if builder.reference_character_tokens.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_character_tokens", + value: "Seedance 2.5 Ultra does not support character references".to_string(), + })); + } + + // 2.5 allows 4-30 second durations (longer than the shared 4-15 range in + // build_common) and generates a single video per request. Plan both here, + // leaving None for the shared builder, then set them after. + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + plan_batch_count(builder.video_batch_count.take(), strategy)?; + + let total_input_seconds = builder.total_reference_video_input_seconds.take(); + + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p5Ultra, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + request.duration_seconds = duration_seconds; + request.video_batch_count = Some(1); + + let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state))) +} + +// Seedance 2.5 supports durations of 4-30 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 4; + const MAX: u16 = 30; + match duration_seconds { + None => Ok(None), + Some(d) if d >= MIN && d <= MAX => Ok(Some(d)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX))), + }, + } +} + +// Seedance 2.5 generates a single video per request (no batching). +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(()), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + }, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Ultra))); + } + + #[test] + fn duration_30_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); })); + assert_eq!(req.request.duration_seconds, Some(30)); + } + + #[test] + fn duration_clamped_to_range() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(30)); + + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); })); + assert_eq!(req.request.duration_seconds, Some(4)); + } + + #[test] + fn batch_count_is_always_one() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(1)); + } + + #[test] + fn total_input_seconds_is_carried_through() { + let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); })); + assert_eq!(req.total_input_seconds, Some(14)); + } + } + + mod modality_rules { + use super::*; + + #[test] + fn keyframes_are_accepted() { + let req = unwrap_request(builder_with(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))); + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))); + })); + assert!(req.request.start_frame_image_media_token.is_some()); + assert!(req.request.end_frame_image_media_token.is_some()); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let result = build_artcraft_seedance_2p5_u(builder_with(|b| { + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))); + })); + assert!(result.is_err()); + } + + #[test] + fn keyframes_plus_references_error() { + let result = build_artcraft_seedance_2p5_u(builder_with(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))); + b.reference_images = Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])); + })); + assert!(result.is_err()); + } + + #[test] + fn character_references_error() { + let result = build_artcraft_seedance_2p5_u(builder_with(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + ])); + })); + assert!(result.is_err()); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_1080p_maps_directly() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + + #[test] + fn res_4k_downgrades_to_1080p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5Ultra, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5UltraRequestState { + let result = build_artcraft_seedance_2p5_u(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state, + _ => panic!("expected ArtcraftSeedance2p5Ultra request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs new file mode 100644 index 0000000000..235aa95e32 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs @@ -0,0 +1,250 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_ultra_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState; + +/// Seedance 2.5 Ultra pricing depends on the resolution, the output +/// duration, and — when reference videos are attached — the total seconds of +/// reference video input, which are billed on top of the output duration (at +/// a lower per-second rate). Input seconds are clamped to +/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of +/// video. No batching. +pub struct ArtcraftSeedance2p5UltraCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub has_video_references: bool, + pub maybe_total_input_seconds: Option, +} + +impl ArtcraftSeedance2p5UltraCostState { + pub fn from_request(request: &ArtcraftSeedance2p5UltraRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + + let has_video_references = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { + resolution, + duration_seconds, + has_video_references, + maybe_total_input_seconds: request.total_input_seconds, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Input-second clamping (the 4..=30 billing range) and the + // unknown-or-zero-input failsafe happen inside the shared pricing + // function. + let maybe_total_input_seconds = self.maybe_total_input_seconds; + + let usd_cents = seedance_2p5_ultra_usd_cents( + self.resolution, + self.duration_seconds, + self.has_video_references, + maybe_total_input_seconds, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + use tokens::tokens::media_files::MediaFileToken; + + mod pricing_without_video_references { + use super::*; + + #[test] + fn table_480p() { + // 13.90946502 ¢/s, rounded up. + assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 56); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 70); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 140); + assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 418); + } + + #[test] + fn table_720p() { + // 31.56378601 ¢/s, rounded up. + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 127); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 158); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 316); + assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 947); + } + + #[test] + fn table_1080p() { + // 50.10486922 ¢/s, rounded up. + assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 201); + assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 251); + assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 502); + assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1504); + } + + #[test] + fn four_k_downgrades_to_1080p_and_prices_accordingly() { + assert_eq!(cents(Some(RouterResolution::FourK), 10), cents(Some(RouterResolution::TenEightyP), 10)); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10)); + } + + #[test] + fn ultra_prices_above_regular_seedance_2p5() { + for (resolution, duration) in [ + (RouterResolution::FourEightyP, 5u16), + (RouterResolution::FourEightyP, 30), + (RouterResolution::SevenTwentyP, 5), + (RouterResolution::SevenTwentyP, 30), + (RouterResolution::TenEightyP, 5), + (RouterResolution::TenEightyP, 30), + ] { + let ultra = cents(Some(resolution), duration); + let regular = estimate_for_model(RouterVideoModel::Seedance2p5, |b| { + b.resolution = Some(resolution); + b.duration_seconds = Some(duration); + }).cost_in_usd_cents.unwrap(); + assert!(ultra > regular, "{resolution:?} {duration}s: ultra {ultra} <= regular {regular}"); + } + } + } + + mod pricing_with_video_references { + use super::*; + + #[test] + fn thirty_second_output_with_ten_input_seconds_bills_forty() { + // 8.55967078 ¢/s × 40 = 342.39 → 343¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343); + // 18.72427984 ¢/s × 40 = 748.97 → 749¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749); + // 29.93674947 ¢/s × 40 = 1197.47 → 1198¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198); + } + + #[test] + fn table_1080p_with_ten_input_seconds() { + // 29.93674947 ¢/s over (output + 10) billed seconds, rounded up. + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 450); + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 599); + assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198); + } + + #[test] + fn fourteen_input_seconds_bill_forty_four() { + // 8.55967078 ¢/s × 44 = 376.63 → 377¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 377); + } + + #[test] + fn missing_or_zero_input_seconds_bill_the_thirty_second_maximum() { + // FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM, + // matching the provider client's fallback: + // 8.55967078 ¢/s × (10+30) = 342.39 → 343¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 343); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 343); + } + + #[test] + fn input_totals_under_four_seconds_clamp_to_four() { + let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4)); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum); + assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum); + } + + #[test] + fn input_seconds_clamp_to_max_billed_input_seconds() { + // 200 input seconds clamp to 30: 8.55967078 ¢/s × (30+30) = 513.58 → 514¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 514); + assert_eq!( + cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), + cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)), + ); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + for dur in [4u16, 10, 30] { + let estimate = estimate_with(|b| { + b.resolution = Some(RouterResolution::FourEightyP); + b.duration_seconds = Some(dur); + }); + assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents); + } + } + } + + // ── Helpers ── + + fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate { + estimate_for_model(RouterVideoModel::Seedance2p5Ultra, f) + } + + fn estimate_for_model( + model: RouterVideoModel, + f: impl FnOnce(&mut GenerateVideoRequestBuilder), + ) -> VideoGenerationCostEstimate { + let mut builder = GenerateVideoRequestBuilder { + model, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + } + + fn cents(resolution: Option, duration_seconds: u16) -> u64 { + estimate_with(|b| { + b.resolution = resolution; + b.duration_seconds = Some(duration_seconds); + }).cost_in_usd_cents.unwrap() + } + + fn cents_with_video_refs( + resolution: Option, + duration_seconds: u16, + total_input_seconds: Option, + ) -> u64 { + estimate_with(|b| { + b.resolution = resolution; + b.duration_seconds = Some(duration_seconds); + b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])); + b.total_reference_video_input_seconds = total_input_seconds; + }).cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/request.rs new file mode 100644 index 0000000000..a2952414e2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/request.rs @@ -0,0 +1,22 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p5UltraRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, + + /// Calculation-only: total seconds of reference video input, billed on top + /// of the output duration when reference videos are attached. Not part of + /// the omni request wire shape. + pub total_input_seconds: Option, +} + +impl ArtcraftSeedance2p5UltraRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs new file mode 100644 index 0000000000..3ca088fe69 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs @@ -0,0 +1,451 @@ +//! Shared pricing for the Seedance 2.0 family of ArtCraft providers. +//! +//! ArtCraft credits equal USD cents (100 credits = $1.00). + +use enums::common::generation::common_resolution::CommonResolution; +use kinovi_web_client::generate::video::generate_seedance_2p5::{ + MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS, +}; + +// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held +// in hundredths of a USD cent per second so the math is exact integer arithmetic +// (no floating point), then rounded up to whole cents. + +/// 4K output price, in hundredths of a USD cent per second (92.50 ¢/s). +const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 9250; + +/// ArtCraft's price (USD cents) for Seedance 2.0 at 4K with no reference +/// videos attached. Each model prices reference videos through its own +/// with-reference rate card. The cost scales with duration and batch count, +/// rounded up to whole cents. +pub fn seedance_2p0_four_k_usd_cents( + duration_seconds: u16, + batch_count: u16, +) -> u64 { + let total_centi_cents = + FOUR_K_CENTI_CENTS_PER_SECOND * duration_seconds as u64 * batch_count as u64; + + // Round up to whole cents. + total_centi_cents.div_ceil(100) +} + +// ── Seedance 2.0 Mini pricing ── +// +// Mini offers only 480p and 720p. The rates below are the price in USD cents +// per second; a reference video adds a per-second surcharge. Rates are +// fractional, so the total is rounded UP to a whole cent once at the end +// (after multiplying by duration × batch). +// +// The regular Mini and the BytePlus / BytePlus Ultra Minis have separate +// rate sets and helpers below. + +/// Regular Mini — 480p price, USD cents per second. +const MINI_CENTS_PER_SECOND_480P: f64 = 3.45; +/// Regular Mini — 480p reference-video surcharge, USD cents per second. +const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.90; +/// Regular Mini — 720p price, USD cents per second. +const MINI_CENTS_PER_SECOND_720P: f64 = 8.90; +/// Regular Mini — 720p reference-video surcharge, USD cents per second. +const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.80; + +/// BytePlus / BytePlus Ultra Mini — 480p price, USD cents per second. +const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.55; +/// BytePlus / BytePlus Ultra Mini — 480p reference-video surcharge, USD cents per second. +const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.95; +/// BytePlus / BytePlus Ultra Mini — 720p price, USD cents per second. +const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 9.10; +/// BytePlus / BytePlus Ultra Mini — 720p reference-video surcharge, USD cents per second. +const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.85; + +/// ArtCraft's price (USD cents) for the regular Seedance 2.0 Mini. +/// +/// Only 480p and 720p are offered; any other resolution prices at 720p. A +/// reference video adds the per-second surcharge. The fractional total is +/// rounded UP to a whole cent. +pub fn seedance_2p0_mini_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + batch_count: u16, + has_video_reference: bool, +) -> u64 { + mini_usd_cents( + resolution, + duration_seconds, + batch_count, + has_video_reference, + MINI_CENTS_PER_SECOND_480P, + MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P, + MINI_CENTS_PER_SECOND_720P, + MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P, + ) +} + +/// ArtCraft's price (USD cents) for the Seedance 2.0 BytePlus Mini and +/// BytePlus Ultra Mini (which share the same rates). +/// +/// Only 480p and 720p are offered; any other resolution prices at 720p. A +/// reference video adds the per-second surcharge. The fractional total is +/// rounded UP to a whole cent. +pub fn seedance_2p0_byteplus_mini_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + batch_count: u16, + has_video_reference: bool, +) -> u64 { + mini_usd_cents( + resolution, + duration_seconds, + batch_count, + has_video_reference, + BYTEPLUS_MINI_CENTS_PER_SECOND_480P, + BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P, + BYTEPLUS_MINI_CENTS_PER_SECOND_720P, + BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P, + ) +} + +// ── Seedance 2.5 Preview pricing ── +// +// 2.5 Preview offers only 480p and 720p, generates one video per request (no +// batching), and — unlike the 2.0 family — references of any kind do NOT +// change the price. + +/// Seedance 2.5 Preview — 480p price, USD cents per second. +const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 21.38234568; +/// Seedance 2.5 Preview — 720p price, USD cents per second. +const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 42.76469136; + +/// ArtCraft's price (USD cents) for Seedance 2.5 Preview. +/// +/// Only 480p and 720p are offered; any other resolution prices at 720p. +/// References never affect the price and there is no batching. The fractional +/// total is rounded UP to a whole cent. +pub fn seedance_2p5_preview_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, +) -> u64 { + let cents_per_second = match resolution { + CommonResolution::FourEightyP => SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P, + }; + + (cents_per_second * duration_seconds as f64).ceil() as u64 +} + +/// Seedance 2.5 — 480p price, USD cents per second. +const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733; +/// Seedance 2.5 — 720p price, USD cents per second. +const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893; +/// Seedance 2.5 — 1080p price, USD cents per second. +const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386; +/// Seedance 2.5 — 480p price with video references, USD cents per second. +const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835; +/// Seedance 2.5 — 720p price with video references, USD cents per second. +const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140; +/// Seedance 2.5 — 1080p price with video references, USD cents per second. +const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680; + +/// ArtCraft's price (USD cents) for Seedance 2.5. +/// +/// 480p, 720p, and 1080p are offered; any other resolution prices at 720p +/// (the request planner maps unsupported resolutions before pricing, so +/// this fallback is only a failsafe). Without video references, billed +/// seconds = output duration. With video references, the per-second rate +/// drops but the billed seconds are the output duration PLUS the total +/// seconds of reference video input (clamped to the 4..=30 second billing +/// range). The fractional total is rounded UP to a whole cent. No batching. +pub fn seedance_2p5_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + has_video_references: bool, + maybe_total_input_seconds: Option, +) -> u64 { + let (cents_per_second, billed_seconds) = if has_video_references { + let rate = match resolution { + CommonResolution::FourEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P, + }; + // The TOTAL input duration clamps to the 4..=30 second billing range + // (three 1s videos sum to 3 and bill 4; three 3s videos bill 9). + // + // FAILSAFE: an unknown (never probed) or zero input duration bills the + // 30-second MAXIMUM, matching the provider client's own fallback. It + // must never default toward the minimum — billing 4 input seconds for + // an unmeasured input while the provider assumes 30 sells input seconds + // far below cost. + let billed_input_seconds = match maybe_total_input_seconds { + None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS), + Some(seconds) => { + seconds.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS)) + } + }; + (rate, u64::from(duration_seconds) + u64::from(billed_input_seconds)) + } else { + let rate = match resolution { + CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_1080P, + _ => SEEDANCE_2P5_CENTS_PER_SECOND_720P, + }; + (rate, u64::from(duration_seconds)) + }; + + (cents_per_second * billed_seconds as f64).ceil() as u64 +} + +/// Seedance 2.5 Ultra — 480p price, USD cents per second. +const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502; +/// Seedance 2.5 Ultra — 720p price, USD cents per second. +const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601; +/// Seedance 2.5 Ultra — 1080p price, USD cents per second. +const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 50.10486922; +/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second. +const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078; +/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second. +const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984; +/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per second. +const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.93674947; + +/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra. +/// +/// 480p, 720p, and 1080p are offered; any other resolution prices at 720p +/// (the request planner maps unsupported resolutions before pricing, so +/// this fallback is only a failsafe). Without video references, billed +/// seconds = output duration. With video references, the per-second rate +/// drops but the billed seconds are the output duration PLUS the total +/// seconds of reference video input. The fractional total is rounded UP to +/// a whole cent. No batching. +pub fn seedance_2p5_ultra_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + has_video_references: bool, + maybe_total_input_seconds: Option, +) -> u64 { + let (cents_per_second, billed_seconds) = if has_video_references { + let rate = match resolution { + CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P, + }; + // The TOTAL input duration clamps to the 4..=30 second billing range + // (three 1s videos sum to 3 and bill 4; three 3s videos bill 9). + // + // FAILSAFE: an unknown (never probed) or zero input duration bills the + // 30-second MAXIMUM, matching the provider client's own fallback. It + // must never default toward the minimum — billing 4 input seconds for + // an unmeasured input while the provider assumes 30 sells input seconds + // far below cost. + let billed_input_seconds = match maybe_total_input_seconds { + None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS), + Some(seconds) => { + seconds.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS)) + } + }; + (rate, u64::from(duration_seconds) + u64::from(billed_input_seconds)) + } else { + let rate = match resolution { + CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P, + _ => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P, + }; + (rate, u64::from(duration_seconds)) + }; + + (cents_per_second * billed_seconds as f64).ceil() as u64 +} + +#[allow(clippy::too_many_arguments)] +fn mini_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + batch_count: u16, + has_video_reference: bool, + cents_per_second_480p: f64, + video_reference_surcharge_per_second_480p: f64, + cents_per_second_720p: f64, + video_reference_surcharge_per_second_720p: f64, +) -> u64 { + let (base_per_second, video_reference_surcharge_per_second) = match resolution { + CommonResolution::FourEightyP => (cents_per_second_480p, video_reference_surcharge_per_second_480p), + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => (cents_per_second_720p, video_reference_surcharge_per_second_720p), + }; + + let mut cents_per_second = base_per_second; + if has_video_reference { + cents_per_second += video_reference_surcharge_per_second; + } + + (cents_per_second * duration_seconds as f64 * batch_count as f64).ceil() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn four_k_base_prices() { + assert_eq!(seedance_2p0_four_k_usd_cents(4, 1), 370); + assert_eq!(seedance_2p0_four_k_usd_cents(5, 1), 463); + assert_eq!(seedance_2p0_four_k_usd_cents(10, 1), 925); + assert_eq!(seedance_2p0_four_k_usd_cents(15, 1), 1388); + } + + #[test] + fn batch_count_multiplies() { + assert_eq!(seedance_2p0_four_k_usd_cents(5, 2), 925); + assert_eq!(seedance_2p0_four_k_usd_cents(5, 4), 1850); + } + + // ── Seedance 2.0 Mini ── + // + // Every combination (480p/720p × with/without video reference × 4/5/10/15s) + // at batch 1, rounded up to whole cents. + + mod mini { + use super::*; + + fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 { + seedance_2p0_mini_usd_cents(res, dur, 1, has_ref) + } + + #[test] + fn four_eighty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14); + assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 18); + assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 35); + assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 52); + } + + #[test] + fn four_eighty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 18); + assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 22); + assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 44); + assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 66); + } + + #[test] + fn seven_twenty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 36); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 45); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 89); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 134); + } + + #[test] + fn seven_twenty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 43); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 54); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 108); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 161); + } + + #[test] + fn video_reference_always_costs_more() { + for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] { + for dur in [4u16, 5, 10, 15] { + assert!( + cents(res, dur, true) > cents(res, dur, false), + "video ref should cost more at {res:?} {dur}s", + ); + } + } + } + + #[test] + fn unsupported_resolution_prices_at_720p() { + assert_eq!( + seedance_2p0_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false), + seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false), + ); + } + + #[test] + fn batch_count_scales_the_total() { + // Batch is baked in before the single round-up, so batched totals can be + // a cent under N× the single price. + assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 89); + assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 178); + assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 35); + } + } + + // ── Seedance 2.0 BytePlus / BytePlus Ultra Mini ── + // + // Same combinations as the regular Mini; the prices are equal-or-higher + // than the regular Mini. + + mod byteplus_mini { + use super::*; + + fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 { + seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref) + } + + #[test] + fn four_eighty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 15); + assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 18); + assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 36); + assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 54); + } + + #[test] + fn four_eighty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 18); + assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 23); + assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 45); + assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 68); + } + + #[test] + fn seven_twenty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 37); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 46); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 91); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 137); + } + + #[test] + fn seven_twenty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 44); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 55); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 110); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 165); + } + + #[test] + fn never_cheaper_than_regular_mini() { + for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] { + for dur in [4u16, 5, 10, 15] { + for has_ref in [false, true] { + assert!( + seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref) + >= seedance_2p0_mini_usd_cents(res, dur, 1, has_ref), + "byteplus mini (6%) should be >= regular mini (5%) at {res:?} {dur}s ref={has_ref}", + ); + } + } + } + } + + #[test] + fn unsupported_resolution_prices_at_720p() { + assert_eq!( + seedance_2p0_byteplus_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false), + seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false), + ); + } + + #[test] + fn batch_count_scales_the_total() { + assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 91); + assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 182); + assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 36); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/build.rs new file mode 100644 index 0000000000..f14aaa1ac1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/build.rs @@ -0,0 +1,49 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::sora_2::request::ArtcraftSora2RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_sora_2(mut builder: GenerateVideoRequestBuilder) -> Result { + // Sora 2 accepts only {4,8,12} seconds; pre-plan so cost parity holds for + // strategies that error or clamp. + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_sora_2_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Sora2, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftSora2RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSora2(state))) +} + +pub(crate) fn plan_sora_2_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) | Some(8) | Some(12) => Ok(duration_seconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(12)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(4)), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/cost.rs new file mode 100644 index 0000000000..c103ec62b8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/cost.rs @@ -0,0 +1,62 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::sora_2::request::ArtcraftSora2RequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftSora2CostState { + pub duration_seconds: u64, +} + +impl ArtcraftSora2CostState { + pub fn from_request(request: &ArtcraftSora2RequestState) -> Self { + Self { + // v1 default: None → 4s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(4), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Sora 2: 11.5¢/second (held in hundredths of a cent, rounded up to + // whole cents). Default duration 4s. + let cost_in_usd_cents = (self.duration_seconds * 1_150).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Sora2, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn four_seconds_is_46() { assert_eq!(cost_cents(Some(4)), 46); } + + #[test] + fn eight_seconds_is_92() { assert_eq!(cost_cents(Some(8)), 92); } + + #[test] + fn twelve_seconds_is_138() { assert_eq!(cost_cents(Some(12)), 138); } + + #[test] + fn default_duration_is_4s_priced_at_46() { assert_eq!(cost_cents(None), 46); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/request.rs new file mode 100644 index 0000000000..74000acbc2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSora2RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSora2RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/build.rs new file mode 100644 index 0000000000..ad59b43139 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/build.rs @@ -0,0 +1,27 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::sora_2::build::plan_sora_2_duration; +use crate::generate::generate_video::providers::artcraft::sora_2_pro::request::ArtcraftSora2ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_sora_2_pro(mut builder: GenerateVideoRequestBuilder) -> Result { + // Sora 2 Pro: same duration constraints as Sora 2 ({4,8,12}); reuse helper. + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_sora_2_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Sora2Pro, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftSora2ProRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSora2Pro(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/cost.rs new file mode 100644 index 0000000000..e74e76631d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/cost.rs @@ -0,0 +1,114 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::sora_2_pro::request::ArtcraftSora2ProRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftSora2ProCostState { + pub duration_seconds: u64, + pub is_ten_eighty_p: bool, +} + +impl ArtcraftSora2ProCostState { + pub fn from_request(request: &ArtcraftSora2ProRequestState) -> Self { + let req = &request.request; + Self { + duration_seconds: req.duration_seconds.map(u64::from).unwrap_or(4), + is_ten_eighty_p: is_ten_eighty_p_for_cost(req.resolution, req.start_frame_image_media_token.is_some()), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Sora 2 Pro: 34.5¢/sec @ 720p, 57.5¢/sec @ 1080p (held in hundredths + // of a cent, rounded up to whole cents). Default 4s. + let per_second_centi_cents: u64 = if self.is_ten_eighty_p { 5_750 } else { 3_450 }; + let cost_in_usd_cents = (per_second_centi_cents * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Fal client defaults: text-to-video → 1080p; image-to-video → Auto (priced as 720p). +fn is_ten_eighty_p_for_cost(resolution: Option, has_start_frame: bool) -> bool { + match resolution { + Some(CommonResolutionEnum::TenEightyP) => true, + Some(CommonResolutionEnum::SevenTwentyP) => false, + // omni-gen drops Auto by returning None; default depends on mode. + Some(_) | None => !has_start_frame, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use tokens::tokens::media_files::MediaFileToken; + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + has_start_frame: bool, + ) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Sora2Pro, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + if has_start_frame { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + } + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + mod numeric_literal_pricing { + use super::*; + + #[test] + fn p720_4s_is_138() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 138); } + + #[test] + fn p720_8s_is_276() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 276); } + + #[test] + fn p720_12s_is_414() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 414); } + + #[test] + fn p1080_4s_is_230() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 230); } + + #[test] + fn p1080_12s_is_690() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 690); } + + #[test] + fn t2v_default_resolution_priced_as_1080p() { + // T2V (no start_frame) with no resolution → 1080p default → 230¢ at 4s. + assert_eq!(cost_cents(Some(4), None, false), 230); + } + + #[test] + fn i2v_default_resolution_priced_as_720p() { + // I2V (with start_frame) with no resolution → 720p default → 138¢ at 4s. + assert_eq!(cost_cents(Some(4), None, true), 138); + } + } + + #[test] + fn higher_resolution_costs_more() { + let c720 = cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false); + let c1080 = cost_cents(Some(8), Some(RouterResolution::TenEightyP), false); + assert!(c720 < c1080); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/request.rs new file mode 100644 index 0000000000..628f7b4e20 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/sora_2_pro/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftSora2ProRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSora2ProRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/build.rs new file mode 100644 index 0000000000..869696c63d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/build.rs @@ -0,0 +1,50 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::veo_2::request::ArtcraftVeo2RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_veo_2(mut builder: GenerateVideoRequestBuilder) -> Result { + // Veo 2 accepts only {5,6,7,8} seconds; omni-gen helper allows the whole + // 4-15 range, so we pre-plan here to maintain v1 parity (especially for + // strategies that clamp). + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_veo_2_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Veo2, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftVeo2RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo2(state))) +} + +fn plan_veo_2_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) | Some(6) | Some(7) | Some(8) => Ok(duration_seconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(8)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(5)), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/cost.rs new file mode 100644 index 0000000000..6d21b61e30 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/cost.rs @@ -0,0 +1,65 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::veo_2::request::ArtcraftVeo2RequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo2CostState { + pub duration_seconds: u64, +} + +impl ArtcraftVeo2CostState { + pub fn from_request(request: &ArtcraftVeo2RequestState) -> Self { + Self { + // v1 default: None → 5s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // 5s = $2.75, +$0.55/s above 5s. + let extra = self.duration_seconds.saturating_sub(5); + let cost_in_usd_cents = 275 + extra * 55; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo2, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn default_is_5s_priced_at_275() { assert_eq!(cost_cents(None), 275); } + + #[test] + fn five_seconds_is_275() { assert_eq!(cost_cents(Some(5)), 275); } + + #[test] + fn six_seconds_is_330() { assert_eq!(cost_cents(Some(6)), 330); } + + #[test] + fn seven_seconds_is_385() { assert_eq!(cost_cents(Some(7)), 385); } + + #[test] + fn eight_seconds_is_440() { assert_eq!(cost_cents(Some(8)), 440); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/request.rs new file mode 100644 index 0000000000..0a76e14750 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_2/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo2RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftVeo2RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/build.rs new file mode 100644 index 0000000000..49ffaec9b3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/build.rs @@ -0,0 +1,27 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::veo_3::request::ArtcraftVeo3RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_veo_3(builder: GenerateVideoRequestBuilder) -> Result { + // Veo 3 cost depends on raw user-requested duration (v1's `duration_seconds_raw`) + // and on generate_audio — both survive omni-gen unchanged because their values + // are within omni-gen's accepted ranges. We just preserve generate_audio + // through the helper, then read both fields in cost.rs. + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Veo3, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + let state = ArtcraftVeo3RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/cost.rs new file mode 100644 index 0000000000..da91e00a13 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/cost.rs @@ -0,0 +1,87 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::veo_3::request::ArtcraftVeo3RequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3CostState { + pub duration_seconds: u64, + pub generate_audio: bool, +} + +impl ArtcraftVeo3CostState { + pub fn from_request(request: &ArtcraftVeo3RequestState) -> Self { + Self { + duration_seconds: duration_seconds_for_cost(request.request.duration_seconds), + // Unset defaults to audio on. + generate_audio: request.request.generate_audio.unwrap_or(true), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // $0.24/sec audio off, $0.48/sec audio on. + let per_second_cents: u64 = if self.generate_audio { 48 } else { 24 }; + let cost_in_usd_cents = per_second_cents * self.duration_seconds; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Mirrors v1 `duration_seconds_for_cost`: s≤4 → 4, s≤6 → 6, else → 8 (incl. None and 7+). +fn duration_seconds_for_cost(d: Option) -> u64 { + match d { + Some(s) if s <= 4 => 4, + Some(s) if s <= 6 => 6, + _ => 8, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); } + + #[test] + fn audio_off_6s_is_144() { assert_eq!(cost_cents(Some(6), Some(false)), 144); } + + #[test] + fn audio_off_8s_is_192() { assert_eq!(cost_cents(Some(8), Some(false)), 192); } + + #[test] + fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); } + + #[test] + fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); } + + #[test] + fn default_duration_is_8s() { + assert_eq!(cost_cents(None, Some(false)), cost_cents(Some(8), Some(false))); + } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(true))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/request.rs new file mode 100644 index 0000000000..7916a8d38f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftVeo3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/build.rs new file mode 100644 index 0000000000..0e763ac952 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/build.rs @@ -0,0 +1,26 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::veo_3_fast::request::ArtcraftVeo3FastRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_veo_3_fast(builder: GenerateVideoRequestBuilder) -> Result { + // Veo 3 Fast always bills 8s (v1 maps everything to EightSeconds). We preserve + // generate_audio, and rely on cost.rs to bill 8s regardless of the request's + // stored duration. + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Veo3Fast, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + let state = ArtcraftVeo3FastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3Fast(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/cost.rs new file mode 100644 index 0000000000..5f28e16cef --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/cost.rs @@ -0,0 +1,102 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::veo_3_fast::request::ArtcraftVeo3FastRequestState; + +/// Per-second rates in hundredths of a US cent. +const CENTI_CENTS_PER_SECOND_AUDIO_OFF: u64 = 1_150; +const CENTI_CENTS_PER_SECOND_AUDIO_ON: u64 = 1_725; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3FastCostState { + pub duration_seconds: u64, + pub generate_audio: bool, +} + +impl ArtcraftVeo3FastCostState { + pub fn from_request(request: &ArtcraftVeo3FastRequestState) -> Self { + Self { + duration_seconds: duration_seconds_for_cost(request.request.duration_seconds), + // Unset defaults to audio on. + generate_audio: request.request.generate_audio.unwrap_or(true), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = if self.generate_audio { + CENTI_CENTS_PER_SECOND_AUDIO_ON + } else { + CENTI_CENTS_PER_SECOND_AUDIO_OFF + }; + let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Mirrors Veo 3's `duration_seconds_for_cost`: s≤4 → 4, s≤6 → 6, else → 8 +/// (incl. None and 7+). +fn duration_seconds_for_cost(d: Option) -> u64 { + match d { + Some(s) if s <= 4 => 4, + Some(s) if s <= 6 => 6, + _ => 8, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3Fast, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_off_4s_is_46() { assert_eq!(cost_cents(Some(4), Some(false)), 46); } + + #[test] + fn audio_off_6s_is_69() { assert_eq!(cost_cents(Some(6), Some(false)), 69); } + + #[test] + fn audio_off_8s_is_92() { assert_eq!(cost_cents(Some(8), Some(false)), 92); } + + #[test] + fn audio_on_4s_is_69() { assert_eq!(cost_cents(Some(4), Some(true)), 69); } + + #[test] + fn audio_on_6s_is_104() { + // 1725 × 6 = 10350 hundredth-cents → 104 cents (rounded up). + assert_eq!(cost_cents(Some(6), Some(true)), 104); + } + + #[test] + fn audio_on_8s_is_138() { assert_eq!(cost_cents(Some(8), Some(true)), 138); } + + #[test] + fn default_duration_is_8s() { + assert_eq!(cost_cents(None, Some(false)), cost_cents(Some(8), Some(false))); + } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(true))); + } + +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/request.rs new file mode 100644 index 0000000000..16a622bdb1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3_fast/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3FastRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftVeo3FastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/build.rs new file mode 100644 index 0000000000..15bf9c7296 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/build.rs @@ -0,0 +1,53 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_veo_3p1(mut builder: GenerateVideoRequestBuilder) -> Result { + // Veo 3.1 accepts only {4,6,8} seconds; pre-plan so cost parity holds for + // strategies that error or clamp. Also preserve generate_audio (omni-gen + // helper hardcodes None on output). + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_veo_3p1_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Veo3p1, + SupportedResolutions::FullWith4k, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + + let state = ArtcraftVeo3p1RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1(state))) +} + +pub(crate) fn plan_veo_3p1_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) | Some(6) | Some(8) => Ok(duration_seconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(8)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(4)), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/cost.rs new file mode 100644 index 0000000000..6c85d3d31b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/cost.rs @@ -0,0 +1,114 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3p1CostState { + pub duration_seconds: u64, + pub generate_audio: bool, + pub is_4k: bool, +} + +impl ArtcraftVeo3p1CostState { + pub fn from_request(request: &ArtcraftVeo3p1RequestState) -> Self { + Self { + // v1 legacy Veo 3.1 multi-function handler defaults None → 6s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6), + // v1 legacy Veo 3.1 handler defaults generate_audio to true. + generate_audio: request.request.generate_audio.unwrap_or(true), + is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // $0.24/sec audio off, $0.48/sec audio on; 4K renders bill a premium: + // $0.48/sec audio off, $0.72/sec audio on. + let per_second_cents: u64 = match (self.is_4k, self.generate_audio) { + (false, false) => 24, + (false, true) => 48, + (true, false) => 48, + (true, true) => 72, + }; + let cost_in_usd_cents = per_second_cents * self.duration_seconds; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + cost_cents_at(duration_seconds, generate_audio, None) + } + + fn cost_cents_at( + duration_seconds: Option, + generate_audio: Option, + resolution: Option, + ) -> u64 { + let b = GenerateVideoRequestBuilder { + resolution, + model: RouterVideoModel::Veo3p1, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); } + + #[test] + fn audio_on_6s_is_288() { assert_eq!(cost_cents(Some(6), Some(true)), 288); } + + #[test] + fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); } + + #[test] + fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); } + + #[test] + fn default_duration_is_6s() { + assert_eq!(cost_cents(None, Some(true)), 288); + } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(6), None), 288); + } + + #[test] + fn four_k_audio_off_4s_is_192() { + assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 192); + } + + #[test] + fn four_k_audio_on_4s_is_288() { + assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 288); + } + + #[test] + fn non_four_k_resolutions_share_the_base_rate() { + assert_eq!( + cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)), + cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)), + ); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/request.rs new file mode 100644 index 0000000000..682b90ed4e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3p1RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftVeo3p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/build.rs new file mode 100644 index 0000000000..85fb743592 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/build.rs @@ -0,0 +1,30 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::veo_3p1::build::plan_veo_3p1_duration; +use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_veo_3p1_fast(mut builder: GenerateVideoRequestBuilder) -> Result { + // Same duration constraints as Veo 3.1 (4/6/8). See veo_3p1::build for rationale. + let strategy = builder.request_mismatch_mitigation_strategy; + let final_duration = plan_veo_3p1_duration(builder.duration_seconds, strategy)?; + builder.duration_seconds = final_duration; + + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Veo3p1Fast, + SupportedResolutions::FullWith4k, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + + let state = ArtcraftVeo3p1FastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1Fast(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..1716521a90 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/cost.rs @@ -0,0 +1,114 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3p1FastCostState { + pub duration_seconds: u64, + pub generate_audio: bool, + pub is_4k: bool, +} + +impl ArtcraftVeo3p1FastCostState { + pub fn from_request(request: &ArtcraftVeo3p1FastRequestState) -> Self { + Self { + // v1 legacy Veo 3.1 Fast multi-function handler defaults None → 6s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6), + // v1 legacy Veo 3.1 Fast handler defaults generate_audio to true. + generate_audio: request.request.generate_audio.unwrap_or(true), + is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Per-second rates in hundredths of a US cent; 4K renders bill a premium. + let rate_hundredth_cents: u64 = match (self.is_4k, self.generate_audio) { + (false, false) => 1_100, + (false, true) => 1_650, + (true, false) => 3_300, + (true, true) => 3_850, + }; + // Round up to the next whole cent. + let cost_in_usd_cents = (rate_hundredth_cents * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + cost_cents_at(duration_seconds, generate_audio, None) + } + + fn cost_cents_at( + duration_seconds: Option, + generate_audio: Option, + resolution: Option, + ) -> u64 { + let b = GenerateVideoRequestBuilder { + resolution, + model: RouterVideoModel::Veo3p1Fast, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_on_4s_is_66() { assert_eq!(cost_cents(Some(4), Some(true)), 66); } + + #[test] + fn audio_on_6s_is_99() { assert_eq!(cost_cents(Some(6), Some(true)), 99); } + + #[test] + fn audio_on_8s_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); } + + #[test] + fn audio_off_4s_is_44() { assert_eq!(cost_cents(Some(4), Some(false)), 44); } + + #[test] + fn default_duration_is_6s() { + assert_eq!(cost_cents(None, Some(true)), 99); + } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(6), None), 99); + } + + #[test] + fn four_k_audio_off_4s_is_132() { + assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 132); + } + + #[test] + fn four_k_audio_on_4s_is_154() { + assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 154); + } + + #[test] + fn non_four_k_resolutions_share_the_base_rate() { + assert_eq!( + cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)), + cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)), + ); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/request.rs new file mode 100644 index 0000000000..a4f2c9d2a3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_fast/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3p1FastRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftVeo3p1FastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/build.rs new file mode 100644 index 0000000000..ad9e587be5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/build.rs @@ -0,0 +1,29 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_veo_3p1_lite(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_veo_3p1_lite_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1Lite(state))) +} + +pub(crate) fn build_artcraft_veo_3p1_lite_state(builder: GenerateVideoRequestBuilder) -> Result { + // Veo 3.1 Lite supports `generate_audio` — preserve it so cost can read it back. + // build_artcraft_omni_video_request hardcodes generate_audio = None on its output. + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Veo3p1Lite, + SupportedResolutions::NoFourEightyP, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + Ok(ArtcraftVeo3p1LiteRequestState { request }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/cost.rs new file mode 100644 index 0000000000..f6332a9607 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/cost.rs @@ -0,0 +1,119 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState; + +/// Per-second rates in hundredths of a US cent. +const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 345; +const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 575; +const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 575; +const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 920; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3p1LiteCostState { + pub duration_seconds: u64, + pub is_1080p: bool, + pub generate_audio: bool, +} + +impl ArtcraftVeo3p1LiteCostState { + pub fn from_request(request: &ArtcraftVeo3p1LiteRequestState) -> Self { + Self { + // Veo 3.1 Lite defaults None → 8s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(8), + is_1080p: is_1080p(request.request.resolution), + // Audio is on when unspecified. + generate_audio: request.request.generate_audio.unwrap_or(true), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = match (self.is_1080p, self.generate_audio) { + (false, false) => SEVEN_TWENTY_P_AUDIO_OFF_RATE, + (false, true) => SEVEN_TWENTY_P_AUDIO_ON_RATE, + (true, false) => TEN_EIGHTY_P_AUDIO_OFF_RATE, + (true, true) => TEN_EIGHTY_P_AUDIO_ON_RATE, + }; + // Round up to the next whole cent. + let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Veo 3.1 Lite renders 720p (the default) or 1080p. +fn is_1080p(resolution: Option) -> bool { + matches!( + resolution, + Some(CommonResolutionEnum::TenEightyP) + | Some(CommonResolutionEnum::TwoK) + | Some(CommonResolutionEnum::ThreeK) + | Some(CommonResolutionEnum::FourK) + ) +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite_state; + use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::cost::ArtcraftVeo3p1LiteCostState; + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + generate_audio: Option, + ) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Lite, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + generate_audio, + ..Default::default() + }; + let state = build_artcraft_veo_3p1_lite_state(b).unwrap(); + ArtcraftVeo3p1LiteCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } + + #[test] + fn defaults_8s_720p_audio_on_is_46() { assert_eq!(cost_cents(None, None, None), 46); } + + #[test] + fn audio_off_8s_720p_is_28() { + // 345 * 8 = 2760 hundredth-cents → 28 cents. + assert_eq!(cost_cents(Some(8), None, Some(false)), 28); + } + + #[test] + fn audio_on_8s_1080p_is_74() { + // 920 * 8 = 7360 hundredth-cents → 74 cents. + assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 74); + } + + #[test] + fn audio_off_8s_1080p_is_46() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 46); + } + + #[test] + fn audio_on_4s_720p_is_23() { + assert_eq!(cost_cents(Some(4), None, Some(true)), 23); + } + + #[test] + fn audio_default_is_on() { + assert_eq!(cost_cents(Some(8), None, None), cost_cents(Some(8), None, Some(true))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/request.rs new file mode 100644 index 0000000000..8b6cdb20ac --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/veo_3p1_lite/request.rs @@ -0,0 +1,104 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftVeo3p1LiteRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftVeo3p1LiteRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A lighthouse beam sweeping across a foggy bay at night.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog turns its head toward the camera.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Lite, + provider: RouterProvider::Artcraft, + duration_seconds: Some(8), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/build.rs new file mode 100644 index 0000000000..4fed289906 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/build.rs @@ -0,0 +1,29 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_vidu_q3(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_vidu_q3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3(state))) +} + +pub(crate) fn build_artcraft_vidu_q3_state(builder: GenerateVideoRequestBuilder) -> Result { + // Vidu Q3 supports `audio` — preserve it so cost can read it back. + // build_artcraft_omni_video_request hardcodes generate_audio = None on its output. + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::ViduQ3, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + Ok(ArtcraftViduQ3RequestState { request }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/cost.rs new file mode 100644 index 0000000000..a78ba2bb37 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/cost.rs @@ -0,0 +1,118 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState; + +/// Per-second rates in hundredths of a US cent. +const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 735; +const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_634; + +#[derive(Clone, Debug)] +pub struct ArtcraftViduQ3CostState { + pub duration_seconds: u64, + pub is_high_res: bool, +} + +impl ArtcraftViduQ3CostState { + pub fn from_request(request: &ArtcraftViduQ3RequestState) -> Self { + Self { + // Vidu Q3 defaults None → 5s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + is_high_res: is_high_res(request.request.resolution), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = if self.is_high_res { + HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC + } else { + LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC + }; + // Round up to the next whole cent. + let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset) +/// resolution is 720p. +fn is_high_res(resolution: Option) -> bool { + match resolution { + None => true, // defaults to 720p + Some(CommonResolutionEnum::SevenTwentyP) + | Some(CommonResolutionEnum::TenEightyP) + | Some(CommonResolutionEnum::OneK) + | Some(CommonResolutionEnum::TwoK) + | Some(CommonResolutionEnum::ThreeK) + | Some(CommonResolutionEnum::FourK) => true, + Some(CommonResolutionEnum::HalfK) + | Some(CommonResolutionEnum::FourEightyP) => false, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3_state; + use crate::generate::generate_video::providers::artcraft::vidu_q3::cost::ArtcraftViduQ3CostState; + + fn cost_cents(duration_seconds: Option, resolution: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + let state = build_artcraft_vidu_q3_state(b).unwrap(); + ArtcraftViduQ3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } + + #[test] + fn default_resolution_5s_is_82() { assert_eq!(cost_cents(Some(5), None), 82); } + + #[test] + fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 82); } + + #[test] + fn high_res_10s_is_164() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 164); } + + #[test] + fn low_res_5s_is_37() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 37); } + + #[test] + fn low_res_10s_is_74() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 74); } + + #[test] + fn seven_twenty_p_is_high_res_tier() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP)), 131); + } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s high res: 1634 * 7 = 11438 hundredth-cents → 115 cents. + assert_eq!(cost_cents(Some(7), None), 115); + } + + #[test] + fn resolution_classifier_defaults_high() { + assert!(super::is_high_res(None)); + assert!(super::is_high_res(Some(CommonResolutionEnum::TenEightyP))); + assert!(!super::is_high_res(Some(CommonResolutionEnum::FourEightyP))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/request.rs new file mode 100644 index 0000000000..5d4b200c0b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3/request.rs @@ -0,0 +1,117 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftViduQ3RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftViduQ3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + use crate::api::image_ref::ImageRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog stands up and walks toward the lake.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn reference_images_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog explores a mossy forest clearing.".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/build.rs new file mode 100644 index 0000000000..e946333830 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/build.rs @@ -0,0 +1,29 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_vidu_q3_turbo(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_vidu_q3_turbo_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3Turbo(state))) +} + +pub(crate) fn build_artcraft_vidu_q3_turbo_state(builder: GenerateVideoRequestBuilder) -> Result { + // Vidu Q3 Turbo supports `audio` — preserve it so cost can read it back. + // build_artcraft_omni_video_request hardcodes generate_audio = None on its output. + let generate_audio = builder.generate_audio; + let mut request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::ViduQ3Turbo, + SupportedResolutions::Full, + UltraWideSupport::Unsupported, + )?; + request.generate_audio = generate_audio; + Ok(ArtcraftViduQ3TurboRequestState { request }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/cost.rs new file mode 100644 index 0000000000..d6abb3ddd8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/cost.rs @@ -0,0 +1,104 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState; + +/// Per-second rates in hundredths of a US cent. +const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 401; +const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 834; + +#[derive(Clone, Debug)] +pub struct ArtcraftViduQ3TurboCostState { + pub duration_seconds: u64, + pub is_high_res: bool, +} + +impl ArtcraftViduQ3TurboCostState { + pub fn from_request(request: &ArtcraftViduQ3TurboRequestState) -> Self { + Self { + // Vidu Q3 Turbo defaults None → 5s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + is_high_res: is_high_res(request.request.resolution), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = if self.is_high_res { + HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC + } else { + LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC + }; + // Round up to the next whole cent. + let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset) +/// resolution is 720p. +fn is_high_res(resolution: Option) -> bool { + match resolution { + None => true, // defaults to 720p + Some(CommonResolutionEnum::SevenTwentyP) + | Some(CommonResolutionEnum::TenEightyP) + | Some(CommonResolutionEnum::OneK) + | Some(CommonResolutionEnum::TwoK) + | Some(CommonResolutionEnum::ThreeK) + | Some(CommonResolutionEnum::FourK) => true, + Some(CommonResolutionEnum::HalfK) + | Some(CommonResolutionEnum::FourEightyP) => false, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo_state; + use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::cost::ArtcraftViduQ3TurboCostState; + + fn cost_cents(duration_seconds: Option, resolution: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3Turbo, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + let state = build_artcraft_vidu_q3_turbo_state(b).unwrap(); + ArtcraftViduQ3TurboCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } + + #[test] + fn default_resolution_5s_is_42() { assert_eq!(cost_cents(Some(5), None), 42); } + + #[test] + fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 42); } + + #[test] + fn high_res_10s_is_84() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 84); } + + #[test] + fn low_res_5s_is_21() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 21); } + + #[test] + fn low_res_10s_is_41() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 41); } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s high res: 834 * 7 = 5838 hundredth-cents → 59 cents. + assert_eq!(cost_cents(Some(7), None), 59); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/request.rs new file mode 100644 index 0000000000..bf8c87aded --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/vidu_q3_turbo/request.rs @@ -0,0 +1,104 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftViduQ3TurboRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftViduQ3TurboRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A paper boat drifting down a rainy gutter stream.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog shakes off water in slow motion.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3Turbo, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/build.rs new file mode 100644 index 0000000000..0fe2f5e08b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/build.rs @@ -0,0 +1,416 @@ +use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest; +use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest; +use fal_client::requests::api::video::text::flux_3::api::{ + Flux3AspectRatio, Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::flux_3::request::{ + FalFlux3Mode, FalFlux3RequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub(crate) const MIN_DURATION_SECONDS: u16 = 5; +pub(crate) const MAX_DURATION_SECONDS: u16 = 20; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Auto, + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +pub fn build_fal_flux_3( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_flux_3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3(state))) +} + +pub(crate) fn build_fal_flux_3_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Flux 3 exposes no reference modality here — only text, starting keyframe, + // and start/end keyframes. + reject_references(&builder)?; + + let start = optional_url(builder.start_frame.clone())?; + let end = optional_url(builder.end_frame.clone())?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?; + let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let generate_audio = builder.generate_audio; + + // Modality dispatch: + // start + end frames → first-last-frame-to-video + // start frame only → image-to-video + // no media → text-to-video + let mode = match (start, end) { + (None, None) => FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest { + prompt, + duration: flexible_duration, + resolution, + aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio), + generate_audio, + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }), + (Some(image_url), None) => FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest { + prompt, + image_url, + duration: flexible_duration, + resolution, + aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio), + generate_audio, + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }), + (Some(start_image_url), Some(end_image_url)) => FalFlux3Mode::FirstLastFrameToVideo( + Flux3FirstLastFrameToVideoRequest { + prompt, + start_image_url, + end_image_url, + duration: fixed_duration, + resolution, + aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio), + generate_audio, + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }, + ), + (None, Some(_)) => { + return Err(unsupported( + "end_frame", + "Flux 3 requires a start_frame when end_frame is provided", + )); + } + }; + + Ok(FalFlux3RequestState { mode }) +} + +// ── Input helpers (shared with the draft variant) ── + +pub(crate) fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +/// Flux 3 supports no reference inputs — no image, video, or audio references. +pub(crate) fn reject_references(builder: &GenerateVideoRequestBuilder) -> Result<(), ArtcraftRouterError> { + let has_reference_images = match &builder.reference_images { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_reference_images { + return Err(unsupported("reference_images", "Flux 3 does not support image references")); + } + + let has_reference_videos = match &builder.reference_videos { + None => false, + Some(VideoListRef::Urls(urls)) => !urls.is_empty(), + Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_reference_videos { + return Err(unsupported("reference_videos", "Flux 3 does not support video references")); + } + + let has_reference_audio = match &builder.reference_audio { + None => false, + Some(AudioListRef::Urls(urls)) => !urls.is_empty(), + Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_reference_audio { + return Err(unsupported("reference_audio", "Flux 3 does not support audio references")); + } + + Ok(()) +} + +pub(crate) fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +// ── Plan helpers (shared with the draft variant) ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +/// Flux 3 renders 720p or 1080p; 480p rounds up to 720p (same billing tier). +pub(crate) fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + Some(RouterResolution::FourEightyP) + | Some(RouterResolution::SevenTwentyP) => Ok(Some(Flux3Resolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(Flux3Resolution::TenEightyP)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", &format!("{:?}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(Flux3Resolution::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(Flux3Resolution::SevenTwentyP)), + }, + } +} + +/// Duration for the modalities that accept `auto` (text, image). `None` stays +/// unset so fal's `auto` default applies. +pub(crate) fn plan_flexible_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + Ok(plan_fixed_duration(duration_seconds, strategy)?.map(Flux3Duration::Seconds)) +} + +/// Duration for the fixed-duration first-last-frame modality. `None` stays +/// unset so fal's 5-second default applies. +pub(crate) fn plan_fixed_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => { + Ok(Some(seconds as u8)) + } + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8)) + } + }, + } +} + +// ── Leaf converters ── + +pub(crate) fn to_flux_3_aspect_ratio(a: PlanAspectRatio) -> Flux3AspectRatio { + match a { + PlanAspectRatio::Auto => Flux3AspectRatio::Auto, + PlanAspectRatio::TwentyOneByNine => Flux3AspectRatio::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => Flux3AspectRatio::SixteenByNine, + PlanAspectRatio::FourByThree => Flux3AspectRatio::FourByThree, + PlanAspectRatio::Square => Flux3AspectRatio::Square, + PlanAspectRatio::ThreeByFour => Flux3AspectRatio::ThreeByFour, + PlanAspectRatio::NineBySixteen => Flux3AspectRatio::NineBySixteen, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + const START_URL: &str = "https://example.com/start.png"; + const END_URL: &str = "https://example.com/end.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn no_inputs_picks_t2v() { + let state = build_fal_flux_3_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + let state = build_fal_flux_3_state(b).expect("build"); + assert!(matches!(state.mode, FalFlux3Mode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_frames_pick_first_last_frame() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + let state = build_fal_flux_3_state(b).expect("build"); + let FalFlux3Mode::FirstLastFrameToVideo(req) = state.mode else { + panic!("expected FirstLastFrameToVideo"); + }; + assert_eq!(req.start_image_url, START_URL); + assert_eq!(req.end_image_url, END_URL); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + assert!(build_fal_flux_3_state(b).is_err()); + } + + #[test] + fn reference_images_error() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])); + assert!(build_fal_flux_3_state(b).is_err()); + } + + #[test] + fn reference_videos_error() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + assert!(build_fal_flux_3_state(b).is_err()); + } + + #[test] + fn reference_audio_errors() { + let mut b = base_builder(); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])); + assert!(build_fal_flux_3_state(b).is_err()); + } + + #[test] + fn empty_reference_lists_are_ignored() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![])); + b.reference_videos = Some(VideoListRef::Urls(vec![])); + b.reference_audio = Some(AudioListRef::Urls(vec![])); + let state = build_fal_flux_3_state(b).expect("build"); + assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_))); + } + } + + mod plan_tests { + use super::*; + + #[test] + fn duration_25_errors_under_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(25); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_flux_3_state(b).is_err()); + } + + #[test] + fn duration_25_clamps_to_20_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(25); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_flux_3_state(b).expect("build"); + let FalFlux3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(Flux3Duration::Seconds(20))); + } + + #[test] + fn resolution_480p_and_720p_map_to_720p() { + for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] { + let mut b = base_builder(); + b.resolution = Some(res); + let state = build_fal_flux_3_state(b).expect("build"); + let FalFlux3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(Flux3Resolution::SevenTwentyP), "res={res:?}"); + } + } + + #[test] + fn auto_aspect_ratio_maps_to_flux_auto() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + let state = build_fal_flux_3_state(b).expect("build"); + let FalFlux3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::Auto)); + } + + #[test] + fn twenty_one_by_nine_is_supported() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + let state = build_fal_flux_3_state(b).expect("build"); + let FalFlux3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::TwentyOneByNine)); + } + + #[test] + fn generate_audio_propagates() { + let mut b = base_builder(); + b.generate_audio = Some(false); + let state = build_fal_flux_3_state(b).expect("build"); + let FalFlux3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.generate_audio, Some(false)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/cost.rs new file mode 100644 index 0000000000..5038ea0d1e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/cost.rs @@ -0,0 +1,106 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::flux_3::request::{ + FalFlux3Mode, FalFlux3RequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalFlux3CostState { + pub cost_in_usd_cents: u64, +} + +impl FalFlux3CostState { + pub fn from_request(request: &FalFlux3RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalFlux3Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalFlux3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalFlux3Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3_state; + + use super::*; + + // Pricing (from fal_client's flux_3 cost modules): + // 720p: $0.17/s, 1080p: $0.29/s (all modalities) + // fal defaults when unset: duration = 5s (auto estimates at the 5s floor), + // resolution = 720p. + + #[test] + fn t2v_default_settings_is_85() { + assert_eq!(cost_cents(base_builder(None, None)), 85); + } + + #[test] + fn t2v_10s_1080p_is_290() { + assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 290); + } + + #[test] + fn i2v_5s_720p_is_85() { + let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(cost_cents(b), 85); + } + + #[test] + fn first_last_frame_20s_1080p_is_580() { + let mut b = base_builder(Some(20), Some(RouterResolution::TenEightyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert_eq!(cost_cents(b), 580); + } + + #[test] + fn estimate_cost_forwards_cents() { + let estimate = FalFlux3CostState { cost_in_usd_cents: 85 }.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(85)); + assert_eq!(estimate.cost_in_credits, Some(85)); + assert!(!estimate.is_free); + } + + fn base_builder( + duration_seconds: Option, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + } + } + + fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 { + let state = build_fal_flux_3_state(builder).expect("build"); + FalFlux3CostState::from_request(&state).cost_in_usd_cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/request.rs new file mode 100644 index 0000000000..ec978079c4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3/request.rs @@ -0,0 +1,144 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest; +use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest; +use fal_client::requests::api::video::text::flux_3::api::Flux3TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalFlux3Mode { + TextToVideo(Flux3TextToVideoRequest), + ImageToVideo(Flux3ImageToVideoRequest), + FirstLastFrameToVideo(Flux3FirstLastFrameToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalFlux3RequestState { + pub mode: FalFlux3Mode, +} + +impl FalFlux3RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalFlux3Mode::TextToVideo(request) => send_request(request, client).await, + FalFlux3Mode::ImageToVideo(request) => send_request(request, client).await, + FalFlux3Mode::FirstLastFrameToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::text::flux_3::api::{ + Flux3AspectRatio, Flux3Duration, Flux3Resolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalFlux3RequestState { + mode: FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(false), + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalFlux3RequestState { + mode: FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + // ── Helpers ── + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/build.rs new file mode 100644 index 0000000000..e041c79368 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/build.rs @@ -0,0 +1,151 @@ +use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest; +use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest; +use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::flux_3::build::{ + optional_url, plan_aspect_ratio, plan_fixed_duration, plan_flexible_duration, + reject_references, to_flux_3_aspect_ratio, unsupported, +}; +use crate::generate::generate_video::providers::fal::flux_3_draft::request::{ + FalFlux3DraftMode, FalFlux3DraftRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_flux_3_draft( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_flux_3_draft_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3Draft(state))) +} + +/// Same modality dispatch as full-quality Flux 3 (see that build module) — +/// text, starting keyframe, and start/end keyframes; no references — but +/// against the draft endpoints, which always render 720p and take no +/// resolution input, so any requested resolution is silently dropped. +pub(crate) fn build_fal_flux_3_draft_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + reject_references(&builder)?; + + let start = optional_url(builder.start_frame.clone())?; + let end = optional_url(builder.end_frame.clone())?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?; + let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let generate_audio = builder.generate_audio; + + let mode = match (start, end) { + (None, None) => FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest { + prompt, + duration: flexible_duration, + aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio), + generate_audio, + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }), + (Some(image_url), None) => FalFlux3DraftMode::ImageToVideo(Flux3DraftImageToVideoRequest { + prompt, + image_url, + duration: flexible_duration, + aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio), + generate_audio, + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }), + (Some(start_image_url), Some(end_image_url)) => FalFlux3DraftMode::FirstLastFrameToVideo( + Flux3DraftFirstLastFrameToVideoRequest { + prompt, + start_image_url, + end_image_url, + duration: fixed_duration, + aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio), + generate_audio, + safety_tolerance: Some(4), // NB: 4 is the most permissive. + }, + ), + (None, Some(_)) => { + return Err(unsupported( + "end_frame", + "Flux 3 Draft requires a start_frame when end_frame is provided", + )); + } + }; + + Ok(FalFlux3DraftRequestState { mode }) +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + + use super::*; + + const START_URL: &str = "https://example.com/start.png"; + const END_URL: &str = "https://example.com/end.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn no_inputs_picks_t2v() { + let state = build_fal_flux_3_draft_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalFlux3DraftMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + let state = build_fal_flux_3_draft_state(b).expect("build"); + assert!(matches!(state.mode, FalFlux3DraftMode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_frames_pick_first_last_frame() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + let state = build_fal_flux_3_draft_state(b).expect("build"); + assert!(matches!(state.mode, FalFlux3DraftMode::FirstLastFrameToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + assert!(build_fal_flux_3_draft_state(b).is_err()); + } + + #[test] + fn reference_images_error() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])); + assert!(build_fal_flux_3_draft_state(b).is_err()); + } + + #[test] + fn reference_videos_error() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + assert!(build_fal_flux_3_draft_state(b).is_err()); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3Draft, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/cost.rs new file mode 100644 index 0000000000..dff4c11f7c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/cost.rs @@ -0,0 +1,76 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::flux_3_draft::request::{ + FalFlux3DraftMode, FalFlux3DraftRequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalFlux3DraftCostState { + pub cost_in_usd_cents: u64, +} + +impl FalFlux3DraftCostState { + pub fn from_request(request: &FalFlux3DraftRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalFlux3DraftMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalFlux3DraftMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalFlux3DraftMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft_state; + + use super::*; + + // Pricing (from fal_client's flux_3_draft cost modules): + // $0.06/s (always 720p, all modalities) + // fal defaults when unset: duration = 5s (auto estimates at the 5s floor). + + #[test] + fn t2v_default_settings_is_30() { + assert_eq!(cost_cents(base_builder(None)), 30); + } + + #[test] + fn t2v_20s_is_120() { + assert_eq!(cost_cents(base_builder(Some(20))), 120); + } + + fn base_builder(duration_seconds: Option) -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Flux3Draft, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + } + } + + fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 { + let state = build_fal_flux_3_draft_state(builder).expect("build"); + FalFlux3DraftCostState::from_request(&state).cost_in_usd_cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/request.rs new file mode 100644 index 0000000000..be7701e606 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/flux_3_draft/request.rs @@ -0,0 +1,121 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest; +use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest; +use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalFlux3DraftMode { + TextToVideo(Flux3DraftTextToVideoRequest), + ImageToVideo(Flux3DraftImageToVideoRequest), + FirstLastFrameToVideo(Flux3DraftFirstLastFrameToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalFlux3DraftRequestState { + pub mode: FalFlux3DraftMode, +} + +impl FalFlux3DraftRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalFlux3DraftMode::TextToVideo(request) => send_request(request, client).await, + FalFlux3DraftMode::ImageToVideo(request) => send_request(request, client).await, + FalFlux3DraftMode::FirstLastFrameToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration}; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalFlux3DraftRequestState { + mode: FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(false), + safety_tolerance: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + // ── Helpers ── + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs new file mode 100644 index 0000000000..0e25c31959 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/build.rs @@ -0,0 +1,446 @@ +use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration, + Kling1p6ProElementsToVideoRequest, +}; +use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + Kling1p6ProImageToVideoRequest, +}; +use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + Kling1p6ProTextToVideoRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::{ + FalKling16ProMode, FalKling16ProRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Five, + Ten, +} + +pub fn build_fal_kling_1_6_pro( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + + let reference_image_urls = resolve_reference_image_urls(builder.reference_images.clone())?; + let start_image_url = optional_url(builder.start_frame.clone())?; + let end_image_url = optional_url(builder.end_frame.clone())?; + + // Dispatch: reference_images → elements; start_frame → image; else → text. + // Elements-to-video has no concept of start/end frame, so passing both with + // reference_images is rejected unless the strategy is lenient. + let mode = if !reference_image_urls.is_empty() { + if (start_image_url.is_some() || end_image_url.is_some()) + && matches!(strategy, RequestMismatchMitigationStrategy::ErrorOut) + { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Kling 1.6 Pro elements-to-video cannot also accept start_frame or end_frame".to_string(), + })); + } + FalKling16ProMode::ElementsToVideo(Kling1p6ProElementsToVideoRequest { + prompt, + input_image_urls: reference_image_urls, + negative_prompt, + duration: duration.map(to_elements_duration), + aspect_ratio: aspect_ratio.map(to_elements_aspect_ratio), + }) + } else if let Some(image_url) = start_image_url { + FalKling16ProMode::ImageToVideo(Kling1p6ProImageToVideoRequest { + prompt, + image_url, + end_image_url, + negative_prompt, + duration: duration.map(to_i2v_duration), + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + cfg_scale: None, + }) + } else { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 1.6 Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling16ProMode::TextToVideo(Kling1p6ProTextToVideoRequest { + prompt, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + cfg_scale: None, + }) + }; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro( + FalKling16ProRequestState { mode }, + ))) +} + +// ── Shared URL helpers ── +// +// `optional_url` is also imported by other Kling router modules (e.g. +// kling_3p0_pro) to avoid copy/paste. Preserve the signature. + +pub(crate) fn require_url( + image_ref: Option, + field: &'static str, + reason: &str, +) -> Result { + match image_ref { + Some(ImageRef::Url(url)) => Ok(url), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + None => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: reason.to_string(), + })), + } +} + +pub(crate) fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn resolve_reference_image_urls( + reference_images: Option, +) -> Result, ArtcraftRouterError> { + match reference_images { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +// ── Plan helpers ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) => Ok(Some(PlanDuration::Five)), + Some(10) => Ok(Some(PlanDuration::Ten)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Ten)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)), + }, + } +} + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProTextToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling1p6ProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling1p6ProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling1p6ProTextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_t2v_duration(d: PlanDuration) -> Kling1p6ProTextToVideoDuration { + match d { + PlanDuration::Five => Kling1p6ProTextToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling1p6ProTextToVideoDuration::TenSeconds, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProImageToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling1p6ProImageToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling1p6ProImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling1p6ProImageToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Kling1p6ProImageToVideoDuration { + match d { + PlanDuration::Five => Kling1p6ProImageToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling1p6ProImageToVideoDuration::TenSeconds, + } +} + +fn to_elements_aspect_ratio(a: PlanAspectRatio) -> Kling1p6ProElementsToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling1p6ProElementsToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling1p6ProElementsToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling1p6ProElementsToVideoAspectRatio::NineBySixteen, + } +} + +fn to_elements_duration(d: PlanDuration) -> Kling1p6ProElementsToVideoDuration { + match d { + PlanDuration::Five => Kling1p6ProElementsToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling1p6ProElementsToVideoDuration::TenSeconds, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling16Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + // ── Mode dispatch ── + + mod mode_dispatch { + use super::*; + + #[test] + fn no_frames_picks_t2v() { + let result = build_fal_kling_1_6_pro(base_builder()).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::ImageToVideo(_))); + } + + #[test] + fn reference_images_picks_elements() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::ElementsToVideo(_))); + } + + #[test] + fn elements_with_start_frame_errors_in_strict_mode() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_kling_1_6_pro(b).is_err()); + } + + #[test] + fn elements_with_start_frame_drops_frame_in_lenient_mode() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + assert!(matches!(s.mode, FalKling16ProMode::ElementsToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_1_6_pro(b).is_err()); + } + } + + // ── Field passthrough ── + + mod field_passthrough { + use super::*; + + #[test] + fn t2v_prompt_and_aspect_propagate() { + let mut b = base_builder(); + b.prompt = Some("custom prompt".to_string()); + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + b.duration_seconds = Some(10); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::TextToVideo(req) = s.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.prompt, "custom prompt"); + assert!(matches!(req.aspect_ratio, Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen))); + assert!(matches!(req.duration, Some(Kling1p6ProTextToVideoDuration::TenSeconds))); + } + + #[test] + fn i2v_start_and_end_frames_propagate() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::ImageToVideo(req) = s.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.image_url, "https://example.com/start.png"); + assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png")); + } + + #[test] + fn elements_input_image_urls_propagate() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ])); + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::ElementsToVideo(req) = s.mode else { + panic!("expected ElementsToVideo"); + }; + assert_eq!(req.input_image_urls.len(), 2); + } + } + + // ── Validation ── + + mod validation { + use super::*; + + #[test] + fn media_file_token_rejected_for_start_frame() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::MediaFileToken( + tokens::tokens::media_files::MediaFileToken::new_from_str("mf_xxx"), + )); + assert!(matches!( + build_fal_kling_1_6_pro(b), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn duration_out_of_range_errors_in_strict_mode() { + let mut b = base_builder(); + b.duration_seconds = Some(7); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_kling_1_6_pro(b).is_err()); + } + + #[test] + fn duration_out_of_range_upgrades() { + let mut b = base_builder(); + b.duration_seconds = Some(7); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + let result = build_fal_kling_1_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling16Pro(s)) = result else { + panic!("expected FalKling16Pro"); + }; + let FalKling16ProMode::TextToVideo(req) = s.mode else { + panic!("expected TextToVideo"); + }; + assert!(matches!(req.duration, Some(Kling1p6ProTextToVideoDuration::TenSeconds))); + } + + #[test] + fn full_combinatorial_pass() { + let aspect_ratios = [None, Some(RouterAspectRatio::Square), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen), Some(RouterAspectRatio::Auto)]; + let durations = [None, Some(5u16), Some(10)]; + let mut combos = 0; + for &aspect_ratio in &aspect_ratios { + for &duration in &durations { + for include_end in [false, true] { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.aspect_ratio = aspect_ratio; + b.duration_seconds = duration; + if include_end { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + } + assert!(build_fal_kling_1_6_pro(b).is_ok()); + combos += 1; + } + } + } + assert_eq!(combos, 5 * 3 * 2); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs new file mode 100644 index 0000000000..8ab1f40627 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/cost.rs @@ -0,0 +1,132 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::{ + FalKling16ProMode, FalKling16ProRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalKling16ProCostState { + pub cost_in_usd_cents: u64, +} + +impl FalKling16ProCostState { + pub fn from_request(request: &FalKling16ProRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint `FalRequestCostCalculator` + // implementations. The router state mirrors whichever calculator matches + // the dispatched mode so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalKling16ProMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling16ProMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalKling16ProMode::ElementsToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents_with(mut configure: impl FnMut(&mut GenerateVideoRequestBuilder)) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling16Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + }; + configure(&mut b); + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // ── Image-to-video ($0.095/sec) ── + + #[test] + fn i2v_5s_is_48() { + // ceil(95 × 5 / 10) = 48¢ + let cost = cost_cents_with(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.duration_seconds = Some(5); + }); + assert_eq!(cost, 48); + } + + #[test] + fn i2v_10s_is_95() { + // ceil(95 × 10 / 10) = 95¢ + let cost = cost_cents_with(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.duration_seconds = Some(10); + }); + assert_eq!(cost, 95); + } + + #[test] + fn i2v_default_duration_is_5s_priced_at_48() { + let cost = cost_cents_with(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + }); + assert_eq!(cost, 48); + } + + // ── Text-to-video ($0.098/sec) ── + + #[test] + fn t2v_5s_is_49() { + // ceil(98 × 5 / 10) = 49¢ + let cost = cost_cents_with(|b| { + b.duration_seconds = Some(5); + }); + assert_eq!(cost, 49); + } + + #[test] + fn t2v_10s_is_98() { + let cost = cost_cents_with(|b| { + b.duration_seconds = Some(10); + }); + assert_eq!(cost, 98); + } + + #[test] + fn t2v_default_duration_is_5s_priced_at_49() { + let cost = cost_cents_with(|_b| {}); + assert_eq!(cost, 49); + } + + // ── Elements-to-video ($0.098/sec, same as t2v) ── + + #[test] + fn elements_5s_is_49() { + let cost = cost_cents_with(|b| { + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.duration_seconds = Some(5); + }); + assert_eq!(cost, 49); + } + + #[test] + fn elements_10s_is_98() { + let cost = cost_cents_with(|b| { + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/r.png".to_string()])); + b.duration_seconds = Some(10); + }); + assert_eq!(cost, 98); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs new file mode 100644 index 0000000000..2e72ea1c28 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_1_6_pro/request.rs @@ -0,0 +1,164 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::Kling1p6ProElementsToVideoRequest; +use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::Kling1p6ProImageToVideoRequest; +use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::Kling1p6ProTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalKling16ProMode { + TextToVideo(Kling1p6ProTextToVideoRequest), + ImageToVideo(Kling1p6ProImageToVideoRequest), + ElementsToVideo(Kling1p6ProElementsToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalKling16ProRequestState { + pub mode: FalKling16ProMode, +} + +impl FalKling16ProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalKling16ProMode::TextToVideo(request) => send_request(request, client).await, + FalKling16ProMode::ImageToVideo(request) => send_request(request, client).await, + FalKling16ProMode::ElementsToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration, + }; + use fal_client::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + }; + use fal_client::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalKling16ProRequestState { + mode: FalKling16ProMode::TextToVideo(Kling1p6ProTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalKling16ProRequestState { + mode: FalKling16ProMode::ImageToVideo(Kling1p6ProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_elements_to_video_webhook() { + let state = FalKling16ProRequestState { + mode: FalKling16ProMode::ElementsToVideo(Kling1p6ProElementsToVideoRequest { + prompt: "the elements come to life and dance".to_string(), + input_image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/build.rs new file mode 100644 index 0000000000..89621e2be4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/build.rs @@ -0,0 +1,123 @@ +use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::{ + Kling2p1MasterAspectRatio, Kling2p1MasterDuration, Kling2p1MasterRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::require_url; +use crate::generate::generate_video::providers::fal::kling_2_1_master::request::FalKling21MasterRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_kling_2_1_master( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let image_url = require_url(builder.start_frame.clone(), "start_frame", "Kling 2.1 Master requires a starting frame")?; + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 2.1 Master does not support an ending frame".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + + let request = Kling2p1MasterRequest { + image_url, + prompt: builder.prompt.clone().unwrap_or_default(), + duration, + aspect_ratio, + }; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling21Master( + FalKling21MasterRequestState { request }, + ))) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Kling2p1MasterAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::WideSixteenNine), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::WideSixteenNine), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Ar::WideSixteenNine), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match duration_seconds { + None => Ok(Kling2p1MasterDuration::Default), + Some(5) => Ok(Kling2p1MasterDuration::FiveSeconds), + Some(10) => Ok(Kling2p1MasterDuration::TenSeconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Kling2p1MasterDuration::TenSeconds), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Kling2p1MasterDuration::FiveSeconds), + }, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling21Master, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), + ..Default::default() + } + } + + #[test] + fn missing_start_frame_errors() { + let mut b = base_builder(); + b.start_frame = None; + assert!(build_fal_kling_2_1_master(b).is_err()); + } + + #[test] + fn end_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_2_1_master(b).is_err()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/cost.rs new file mode 100644 index 0000000000..f20f8297c6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/cost.rs @@ -0,0 +1,61 @@ +use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::Kling2p1MasterDuration; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_2_1_master::request::FalKling21MasterRequestState; + +#[derive(Clone, Debug)] +pub struct FalKling21MasterCostState { + pub is_ten_seconds: bool, +} + +impl FalKling21MasterCostState { + pub fn from_request(request: &FalKling21MasterRequestState) -> Self { + Self { + is_ten_seconds: matches!(request.request.duration, Kling2p1MasterDuration::TenSeconds), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_v2p1_master: 5s = $1.40, 10s = $2.80. + let cost_in_usd_cents: u64 = if self.is_ten_seconds { 280 } else { 140 }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling21Master, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn five_seconds_is_140() { assert_eq!(cost_cents(Some(5)), 140); } + + #[test] + fn ten_seconds_is_280() { assert_eq!(cost_cents(Some(10)), 280); } + + #[test] + fn default_duration_is_5s_priced_at_140() { assert_eq!(cost_cents(None), 140); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs new file mode 100644 index 0000000000..98ab2fb5b3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_master/request.rs @@ -0,0 +1,45 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::{ + enqueue_kling_v2p1_master_image_to_video_webhook, Kling2p1MasterArgs, Kling2p1MasterRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalKling21MasterRequestState { + pub request: Kling2p1MasterRequest, +} + +impl FalKling21MasterRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let outbound_request: Arc = Arc::new(self.request.clone()); + + let args = Kling2p1MasterArgs { + request: self.request.clone(), + webhook_url, + api_key: &client.api_key, + }; + + let webhook_response = enqueue_kling_v2p1_master_image_to_video_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/build.rs new file mode 100644 index 0000000000..4c21b7eb83 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/build.rs @@ -0,0 +1,85 @@ +use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::{ + Kling2p1ProAspectRatio, Kling2p1ProDuration, Kling2p1ProRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::{optional_url, require_url}; +use crate::generate::generate_video::providers::fal::kling_2_1_pro::request::FalKling21ProRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_kling_2_1_pro( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let image_url = require_url(builder.start_frame.clone(), "start_frame", "Kling 2.1 Pro requires a starting frame")?; + let end_frame_image_url = optional_url(builder.end_frame.clone())?; + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + + let request = Kling2p1ProRequest { + image_url, + end_frame_image_url, + prompt: builder.prompt.clone().unwrap_or_default(), + duration, + aspect_ratio, + }; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling21Pro( + FalKling21ProRequestState { request }, + ))) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Kling2p1ProAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::WideSixteenNine), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::WideSixteenNine), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Ar::WideSixteenNine), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match duration_seconds { + None => Ok(Kling2p1ProDuration::Default), + Some(5) => Ok(Kling2p1ProDuration::FiveSeconds), + Some(10) => Ok(Kling2p1ProDuration::TenSeconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Kling2p1ProDuration::TenSeconds), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Kling2p1ProDuration::FiveSeconds), + }, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/cost.rs new file mode 100644 index 0000000000..2613c6bb3f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/cost.rs @@ -0,0 +1,61 @@ +use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::Kling2p1ProDuration; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_2_1_pro::request::FalKling21ProRequestState; + +#[derive(Clone, Debug)] +pub struct FalKling21ProCostState { + pub is_ten_seconds: bool, +} + +impl FalKling21ProCostState { + pub fn from_request(request: &FalKling21ProRequestState) -> Self { + Self { + is_ten_seconds: matches!(request.request.duration, Kling2p1ProDuration::TenSeconds), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_v2p1_pro: 5s = 45¢, 10s = 90¢. + let cost_in_usd_cents: u64 = if self.is_ten_seconds { 90 } else { 45 }; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling21Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn five_seconds_is_45() { assert_eq!(cost_cents(Some(5)), 45); } + + #[test] + fn ten_seconds_is_90() { assert_eq!(cost_cents(Some(10)), 90); } + + #[test] + fn default_duration_is_5s_priced_at_45() { assert_eq!(cost_cents(None), 45); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs new file mode 100644 index 0000000000..80748ef6d0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_1_pro/request.rs @@ -0,0 +1,45 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::{ + enqueue_kling_v2p1_pro_image_to_video_webhook, Kling2p1ProArgs, Kling2p1ProRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalKling21ProRequestState { + pub request: Kling2p1ProRequest, +} + +impl FalKling21ProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let outbound_request: Arc = Arc::new(self.request.clone()); + + let args = Kling2p1ProArgs { + request: self.request.clone(), + webhook_url, + api_key: &client.api_key, + }; + + let webhook_response = enqueue_kling_v2p1_pro_image_to_video_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs new file mode 100644 index 0000000000..37afc8840a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/build.rs @@ -0,0 +1,204 @@ +use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::{ + Kling2p5TurboProImageToVideoDuration, Kling2p5TurboProImageToVideoRequest, +}; +use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + Kling2p5TurboProTextToVideoRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::request::{ + FalKling2p5TurboProMode, FalKling2p5TurboProRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Five, + Ten, +} + +pub fn build_fal_kling_2_5_turbo_pro( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + + let mode = match optional_url(builder.start_frame.clone())? { + None => { + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 2.5 Turbo Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling2p5TurboProMode::TextToVideo(Kling2p5TurboProTextToVideoRequest { + prompt, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + cfg_scale: None, + }) + } + Some(image_url) => FalKling2p5TurboProMode::ImageToVideo(Kling2p5TurboProImageToVideoRequest { + prompt, + image_url, + tail_image_url: optional_url(builder.end_frame.clone())?, + duration: duration.map(to_i2v_duration), + negative_prompt, + cfg_scale: None, + }), + }; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro( + FalKling2p5TurboProRequestState { mode }, + ))) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) => Ok(Some(PlanDuration::Five)), + Some(10) => Ok(Some(PlanDuration::Ten)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Ten)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)), + }, + } +} + +fn to_t2v_duration(d: PlanDuration) -> Kling2p5TurboProTextToVideoDuration { + match d { + PlanDuration::Five => Kling2p5TurboProTextToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p5TurboProTextToVideoDuration::TenSeconds, + } +} + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling2p5TurboProTextToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling2p5TurboProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Kling2p5TurboProImageToVideoDuration { + match d { + PlanDuration::Five => Kling2p5TurboProImageToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p5TurboProImageToVideoDuration::TenSeconds, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling2p5TurboPro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + #[test] + fn no_start_frame_picks_t2v() { + let result = build_fal_kling_2_5_turbo_pro(base_builder()).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else { + panic!("expected FalKling2p5TurboPro"); + }; + assert!(matches!(s.mode, FalKling2p5TurboProMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let result = build_fal_kling_2_5_turbo_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else { + panic!("expected FalKling2p5TurboPro"); + }; + assert!(matches!(s.mode, FalKling2p5TurboProMode::ImageToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_2_5_turbo_pro(b).is_err()); + } + + #[test] + fn tail_image_url_propagates() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let result = build_fal_kling_2_5_turbo_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p5TurboPro(s)) = result else { + panic!("expected FalKling2p5TurboPro"); + }; + let FalKling2p5TurboProMode::ImageToVideo(req) = s.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.tail_image_url.as_deref(), Some("https://example.com/end.png")); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs new file mode 100644 index 0000000000..dac10be4ca --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/cost.rs @@ -0,0 +1,74 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::request::{ + FalKling2p5TurboProMode, FalKling2p5TurboProRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalKling2p5TurboProCostState { + pub cost_in_usd_cents: u64, +} + +impl FalKling2p5TurboProCostState { + pub fn from_request(request: &FalKling2p5TurboProRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint `FalRequestCostCalculator` + // implementations. + let cost_in_usd_cents = match &request.mode { + FalKling2p5TurboProMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling2p5TurboProMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, has_start: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling2p5TurboPro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + if has_start { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // Pricing: $0.07/sec flat → 5s = 35¢, 10s = 70¢. Same for t2v and i2v. + + #[test] + fn t2v_5s_is_35() { assert_eq!(cost_cents(Some(5), false), 35); } + + #[test] + fn t2v_10s_is_70() { assert_eq!(cost_cents(Some(10), false), 70); } + + #[test] + fn i2v_5s_is_35() { assert_eq!(cost_cents(Some(5), true), 35); } + + #[test] + fn i2v_10s_is_70() { assert_eq!(cost_cents(Some(10), true), 70); } + + #[test] + fn default_is_5s_priced_at_35() { assert_eq!(cost_cents(None, false), 35); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs new file mode 100644 index 0000000000..43e0fe2361 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_5_turbo_pro/request.rs @@ -0,0 +1,138 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::Kling2p5TurboProImageToVideoRequest; +use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::Kling2p5TurboProTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalKling2p5TurboProMode { + TextToVideo(Kling2p5TurboProTextToVideoRequest), + ImageToVideo(Kling2p5TurboProImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalKling2p5TurboProRequestState { + pub mode: FalKling2p5TurboProMode, +} + +impl FalKling2p5TurboProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalKling2p5TurboProMode::TextToVideo(request) => send_request(request, client).await, + FalKling2p5TurboProMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::Kling2p5TurboProImageToVideoDuration; + use fal_client::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalKling2p5TurboProRequestState { + mode: FalKling2p5TurboProMode::TextToVideo(Kling2p5TurboProTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalKling2p5TurboProRequestState { + mode: FalKling2p5TurboProMode::ImageToVideo(Kling2p5TurboProImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs new file mode 100644 index 0000000000..bd821b1756 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/build.rs @@ -0,0 +1,223 @@ +use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::{ + Kling2p6ProImageToVideoDuration, Kling2p6ProImageToVideoRequest, +}; +use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + Kling2p6ProTextToVideoRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::{ + FalKling2p6ProMode, FalKling2p6ProRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Five, + Ten, +} + +pub fn build_fal_kling_2_6_pro( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let generate_audio = builder.generate_audio; + let end_image_url = optional_url(builder.end_frame.clone())?; + + let mode = match optional_url(builder.start_frame.clone())? { + None => { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 2.6 Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling2p6ProMode::TextToVideo(Kling2p6ProTextToVideoRequest { + prompt, + generate_audio, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + cfg_scale: None, + }) + } + Some(start_image_url) => FalKling2p6ProMode::ImageToVideo(Kling2p6ProImageToVideoRequest { + prompt, + start_image_url, + end_image_url, + duration: duration.map(to_i2v_duration), + negative_prompt, + generate_audio, + voice_ids: None, + }), + }; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro( + FalKling2p6ProRequestState { mode }, + ))) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) => Ok(Some(PlanDuration::Five)), + Some(10) => Ok(Some(PlanDuration::Ten)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Ten)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)), + }, + } +} + +fn to_t2v_duration(d: PlanDuration) -> Kling2p6ProTextToVideoDuration { + match d { + PlanDuration::Five => Kling2p6ProTextToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p6ProTextToVideoDuration::TenSeconds, + } +} + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling2p6ProTextToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling2p6ProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling2p6ProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling2p6ProTextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Kling2p6ProImageToVideoDuration { + match d { + PlanDuration::Five => Kling2p6ProImageToVideoDuration::FiveSeconds, + PlanDuration::Ten => Kling2p6ProImageToVideoDuration::TenSeconds, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling2p6Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + #[test] + fn no_start_frame_picks_t2v() { + let result = build_fal_kling_2_6_pro(base_builder()).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + assert!(matches!(s.mode, FalKling2p6ProMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let result = build_fal_kling_2_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + assert!(matches!(s.mode, FalKling2p6ProMode::ImageToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_2_6_pro(b).is_err()); + } + + #[test] + fn end_frame_with_start_frame_propagates_through_i2v() { + // Kling 2.6 Pro image-to-video accepts an end frame (`end_image_url`). + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let result = build_fal_kling_2_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + let FalKling2p6ProMode::ImageToVideo(req) = s.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png")); + } + + #[test] + fn generate_audio_propagates() { + let mut b = base_builder(); + b.generate_audio = Some(false); + let result = build_fal_kling_2_6_pro(b).expect("build"); + let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling2p6Pro(s)) = result else { + panic!("expected FalKling2p6Pro"); + }; + let FalKling2p6ProMode::TextToVideo(req) = s.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.generate_audio, Some(false)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs new file mode 100644 index 0000000000..f097812ad5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/cost.rs @@ -0,0 +1,92 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::{ + FalKling2p6ProMode, FalKling2p6ProRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalKling2p6ProCostState { + pub cost_in_usd_cents: u64, +} + +impl FalKling2p6ProCostState { + pub fn from_request(request: &FalKling2p6ProRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalKling2p6ProMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling2p6ProMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option, has_start: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling2p6Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + if has_start { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + // Pricing: + // audio off: $0.07/sec → 5s=35¢, 10s=70¢ + // audio on: $0.14/sec → 5s=70¢, 10s=140¢ + // Image-to-video adds a third tier ($0.168/sec) for voice control, but + // the router build doesn't expose voice_ids yet, so it's not tested here. + + #[test] + fn t2v_audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false), false), 35); } + + #[test] + fn t2v_audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false), false), 70); } + + #[test] + fn t2v_audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true), false), 70); } + + #[test] + fn t2v_audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true), false), 140); } + + #[test] + fn i2v_audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false), true), 35); } + + #[test] + fn i2v_audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true), true), 70); } + + #[test] + fn audio_default_is_on() { + // generate_audio=None defaults to true on fal's server. + assert_eq!( + cost_cents(Some(5), None, false), + cost_cents(Some(5), Some(true), false), + ); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs new file mode 100644 index 0000000000..6a24cec31d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_2_6_pro/request.rs @@ -0,0 +1,140 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::Kling2p6ProImageToVideoRequest; +use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::Kling2p6ProTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalKling2p6ProMode { + TextToVideo(Kling2p6ProTextToVideoRequest), + ImageToVideo(Kling2p6ProImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalKling2p6ProRequestState { + pub mode: FalKling2p6ProMode, +} + +impl FalKling2p6ProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalKling2p6ProMode::TextToVideo(request) => send_request(request, client).await, + FalKling2p6ProMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::kling_2p6_pro_image_to_video::api::Kling2p6ProImageToVideoDuration; + use fal_client::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalKling2p6ProRequestState { + mode: FalKling2p6ProMode::TextToVideo(Kling2p6ProTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalKling2p6ProRequestState { + mode: FalKling2p6ProMode::ImageToVideo(Kling2p6ProImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/build.rs new file mode 100644 index 0000000000..8d05e686cd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/build.rs @@ -0,0 +1,253 @@ +use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_pro_image_to_video_webhook::{ + EnqueueKling3p0ProImageToVideoAspectRatio, EnqueueKling3p0ProImageToVideoDuration, + EnqueueKling3p0ProImageToVideoRequest, +}; +use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_pro_text_to_video_webhook::{ + EnqueueKling3p0ProTextToVideoAspectRatio, EnqueueKling3p0ProTextToVideoDuration, + EnqueueKling3p0ProTextToVideoRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::{ + FalKling3p0ProMode, FalKling3p0ProRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +/// 3-15 seconds inclusive — represented as a `u8` because the fal_client side +/// has 13 enum variants (Three..Fifteen). Stored here so the cost calculator +/// can recover the exact second count for billing. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PlanDuration(pub u8); + +const MIN_DURATION: u16 = 3; +const MAX_DURATION: u16 = 15; + +pub fn build_fal_kling_3p0_pro( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_kling_3p0_pro_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Pro(state))) +} + +fn build_kling_3p0_pro_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let generate_audio = builder.generate_audio; + + let mode = match optional_url(builder.start_frame.clone())? { + None => { + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 3.0 Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling3p0ProMode::TextToVideo(EnqueueKling3p0ProTextToVideoRequest { + prompt, + generate_audio, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + shot_type: None, + }) + } + Some(image_url) => FalKling3p0ProMode::ImageToVideo(EnqueueKling3p0ProImageToVideoRequest { + prompt, + image_url, + end_image_url: optional_url(builder.end_frame.clone())?, + generate_audio, + negative_prompt, + duration: duration.map(to_i2v_duration), + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + shot_type: None, + }), + }; + + Ok(FalKling3p0ProRequestState { mode }) +} + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +pub(crate) fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(d) if (MIN_DURATION..=MAX_DURATION).contains(&d) => Ok(Some(PlanDuration(d as u8))), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(PlanDuration(d.clamp(MIN_DURATION, MAX_DURATION) as u8))), + }, + } +} + +fn to_t2v_duration(d: PlanDuration) -> EnqueueKling3p0ProTextToVideoDuration { + use EnqueueKling3p0ProTextToVideoDuration as D; + match d.0 { + 3 => D::ThreeSeconds, + 4 => D::FourSeconds, + 5 => D::FiveSeconds, + 6 => D::SixSeconds, + 7 => D::SevenSeconds, + 8 => D::EightSeconds, + 9 => D::NineSeconds, + 10 => D::TenSeconds, + 11 => D::ElevenSeconds, + 12 => D::TwelveSeconds, + 13 => D::ThirteenSeconds, + 14 => D::FourteenSeconds, + _ => D::FifteenSeconds, + } +} + +fn to_i2v_duration(d: PlanDuration) -> EnqueueKling3p0ProImageToVideoDuration { + use EnqueueKling3p0ProImageToVideoDuration as D; + match d.0 { + 3 => D::ThreeSeconds, + 4 => D::FourSeconds, + 5 => D::FiveSeconds, + 6 => D::SixSeconds, + 7 => D::SevenSeconds, + 8 => D::EightSeconds, + 9 => D::NineSeconds, + 10 => D::TenSeconds, + 11 => D::ElevenSeconds, + 12 => D::TwelveSeconds, + 13 => D::ThirteenSeconds, + 14 => D::FourteenSeconds, + _ => D::FifteenSeconds, + } +} + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKling3p0ProTextToVideoAspectRatio { + match a { + PlanAspectRatio::Square => EnqueueKling3p0ProTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => EnqueueKling3p0ProTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => EnqueueKling3p0ProTextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKling3p0ProImageToVideoAspectRatio { + match a { + PlanAspectRatio::Square => EnqueueKling3p0ProImageToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => EnqueueKling3p0ProImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => EnqueueKling3p0ProImageToVideoAspectRatio::NineBySixteen, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + #[test] + fn no_start_frame_picks_t2v() { + let result = build_fal_kling_3p0_pro(base_builder()).expect("build"); + if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Pro(s)) = result { + assert!(matches!(s.mode, FalKling3p0ProMode::TextToVideo(_))); + } else { panic!("expected FalKling3p0Pro"); } + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_3p0_pro(b).is_err()); + } + + #[test] + fn duration_3s_accepted() { + let mut b = base_builder(); + b.duration_seconds = Some(3); + assert!(build_fal_kling_3p0_pro(b).is_ok()); + } + + #[test] + fn duration_15s_accepted() { + let mut b = base_builder(); + b.duration_seconds = Some(15); + assert!(build_fal_kling_3p0_pro(b).is_ok()); + } + + #[test] + fn duration_out_of_range_errors_with_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(2); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_kling_3p0_pro(b).is_err()); + } + + #[test] + fn duration_out_of_range_clamps_with_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + assert!(build_fal_kling_3p0_pro(b).is_ok()); + } + + use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::FalKling3p0ProMode; +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/cost.rs new file mode 100644 index 0000000000..05d5c0a717 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/cost.rs @@ -0,0 +1,136 @@ +use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_pro_image_to_video_webhook::EnqueueKling3p0ProImageToVideoDuration; +use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_pro_text_to_video_webhook::EnqueueKling3p0ProTextToVideoDuration; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::{ + FalKling3p0ProMode, FalKling3p0ProRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalKling3p0ProCostState { + pub duration_seconds: u64, + pub generate_audio: bool, +} + +impl FalKling3p0ProCostState { + pub fn from_request(request: &FalKling3p0ProRequestState) -> Self { + let (duration_seconds, generate_audio) = match &request.mode { + FalKling3p0ProMode::TextToVideo(req) => ( + t2v_duration_seconds(req.duration), + req.generate_audio.unwrap_or(true), + ), + FalKling3p0ProMode::ImageToVideo(req) => ( + i2v_duration_seconds(req.duration), + req.generate_audio.unwrap_or(true), + ), + }; + Self { duration_seconds, generate_audio } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors fal_client kling_3p0_pro: + // audio off: $0.224/sec (rate=224 in tenths-of-cents) + // audio on: $0.336/sec (rate=336) + // ceiling-divided to whole cents. + let rate: u64 = if self.generate_audio { 336 } else { 224 }; + let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Default duration is 5s when None per Fal client. +fn t2v_duration_seconds(d: Option) -> u64 { + use EnqueueKling3p0ProTextToVideoDuration as D; + match d { + None => 5, + Some(D::ThreeSeconds) => 3, + Some(D::FourSeconds) => 4, + Some(D::FiveSeconds) => 5, + Some(D::SixSeconds) => 6, + Some(D::SevenSeconds) => 7, + Some(D::EightSeconds) => 8, + Some(D::NineSeconds) => 9, + Some(D::TenSeconds) => 10, + Some(D::ElevenSeconds) => 11, + Some(D::TwelveSeconds) => 12, + Some(D::ThirteenSeconds) => 13, + Some(D::FourteenSeconds) => 14, + Some(D::FifteenSeconds) => 15, + } +} + +fn i2v_duration_seconds(d: Option) -> u64 { + use EnqueueKling3p0ProImageToVideoDuration as D; + match d { + None => 5, + Some(D::ThreeSeconds) => 3, + Some(D::FourSeconds) => 4, + Some(D::FiveSeconds) => 5, + Some(D::SixSeconds) => 6, + Some(D::SevenSeconds) => 7, + Some(D::EightSeconds) => 8, + Some(D::NineSeconds) => 9, + Some(D::TenSeconds) => 10, + Some(D::ElevenSeconds) => 11, + Some(D::TwelveSeconds) => 12, + Some(D::ThirteenSeconds) => 13, + Some(D::FourteenSeconds) => 14, + Some(D::FifteenSeconds) => 15, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn audio_on_5s_is_168() { + // rate=336, (336*5 + 9) / 10 = 1689/10 = 168. + assert_eq!(cost_cents(Some(5), Some(true)), 168); + } + + #[test] + fn audio_off_5s_is_112() { + // rate=224, (224*5 + 9) / 10 = 1129/10 = 112. + assert_eq!(cost_cents(Some(5), Some(false)), 112); + } + + #[test] + fn audio_on_10s_is_336() { + assert_eq!(cost_cents(Some(10), Some(true)), 336); + } + + #[test] + fn audio_off_15s_is_336() { + // rate=224, (224*15 + 9) / 10 = 3369/10 = 336. + assert_eq!(cost_cents(Some(15), Some(false)), 336); + } + + #[test] + fn default_duration_is_5s() { + assert_eq!(cost_cents(None, Some(true)), cost_cents(Some(5), Some(true))); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs new file mode 100644 index 0000000000..2bb8017559 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_pro/request.rs @@ -0,0 +1,68 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_pro_image_to_video_webhook::{ + enqueue_kling_3p0_pro_image_to_video_webhook, EnqueueKling3p0ProImageToVideoArgs, + EnqueueKling3p0ProImageToVideoRequest, +}; +use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_pro_text_to_video_webhook::{ + enqueue_kling_3p0_pro_text_to_video_webhook, EnqueueKling3p0ProTextToVideoArgs, + EnqueueKling3p0ProTextToVideoRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalKling3p0ProMode { + TextToVideo(EnqueueKling3p0ProTextToVideoRequest), + ImageToVideo(EnqueueKling3p0ProImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalKling3p0ProRequestState { + pub mode: FalKling3p0ProMode, +} + +impl FalKling3p0ProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound_request): (_, Arc) = match &self.mode { + FalKling3p0ProMode::TextToVideo(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueKling3p0ProTextToVideoArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_kling_3p0_pro_text_to_video_webhook(args).await, outbound) + } + FalKling3p0ProMode::ImageToVideo(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueKling3p0ProImageToVideoArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_kling_3p0_pro_image_to_video_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/build.rs new file mode 100644 index 0000000000..fecd3d58e7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/build.rs @@ -0,0 +1,164 @@ +use fal_client::requests::api::video::image::kling_3p0_standard_image_to_video::api::{ + Kling3p0StandardImageToVideoAspectRatio, Kling3p0StandardImageToVideoDuration, + Kling3p0StandardImageToVideoRequest, +}; +use fal_client::requests::api::video::text::kling_3p0_standard_text_to_video::api::{ + Kling3p0StandardTextToVideoAspectRatio, Kling3p0StandardTextToVideoDuration, + Kling3p0StandardTextToVideoRequest, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::kling_3p0_pro::build::{ + plan_aspect_ratio, plan_duration, PlanAspectRatio, PlanDuration, +}; +use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::{ + FalKling3p0StandardMode, FalKling3p0StandardRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_kling_3p0_standard( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_kling_3p0_standard_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(state))) +} + +pub(crate) fn build_fal_kling_3p0_standard_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let generate_audio = builder.generate_audio; + + let mode = match optional_url(builder.start_frame.clone())? { + None => { + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Kling 3.0 Standard requires a start_frame when end_frame is provided".to_string(), + })); + } + FalKling3p0StandardMode::TextToVideo(Kling3p0StandardTextToVideoRequest { + prompt, + generate_audio, + negative_prompt, + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + shot_type: None, + }) + } + Some(image_url) => FalKling3p0StandardMode::ImageToVideo(Kling3p0StandardImageToVideoRequest { + prompt, + image_url, + end_image_url: optional_url(builder.end_frame.clone())?, + generate_audio, + negative_prompt, + duration: duration.map(to_i2v_duration), + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + shot_type: None, + }), + }; + + Ok(FalKling3p0StandardRequestState { mode }) +} + +fn to_t2v_duration(d: PlanDuration) -> Kling3p0StandardTextToVideoDuration { + use Kling3p0StandardTextToVideoDuration as D; + match d.0 { + 3 => D::ThreeSeconds, + 4 => D::FourSeconds, + 5 => D::FiveSeconds, + 6 => D::SixSeconds, + 7 => D::SevenSeconds, + 8 => D::EightSeconds, + 9 => D::NineSeconds, + 10 => D::TenSeconds, + 11 => D::ElevenSeconds, + 12 => D::TwelveSeconds, + 13 => D::ThirteenSeconds, + 14 => D::FourteenSeconds, + _ => D::FifteenSeconds, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Kling3p0StandardImageToVideoDuration { + use Kling3p0StandardImageToVideoDuration as D; + match d.0 { + 3 => D::ThreeSeconds, + 4 => D::FourSeconds, + 5 => D::FiveSeconds, + 6 => D::SixSeconds, + 7 => D::SevenSeconds, + 8 => D::EightSeconds, + 9 => D::NineSeconds, + 10 => D::TenSeconds, + 11 => D::ElevenSeconds, + 12 => D::TwelveSeconds, + 13 => D::ThirteenSeconds, + 14 => D::FourteenSeconds, + _ => D::FifteenSeconds, + } +} + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling3p0StandardTextToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling3p0StandardTextToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling3p0StandardTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling3p0StandardTextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Kling3p0StandardImageToVideoAspectRatio { + match a { + PlanAspectRatio::Square => Kling3p0StandardImageToVideoAspectRatio::Square, + PlanAspectRatio::SixteenByNine => Kling3p0StandardImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Kling3p0StandardImageToVideoAspectRatio::NineBySixteen, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + + use super::*; + + #[test] + fn no_start_frame_picks_t2v() { + let state = build_fal_kling_3p0_standard_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalKling3p0StandardMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let state = build_fal_kling_3p0_standard_state(b).expect("build"); + assert!(matches!(state.mode, FalKling3p0StandardMode::ImageToVideo(_))); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_kling_3p0_standard_state(b).is_err()); + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Standard, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/cost.rs new file mode 100644 index 0000000000..d8a9cab57c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/cost.rs @@ -0,0 +1,88 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::{ + FalKling3p0StandardMode, FalKling3p0StandardRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalKling3p0StandardCostState { + pub cost_in_usd_cents: u64, +} + +impl FalKling3p0StandardCostState { + pub fn from_request(request: &FalKling3p0StandardRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalKling3p0StandardMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalKling3p0StandardMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::kling_3p0_standard::build::build_fal_kling_3p0_standard_state; + + use super::*; + + // Pricing (fal): audio off $0.168/sec (rate=168 tenths-of-cents), audio on + // $0.252/sec (rate=252), rounded up to whole cents. + + #[test] + fn audio_on_5s_is_126() { + // rate=252, (252*5 + 9) / 10 = 1269/10 = 126. + assert_eq!(cost_cents(Some(5), Some(true)), 126); + } + + #[test] + fn audio_off_5s_is_84() { + // rate=168, (168*5 + 9) / 10 = 849/10 = 84. + assert_eq!(cost_cents(Some(5), Some(false)), 84); + } + + #[test] + fn audio_on_10s_is_252() { + assert_eq!(cost_cents(Some(10), Some(true)), 252); + } + + #[test] + fn audio_off_15s_is_252() { + // (168*15 + 9) / 10 = 2529/10 = 252. + assert_eq!(cost_cents(Some(15), Some(false)), 252); + } + + fn cost_cents(duration_seconds: Option, generate_audio: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Standard, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + let state = build_fal_kling_3p0_standard_state(b).expect("build_fal_kling_3p0_standard_state"); + FalKling3p0StandardCostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost_in_usd_cents") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs new file mode 100644 index 0000000000..2497209ec8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/kling_3p0_standard/request.rs @@ -0,0 +1,79 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::kling_3p0_standard_image_to_video::api::Kling3p0StandardImageToVideoRequest; +use fal_client::requests::api::video::text::kling_3p0_standard_text_to_video::api::Kling3p0StandardTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalKling3p0StandardMode { + TextToVideo(Kling3p0StandardTextToVideoRequest), + ImageToVideo(Kling3p0StandardImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalKling3p0StandardRequestState { + pub mode: FalKling3p0StandardMode, +} + +impl FalKling3p0StandardRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalKling3p0StandardMode::TextToVideo(request) => send_request(request, client).await, + FalKling3p0StandardMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/build.rs new file mode 100644 index 0000000000..88e18796a7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/build.rs @@ -0,0 +1,543 @@ +use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest; +use fal_client::requests::api::video::reference::minimax_h3::api::{ + MinimaxH3ReferenceToVideoAspectRatio, MinimaxH3ReferenceToVideoRequest, +}; +use fal_client::requests::api::video::text::minimax_h3::api::{ + MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio, MinimaxH3TextToVideoRequest, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::minimax_h3::request::{ + FalMinimaxH3Mode, FalMinimaxH3RequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +const MAX_REFERENCE_IMAGES: usize = 9; +const MAX_REFERENCE_VIDEOS: usize = 3; +const MAX_REFERENCE_AUDIO: usize = 3; +const MAX_REFERENCE_FILES: usize = 12; +const MIN_DURATION_SECONDS: u16 = 5; +const MAX_DURATION_SECONDS: u16 = 15; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +pub fn build_fal_minimax_h3( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_minimax_h3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalMinimaxH3(state))) +} + +pub(crate) fn build_fal_minimax_h3_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let reference_image_urls = resolve_image_urls(builder.reference_images.clone())?; + let reference_video_urls = resolve_video_urls(builder.reference_videos.clone())?; + let reference_audio_urls = resolve_audio_urls(builder.reference_audio.clone())?; + let start_image_url = optional_url(builder.start_frame.clone())?; + let end_image_url = optional_url(builder.end_frame.clone())?; + + let has_references = !reference_image_urls.is_empty() + || !reference_video_urls.is_empty() + || !reference_audio_urls.is_empty(); + + // Dispatch: any reference asset → reference-to-video; start_frame → image; else → text. + let mode = if has_references { + if start_image_url.is_some() || end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "MiniMax H3 reference-to-video cannot also accept a start_frame or end_frame".to_string(), + })); + } + check_reference_counts( + reference_image_urls.len(), + reference_video_urls.len(), + reference_audio_urls.len(), + )?; + FalMinimaxH3Mode::ReferenceToVideo(MinimaxH3ReferenceToVideoRequest { + prompt, + reference_image_urls: non_empty(reference_image_urls), + reference_video_urls: non_empty(reference_video_urls), + reference_audio_urls: non_empty(reference_audio_urls), + duration, + resolution, + aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio), + }) + } else if let Some(image_url) = start_image_url { + // Image-to-video has no aspect_ratio input — the output follows the start + // frame — so any requested aspect ratio is silently dropped here. + FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest { + prompt, + image_url, + end_image_url, + duration, + resolution, + }) + } else { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "MiniMax H3 requires a start_frame when end_frame is provided".to_string(), + })); + } + FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest { + prompt, + duration, + resolution, + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + }) + }; + + Ok(FalMinimaxH3RequestState { mode }) +} + +// ── Input helpers ── + +/// fal's reference limits: at most 9 images, 3 videos, 3 audio clips, and 12 +/// files overall; audio cannot be the only reference input. +fn check_reference_counts( + image_count: usize, + video_count: usize, + audio_count: usize, +) -> Result<(), ArtcraftRouterError> { + if image_count > MAX_REFERENCE_IMAGES { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_IMAGES} reference images, got {image_count}"), + })); + } + if video_count > MAX_REFERENCE_VIDEOS { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_videos", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_VIDEOS} reference videos, got {video_count}"), + })); + } + if audio_count > MAX_REFERENCE_AUDIO { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_audio", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_AUDIO} reference audio clips, got {audio_count}"), + })); + } + let total = image_count + video_count + audio_count; + if total > MAX_REFERENCE_FILES { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_FILES} reference files overall, got {total}"), + })); + } + if audio_count > 0 && image_count == 0 && video_count == 0 { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_audio", + value: "MiniMax H3 requires at least one reference image or video alongside reference audio".to_string(), + })); + } + Ok(()) +} + +fn resolve_image_urls( + reference_images: Option, +) -> Result, ArtcraftRouterError> { + match reference_images { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn resolve_video_urls( + reference_videos: Option, +) -> Result, ArtcraftRouterError> { + match reference_videos { + None => Ok(vec![]), + Some(VideoListRef::Urls(urls)) => Ok(urls), + Some(VideoListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn resolve_audio_urls( + reference_audio: Option, +) -> Result, ArtcraftRouterError> { + match reference_audio { + None => Ok(vec![]), + Some(AudioListRef::Urls(urls)) => Ok(urls), + Some(AudioListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn non_empty(urls: Vec) -> Option> { + if urls.is_empty() { None } else { Some(urls) } +} + +// ── Plan helpers ── + +/// `None` (including the `Auto*` ratios) leaves the field unset so fal's +/// per-mode default applies: 16:9 for text-to-video, adaptive for +/// reference-to-video. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(None), + + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +/// MiniMax H3 offers exactly two output sizes: 768P and 2K. +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + // 480p/720p round up to 768P, the smallest size H3 offers. + Some(RouterResolution::FourEightyP) + | Some(RouterResolution::SevenTwentyP) => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)), + Some(RouterResolution::TenEightyP) + | Some(RouterResolution::TwoK) => Ok(Some(MinimaxH3Resolution::TwoK)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(MinimaxH3Resolution::TwoK)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => { + Ok(Some(seconds as u8)) + } + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8)) + } + }, + } +} + +// ── Leaf converters ── + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3TextToVideoAspectRatio { + match a { + PlanAspectRatio::TwentyOneByNine => MinimaxH3TextToVideoAspectRatio::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => MinimaxH3TextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::FourByThree => MinimaxH3TextToVideoAspectRatio::FourByThree, + PlanAspectRatio::Square => MinimaxH3TextToVideoAspectRatio::Square, + PlanAspectRatio::ThreeByFour => MinimaxH3TextToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::NineBySixteen => MinimaxH3TextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_reference_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3ReferenceToVideoAspectRatio { + match a { + PlanAspectRatio::TwentyOneByNine => MinimaxH3ReferenceToVideoAspectRatio::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => MinimaxH3ReferenceToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::FourByThree => MinimaxH3ReferenceToVideoAspectRatio::FourByThree, + PlanAspectRatio::Square => MinimaxH3ReferenceToVideoAspectRatio::Square, + PlanAspectRatio::ThreeByFour => MinimaxH3ReferenceToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::NineBySixteen => MinimaxH3ReferenceToVideoAspectRatio::NineBySixteen, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + const START_URL: &str = "https://example.com/start.png"; + const END_URL: &str = "https://example.com/end.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn no_inputs_picks_t2v() { + let state = build_fal_minimax_h3_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalMinimaxH3Mode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + let state = build_fal_minimax_h3_state(b).expect("build"); + assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_frame_picks_i2v_with_end_image_url() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::ImageToVideo(req) = state.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.image_url, START_URL); + assert_eq!(req.end_image_url.as_deref(), Some(END_URL)); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn reference_images_pick_reference_to_video() { + let state = build_fal_minimax_h3_state(builder_with_reference_images(3)).expect("build"); + let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else { + panic!("expected ReferenceToVideo"); + }; + assert_eq!(req.reference_image_urls.map(|urls| urls.len()), Some(3)); + assert_eq!(req.reference_video_urls, None); + assert_eq!(req.reference_audio_urls, None); + } + + #[test] + fn reference_videos_pick_reference_to_video() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()])); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else { + panic!("expected ReferenceToVideo"); + }; + assert_eq!(req.reference_video_urls.map(|urls| urls.len()), Some(1)); + } + + #[test] + fn ten_reference_images_error() { + assert!(build_fal_minimax_h3_state(builder_with_reference_images(10)).is_err()); + } + + #[test] + fn four_reference_videos_error() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls( + (0..4).map(|i| format!("https://example.com/v-{i}.mp4")).collect(), + )); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn thirteen_reference_files_error() { + // 9 images + 3 videos + 1 audio = 13 > 12 total. + let mut b = builder_with_reference_images(9); + b.reference_videos = Some(VideoListRef::Urls( + (0..3).map(|i| format!("https://example.com/v-{i}.mp4")).collect(), + )); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn audio_only_reference_errors() { + let mut b = base_builder(); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn reference_images_with_start_frame_error() { + let mut b = builder_with_reference_images(2); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + } + + mod plan_tests { + use super::*; + + #[test] + fn duration_20_errors_under_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn duration_20_clamps_to_15_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(15)); + } + + #[test] + fn duration_3_clamps_to_5_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(3); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(5)); + } + + #[test] + fn resolution_480p_and_720p_map_to_768p() { + for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] { + let mut b = base_builder(); + b.resolution = Some(res); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(MinimaxH3Resolution::SevenSixtyEightP), "res={res:?}"); + } + } + + #[test] + fn resolution_1080p_and_2k_map_to_2k() { + for res in [RouterResolution::TenEightyP, RouterResolution::TwoK] { + let mut b = base_builder(); + b.resolution = Some(res); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(MinimaxH3Resolution::TwoK), "res={res:?}"); + } + } + + #[test] + fn resolution_4k_errors_under_error_out() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn twenty_one_by_nine_is_supported() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.aspect_ratio, Some(MinimaxH3TextToVideoAspectRatio::TwentyOneByNine)); + } + + #[test] + fn auto_aspect_ratio_is_left_unset() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.aspect_ratio, None); + } + + #[test] + fn i2v_aspect_ratio_is_silently_dropped() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + // Image-to-video has no aspect_ratio field; the request still builds. + let state = build_fal_minimax_h3_state(b).expect("build"); + assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_))); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn builder_with_reference_images(count: usize) -> GenerateVideoRequestBuilder { + let urls = (0..count) + .map(|i| format!("https://example.com/ref-{}.png", i)) + .collect(); + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(urls)); + b + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/cost.rs new file mode 100644 index 0000000000..01c5238638 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/cost.rs @@ -0,0 +1,129 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::minimax_h3::request::{ + FalMinimaxH3Mode, FalMinimaxH3RequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalMinimaxH3CostState { + pub cost_in_usd_cents: u64, +} + +impl FalMinimaxH3CostState { + pub fn from_request(request: &FalMinimaxH3RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalMinimaxH3Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalMinimaxH3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalMinimaxH3Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3_state; + + use super::*; + + // Pricing (from fal_client's minimax_h3 cost modules): + // 768P: $0.16/sec → 5s = 80¢ + // 2K: $0.26/sec → 5s = 130¢, 10s = 260¢ + // Reference-to-video additionally bills 8¢ per reference image beyond the + // first 5. fal defaults when unset: duration = 5s, resolution = 2K. + + #[test] + fn t2v_default_settings_is_130() { + assert_eq!(cost_cents(base_builder(None, None)), 130); + } + + #[test] + fn t2v_5s_768p_is_80() { + // RouterResolution::SevenTwentyP maps to H3's 768P. + assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 80); + } + + #[test] + fn t2v_10s_2k_is_260() { + assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 260); + } + + #[test] + fn i2v_5s_768p_is_80() { + let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(cost_cents(b), 80); + } + + #[test] + fn reference_five_images_5s_2k_is_130() { + assert_eq!(cost_cents(builder_with_references(5, Some(RouterResolution::TenEightyP))), 130); + } + + #[test] + fn reference_nine_images_5s_2k_is_162() { + // 130¢ video + 4 extra images × 8¢ = 162¢. + assert_eq!(cost_cents(builder_with_references(9, Some(RouterResolution::TenEightyP))), 162); + } + + #[test] + fn estimate_cost_forwards_cents() { + let estimate = FalMinimaxH3CostState { cost_in_usd_cents: 130 }.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(130)); + assert_eq!(estimate.cost_in_credits, Some(130)); + assert!(!estimate.is_free); + } + + fn base_builder( + duration_seconds: Option, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + } + } + + fn builder_with_references( + image_count: usize, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + let urls = (0..image_count) + .map(|i| format!("https://example.com/ref-{}.png", i)) + .collect(); + let mut b = base_builder(Some(5), resolution); + b.reference_images = Some(ImageListRef::Urls(urls)); + b + } + + fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 { + let state = build_fal_minimax_h3_state(builder).expect("build"); + FalMinimaxH3CostState::from_request(&state).cost_in_usd_cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/request.rs new file mode 100644 index 0000000000..b466624d48 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/request.rs @@ -0,0 +1,140 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest; +use fal_client::requests::api::video::reference::minimax_h3::api::MinimaxH3ReferenceToVideoRequest; +use fal_client::requests::api::video::text::minimax_h3::api::MinimaxH3TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalMinimaxH3Mode { + TextToVideo(MinimaxH3TextToVideoRequest), + ImageToVideo(MinimaxH3ImageToVideoRequest), + ReferenceToVideo(MinimaxH3ReferenceToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalMinimaxH3RequestState { + pub mode: FalMinimaxH3Mode, +} + +impl FalMinimaxH3RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalMinimaxH3Mode::TextToVideo(request) => send_request(request, client).await, + FalMinimaxH3Mode::ImageToVideo(request) => send_request(request, client).await, + FalMinimaxH3Mode::ReferenceToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::text::minimax_h3::api::{ + MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalMinimaxH3RequestState { + mode: FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalMinimaxH3RequestState { + mode: FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + // ── Helpers ── + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs new file mode 100644 index 0000000000..1476919317 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs @@ -0,0 +1,22 @@ +pub mod flux_3; +pub mod flux_3_draft; +pub mod kling_1_6_pro; +pub mod kling_2_1_master; +pub mod kling_2_1_pro; +pub mod kling_2_5_turbo_pro; +pub mod kling_2_6_pro; +pub mod kling_3p0_pro; +pub mod kling_3p0_standard; +pub mod minimax_h3; +pub mod seedance_1p0_lite; +pub mod seedance_1p5_pro; +pub mod sora_2; +pub mod sora_2_pro; +pub mod veo_2; +pub mod veo_3; +pub mod veo_3_fast; +pub mod veo_3p1; +pub mod veo_3p1_fast; +pub mod veo_3p1_lite; +pub mod vidu_q3; +pub mod vidu_q3_turbo; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/build.rs new file mode 100644 index 0000000000..de0dba64b0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/build.rs @@ -0,0 +1,526 @@ +use fal_client::requests_old::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{ + Seedance1LiteAspectRatio, Seedance1LiteDuration, Seedance1LiteRequest, Seedance1LiteResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_seedance_1p0_lite( + mut builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let image_url = require_url( + builder.start_frame.take(), + "start_frame", + "Seedance 1.0 Lite requires a starting frame", + )?; + let end_frame_image_url = optional_url(builder.end_frame.take())?; + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_resolution(builder.resolution.take(), strategy)?; + let duration = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + + let request = Seedance1LiteRequest { + image_url, + end_frame_image_url, + prompt, + duration, + resolution, + aspect_ratio, + camera_fixed: false, + seed: None, + }; + + let state = FalSeedance10LiteRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance10Lite(state))) +} + +// ── Plan helpers (copies of v1 logic; kept in sync intentionally) ── + +fn require_url( + start_frame: Option, + field: &'static str, + msg: &'static str, +) -> Result { + match start_frame { + Some(ImageRef::Url(url)) => Ok(url), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: "Fal only supports image URLs, not media file tokens".to_string(), + })) + } + None => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: msg.to_string(), + })), + } +} + +fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Fal only supports image URLs, not media file tokens".to_string(), + })) + } + } +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Seedance1LiteAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use Seedance1LiteResolution as R; + match resolution { + None => Ok(R::SevenTwentyP), + Some(RouterResolution::FourEightyP) => Ok(R::FourEightyP), + Some(RouterResolution::SevenTwentyP) => Ok(R::SevenTwentyP), + Some(RouterResolution::TenEightyP) => Ok(R::TenEightyP), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(R::TenEightyP), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(R::FourEightyP), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match duration_seconds { + None => Ok(Seedance1LiteDuration::FiveSeconds), + Some(5) => Ok(Seedance1LiteDuration::FiveSeconds), + Some(10) => Ok(Seedance1LiteDuration::TenSeconds), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Seedance1LiteDuration::TenSeconds), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Seedance1LiteDuration::FiveSeconds), + }, + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance10Lite, + provider: RouterProvider::Fal, + prompt: Some("a corgi running".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut b = base_builder(); + f(&mut b); + b + } + + fn unwrap_request(result: Result) -> Seedance1LiteRequest { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance10Lite(state)) => state.request, + _ => panic!("expected FalSeedance10Lite request"), + } + } + + // ── Materialized field conversions ── + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.prompt = Some("hi".to_string()); + }))); + assert_eq!(req.prompt, "hi"); + } + + #[test] + fn prompt_defaults_to_empty() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { b.prompt = None; }))); + assert_eq!(req.prompt, ""); + } + + #[test] + fn start_frame_passed_through() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + }))); + assert_eq!(req.image_url, "https://example.com/a.png"); + } + + #[test] + fn end_frame_passed_through() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + }))); + assert_eq!(req.end_frame_image_url.as_deref(), Some("https://example.com/end.png")); + } + + #[test] + fn end_frame_absent_is_none() { + let req = unwrap_request(build_fal_seedance_1p0_lite(base_builder())); + assert!(req.end_frame_image_url.is_none()); + } + + #[test] + fn camera_fixed_defaults_to_false() { + let req = unwrap_request(build_fal_seedance_1p0_lite(base_builder())); + assert!(!req.camera_fixed); + } + + #[test] + fn seed_defaults_to_none() { + let req = unwrap_request(build_fal_seedance_1p0_lite(base_builder())); + assert!(req.seed.is_none()); + } + } + + // ── start_frame is required for 1.0 lite ── + + mod start_frame_required { + use super::*; + + #[test] + fn missing_start_frame_errors() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { b.start_frame = None; })); + assert!(result.is_err()); + } + + #[test] + fn end_frame_alone_errors() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { + b.start_frame = None; + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + })); + assert!(result.is_err()); + } + + #[test] + fn media_file_token_for_start_frame_errors() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + })); + assert!(result.is_err()); + } + + #[test] + fn media_file_token_for_end_frame_errors() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + })); + assert!(result.is_err()); + } + } + + // ── Resolution conversions ── + + mod resolution_conversions { + use super::*; + + #[test] + fn resolution_480p() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.resolution = Some(RouterResolution::FourEightyP); + }))); + assert_eq!(req.resolution, Seedance1LiteResolution::FourEightyP); + } + + #[test] + fn resolution_720p() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.resolution = Some(RouterResolution::SevenTwentyP); + }))); + assert_eq!(req.resolution, Seedance1LiteResolution::SevenTwentyP); + } + + #[test] + fn resolution_1080p() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.resolution = Some(RouterResolution::TenEightyP); + }))); + assert_eq!(req.resolution, Seedance1LiteResolution::TenEightyP); + } + + #[test] + fn resolution_none_defaults_to_720p() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { b.resolution = None; }))); + assert_eq!(req.resolution, Seedance1LiteResolution::SevenTwentyP); + } + + #[test] + fn unsupported_resolution_errors_with_error_out() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + + #[test] + fn unsupported_resolution_upgrades_with_pay_more() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + }))); + assert_eq!(req.resolution, Seedance1LiteResolution::TenEightyP); + } + + #[test] + fn unsupported_resolution_downgrades_with_pay_less() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + }))); + assert_eq!(req.resolution, Seedance1LiteResolution::FourEightyP); + } + } + + // ── Duration conversions ── + + mod duration_conversions { + use super::*; + + #[test] + fn duration_5s() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { b.duration_seconds = Some(5); }))); + assert_eq!(req.duration, Seedance1LiteDuration::FiveSeconds); + } + + #[test] + fn duration_10s() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { b.duration_seconds = Some(10); }))); + assert_eq!(req.duration, Seedance1LiteDuration::TenSeconds); + } + + #[test] + fn duration_none_defaults_to_5s() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { b.duration_seconds = None; }))); + assert_eq!(req.duration, Seedance1LiteDuration::FiveSeconds); + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { + b.duration_seconds = Some(7); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + + #[test] + fn unsupported_duration_upgrades_with_pay_more() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.duration_seconds = Some(8); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + }))); + assert_eq!(req.duration, Seedance1LiteDuration::TenSeconds); + } + + #[test] + fn unsupported_duration_downgrades_with_pay_less() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.duration_seconds = Some(8); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + }))); + assert_eq!(req.duration, Seedance1LiteDuration::FiveSeconds); + } + } + + // ── Aspect ratio conversions ── + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn auto() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::Auto); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::Auto))); + } + + #[test] + fn square() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::Square); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::Square))); + } + + #[test] + fn sixteen_by_nine() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::SixteenByNine))); + } + + #[test] + fn nine_by_sixteen() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::NineBySixteen))); + } + + #[test] + fn twenty_one_by_nine() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::TwentyOneByNine))); + } + + #[test] + fn four_by_three() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::FourByThree))); + } + + #[test] + fn three_by_four() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::TallThreeByFour); + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::ThreeByFour))); + } + + #[test] + fn none() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { b.aspect_ratio = None; }))); + assert!(req.aspect_ratio.is_none()); + } + + #[test] + fn unsupported_aspect_ratio_errors_with_error_out() { + let result = build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideFiveByFour); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + + #[test] + fn unsupported_aspect_ratio_falls_back_to_auto_with_pay_more() { + let req = unwrap_request(build_fal_seedance_1p0_lite(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideFiveByFour); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + }))); + assert!(matches!(req.aspect_ratio, Some(Seedance1LiteAspectRatio::Auto))); + } + } + + // ── Full combinatorial pass ── + + #[test] + fn full_combinatorial_pass() { + let resolutions = [ + None, + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + ]; + let durations = [None, Some(5u16), Some(10u16)]; + let aspect_ratios = [ + None, + Some(RouterAspectRatio::Auto), + Some(RouterAspectRatio::Square), + Some(RouterAspectRatio::WideFourByThree), + Some(RouterAspectRatio::WideSixteenByNine), + Some(RouterAspectRatio::WideTwentyOneByNine), + Some(RouterAspectRatio::TallThreeByFour), + Some(RouterAspectRatio::TallNineBySixteen), + ]; + + let mut combos = 0; + for &res in &resolutions { + for &dur in &durations { + for &ar in &aspect_ratios { + for has_end in [false, true] { + let mut b = base_builder(); + b.resolution = res; + b.duration_seconds = dur; + b.aspect_ratio = ar; + if has_end { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + } + let result = build_fal_seedance_1p0_lite(b); + assert!(result.is_ok(), "expected ok for res={:?} dur={:?} ar={:?} end={}", res, dur, ar, has_end); + combos += 1; + } + } + } + } + assert!(combos >= 192, "expected ≥192 combos, got {}", combos); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/cost.rs new file mode 100644 index 0000000000..7461254305 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/cost.rs @@ -0,0 +1,204 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState; + +pub struct FalSeedance10LiteCostState { + request: FalSeedance10LiteRequestState, +} + +impl FalSeedance10LiteCostState { + pub fn from_request(request: &FalSeedance10LiteRequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Mirrors v1 — delegate to the Fal client's cost calculator for billing parity. + let cost_in_usd_cents = self.request.request.calculate_cost_in_cents(); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance10Lite, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())), + ..Default::default() + } + } + + fn cost_cents(resolution: Option, duration_seconds: Option) -> u64 { + let mut b = base_builder(); + b.resolution = resolution; + b.duration_seconds = duration_seconds; + b.build2() + .expect("build2") + .estimate_cost() + .expect("estimate_cost") + .cost_in_usd_cents + .expect("cost_in_usd_cents") + } + + // ── Numeric literal price assertions (break if pricing changes) ── + + mod pricing_720p { + use super::*; + + #[test] + fn p720_5s_special_case() { + // Per the Fal cost calculator: 720p + 5s short-circuits to 18¢. + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), Some(5)), 18); + } + + #[test] + fn p720_10s() { + // 1280×720×30 fps × 10s / 1024 = 270000 tokens × $1.8/M = $0.486 → ceil = 49¢. + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), Some(10)), 49); + } + + #[test] + fn p720_default_duration_matches_5s() { + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), None), 18); + } + + #[test] + fn default_resolution_default_duration_matches_720p_5s() { + assert_eq!(cost_cents(None, None), 18); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn p480_5s() { + // 640×480×30×5/1024 = 45000 × $1.8/M = $0.081 → ceil = 9¢. + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), Some(5)), 9); + } + + #[test] + fn p480_10s() { + // 640×480×30×10/1024 = 90000 × $1.8/M = $0.162 → ceil = 17¢. + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), Some(10)), 17); + } + } + + mod pricing_1080p { + use super::*; + + #[test] + fn p1080_5s() { + // 1920×1080×30×5/1024 = 303750 × $1.8/M = $0.54675 → ceil = 55¢. + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), Some(5)), 55); + } + + #[test] + fn p1080_10s() { + // 1920×1080×30×10/1024 = 607500 × $1.8/M = $1.0935 → ceil = 110¢. + assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), Some(10)), 110); + } + } + + // ── Relative pricing invariants ── + + mod relative_pricing { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = cost_cents(Some(RouterResolution::FourEightyP), Some(10)); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), Some(10)); + let p1080 = cost_cents(Some(RouterResolution::TenEightyP), Some(10)); + assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})"); + assert!(p720 < p1080, "720p ({p720}) should be < 1080p ({p1080})"); + } + + #[test] + fn longer_duration_costs_more() { + let d5 = cost_cents(Some(RouterResolution::TenEightyP), Some(5)); + let d10 = cost_cents(Some(RouterResolution::TenEightyP), Some(10)); + assert!(d5 < d10, "5s ({d5}) should be < 10s ({d10})"); + } + } + + // ── Combinatorial sanity: every supported (res, dur, ar) yields a positive cost ── + + #[test] + fn combinatorial_positive_cost() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + ]; + let durations = [Some(5u16), Some(10u16)]; + let aspect_ratios = [ + None, + Some(RouterAspectRatio::Auto), + Some(RouterAspectRatio::Square), + Some(RouterAspectRatio::WideSixteenByNine), + Some(RouterAspectRatio::TallNineBySixteen), + ]; + + let mut combos = 0; + for &res in &resolutions { + for &dur in &durations { + for &ar in &aspect_ratios { + let mut b = base_builder(); + b.resolution = res; + b.duration_seconds = dur; + b.aspect_ratio = ar; + let cents = b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap(); + assert!(cents > 0, "expected positive cost for res={:?} dur={:?} ar={:?}", res, dur, ar); + combos += 1; + } + } + } + assert_eq!(combos, 30); + } + + // Aspect ratio doesn't influence cost (calculator only uses resolution and duration). + #[test] + fn aspect_ratio_does_not_affect_cost() { + let with_wide = cost_cents(Some(RouterResolution::SevenTwentyP), Some(10)); + let mut b = base_builder(); + b.resolution = Some(RouterResolution::SevenTwentyP); + b.duration_seconds = Some(10); + b.aspect_ratio = Some(RouterAspectRatio::Square); + let with_square = b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap(); + assert_eq!(with_wide, with_square); + } + + // End frame doesn't influence cost. + #[test] + fn end_frame_does_not_affect_cost() { + let without = cost_cents(Some(RouterResolution::TenEightyP), Some(5)); + let mut b = base_builder(); + b.resolution = Some(RouterResolution::TenEightyP); + b.duration_seconds = Some(5); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + let with_end = b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap(); + assert_eq!(without, with_end); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs new file mode 100644 index 0000000000..93bdef61f2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p0_lite/request.rs @@ -0,0 +1,129 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{ + enqueue_seedance_1_lite_image_to_video_webhook, Seedance1LiteArgs, Seedance1LiteRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalSeedance10LiteRequestState { + pub request: Seedance1LiteRequest, +} + +impl FalSeedance10LiteRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let outbound_request: Arc = Arc::new(self.request.clone()); + + let args = Seedance1LiteArgs { + request: self.request.clone(), + api_key: &client.api_key, + webhook_url, + }; + + let webhook_response = enqueue_seedance_1_lite_image_to_video_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + // ── Live integration tests (require Fal credentials, incur costs) ── + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_image_to_video_720p_5s() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the dog leaps into the lake and splashes around.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + ..fal_seedance_1p0_lite_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_image_to_video_480p_10s() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a vivid splash of color filling the frame".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + resolution: Some(RouterResolution::FourEightyP), + duration_seconds: Some(10), + ..fal_seedance_1p0_lite_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_image_to_video_with_end_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a smooth transition between two scenes".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + end_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(5), + ..fal_seedance_1p0_lite_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + fn fal_seedance_1p0_lite_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance10Lite, + provider: RouterProvider::Fal, + video_batch_count: Some(1), + ..Default::default() + } + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal skips draft)"), + }; + let response = request.send_request(&client).await.expect("send_request should succeed"); + match &response { + GenerateVideoResponse::Fal(p) => println!("fal request_id={:?}", p.request_id), + other => println!("unexpected response: {:?}", other), + } + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/build.rs new file mode 100644 index 0000000000..32b2ba23dd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/build.rs @@ -0,0 +1,704 @@ +use fal_client::requests_old::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{ + EnqueueSeedance1p5ProImageToVideoAspectRatio, EnqueueSeedance1p5ProImageToVideoDuration, + EnqueueSeedance1p5ProImageToVideoRequest, EnqueueSeedance1p5ProImageToVideoResolution, +}; +use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{ + EnqueueSeedance1p5ProTextToVideoAspectRatio, EnqueueSeedance1p5ProTextToVideoDuration, + EnqueueSeedance1p5ProTextToVideoRequest, EnqueueSeedance1p5ProTextToVideoResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::seedance_1p5_pro::request::{ + FalSeedance1p5ProMode, FalSeedance1p5ProRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +// Resolution / duration / aspect-ratio variants used for planning. Mirrors the +// v1 plan types, but lives entirely inside this module so the v2 dir is self-contained. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PlanResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PlanDuration { + Four, + Five, + Six, + Seven, + Eight, + Nine, + Ten, + Eleven, + Twelve, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PlanAspectRatio { + Auto, + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +pub fn build_fal_seedance_1p5_pro( + mut builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Decide t2v vs i2v based on whether a start_frame was provided. + let image_url = optional_url(builder.start_frame.take())?; + let end_image_url = optional_url(builder.end_frame.take())?; + + if image_url.is_none() && end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 1.5 Pro requires a start_frame when end_frame is provided".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_resolution(builder.resolution.take(), strategy)?; + let duration = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let generate_audio = builder.generate_audio.take(); + + let mode = match image_url { + None => FalSeedance1p5ProMode::TextToVideo(EnqueueSeedance1p5ProTextToVideoRequest { + prompt, + resolution: resolution.map(to_t2v_resolution), + duration: duration.map(to_t2v_duration), + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + generate_audio, + }), + Some(image_url) => FalSeedance1p5ProMode::ImageToVideo(EnqueueSeedance1p5ProImageToVideoRequest { + prompt, + image_url, + end_image_url, + resolution: resolution.map(to_i2v_resolution), + duration: duration.map(to_i2v_duration), + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + generate_audio, + }), + }; + + let state = FalSeedance1p5ProRequestState { mode }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance1p5Pro(state))) +} + +// ── Plan helpers (kept in sync with v1 plan_generate_video_fal_seedance_1p5_pro.rs) ── + +fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "start_frame/end_frame", + value: "Fal only supports image URLs, not media file tokens".to_string(), + })) + } + } +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(nearest_aspect_ratio(unsupported))), + }, + } +} + +/// Pick the nearest supported aspect ratio for unsupported inputs. +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> PlanAspectRatio { + use PlanAspectRatio as Ar; + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => Ar::FourByThree, + RouterAspectRatio::WideThreeByTwo => Ar::FourByThree, + RouterAspectRatio::TallFourByFive => Ar::ThreeByFour, + RouterAspectRatio::TallTwoByThree => Ar::ThreeByFour, + RouterAspectRatio::TallNineByTwentyOne => Ar::NineBySixteen, + _ => Ar::Square, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanResolution as R; + match resolution { + None => Ok(None), + Some(RouterResolution::FourEightyP) => Ok(Some(R::FourEightyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::FourEightyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanDuration as D; + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(D::Four)), + Some(5) => Ok(Some(D::Five)), + Some(6) => Ok(Some(D::Six)), + Some(7) => Ok(Some(D::Seven)), + Some(8) => Ok(Some(D::Eight)), + Some(9) => Ok(Some(D::Nine)), + Some(10) => Ok(Some(D::Ten)), + Some(11) => Ok(Some(D::Eleven)), + Some(12) => Ok(Some(D::Twelve)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(D::Twelve)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(D::Four)), + }, + } +} + +// ── PlanXxx → fal_client request enum mapping ── + +fn to_t2v_resolution(r: PlanResolution) -> EnqueueSeedance1p5ProTextToVideoResolution { + use EnqueueSeedance1p5ProTextToVideoResolution as R; + match r { + PlanResolution::FourEightyP => R::FourEightyP, + PlanResolution::SevenTwentyP => R::SevenTwentyP, + PlanResolution::TenEightyP => R::TenEightyP, + } +} + +fn to_t2v_duration(d: PlanDuration) -> EnqueueSeedance1p5ProTextToVideoDuration { + use EnqueueSeedance1p5ProTextToVideoDuration as D; + match d { + PlanDuration::Four => D::FourSeconds, + PlanDuration::Five => D::FiveSeconds, + PlanDuration::Six => D::SixSeconds, + PlanDuration::Seven => D::SevenSeconds, + PlanDuration::Eight => D::EightSeconds, + PlanDuration::Nine => D::NineSeconds, + PlanDuration::Ten => D::TenSeconds, + PlanDuration::Eleven => D::ElevenSeconds, + PlanDuration::Twelve => D::TwelveSeconds, + } +} + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueSeedance1p5ProTextToVideoAspectRatio { + use EnqueueSeedance1p5ProTextToVideoAspectRatio as Ar; + match a { + PlanAspectRatio::Auto => Ar::Auto, + PlanAspectRatio::TwentyOneByNine => Ar::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => Ar::SixteenByNine, + PlanAspectRatio::FourByThree => Ar::FourByThree, + PlanAspectRatio::Square => Ar::Square, + PlanAspectRatio::ThreeByFour => Ar::ThreeByFour, + PlanAspectRatio::NineBySixteen => Ar::NineBySixteen, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> EnqueueSeedance1p5ProImageToVideoResolution { + use EnqueueSeedance1p5ProImageToVideoResolution as R; + match r { + PlanResolution::FourEightyP => R::FourEightyP, + PlanResolution::SevenTwentyP => R::SevenTwentyP, + PlanResolution::TenEightyP => R::TenEightyP, + } +} + +fn to_i2v_duration(d: PlanDuration) -> EnqueueSeedance1p5ProImageToVideoDuration { + use EnqueueSeedance1p5ProImageToVideoDuration as D; + match d { + PlanDuration::Four => D::FourSeconds, + PlanDuration::Five => D::FiveSeconds, + PlanDuration::Six => D::SixSeconds, + PlanDuration::Seven => D::SevenSeconds, + PlanDuration::Eight => D::EightSeconds, + PlanDuration::Nine => D::NineSeconds, + PlanDuration::Ten => D::TenSeconds, + PlanDuration::Eleven => D::ElevenSeconds, + PlanDuration::Twelve => D::TwelveSeconds, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueSeedance1p5ProImageToVideoAspectRatio { + use EnqueueSeedance1p5ProImageToVideoAspectRatio as Ar; + match a { + PlanAspectRatio::Auto => Ar::Auto, + PlanAspectRatio::TwentyOneByNine => Ar::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => Ar::SixteenByNine, + PlanAspectRatio::FourByThree => Ar::FourByThree, + PlanAspectRatio::Square => Ar::Square, + PlanAspectRatio::ThreeByFour => Ar::ThreeByFour, + PlanAspectRatio::NineBySixteen => Ar::NineBySixteen, + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Helpers ── + + fn base_t2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance1p5Pro, + provider: RouterProvider::Fal, + prompt: Some("a corgi running".to_string()), + // No start_frame → text-to-video. + ..Default::default() + } + } + + fn base_i2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance1p5Pro, + provider: RouterProvider::Fal, + prompt: Some("a corgi running".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())), + ..Default::default() + } + } + + fn make_t2v(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut b = base_t2v_builder(); + f(&mut b); + b + } + + fn make_i2v(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut b = base_i2v_builder(); + f(&mut b); + b + } + + fn unwrap_t2v(result: Result) -> EnqueueSeedance1p5ProTextToVideoRequest { + use crate::generate::generate_video::providers::fal::seedance_1p5_pro::request::FalSeedance1p5ProMode; + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance1p5Pro(state)) => match state.mode { + FalSeedance1p5ProMode::TextToVideo(req) => req, + FalSeedance1p5ProMode::ImageToVideo(_) => panic!("expected TextToVideo mode"), + }, + _ => panic!("expected FalSeedance1p5Pro request"), + } + } + + fn unwrap_i2v(result: Result) -> EnqueueSeedance1p5ProImageToVideoRequest { + use crate::generate::generate_video::providers::fal::seedance_1p5_pro::request::FalSeedance1p5ProMode; + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSeedance1p5Pro(state)) => match state.mode { + FalSeedance1p5ProMode::ImageToVideo(req) => req, + FalSeedance1p5ProMode::TextToVideo(_) => panic!("expected ImageToVideo mode"), + }, + _ => panic!("expected FalSeedance1p5Pro request"), + } + } + + // ── Mode selection (t2v vs i2v) ── + + mod mode_selection { + use super::*; + + #[test] + fn no_start_frame_picks_t2v() { + let _ = unwrap_t2v(build_fal_seedance_1p5_pro(base_t2v_builder())); + } + + #[test] + fn start_frame_picks_i2v() { + let _ = unwrap_i2v(build_fal_seedance_1p5_pro(base_i2v_builder())); + } + + #[test] + fn end_frame_alone_without_start_frame_errors() { + let result = build_fal_seedance_1p5_pro(make_t2v(|b| { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + })); + assert!(result.is_err()); + } + + #[test] + fn i2v_with_end_frame_succeeds() { + let req = unwrap_i2v(build_fal_seedance_1p5_pro(make_i2v(|b| { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + }))); + assert_eq!(req.end_image_url.as_deref(), Some("https://example.com/end.png")); + } + + #[test] + fn media_file_token_for_start_frame_errors() { + let result = build_fal_seedance_1p5_pro(make_t2v(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + })); + assert!(result.is_err()); + } + + #[test] + fn media_file_token_for_end_frame_errors() { + let result = build_fal_seedance_1p5_pro(make_i2v(|b| { + b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + })); + assert!(result.is_err()); + } + } + + // ── Materialized field conversions ── + + mod materialized_field_conversions { + use super::*; + + #[test] + fn t2v_prompt_passed_through() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.prompt = Some("hello world".to_string()); + }))); + assert_eq!(req.prompt, "hello world"); + } + + #[test] + fn i2v_prompt_passed_through() { + let req = unwrap_i2v(build_fal_seedance_1p5_pro(make_i2v(|b| { + b.prompt = Some("hello world".to_string()); + }))); + assert_eq!(req.prompt, "hello world"); + } + + #[test] + fn t2v_prompt_defaults_to_empty() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { b.prompt = None; }))); + assert_eq!(req.prompt, ""); + } + + #[test] + fn i2v_image_url_passed_through() { + let req = unwrap_i2v(build_fal_seedance_1p5_pro(make_i2v(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + }))); + assert_eq!(req.image_url, "https://example.com/a.png"); + } + + #[test] + fn i2v_end_image_absent_is_none() { + let req = unwrap_i2v(build_fal_seedance_1p5_pro(base_i2v_builder())); + assert!(req.end_image_url.is_none()); + } + + #[test] + fn t2v_generate_audio_passed_through() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.generate_audio = Some(true); + }))); + assert_eq!(req.generate_audio, Some(true)); + } + + #[test] + fn t2v_generate_audio_none_passed_through() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.generate_audio = None; + }))); + assert!(req.generate_audio.is_none()); + } + + #[test] + fn i2v_generate_audio_false_passed_through() { + let req = unwrap_i2v(build_fal_seedance_1p5_pro(make_i2v(|b| { + b.generate_audio = Some(false); + }))); + assert_eq!(req.generate_audio, Some(false)); + } + } + + // ── Resolution conversions ── + + mod resolution_conversions { + use super::*; + + #[test] + fn t2v_resolution_480p() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.resolution = Some(RouterResolution::FourEightyP); + }))); + assert!(matches!(req.resolution, Some(EnqueueSeedance1p5ProTextToVideoResolution::FourEightyP))); + } + + #[test] + fn t2v_resolution_720p() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.resolution = Some(RouterResolution::SevenTwentyP); + }))); + assert!(matches!(req.resolution, Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP))); + } + + #[test] + fn t2v_resolution_1080p() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.resolution = Some(RouterResolution::TenEightyP); + }))); + assert!(matches!(req.resolution, Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP))); + } + + #[test] + fn t2v_resolution_none_stays_none() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { b.resolution = None; }))); + assert!(req.resolution.is_none()); + } + + #[test] + fn unsupported_resolution_errors_with_error_out() { + let result = build_fal_seedance_1p5_pro(make_t2v(|b| { + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + + #[test] + fn unsupported_resolution_upgrades_with_pay_more() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + }))); + assert!(matches!(req.resolution, Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP))); + } + + #[test] + fn unsupported_resolution_downgrades_with_pay_less() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + }))); + assert!(matches!(req.resolution, Some(EnqueueSeedance1p5ProTextToVideoResolution::FourEightyP))); + } + } + + // ── Duration conversions (4 through 12 seconds supported) ── + + mod duration_conversions { + use super::*; + + #[test] + fn duration_4s() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { b.duration_seconds = Some(4); }))); + assert!(matches!(req.duration, Some(EnqueueSeedance1p5ProTextToVideoDuration::FourSeconds))); + } + + #[test] + fn duration_12s() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { b.duration_seconds = Some(12); }))); + assert!(matches!(req.duration, Some(EnqueueSeedance1p5ProTextToVideoDuration::TwelveSeconds))); + } + + #[test] + fn duration_none_stays_none() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { b.duration_seconds = None; }))); + assert!(req.duration.is_none()); + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let result = build_fal_seedance_1p5_pro(make_t2v(|b| { + b.duration_seconds = Some(13); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + + #[test] + fn unsupported_duration_upgrades_to_max_with_pay_more() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + }))); + assert!(matches!(req.duration, Some(EnqueueSeedance1p5ProTextToVideoDuration::TwelveSeconds))); + } + + #[test] + fn unsupported_duration_downgrades_to_min_with_pay_less() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.duration_seconds = Some(2); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + }))); + assert!(matches!(req.duration, Some(EnqueueSeedance1p5ProTextToVideoDuration::FourSeconds))); + } + } + + // ── Aspect ratio conversions ── + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn auto() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.aspect_ratio = Some(RouterAspectRatio::Auto); + }))); + assert!(matches!(req.aspect_ratio, Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::Auto))); + } + + #[test] + fn sixteen_by_nine() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + }))); + assert!(matches!(req.aspect_ratio, Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::SixteenByNine))); + } + + #[test] + fn square() { + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.aspect_ratio = Some(RouterAspectRatio::Square); + }))); + assert!(matches!(req.aspect_ratio, Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::Square))); + } + + #[test] + fn unsupported_aspect_ratio_falls_back_to_nearest_with_pay_more() { + // WideFiveByFour → nearest wide → FourByThree (per nearest_aspect_ratio). + let req = unwrap_t2v(build_fal_seedance_1p5_pro(make_t2v(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideFiveByFour); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + }))); + assert!(matches!(req.aspect_ratio, Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::FourByThree))); + } + + #[test] + fn unsupported_aspect_ratio_errors_with_error_out() { + let result = build_fal_seedance_1p5_pro(make_t2v(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideFiveByFour); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + })); + assert!(result.is_err()); + } + } + + // ── Full combinatorial pass (t2v + i2v) ── + + #[test] + fn t2v_combinatorial_pass() { + let resolutions = [ + None, + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + ]; + let durations = [None, Some(4u16), Some(8u16), Some(12u16)]; + let aspect_ratios = [ + None, + Some(RouterAspectRatio::Auto), + Some(RouterAspectRatio::Square), + Some(RouterAspectRatio::WideSixteenByNine), + Some(RouterAspectRatio::TallNineBySixteen), + ]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &res in &resolutions { + for &dur in &durations { + for &ar in &aspect_ratios { + for &audio in &audios { + let mut b = base_t2v_builder(); + b.resolution = res; + b.duration_seconds = dur; + b.aspect_ratio = ar; + b.generate_audio = audio; + assert!(build_fal_seedance_1p5_pro(b).is_ok()); + combos += 1; + } + } + } + } + assert_eq!(combos, 4 * 4 * 5 * 3); + } + + #[test] + fn i2v_combinatorial_pass() { + let resolutions = [ + None, + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + ]; + let durations = [None, Some(5u16), Some(12u16)]; + let aspect_ratios = [None, Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::Square)]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &res in &resolutions { + for &dur in &durations { + for &ar in &aspect_ratios { + for &audio in &audios { + for has_end in [false, true] { + let mut b = base_i2v_builder(); + b.resolution = res; + b.duration_seconds = dur; + b.aspect_ratio = ar; + b.generate_audio = audio; + if has_end { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + } + assert!(build_fal_seedance_1p5_pro(b).is_ok()); + combos += 1; + } + } + } + } + } + assert_eq!(combos, 3 * 3 * 3 * 3 * 2); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/cost.rs new file mode 100644 index 0000000000..f5479d81dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/cost.rs @@ -0,0 +1,323 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{ + EnqueueSeedance1p5ProTextToVideoDuration, EnqueueSeedance1p5ProTextToVideoRequest, + EnqueueSeedance1p5ProTextToVideoResolution, +}; +use fal_client::requests_old::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{ + EnqueueSeedance1p5ProImageToVideoDuration, EnqueueSeedance1p5ProImageToVideoResolution, +}; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::seedance_1p5_pro::request::{ + FalSeedance1p5ProMode, FalSeedance1p5ProRequestState, +}; + +pub struct FalSeedance1p5ProCostState { + request: FalSeedance1p5ProRequestState, +} + +impl FalSeedance1p5ProCostState { + pub fn from_request(request: &FalSeedance1p5ProRequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Seedance 1.5 Pro t2v and i2v have identical pricing. v1 delegates both + // to the t2v calculator; v2 does the same to guarantee billing parity. + let t2v_request = match &self.request.mode { + FalSeedance1p5ProMode::TextToVideo(req) => req.clone(), + FalSeedance1p5ProMode::ImageToVideo(req) => EnqueueSeedance1p5ProTextToVideoRequest { + prompt: String::new(), + resolution: req.resolution.map(i2v_to_t2v_resolution), + duration: req.duration.map(i2v_to_t2v_duration), + aspect_ratio: None, + generate_audio: req.generate_audio, + }, + }; + + let cost_in_usd_cents = t2v_request.calculate_cost_in_cents(); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn i2v_to_t2v_resolution(r: EnqueueSeedance1p5ProImageToVideoResolution) -> EnqueueSeedance1p5ProTextToVideoResolution { + use EnqueueSeedance1p5ProImageToVideoResolution as I; + use EnqueueSeedance1p5ProTextToVideoResolution as T; + match r { + I::FourEightyP => T::FourEightyP, + I::SevenTwentyP => T::SevenTwentyP, + I::TenEightyP => T::TenEightyP, + } +} + +fn i2v_to_t2v_duration(d: EnqueueSeedance1p5ProImageToVideoDuration) -> EnqueueSeedance1p5ProTextToVideoDuration { + use EnqueueSeedance1p5ProImageToVideoDuration as I; + use EnqueueSeedance1p5ProTextToVideoDuration as T; + match d { + I::FourSeconds => T::FourSeconds, + I::FiveSeconds => T::FiveSeconds, + I::SixSeconds => T::SixSeconds, + I::SevenSeconds => T::SevenSeconds, + I::EightSeconds => T::EightSeconds, + I::NineSeconds => T::NineSeconds, + I::TenSeconds => T::TenSeconds, + I::ElevenSeconds => T::ElevenSeconds, + I::TwelveSeconds => T::TwelveSeconds, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // ── Helpers ── + + fn t2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance1p5Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn i2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance1p5Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())), + ..Default::default() + } + } + + fn cost_cents( + builder_factory: fn() -> GenerateVideoRequestBuilder, + resolution: Option, + duration_seconds: Option, + generate_audio: Option, + ) -> u64 { + let mut b = builder_factory(); + b.resolution = resolution; + b.duration_seconds = duration_seconds; + b.generate_audio = generate_audio; + b.build2() + .expect("build2") + .estimate_cost() + .expect("estimate_cost") + .cost_in_usd_cents + .expect("cost_in_usd_cents") + } + + // ── T2V numeric literal pricing assertions ── + + mod t2v_pricing_720p { + use super::*; + + #[test] + fn p720_5s_audio_special_case() { + // Special-case: 720p + 5s + audio short-circuits to 26¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(5), Some(true)), 26); + } + + #[test] + fn p720_5s_no_audio_special_case() { + // Special-case: 720p + 5s without audio → 13¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(5), Some(false)), 13); + } + + #[test] + fn p720_5s_default_audio_is_with_audio() { + // generate_audio = None defaults to true in the cost calculator. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(5), None), 26); + } + + #[test] + fn p720_10s_with_audio() { + // 1280×720×30×10/1024 = 270000 × $2.4/M = $0.648 → ceil = 65¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(10), Some(true)), 65); + } + + #[test] + fn p720_10s_no_audio() { + // 270000 × $1.2/M = $0.324 → ceil = 33¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(10), Some(false)), 33); + } + + #[test] + fn p720_12s_with_audio() { + // 1280×720×30×12/1024 = 324000 × $2.4/M = $0.7776 → ceil = 78¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(12), Some(true)), 78); + } + } + + mod t2v_pricing_480p { + use super::*; + + #[test] + fn p480_5s_audio() { + // 640×480×30×5/1024 = 45000 × $2.4/M = $0.108 → ceil = 11¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::FourEightyP), Some(5), Some(true)), 11); + } + + #[test] + fn p480_5s_no_audio() { + // 45000 × $1.2/M = $0.054 → ceil = 6¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::FourEightyP), Some(5), Some(false)), 6); + } + + #[test] + fn p480_10s_audio() { + // 90000 × $2.4/M = $0.216 → ceil = 22¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::FourEightyP), Some(10), Some(true)), 22); + } + } + + mod t2v_pricing_1080p { + use super::*; + + #[test] + fn p1080_5s_audio() { + // 1920×1080×30×5/1024 = 303750 × $2.4/M = $0.729 → ceil = 73¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(5), Some(true)), 73); + } + + #[test] + fn p1080_5s_no_audio() { + // 303750 × $1.2/M = $0.36450 → ceil = 37¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(5), Some(false)), 37); + } + + #[test] + fn p1080_10s_audio() { + // 607500 × $2.4/M = $1.458 → ceil = 146¢. + assert_eq!(cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(10), Some(true)), 146); + } + } + + // ── I2V costs match T2V at identical params ── + + mod i2v_matches_t2v { + use super::*; + + #[test] + fn p720_5s_audio() { + assert_eq!( + cost_cents(i2v_builder, Some(RouterResolution::SevenTwentyP), Some(5), Some(true)), + cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(5), Some(true)), + ); + } + + #[test] + fn p1080_10s_no_audio() { + assert_eq!( + cost_cents(i2v_builder, Some(RouterResolution::TenEightyP), Some(10), Some(false)), + cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(10), Some(false)), + ); + } + + #[test] + fn p480_12s_audio() { + assert_eq!( + cost_cents(i2v_builder, Some(RouterResolution::FourEightyP), Some(12), Some(true)), + cost_cents(t2v_builder, Some(RouterResolution::FourEightyP), Some(12), Some(true)), + ); + } + } + + // ── Relative pricing invariants ── + + mod relative_pricing { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = cost_cents(t2v_builder, Some(RouterResolution::FourEightyP), Some(5), Some(true)); + let p720 = cost_cents(t2v_builder, Some(RouterResolution::SevenTwentyP), Some(5), Some(true)); + let p1080 = cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(5), Some(true)); + assert!(p480 < p720, "480p ({p480}) < 720p ({p720})"); + assert!(p720 < p1080, "720p ({p720}) < 1080p ({p1080})"); + } + + #[test] + fn longer_duration_costs_more() { + let d4 = cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(4), Some(true)); + let d8 = cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(8), Some(true)); + let d12 = cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(12), Some(true)); + assert!(d4 < d8); + assert!(d8 < d12); + } + + #[test] + fn audio_costs_more_than_no_audio() { + let with_audio = cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(10), Some(true)); + let no_audio = cost_cents(t2v_builder, Some(RouterResolution::TenEightyP), Some(10), Some(false)); + assert!(no_audio < with_audio, "no-audio ({no_audio}) < audio ({with_audio})"); + } + } + + // ── Aspect ratio doesn't affect cost ── + + #[test] + fn aspect_ratio_does_not_affect_cost() { + let mut b = t2v_builder(); + b.resolution = Some(RouterResolution::SevenTwentyP); + b.duration_seconds = Some(10); + b.generate_audio = Some(true); + let baseline = b.clone().build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap(); + + for ar in [ + Some(RouterAspectRatio::Auto), + Some(RouterAspectRatio::Square), + Some(RouterAspectRatio::WideSixteenByNine), + Some(RouterAspectRatio::TallNineBySixteen), + Some(RouterAspectRatio::WideTwentyOneByNine), + ] { + let mut variant = b.clone(); + variant.aspect_ratio = ar; + let cost = variant.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap(); + assert_eq!(cost, baseline, "aspect_ratio {:?} changed cost", ar); + } + } + + // ── Combinatorial sanity ── + + #[test] + fn combinatorial_positive_cost() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), + ]; + let durations = [Some(4u16), Some(5), Some(8), Some(12)]; + let audios = [Some(true), Some(false), None]; + + let mut combos = 0; + for &res in &resolutions { + for &dur in &durations { + for &audio in &audios { + for factory in [t2v_builder, i2v_builder] { + let cents = cost_cents(factory, res, dur, audio); + assert!(cents > 0, "expected positive cost: res={:?} dur={:?} audio={:?}", res, dur, audio); + combos += 1; + } + } + } + } + assert_eq!(combos, 3 * 4 * 3 * 2); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs new file mode 100644 index 0000000000..b2e2895b0c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/seedance_1p5_pro/request.rs @@ -0,0 +1,167 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{ + enqueue_seedance_1p5_pro_image_to_video_webhook, EnqueueSeedance1p5ProImageToVideoArgs, + EnqueueSeedance1p5ProImageToVideoRequest, +}; +use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{ + enqueue_seedance_1p5_pro_text_to_video_webhook, EnqueueSeedance1p5ProTextToVideoArgs, + EnqueueSeedance1p5ProTextToVideoRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalSeedance1p5ProMode { + TextToVideo(EnqueueSeedance1p5ProTextToVideoRequest), + ImageToVideo(EnqueueSeedance1p5ProImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalSeedance1p5ProRequestState { + pub mode: FalSeedance1p5ProMode, +} + +impl FalSeedance1p5ProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound_request): (_, Arc) = match &self.mode { + FalSeedance1p5ProMode::TextToVideo(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueSeedance1p5ProTextToVideoArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_seedance_1p5_pro_text_to_video_webhook(args).await, outbound) + } + FalSeedance1p5ProMode::ImageToVideo(request) => { + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueSeedance1p5ProImageToVideoArgs { + request: request.clone(), + webhook_url, + api_key: &client.api_key, + }; + (enqueue_seedance_1p5_pro_image_to_video_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + // ── Live integration tests (require Fal credentials, incur costs) ── + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_text_to_video_720p_5s() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a serene mountain landscape at sunrise.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + generate_audio: Some(true), + ..fal_seedance_1p5_pro_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_text_to_video_1080p_no_audio() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a city street bustling with people".to_string()), + resolution: Some(RouterResolution::TenEightyP), + duration_seconds: Some(5), + generate_audio: Some(false), + ..fal_seedance_1p5_pro_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_image_to_video_720p_5s() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the dog leaps into the lake and splashes around.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + ..fal_seedance_1p5_pro_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn live_image_to_video_with_end_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a smooth transition between two scenes".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + end_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(5), + ..fal_seedance_1p5_pro_builder() + }).await; + println!("response: {:?}", response); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + fn fal_seedance_1p5_pro_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance1p5Pro, + provider: RouterProvider::Fal, + video_batch_count: Some(1), + ..Default::default() + } + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal skips draft)"), + }; + let response = request.send_request(&client).await.expect("send_request should succeed"); + match &response { + GenerateVideoResponse::Fal(p) => println!("fal request_id={:?}", p.request_id), + other => println!("unexpected response: {:?}", other), + } + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/build.rs new file mode 100644 index 0000000000..2a2f0a15d3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/build.rs @@ -0,0 +1,166 @@ +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::sora_2::request::{ + FalSora2AspectRatio, FalSora2Duration, FalSora2Mode, FalSora2RequestState, FalSora2Resolution, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_sora_2( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Sora 2 does not support an ending frame".to_string(), + })); + } + + let mode = match optional_url(builder.start_frame.clone())? { + None => FalSora2Mode::TextToVideo, + Some(image_url) => FalSora2Mode::ImageToVideo { image_url }, + }; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSora2( + FalSora2RequestState { + prompt: builder.prompt.clone().unwrap_or_default(), + mode, + aspect_ratio, + resolution, + duration, + }, + ))) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use FalSora2AspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use FalSora2Resolution as R; + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)), + // Only 720p is supported; everything else falls back or errors. + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(R::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use FalSora2Duration as D; + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(D::Four)), + Some(8) => Ok(Some(D::Eight)), + Some(12) => Ok(Some(D::Twelve)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(D::Twelve)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(D::Four)), + }, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Sora2, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + #[test] + fn no_start_frame_picks_t2v() { + let r = build_fal_sora_2(base_builder()).expect("build"); + if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSora2(s)) = r { + assert!(matches!(s.mode, FalSora2Mode::TextToVideo)); + } else { panic!("expected FalSora2"); } + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let r = build_fal_sora_2(b).expect("build"); + if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSora2(s)) = r { + assert!(matches!(s.mode, FalSora2Mode::ImageToVideo { .. })); + } else { panic!("expected FalSora2"); } + } + + #[test] + fn end_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_sora_2(b).is_err()); + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_sora_2(b).is_err()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/cost.rs new file mode 100644 index 0000000000..4a30ee82ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/cost.rs @@ -0,0 +1,70 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::sora_2::request::{ + FalSora2Duration, FalSora2RequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalSora2CostState { + pub duration_seconds: u64, +} + +impl FalSora2CostState { + pub fn from_request(request: &FalSora2RequestState) -> Self { + Self { + duration_seconds: duration_seconds_for_cost(request.duration), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Sora 2: $0.10/second. Default duration (Fal client): 4s. + let cost_in_usd_cents = self.duration_seconds * 10; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +fn duration_seconds_for_cost(d: Option) -> u64 { + match d { + Some(FalSora2Duration::Four) | None => 4, + Some(FalSora2Duration::Eight) => 8, + Some(FalSora2Duration::Twelve) => 12, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option) -> u64 { + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Sora2, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn four_seconds_is_40() { assert_eq!(cost_cents(Some(4)), 40); } + + #[test] + fn eight_seconds_is_80() { assert_eq!(cost_cents(Some(8)), 80); } + + #[test] + fn twelve_seconds_is_120() { assert_eq!(cost_cents(Some(12)), 120); } + + #[test] + fn default_duration_is_4s_priced_at_40() { assert_eq!(cost_cents(None), 40); } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs new file mode 100644 index 0000000000..3c59ea99cb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2/request.rs @@ -0,0 +1,158 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_sora_2_image_to_video_webhook::{ + enqueue_sora_2_image_to_video_webhook, EnqueueSora2ImageToVideoArgs, + EnqueueSora2ImageToVideoAspectRatio, EnqueueSora2ImageToVideoDurationSeconds, + EnqueueSora2ImageToVideoRequest, EnqueueSora2ImageToVideoResolution, +}; +use fal_client::requests_old::webhook::video::text::enqueue_sora_2_text_to_video_webhook::{ + enqueue_sora_2_text_to_video_webhook, EnqueueSora2TextToVideoArgs, + EnqueueSora2TextToVideoAspectRatio, EnqueueSora2TextToVideoDurationSeconds, + EnqueueSora2TextToVideoRequest, EnqueueSora2TextToVideoResolution, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +/// Router-level aspect ratio retained so the cost calculator and request +/// builder both see the same source of truth — fal_client's text-to-video +/// types do not represent `Auto`, so we must preserve it here. +#[derive(Debug, Clone, Copy)] +pub enum FalSora2AspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +/// Sora 2 supports 720p (and `auto` on image-to-video). +#[derive(Debug, Clone, Copy)] +pub enum FalSora2Resolution { + Auto, + SevenTwentyP, +} + +#[derive(Debug, Clone, Copy)] +pub enum FalSora2Duration { + Four, + Eight, + Twelve, +} + +#[derive(Debug, Clone)] +pub enum FalSora2Mode { + TextToVideo, + ImageToVideo { image_url: String }, +} + +#[derive(Debug, Clone)] +pub struct FalSora2RequestState { + pub prompt: String, + pub mode: FalSora2Mode, + pub aspect_ratio: Option, + pub resolution: Option, + pub duration: Option, +} + +impl FalSora2RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound_request): (_, Arc) = match &self.mode { + FalSora2Mode::TextToVideo => { + // Text-to-video does not support Auto aspect ratio or Auto resolution. + let request = EnqueueSora2TextToVideoRequest { + prompt: self.prompt.clone(), + resolution: self.resolution.and_then(to_t2v_resolution), + duration: self.duration.map(to_t2v_duration), + aspect_ratio: self.aspect_ratio.and_then(to_t2v_aspect_ratio), + }; + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueSora2TextToVideoArgs { + request, + webhook_url, + api_key: &client.api_key, + }; + (enqueue_sora_2_text_to_video_webhook(args).await, outbound) + } + FalSora2Mode::ImageToVideo { image_url } => { + let request = EnqueueSora2ImageToVideoRequest { + prompt: self.prompt.clone(), + image_url: image_url.clone(), + duration: self.duration.map(to_i2v_duration), + resolution: self.resolution.map(to_i2v_resolution), + aspect_ratio: self.aspect_ratio.map(to_i2v_aspect_ratio), + }; + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueSora2ImageToVideoArgs { + request, + webhook_url, + api_key: &client.api_key, + }; + (enqueue_sora_2_image_to_video_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +fn to_t2v_duration(d: FalSora2Duration) -> EnqueueSora2TextToVideoDurationSeconds { + match d { + FalSora2Duration::Four => EnqueueSora2TextToVideoDurationSeconds::Four, + FalSora2Duration::Eight => EnqueueSora2TextToVideoDurationSeconds::Eight, + FalSora2Duration::Twelve => EnqueueSora2TextToVideoDurationSeconds::Twelve, + } +} + +fn to_t2v_resolution(r: FalSora2Resolution) -> Option { + match r { + FalSora2Resolution::SevenTwentyP => Some(EnqueueSora2TextToVideoResolution::SevenTwentyP), + FalSora2Resolution::Auto => None, + } +} + +fn to_t2v_aspect_ratio(a: FalSora2AspectRatio) -> Option { + match a { + FalSora2AspectRatio::SixteenByNine => Some(EnqueueSora2TextToVideoAspectRatio::SixteenByNine), + FalSora2AspectRatio::NineBySixteen => Some(EnqueueSora2TextToVideoAspectRatio::NineBySixteen), + FalSora2AspectRatio::Auto => None, + } +} + +fn to_i2v_duration(d: FalSora2Duration) -> EnqueueSora2ImageToVideoDurationSeconds { + match d { + FalSora2Duration::Four => EnqueueSora2ImageToVideoDurationSeconds::Four, + FalSora2Duration::Eight => EnqueueSora2ImageToVideoDurationSeconds::Eight, + FalSora2Duration::Twelve => EnqueueSora2ImageToVideoDurationSeconds::Twelve, + } +} + +fn to_i2v_resolution(r: FalSora2Resolution) -> EnqueueSora2ImageToVideoResolution { + match r { + FalSora2Resolution::SevenTwentyP => EnqueueSora2ImageToVideoResolution::SevenTwentyP, + FalSora2Resolution::Auto => EnqueueSora2ImageToVideoResolution::Auto, + } +} + +fn to_i2v_aspect_ratio(a: FalSora2AspectRatio) -> EnqueueSora2ImageToVideoAspectRatio { + match a { + FalSora2AspectRatio::SixteenByNine => EnqueueSora2ImageToVideoAspectRatio::SixteenByNine, + FalSora2AspectRatio::NineBySixteen => EnqueueSora2ImageToVideoAspectRatio::NineBySixteen, + FalSora2AspectRatio::Auto => EnqueueSora2ImageToVideoAspectRatio::Auto, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/build.rs new file mode 100644 index 0000000000..50fe89ec4e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/build.rs @@ -0,0 +1,167 @@ +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::sora_2_pro::request::{ + FalSora2ProAspectRatio, FalSora2ProDuration, FalSora2ProMode, FalSora2ProRequestState, + FalSora2ProResolution, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_fal_sora_2_pro( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Sora 2 Pro does not support an ending frame".to_string(), + })); + } + + let mode = match optional_url(builder.start_frame.clone())? { + None => FalSora2ProMode::TextToVideo, + Some(image_url) => FalSora2ProMode::ImageToVideo { image_url }, + }; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSora2Pro( + FalSora2ProRequestState { + prompt: builder.prompt.clone().unwrap_or_default(), + mode, + aspect_ratio, + resolution, + duration, + }, + ))) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use FalSora2ProAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use FalSora2ProResolution as R; + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use FalSora2ProDuration as D; + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(D::Four)), + Some(8) => Ok(Some(D::Eight)), + Some(12) => Ok(Some(D::Twelve)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(D::Twelve)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(D::Four)), + }, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Sora2Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + #[test] + fn no_start_frame_picks_t2v() { + let r = build_fal_sora_2_pro(base_builder()).expect("build"); + if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSora2Pro(s)) = r { + assert!(matches!(s.mode, FalSora2ProMode::TextToVideo)); + } else { panic!("expected FalSora2Pro"); } + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let r = build_fal_sora_2_pro(b).expect("build"); + if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalSora2Pro(s)) = r { + assert!(matches!(s.mode, FalSora2ProMode::ImageToVideo { .. })); + } else { panic!("expected FalSora2Pro"); } + } + + #[test] + fn end_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_sora_2_pro(b).is_err()); + } + + #[test] + fn resolution_1080p_supported() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::TenEightyP); + assert!(build_fal_sora_2_pro(b).is_ok()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/cost.rs new file mode 100644 index 0000000000..293050895a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/cost.rs @@ -0,0 +1,133 @@ +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::sora_2_pro::request::{ + FalSora2ProDuration, FalSora2ProMode, FalSora2ProRequestState, FalSora2ProResolution, +}; + +#[derive(Clone, Debug)] +pub struct FalSora2ProCostState { + pub duration_seconds: u64, + pub is_ten_eighty_p: bool, +} + +impl FalSora2ProCostState { + pub fn from_request(request: &FalSora2ProRequestState) -> Self { + Self { + duration_seconds: duration_seconds_for_cost(request.duration), + is_ten_eighty_p: is_ten_eighty_p_for_cost(&request.mode, request.resolution), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Sora 2 Pro: $0.30/second @ 720p, $0.50/second @ 1080p. + let per_second_cents: u64 = if self.is_ten_eighty_p { 50 } else { 30 }; + let cost_in_usd_cents = per_second_cents * self.duration_seconds; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +/// Fal client default: 4s when no duration is supplied. +fn duration_seconds_for_cost(d: Option) -> u64 { + match d { + Some(FalSora2ProDuration::Four) | None => 4, + Some(FalSora2ProDuration::Eight) => 8, + Some(FalSora2ProDuration::Twelve) => 12, + } +} + +/// Fal client default varies by mode: text-to-video defaults to 1080p; image-to-video +/// defaults to auto (priced as 720p). +fn is_ten_eighty_p_for_cost(mode: &FalSora2ProMode, resolution: Option) -> bool { + match resolution { + Some(FalSora2ProResolution::TenEightyP) => true, + Some(FalSora2ProResolution::SevenTwentyP) | Some(FalSora2ProResolution::Auto) => false, + None => matches!(mode, FalSora2ProMode::TextToVideo), + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + fn cost_cents(duration_seconds: Option, resolution: Option, has_start: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Sora2Pro, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + }; + if has_start { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + mod numeric_literal_pricing { + use super::*; + + #[test] + fn p720_4s_is_120() { + // $0.30/sec × 4s = 120¢. + assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 120); + } + + #[test] + fn p720_8s_is_240() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 240); } + + #[test] + fn p720_12s_is_360() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 360); } + + #[test] + fn p1080_4s_is_200() { + // $0.50/sec × 4s = 200¢. + assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 200); + } + + #[test] + fn p1080_8s_is_400() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), false), 400); } + + #[test] + fn p1080_12s_is_600() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 600); } + + #[test] + fn t2v_default_resolution_priced_as_1080p() { + // T2V with no resolution → fal client defaults to 1080p, so cost = 200¢. + assert_eq!(cost_cents(Some(4), None, false), 200); + } + + #[test] + fn i2v_default_resolution_priced_as_720p() { + // I2V with no resolution → fal client defaults to auto (720p), so cost = 120¢. + assert_eq!(cost_cents(Some(4), None, true), 120); + } + } + + #[test] + fn higher_resolution_costs_more() { + assert!( + cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false) + < cost_cents(Some(8), Some(RouterResolution::TenEightyP), false) + ); + } + + #[test] + fn longer_duration_costs_more() { + let r = Some(RouterResolution::TenEightyP); + assert!(cost_cents(Some(4), r, false) < cost_cents(Some(8), r, false)); + assert!(cost_cents(Some(8), r, false) < cost_cents(Some(12), r, false)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs new file mode 100644 index 0000000000..4f1ecacd9b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/sora_2_pro/request.rs @@ -0,0 +1,157 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests_old::webhook::video::image::enqueue_sora_2_pro_image_to_video_webhook::{ + enqueue_sora_2_pro_image_to_video_webhook, EnqueueSora2ProImageToVideoArgs, + EnqueueSora2ProImageToVideoAspectRatio, EnqueueSora2ProImageToVideoDurationSeconds, + EnqueueSora2ProImageToVideoRequest, EnqueueSora2ProImageToVideoResolution, +}; +use fal_client::requests_old::webhook::video::text::enqueue_sora_2_pro_text_to_video_webhook::{ + enqueue_sora_2_pro_text_to_video_webhook, EnqueueSora2ProTextToVideoArgs, + EnqueueSora2ProTextToVideoAspectRatio, EnqueueSora2ProTextToVideoDurationSeconds, + EnqueueSora2ProTextToVideoRequest, EnqueueSora2ProTextToVideoResolution, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Debug, Clone, Copy)] +pub enum FalSora2ProAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub enum FalSora2ProResolution { + Auto, + SevenTwentyP, + TenEightyP, +} + +#[derive(Debug, Clone, Copy)] +pub enum FalSora2ProDuration { + Four, + Eight, + Twelve, +} + +#[derive(Debug, Clone)] +pub enum FalSora2ProMode { + TextToVideo, + ImageToVideo { image_url: String }, +} + +#[derive(Debug, Clone)] +pub struct FalSora2ProRequestState { + pub prompt: String, + pub mode: FalSora2ProMode, + pub aspect_ratio: Option, + pub resolution: Option, + pub duration: Option, +} + +impl FalSora2ProRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let webhook_url = client.webhook_url.as_deref() + .ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?; + let (webhook_response, outbound_request): (_, Arc) = match &self.mode { + FalSora2ProMode::TextToVideo => { + // Text-to-video does not support Auto aspect ratio or Auto resolution. + let request = EnqueueSora2ProTextToVideoRequest { + prompt: self.prompt.clone(), + resolution: self.resolution.and_then(to_t2v_resolution), + duration: self.duration.map(to_t2v_duration), + aspect_ratio: self.aspect_ratio.and_then(to_t2v_aspect_ratio), + }; + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueSora2ProTextToVideoArgs { + request, + webhook_url, + api_key: &client.api_key, + }; + (enqueue_sora_2_pro_text_to_video_webhook(args).await, outbound) + } + FalSora2ProMode::ImageToVideo { image_url } => { + let request = EnqueueSora2ProImageToVideoRequest { + prompt: self.prompt.clone(), + image_url: image_url.clone(), + duration: self.duration.map(to_i2v_duration), + resolution: self.resolution.map(to_i2v_resolution), + aspect_ratio: self.aspect_ratio.map(to_i2v_aspect_ratio), + }; + let outbound: Arc = Arc::new(request.clone()); + let args = EnqueueSora2ProImageToVideoArgs { + request, + webhook_url, + api_key: &client.api_key, + }; + (enqueue_sora_2_pro_image_to_video_webhook(args).await, outbound) + } + }; + + let webhook_response = webhook_response + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +fn to_t2v_duration(d: FalSora2ProDuration) -> EnqueueSora2ProTextToVideoDurationSeconds { + match d { + FalSora2ProDuration::Four => EnqueueSora2ProTextToVideoDurationSeconds::Four, + FalSora2ProDuration::Eight => EnqueueSora2ProTextToVideoDurationSeconds::Eight, + FalSora2ProDuration::Twelve => EnqueueSora2ProTextToVideoDurationSeconds::Twelve, + } +} + +fn to_t2v_resolution(r: FalSora2ProResolution) -> Option { + match r { + FalSora2ProResolution::SevenTwentyP => Some(EnqueueSora2ProTextToVideoResolution::SevenTwentyP), + FalSora2ProResolution::TenEightyP => Some(EnqueueSora2ProTextToVideoResolution::TenEightyP), + FalSora2ProResolution::Auto => None, + } +} + +fn to_t2v_aspect_ratio(a: FalSora2ProAspectRatio) -> Option { + match a { + FalSora2ProAspectRatio::SixteenByNine => Some(EnqueueSora2ProTextToVideoAspectRatio::SixteenByNine), + FalSora2ProAspectRatio::NineBySixteen => Some(EnqueueSora2ProTextToVideoAspectRatio::NineBySixteen), + FalSora2ProAspectRatio::Auto => None, + } +} + +fn to_i2v_duration(d: FalSora2ProDuration) -> EnqueueSora2ProImageToVideoDurationSeconds { + match d { + FalSora2ProDuration::Four => EnqueueSora2ProImageToVideoDurationSeconds::Four, + FalSora2ProDuration::Eight => EnqueueSora2ProImageToVideoDurationSeconds::Eight, + FalSora2ProDuration::Twelve => EnqueueSora2ProImageToVideoDurationSeconds::Twelve, + } +} + +fn to_i2v_resolution(r: FalSora2ProResolution) -> EnqueueSora2ProImageToVideoResolution { + match r { + FalSora2ProResolution::Auto => EnqueueSora2ProImageToVideoResolution::Auto, + FalSora2ProResolution::SevenTwentyP => EnqueueSora2ProImageToVideoResolution::SevenTwentyP, + FalSora2ProResolution::TenEightyP => EnqueueSora2ProImageToVideoResolution::TenEightyP, + } +} + +fn to_i2v_aspect_ratio(a: FalSora2ProAspectRatio) -> EnqueueSora2ProImageToVideoAspectRatio { + match a { + FalSora2ProAspectRatio::Auto => EnqueueSora2ProImageToVideoAspectRatio::Auto, + FalSora2ProAspectRatio::SixteenByNine => EnqueueSora2ProImageToVideoAspectRatio::SixteenByNine, + FalSora2ProAspectRatio::NineBySixteen => EnqueueSora2ProImageToVideoAspectRatio::NineBySixteen, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/build.rs new file mode 100644 index 0000000000..93e06117d3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/build.rs @@ -0,0 +1,338 @@ +use fal_client::requests::api::video::image::veo_2::api::{ + Veo2ImageToVideoDuration, Veo2ImageToVideoRequest, +}; +use fal_client::requests::api::video::text::veo_2::api::{ + Veo2TextToVideoAspectRatio, Veo2TextToVideoDuration, Veo2TextToVideoRequest, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::veo_2::request::{FalVeo2Mode, FalVeo2RequestState}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Router-level duration plan shared by both modes — each mode maps it onto +/// its own fal duration enum. +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Five, + Six, + Seven, + Eight, +} + +pub fn build_fal_veo_2( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_veo_2_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state))) +} + +pub(crate) fn build_fal_veo_2_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + if builder.end_frame.is_some() { + return Err(unsupported("end_frame", "Veo 2 does not support an ending frame")); + } + + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let duration = plan_duration(builder.duration_seconds, strategy)?; + + let mode = match builder.start_frame.clone() { + Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo(Veo2ImageToVideoRequest { + prompt, + image_url: url, + duration: duration.map(to_i2v_duration), + }), + Some(ImageRef::MediaFileToken(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + None => { + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + FalVeo2Mode::TextToVideo(Veo2TextToVideoRequest { + prompt, + aspect_ratio, + duration: duration.map(to_t2v_duration), + negative_prompt, + enhance_prompt: None, + seed: None, + auto_fix: None, + }) + } + }; + + Ok(FalVeo2RequestState { mode }) +} + +/// `None` lets fal pick its default (16:9); the old endpoint's explicit "auto" +/// no longer exists in the new API, so Auto maps to unset. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo2TextToVideoAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(None), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(unsupported_ar) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar))) + } + _ => Ok(None), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(5) => Ok(Some(PlanDuration::Five)), + Some(6) => Ok(Some(PlanDuration::Six)), + Some(7) => Ok(Some(PlanDuration::Seven)), + Some(8) => Ok(Some(PlanDuration::Eight)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)), + }, + } +} + +fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +fn to_t2v_duration(d: PlanDuration) -> Veo2TextToVideoDuration { + match d { + PlanDuration::Five => Veo2TextToVideoDuration::FiveSeconds, + PlanDuration::Six => Veo2TextToVideoDuration::SixSeconds, + PlanDuration::Seven => Veo2TextToVideoDuration::SevenSeconds, + PlanDuration::Eight => Veo2TextToVideoDuration::EightSeconds, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Veo2ImageToVideoDuration { + match d { + PlanDuration::Five => Veo2ImageToVideoDuration::FiveSeconds, + PlanDuration::Six => Veo2ImageToVideoDuration::SixSeconds, + PlanDuration::Seven => Veo2ImageToVideoDuration::SevenSeconds, + PlanDuration::Eight => Veo2ImageToVideoDuration::EightSeconds, + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + mod mode_selection { + use super::*; + + #[test] + fn no_start_frame_picks_t2v() { + let _ = t2v_request(base_builder()); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + let _ = i2v_request(b); + } + + #[test] + fn end_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_veo_2_state(b).is_err()); + } + + #[test] + fn media_file_token_for_start_frame_errors() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + assert!(build_fal_veo_2_state(b).is_err()); + } + } + + mod materialized_fields { + use super::*; + + #[test] + fn t2v_prompt_passed_through() { + let mut b = base_builder(); + b.prompt = Some("hello".to_string()); + assert_eq!(t2v_request(b).prompt, "hello"); + } + + #[test] + fn t2v_negative_prompt_passed_through() { + let mut b = base_builder(); + b.negative_prompt = Some("no rain".to_string()); + assert_eq!(t2v_request(b).negative_prompt.as_deref(), Some("no rain")); + } + + #[test] + fn i2v_image_url_passed_through() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(i2v_request(b).image_url, "https://example.com/start.png"); + } + } + + mod duration_conversions { + use super::*; + + #[test] + fn duration_none_is_unset() { + // fal defaults to 5s when unset. + assert!(t2v_request(base_builder()).duration.is_none()); + } + + #[test] + fn duration_5_through_8() { + for (seconds, expected) in [ + (5, Veo2TextToVideoDuration::FiveSeconds), + (6, Veo2TextToVideoDuration::SixSeconds), + (7, Veo2TextToVideoDuration::SevenSeconds), + (8, Veo2TextToVideoDuration::EightSeconds), + ] { + let mut b = base_builder(); + b.duration_seconds = Some(seconds); + assert_eq!(t2v_request(b).duration, Some(expected)); + } + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(10); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_2_state(b).is_err()); + } + + #[test] + fn unsupported_duration_upgrades_with_pay_more() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + assert_eq!(t2v_request(b).duration, Some(Veo2TextToVideoDuration::EightSeconds)); + } + + #[test] + fn unsupported_duration_downgrades_with_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(2); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + assert_eq!(t2v_request(b).duration, Some(Veo2TextToVideoDuration::FiveSeconds)); + } + } + + mod aspect_ratio_conversions { + use super::*; + use crate::api::router_aspect_ratio::RouterAspectRatio; + + #[test] + fn t2v_auto_is_unset() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + assert!(t2v_request(b).aspect_ratio.is_none()); + } + + #[test] + fn t2v_sixteen_nine() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + assert_eq!(t2v_request(b).aspect_ratio, Some(Veo2TextToVideoAspectRatio::SixteenByNine)); + } + + #[test] + fn t2v_unsupported_aspect_ratio_falls_back_with_pay_more() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Square); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + assert!(t2v_request(b).aspect_ratio.is_none()); + } + + #[test] + fn t2v_unsupported_aspect_ratio_errors_with_error_out() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Square); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_2_state(b).is_err()); + } + } + + #[test] + fn full_combinatorial_pass() { + use crate::api::router_aspect_ratio::RouterAspectRatio; + let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)]; + let durations = [None, Some(5u16), Some(6), Some(7), Some(8)]; + + let mut combos = 0; + for &aspect_ratio in &aspect_ratios { + for &duration in &durations { + for has_start_frame in [false, true] { + let mut b = base_builder(); + b.aspect_ratio = aspect_ratio; + b.duration_seconds = duration; + if has_start_frame { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + assert!(build_fal_veo_2_state(b).is_ok()); + combos += 1; + } + } + } + assert_eq!(combos, 4 * 5 * 2); + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo2, + provider: RouterProvider::Fal, + prompt: Some("a corgi running".to_string()), + ..Default::default() + } + } + + fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo2TextToVideoRequest { + match build_fal_veo_2_state(b).expect("build_fal_veo_2_state").mode { + FalVeo2Mode::TextToVideo(r) => r, + FalVeo2Mode::ImageToVideo(_) => panic!("expected TextToVideo"), + } + } + + fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo2ImageToVideoRequest { + match build_fal_veo_2_state(b).expect("build_fal_veo_2_state").mode { + FalVeo2Mode::ImageToVideo(r) => r, + FalVeo2Mode::TextToVideo(_) => panic!("expected ImageToVideo"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/cost.rs new file mode 100644 index 0000000000..9bc56eb65c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/cost.rs @@ -0,0 +1,109 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::veo_2::request::{FalVeo2Mode, FalVeo2RequestState}; + +#[derive(Clone, Debug)] +pub struct FalVeo2CostState { + pub cost_in_usd_cents: u64, +} + +impl FalVeo2CostState { + pub fn from_request(request: &FalVeo2RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalVeo2Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalVeo2Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_2::build::build_fal_veo_2_state; + + use super::*; + + // Pricing (fal): 5s = $2.50, +$0.50 per additional second. Flat across + // modalities; no audio or resolution knobs. + + mod numeric_literal_pricing { + use super::*; + + #[test] + fn default_duration_is_5s_priced_at_250() { + // 5s = $2.50 baseline. + assert_eq!(cost_cents(None, false), 250); + } + + #[test] + fn five_seconds_is_250() { + assert_eq!(cost_cents(Some(5), false), 250); + } + + #[test] + fn six_seconds_is_300() { + // +$0.50 per second past 5s. + assert_eq!(cost_cents(Some(6), false), 300); + } + + #[test] + fn seven_seconds_is_350() { + assert_eq!(cost_cents(Some(7), false), 350); + } + + #[test] + fn eight_seconds_is_400() { + assert_eq!(cost_cents(Some(8), false), 400); + } + + #[test] + fn i2v_matches_t2v() { + assert_eq!(cost_cents(Some(7), false), cost_cents(Some(7), true)); + } + } + + #[test] + fn longer_duration_costs_more() { + assert!(cost_cents(Some(5), false) < cost_cents(Some(6), false)); + assert!(cost_cents(Some(6), false) < cost_cents(Some(7), false)); + assert!(cost_cents(Some(7), false) < cost_cents(Some(8), false)); + } + + fn cost_cents(duration_seconds: Option, has_start_frame: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo2, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + ..Default::default() + }; + if has_start_frame { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + let state = build_fal_veo_2_state(b).expect("build_fal_veo_2_state"); + FalVeo2CostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost_in_usd_cents") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs new file mode 100644 index 0000000000..268eb1ce4f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_2/request.rs @@ -0,0 +1,135 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::veo_2::api::Veo2ImageToVideoRequest; +use fal_client::requests::api::video::text::veo_2::api::Veo2TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalVeo2Mode { + TextToVideo(Veo2TextToVideoRequest), + ImageToVideo(Veo2ImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalVeo2RequestState { + pub mode: FalVeo2Mode, +} + +impl FalVeo2RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalVeo2Mode::TextToVideo(request) => send_request(request, client).await, + FalVeo2Mode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::veo_2::api::Veo2ImageToVideoDuration; + use fal_client::requests::api::video::text::veo_2::api::{ + Veo2TextToVideoAspectRatio, Veo2TextToVideoDuration, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_text_to_video_5s() { + let state = FalVeo2RequestState { + mode: FalVeo2Mode::TextToVideo(Veo2TextToVideoRequest { + prompt: "a peaceful lake at dawn".to_string(), + aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo2TextToVideoDuration::FiveSeconds), + negative_prompt: None, + enhance_prompt: None, + seed: None, + auto_fix: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_image_to_video_7s() { + let state = FalVeo2RequestState { + mode: FalVeo2Mode::ImageToVideo(Veo2ImageToVideoRequest { + prompt: "the dog leaps into the lake and splashes around.".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + duration: Some(Veo2ImageToVideoDuration::SevenSeconds), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/build.rs new file mode 100644 index 0000000000..17dfe8acd5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/build.rs @@ -0,0 +1,373 @@ +use fal_client::requests::api::video::image::veo_3::api::{ + Veo3ImageToVideoAspectRatio, Veo3ImageToVideoDuration, Veo3ImageToVideoRequest, + Veo3ImageToVideoResolution, +}; +use fal_client::requests::api::video::text::veo_3::api::{ + Veo3TextToVideoAspectRatio, Veo3TextToVideoDuration, Veo3TextToVideoRequest, + Veo3TextToVideoResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mode, FalVeo3RequestState}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Router-level resolution shared between both modes — each mode maps it onto +/// its own fal resolution enum. +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +pub fn build_fal_veo_3( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_veo_3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state))) +} + +pub(crate) fn build_fal_veo_3_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + if builder.end_frame.is_some() { + return Err(unsupported("end_frame", "Veo 3 does not support an ending frame")); + } + + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + // fal's server default is audio-on; keep the explicit `true` the old + // endpoint sent so pricing stays deterministic. + let generate_audio = Some(builder.generate_audio.unwrap_or(true)); + + let mode = match builder.start_frame.clone() { + Some(ImageRef::Url(url)) => { + let i2v_aspect_ratio = plan_i2v_aspect_ratio(builder.aspect_ratio, strategy)?; + FalVeo3Mode::ImageToVideo(Veo3ImageToVideoRequest { + prompt, + image_url: url, + aspect_ratio: i2v_aspect_ratio, + duration: duration.map(to_i2v_duration), + resolution: resolution.map(to_i2v_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }) + } + Some(ImageRef::MediaFileToken(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + None => { + let t2v_aspect_ratio = plan_t2v_aspect_ratio(builder.aspect_ratio, strategy)?; + FalVeo3Mode::TextToVideo(Veo3TextToVideoRequest { + prompt, + aspect_ratio: t2v_aspect_ratio, + duration: duration.map(to_t2v_duration), + resolution: resolution.map(to_t2v_resolution), + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }) + } + }; + + Ok(FalVeo3RequestState { mode }) +} + +/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). `None` lets fal +/// pick its default (16:9). +fn plan_t2v_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo3TextToVideoAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(None), + }, + } +} + +/// Image-to-video: Auto, 16:9, 9:16 (no Square). +fn plan_i2v_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo3ImageToVideoAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", &format!("{:?}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(PlanDuration::FourSeconds)), + Some(6) => Ok(Some(PlanDuration::SixSeconds)), + Some(8) => Ok(Some(PlanDuration::EightSeconds)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::EightSeconds)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::FourSeconds)), + }, + } +} + +fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +fn to_t2v_duration(d: PlanDuration) -> Veo3TextToVideoDuration { + match d { + PlanDuration::FourSeconds => Veo3TextToVideoDuration::FourSeconds, + PlanDuration::SixSeconds => Veo3TextToVideoDuration::SixSeconds, + PlanDuration::EightSeconds => Veo3TextToVideoDuration::EightSeconds, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Veo3ImageToVideoDuration { + match d { + PlanDuration::FourSeconds => Veo3ImageToVideoDuration::FourSeconds, + PlanDuration::SixSeconds => Veo3ImageToVideoDuration::SixSeconds, + PlanDuration::EightSeconds => Veo3ImageToVideoDuration::EightSeconds, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> Veo3TextToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3TextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3TextToVideoResolution::TenEightyP, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> Veo3ImageToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3ImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3ImageToVideoResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + mod mode_selection { + use super::*; + + #[test] + fn no_start_frame_picks_t2v() { let _ = t2v_request(base_t2v_builder()); } + + #[test] + fn start_frame_picks_i2v() { let _ = i2v_request(base_i2v_builder()); } + + #[test] + fn end_frame_errors() { + let mut b = base_t2v_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_veo_3_state(b).is_err()); + } + } + + mod resolution_conversions { + use super::*; + + #[test] + fn t2v_720p() { + let mut b = base_t2v_builder(); + b.resolution = Some(RouterResolution::SevenTwentyP); + assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::SevenTwentyP)); + } + + #[test] + fn t2v_1080p() { + let mut b = base_t2v_builder(); + b.resolution = Some(RouterResolution::TenEightyP); + assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::TenEightyP)); + } + + #[test] + fn t2v_unsupported_upgrades_with_pay_more() { + let mut b = base_t2v_builder(); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::TenEightyP)); + } + } + + mod duration_conversions { + use super::*; + + #[test] + fn t2v_4s_6s_8s() { + for (s, expected) in [ + (4, Veo3TextToVideoDuration::FourSeconds), + (6, Veo3TextToVideoDuration::SixSeconds), + (8, Veo3TextToVideoDuration::EightSeconds), + ] { + let mut b = base_t2v_builder(); + b.duration_seconds = Some(s); + assert_eq!(t2v_request(b).duration, Some(expected)); + } + } + } + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn t2v_sixteen_nine() { + let mut b = base_t2v_builder(); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + assert_eq!(t2v_request(b).aspect_ratio, Some(Veo3TextToVideoAspectRatio::SixteenByNine)); + } + + #[test] + fn i2v_auto_when_unspecified() { + assert_eq!(i2v_request(base_i2v_builder()).aspect_ratio, Some(Veo3ImageToVideoAspectRatio::Auto)); + } + } + + mod audio_handling { + use super::*; + + #[test] + fn audio_defaults_to_true() { + assert_eq!(t2v_request(base_t2v_builder()).generate_audio, Some(true)); + } + + #[test] + fn audio_false_is_passed_through() { + let mut b = base_t2v_builder(); + b.generate_audio = Some(false); + assert_eq!(t2v_request(b).generate_audio, Some(false)); + } + } + + #[test] + fn full_combinatorial_pass() { + let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)]; + let durations = [None, Some(4u16), Some(6), Some(8)]; + let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &resolution in &resolutions { + for &duration in &durations { + for &aspect_ratio in &aspect_ratios { + for &generate_audio in &audios { + for has_start_frame in [false, true] { + let mut b = if has_start_frame { base_i2v_builder() } else { base_t2v_builder() }; + b.resolution = resolution; + b.duration_seconds = duration; + b.aspect_ratio = aspect_ratio; + b.generate_audio = generate_audio; + assert!(build_fal_veo_3_state(b).is_ok()); + combos += 1; + } + } + } + } + } + assert_eq!(combos, 3 * 4 * 4 * 3 * 2); + } + + fn base_t2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn base_i2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), + ..base_t2v_builder() + } + } + + fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3TextToVideoRequest { + match build_fal_veo_3_state(b).expect("build_fal_veo_3_state").mode { + FalVeo3Mode::TextToVideo(r) => r, + FalVeo3Mode::ImageToVideo(_) => panic!("expected TextToVideo"), + } + } + + fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3ImageToVideoRequest { + match build_fal_veo_3_state(b).expect("build_fal_veo_3_state").mode { + FalVeo3Mode::ImageToVideo(r) => r, + FalVeo3Mode::TextToVideo(_) => panic!("expected ImageToVideo"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/cost.rs new file mode 100644 index 0000000000..b78700101e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/cost.rs @@ -0,0 +1,114 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mode, FalVeo3RequestState}; + +#[derive(Clone, Debug)] +pub struct FalVeo3CostState { + pub cost_in_usd_cents: u64, +} + +impl FalVeo3CostState { + pub fn from_request(request: &FalVeo3RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalVeo3Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo_3_state; + + use super::*; + + // Pricing (fal): $0.20/sec audio off, $0.40/sec audio on. Flat across + // modalities; resolution (720p/1080p) does not change the price. + + mod numeric_literal_pricing { + use super::*; + + #[test] + fn audio_on_4s_is_160() { + // $0.40/sec audio on × 4s = 160¢. + assert_eq!(cost_cents(Some(4), Some(true), false), 160); + } + + #[test] + fn audio_on_6s_is_240() { assert_eq!(cost_cents(Some(6), Some(true), false), 240); } + + #[test] + fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true), false), 320); } + + #[test] + fn audio_off_4s_is_80() { + // $0.20/sec audio off × 4s = 80¢. + assert_eq!(cost_cents(Some(4), Some(false), false), 80); + } + + #[test] + fn audio_off_8s_is_160() { assert_eq!(cost_cents(Some(8), Some(false), false), 160); } + + #[test] + fn duration_default_is_8s() { + // No duration → fal default 8s. + assert_eq!(cost_cents(None, Some(true), false), 320); + } + + #[test] + fn audio_default_is_true() { + // None → defaults to audio=true via builder. + assert_eq!(cost_cents(Some(6), None, false), 240); + } + + #[test] + fn i2v_matches_t2v() { + assert_eq!(cost_cents(Some(6), Some(true), false), cost_cents(Some(6), Some(true), true)); + } + } + + #[test] + fn audio_costs_more_than_no_audio() { + assert!(cost_cents(Some(8), Some(false), false) < cost_cents(Some(8), Some(true), false)); + } + + fn cost_cents(duration_seconds: Option, generate_audio: Option, has_start_frame: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + if has_start_frame { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + let state = build_fal_veo_3_state(b).expect("build_fal_veo_3_state"); + FalVeo3CostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost_in_usd_cents") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs new file mode 100644 index 0000000000..69930757b1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3/request.rs @@ -0,0 +1,146 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::veo_3::api::Veo3ImageToVideoRequest; +use fal_client::requests::api::video::text::veo_3::api::Veo3TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalVeo3Mode { + TextToVideo(Veo3TextToVideoRequest), + ImageToVideo(Veo3ImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalVeo3RequestState { + pub mode: FalVeo3Mode, +} + +impl FalVeo3RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalVeo3Mode::TextToVideo(request) => send_request(request, client).await, + FalVeo3Mode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::veo_3::api::{ + Veo3ImageToVideoDuration, Veo3ImageToVideoResolution, + }; + use fal_client::requests::api::video::text::veo_3::api::{ + Veo3TextToVideoDuration, Veo3TextToVideoResolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_text_to_video_720p_4s_audio_on() { + let state = FalVeo3RequestState { + mode: FalVeo3Mode::TextToVideo(Veo3TextToVideoRequest { + prompt: "a serene mountain landscape".to_string(), + aspect_ratio: None, + duration: Some(Veo3TextToVideoDuration::FourSeconds), + resolution: Some(Veo3TextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(true), + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_image_to_video_1080p_8s_no_audio() { + let state = FalVeo3RequestState { + mode: FalVeo3Mode::ImageToVideo(Veo3ImageToVideoRequest { + prompt: "the dog leaps into the lake.".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3ImageToVideoDuration::EightSeconds), + resolution: Some(Veo3ImageToVideoResolution::TenEightyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/build.rs new file mode 100644 index 0000000000..3c3a2893e7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/build.rs @@ -0,0 +1,342 @@ +use fal_client::requests::api::video::image::veo_3_fast::api::{ + Veo3FastImageToVideoAspectRatio, Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest, + Veo3FastImageToVideoResolution, +}; +use fal_client::requests::api::video::text::veo_3_fast::api::{ + Veo3FastTextToVideoAspectRatio, Veo3FastTextToVideoDuration, Veo3FastTextToVideoRequest, + Veo3FastTextToVideoResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::veo_3_fast::request::{ + FalVeo3FastMode, FalVeo3FastRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Router-level resolution shared between both modes — each mode maps it onto +/// its own fal resolution enum. +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +pub fn build_fal_veo_3_fast( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_veo_3_fast_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(state))) +} + +pub(crate) fn build_fal_veo_3_fast_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + if builder.end_frame.is_some() { + return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame")); + } + + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + // fal's server default is audio-on; keep the explicit `true` the old + // endpoint sent so pricing stays deterministic. + let generate_audio = Some(builder.generate_audio.unwrap_or(true)); + + let mode = match builder.start_frame.clone() { + Some(ImageRef::Url(url)) => { + let i2v_aspect_ratio = plan_i2v_aspect_ratio(builder.aspect_ratio, strategy)?; + FalVeo3FastMode::ImageToVideo(Veo3FastImageToVideoRequest { + prompt, + image_url: url, + aspect_ratio: i2v_aspect_ratio, + duration: duration.map(to_i2v_duration), + resolution: resolution.map(to_i2v_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }) + } + Some(ImageRef::MediaFileToken(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + None => { + let t2v_aspect_ratio = plan_t2v_aspect_ratio(builder.aspect_ratio, strategy)?; + FalVeo3FastMode::TextToVideo(Veo3FastTextToVideoRequest { + prompt, + aspect_ratio: t2v_aspect_ratio, + duration: duration.map(to_t2v_duration), + resolution: resolution.map(to_t2v_resolution), + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }) + } + }; + + Ok(FalVeo3FastRequestState { mode }) +} + +/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). `None` lets fal +/// pick its default (16:9). +fn plan_t2v_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo3FastTextToVideoAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(None), + }, + } +} + +/// Image-to-video: Auto, 16:9, 9:16 (no Square). +fn plan_i2v_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo3FastImageToVideoAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(unsupported_ar) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar))) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", &format!("{:?}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(PlanDuration::FourSeconds)), + Some(6) => Ok(Some(PlanDuration::SixSeconds)), + Some(8) => Ok(Some(PlanDuration::EightSeconds)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::EightSeconds)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::FourSeconds)), + }, + } +} + +fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +fn to_t2v_duration(d: PlanDuration) -> Veo3FastTextToVideoDuration { + match d { + PlanDuration::FourSeconds => Veo3FastTextToVideoDuration::FourSeconds, + PlanDuration::SixSeconds => Veo3FastTextToVideoDuration::SixSeconds, + PlanDuration::EightSeconds => Veo3FastTextToVideoDuration::EightSeconds, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Veo3FastImageToVideoDuration { + match d { + PlanDuration::FourSeconds => Veo3FastImageToVideoDuration::FourSeconds, + PlanDuration::SixSeconds => Veo3FastImageToVideoDuration::SixSeconds, + PlanDuration::EightSeconds => Veo3FastImageToVideoDuration::EightSeconds, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> Veo3FastTextToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3FastTextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3FastTextToVideoResolution::TenEightyP, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> Veo3FastImageToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3FastImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3FastImageToVideoResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + + use super::*; + + mod mode_selection { + use super::*; + + #[test] + fn no_start_frame_picks_t2v() { + let _ = t2v_request(base_t2v_builder()); + } + + #[test] + fn start_frame_picks_i2v() { + let _ = i2v_request(base_i2v_builder()); + } + + #[test] + fn end_frame_errors() { + let mut b = base_i2v_builder(); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + assert!(build_fal_veo_3_fast_state(b).is_err()); + } + + #[test] + fn media_file_token_for_start_frame_errors() { + let mut b = base_t2v_builder(); + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))); + assert!(build_fal_veo_3_fast_state(b).is_err()); + } + } + + mod materialized_fields { + use super::*; + + #[test] + fn i2v_prompt_passed_through() { + let mut b = base_i2v_builder(); + b.prompt = Some("hello world".to_string()); + assert_eq!(i2v_request(b).prompt, "hello world"); + } + + #[test] + fn i2v_image_url_passed_through() { + assert_eq!(i2v_request(base_i2v_builder()).image_url, "https://example.com/a.png"); + } + + #[test] + fn audio_defaults_to_true() { + assert_eq!(i2v_request(base_i2v_builder()).generate_audio, Some(true)); + assert_eq!(t2v_request(base_t2v_builder()).generate_audio, Some(true)); + } + + #[test] + fn audio_false_passed_through() { + let mut b = base_i2v_builder(); + b.generate_audio = Some(false); + assert_eq!(i2v_request(b).generate_audio, Some(false)); + } + } + + #[test] + fn full_combinatorial_pass() { + let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)]; + let durations = [None, Some(4u16), Some(6), Some(8)]; + let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &resolution in &resolutions { + for &duration in &durations { + for &aspect_ratio in &aspect_ratios { + for &generate_audio in &audios { + for has_start_frame in [false, true] { + let mut b = if has_start_frame { base_i2v_builder() } else { base_t2v_builder() }; + b.resolution = resolution; + b.duration_seconds = duration; + b.aspect_ratio = aspect_ratio; + b.generate_audio = generate_audio; + assert!(build_fal_veo_3_fast_state(b).is_ok()); + combos += 1; + } + } + } + } + } + assert_eq!(combos, 3 * 4 * 4 * 3 * 2); + } + + fn base_t2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3Fast, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn base_i2v_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())), + ..base_t2v_builder() + } + } + + fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3FastTextToVideoRequest { + match build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state").mode { + FalVeo3FastMode::TextToVideo(r) => r, + FalVeo3FastMode::ImageToVideo(_) => panic!("expected TextToVideo"), + } + } + + fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3FastImageToVideoRequest { + match build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state").mode { + FalVeo3FastMode::ImageToVideo(r) => r, + FalVeo3FastMode::TextToVideo(_) => panic!("expected ImageToVideo"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/cost.rs new file mode 100644 index 0000000000..255128780c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/cost.rs @@ -0,0 +1,115 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::fal::veo_3_fast::request::{ + FalVeo3FastMode, FalVeo3FastRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalVeo3FastCostState { + pub cost_in_usd_cents: u64, +} + +impl FalVeo3FastCostState { + pub fn from_request(request: &FalVeo3FastRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalVeo3FastMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3FastMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast_state; + + use super::*; + + // Pricing (fal): $0.10/sec audio off, $0.15/sec audio on. Flat across + // modalities; resolution (720p/1080p) does not change the price. + + mod numeric_literal_pricing { + use super::*; + + #[test] + fn audio_on_4s_is_60() { + // $0.15/sec × 4s = 60¢. + assert_eq!(cost_cents(Some(4), Some(true), true), 60); + } + + #[test] + fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true), true), 90); } + + #[test] + fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true), true), 120); } + + #[test] + fn audio_off_4s_is_40() { + // $0.10/sec × 4s = 40¢. + assert_eq!(cost_cents(Some(4), Some(false), true), 40); + } + + #[test] + fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false), true), 80); } + + #[test] + fn default_duration_is_8s() { + assert_eq!(cost_cents(None, Some(true), true), 120); + } + + #[test] + fn t2v_matches_i2v() { + assert_eq!(cost_cents(Some(6), Some(true), false), cost_cents(Some(6), Some(true), true)); + } + } + + #[test] + fn audio_costs_more_than_no_audio() { + assert!(cost_cents(Some(8), Some(false), true) < cost_cents(Some(8), Some(true), true)); + } + + #[test] + fn longer_duration_costs_more() { + assert!(cost_cents(Some(4), Some(true), true) < cost_cents(Some(6), Some(true), true)); + assert!(cost_cents(Some(6), Some(true), true) < cost_cents(Some(8), Some(true), true)); + } + + fn cost_cents(duration_seconds: Option, generate_audio: Option, has_start_frame: bool) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3Fast, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + generate_audio, + ..Default::default() + }; + if has_start_frame { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + let state = build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state"); + FalVeo3FastCostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost_in_usd_cents") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs new file mode 100644 index 0000000000..980e58695f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3_fast/request.rs @@ -0,0 +1,123 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoRequest; +use fal_client::requests::api::video::text::veo_3_fast::api::Veo3FastTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalVeo3FastMode { + TextToVideo(Veo3FastTextToVideoRequest), + ImageToVideo(Veo3FastImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalVeo3FastRequestState { + pub mode: FalVeo3FastMode, +} + +impl FalVeo3FastRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalVeo3FastMode::TextToVideo(request) => send_request(request, client).await, + FalVeo3FastMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::veo_3_fast::api::{ + Veo3FastImageToVideoDuration, Veo3FastImageToVideoResolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_image_to_video_720p_4s_audio() { + let state = FalVeo3FastRequestState { + mode: FalVeo3FastMode::ImageToVideo(Veo3FastImageToVideoRequest { + prompt: "the dog leaps into the lake.".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3FastImageToVideoDuration::FourSeconds), + resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP), + generate_audio: Some(true), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + assert!(matches!(response, GenerateVideoResponse::Fal(_))); + } + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/build.rs new file mode 100644 index 0000000000..ece09f609e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/build.rs @@ -0,0 +1,851 @@ +use fal_client::requests::api::video::extend::veo_3p1::api::{ + Veo3p1ExtendVideoAspectRatio, Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest, + Veo3p1ExtendVideoResolution, +}; +use fal_client::requests::api::video::image::veo_3p1::api::{ + Veo3p1ImageToVideoAspectRatio, Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest, + Veo3p1ImageToVideoResolution, +}; +use fal_client::requests::api::video::images::veo_3p1::api::{ + Veo3p1FirstLastFrameToVideoAspectRatio, Veo3p1FirstLastFrameToVideoDuration, + Veo3p1FirstLastFrameToVideoRequest, Veo3p1FirstLastFrameToVideoResolution, +}; +use fal_client::requests::api::video::reference::veo_3p1::api::{ + Veo3p1ReferenceToVideoAspectRatio, Veo3p1ReferenceToVideoDuration, Veo3p1ReferenceToVideoRequest, + Veo3p1ReferenceToVideoResolution, +}; +use fal_client::requests::api::video::text::veo_3p1::api::{ + Veo3p1TextToVideoAspectRatio, Veo3p1TextToVideoDuration, Veo3p1TextToVideoRequest, + Veo3p1TextToVideoResolution, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::veo_3p1::request::{FalVeo3p1Mode, FalVeo3p1RequestState}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Four, + Six, + Eight, +} + +pub fn build_fal_veo_3p1( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_veo_3p1_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(state))) +} + +pub(crate) fn build_fal_veo_3p1_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let start = optional_url(builder.start_frame.clone())?; + let end = optional_url(builder.end_frame.clone())?; + let reference_images = reference_image_urls(builder.reference_images.clone())?; + let reference_videos = reference_video_urls(builder.reference_videos.clone())?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let generate_audio = builder.generate_audio; + + // Modality dispatch, most specific inputs first: + // 1. reference videos → extend-video + // 2. reference images → reference-to-video + // 3. start + end frames → first-last-frame-to-video + // 4. start frame only → image-to-video + // 5. no media → text-to-video + let mode = if let Some(video_urls) = reference_videos { + if reference_images.is_some() { + return Err(unsupported( + "reference_images", + "Veo 3.1 extend-video cannot combine reference_images with a reference video", + )); + } + if start.is_some() { + return Err(unsupported( + "start_frame", + "Veo 3.1 extend-video cannot combine start_frame with a reference video", + )); + } + if end.is_some() { + return Err(unsupported( + "end_frame", + "Veo 3.1 extend-video cannot combine end_frame with a reference video", + )); + } + if video_urls.len() != 1 { + return Err(unsupported( + "reference_videos", + &format!("Veo 3.1 extend-video requires exactly 1 reference video, got {}", video_urls.len()), + )); + } + let video_url = video_urls.into_iter().next().expect("checked len == 1"); + FalVeo3p1Mode::ExtendVideo(Veo3p1ExtendVideoRequest { + prompt, + video_url, + aspect_ratio: aspect_ratio.map(to_extend_aspect_ratio), + duration: plan_extend_duration(builder.duration_seconds, strategy)?, + resolution: resolution.map(|r| to_extend_resolution(r, strategy)).transpose()?, + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }) + } else if let Some(image_urls) = reference_images { + if start.is_some() { + return Err(unsupported( + "start_frame", + "Veo 3.1 reference-to-video cannot combine start_frame with reference_images", + )); + } + if end.is_some() { + return Err(unsupported( + "end_frame", + "Veo 3.1 reference-to-video cannot combine end_frame with reference_images", + )); + } + // NB: reference-to-video has no negative_prompt or seed fields on fal's + // schema — those builder inputs are dropped silently. + FalVeo3p1Mode::ReferenceToVideo(Veo3p1ReferenceToVideoRequest { + prompt, + image_urls, + aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio), + duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration), + resolution: resolution.map(to_reference_resolution), + generate_audio, + auto_fix: None, + safety_tolerance: None, + }) + } else { + let duration = plan_duration(builder.duration_seconds, strategy)?; + match (start, end) { + (None, None) => FalVeo3p1Mode::TextToVideo(Veo3p1TextToVideoRequest { + prompt, + aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio), + duration: duration.map(to_t2v_duration), + resolution: resolution.map(to_t2v_resolution), + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + (Some(image_url), None) => FalVeo3p1Mode::ImageToVideo(Veo3p1ImageToVideoRequest { + prompt, + image_url, + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + duration: duration.map(to_i2v_duration), + resolution: resolution.map(to_i2v_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + (Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1Mode::FirstLastFrameToVideo( + Veo3p1FirstLastFrameToVideoRequest { + prompt, + first_frame_url, + last_frame_url, + aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio), + duration: duration.map(to_flf_duration), + resolution: resolution.map(to_flf_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }, + ), + (None, Some(_)) => { + return Err(unsupported( + "end_frame", + "Veo 3.1 requires a start_frame when end_frame is provided", + )); + } + } + }; + + Ok(FalVeo3p1RequestState { mode }) +} + +fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn reference_image_urls(refs: Option) -> Result>, ArtcraftRouterError> { + match refs { + None => Ok(None), + Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None), + Some(ImageListRef::Urls(urls)) => Ok(Some(urls)), + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn reference_video_urls(refs: Option) -> Result>, ArtcraftRouterError> { + match refs { + None => Ok(None), + Some(VideoListRef::Urls(urls)) if urls.is_empty() => Ok(None), + Some(VideoListRef::Urls(urls)) => Ok(Some(urls)), + Some(VideoListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None), + Some(VideoListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanResolution as R; + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)), + Some(RouterResolution::FourK) => Ok(Some(R::FourK)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", &format!("{:?}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(PlanDuration::Four)), + Some(6) => Ok(Some(PlanDuration::Six)), + Some(8) => Ok(Some(PlanDuration::Eight)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + // Nearest supported duration above/below, clamped to the 4s–8s range. + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other { + 0..=4 => PlanDuration::Four, + 5..=6 => PlanDuration::Six, + _ => PlanDuration::Eight, + })), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other { + 0..=5 => PlanDuration::Four, + 6..=7 => PlanDuration::Six, + _ => PlanDuration::Eight, + })), + }, + } +} + +/// Extend-video also supports fal's 7s default in addition to 4/6/8. +fn plan_extend_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo3p1ExtendVideoDuration as D; + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(D::FourSeconds)), + Some(6) => Ok(Some(D::SixSeconds)), + Some(7) => Ok(Some(D::SevenSeconds)), + Some(8) => Ok(Some(D::EightSeconds)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds })) + } + }, + } +} + +fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +// The text-to-video endpoint has no `auto` aspect ratio; omit the field and +// let fal apply its own default (16:9). +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option { + match a { + PlanAspectRatio::Auto => None, + PlanAspectRatio::SixteenByNine => Some(Veo3p1TextToVideoAspectRatio::SixteenByNine), + PlanAspectRatio::NineBySixteen => Some(Veo3p1TextToVideoAspectRatio::NineBySixteen), + } +} + +fn to_t2v_duration(d: PlanDuration) -> Veo3p1TextToVideoDuration { + match d { + PlanDuration::Four => Veo3p1TextToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1TextToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1TextToVideoDuration::EightSeconds, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> Veo3p1TextToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1TextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1TextToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1TextToVideoResolution::FourK, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1ImageToVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1ImageToVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1ImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1ImageToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Veo3p1ImageToVideoDuration { + match d { + PlanDuration::Four => Veo3p1ImageToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1ImageToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1ImageToVideoDuration::EightSeconds, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> Veo3p1ImageToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1ImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1ImageToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1ImageToVideoResolution::FourK, + } +} + +fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FirstLastFrameToVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1FirstLastFrameToVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1FirstLastFrameToVideoAspectRatio::NineBySixteen, + } +} + +fn to_flf_duration(d: PlanDuration) -> Veo3p1FirstLastFrameToVideoDuration { + match d { + PlanDuration::Four => Veo3p1FirstLastFrameToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1FirstLastFrameToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1FirstLastFrameToVideoDuration::EightSeconds, + } +} + +fn to_flf_resolution(r: PlanResolution) -> Veo3p1FirstLastFrameToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1FirstLastFrameToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1FirstLastFrameToVideoResolution::FourK, + } +} + +// The reference-to-video endpoint has no `auto` aspect ratio; omit the field +// and let fal apply its own default (16:9). +fn to_reference_aspect_ratio(a: PlanAspectRatio) -> Option { + match a { + PlanAspectRatio::Auto => None, + PlanAspectRatio::SixteenByNine => Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine), + PlanAspectRatio::NineBySixteen => Some(Veo3p1ReferenceToVideoAspectRatio::NineBySixteen), + } +} + +fn to_reference_duration(d: PlanDuration) -> Veo3p1ReferenceToVideoDuration { + match d { + PlanDuration::Four => Veo3p1ReferenceToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1ReferenceToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1ReferenceToVideoDuration::EightSeconds, + } +} + +fn to_reference_resolution(r: PlanResolution) -> Veo3p1ReferenceToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1ReferenceToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1ReferenceToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1ReferenceToVideoResolution::FourK, + } +} + +fn to_extend_aspect_ratio(a: PlanAspectRatio) -> Veo3p1ExtendVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1ExtendVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1ExtendVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1ExtendVideoAspectRatio::NineBySixteen, + } +} + +// Extend-video has no 4k tier; the nearest supported resolution in either +// direction is 1080p. +fn to_extend_resolution( + r: PlanResolution, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match r { + PlanResolution::SevenTwentyP => Ok(Veo3p1ExtendVideoResolution::SevenTwentyP), + PlanResolution::TenEightyP => Ok(Veo3p1ExtendVideoResolution::TenEightyP), + PlanResolution::FourK => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", "Veo 3.1 extend-video does not support 4k")) + } + _ => Ok(Veo3p1ExtendVideoResolution::TenEightyP), + }, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + const START_FRAME_URL: &str = "https://example.com/a.png"; + const END_FRAME_URL: &str = "https://example.com/b.png"; + const REFERENCE_IMAGE_URL_A: &str = "https://example.com/ref_a.png"; + const REFERENCE_IMAGE_URL_B: &str = "https://example.com/ref_b.png"; + const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4"; + + mod mode_selection { + use super::*; + + #[test] + fn no_frames_picks_t2v() { + assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1Mode::TextToVideo(_))); + } + + #[test] + fn start_only_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_picks_first_last_frame() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::FirstLastFrameToVideo(_))); + } + + #[test] + fn end_only_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn reference_images_pick_reference_to_video() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()])); + assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ReferenceToVideo(_))); + } + + #[test] + fn reference_videos_pick_extend_video() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ExtendVideo(_))); + } + + #[test] + fn empty_reference_lists_fall_through_to_t2v() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![])); + b.reference_videos = Some(VideoListRef::Urls(vec![])); + assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::TextToVideo(_))); + } + + #[test] + fn public_build_fn_wraps_request_enum() { + match build_fal_veo_3p1(base_builder()).expect("build") { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(s)) => { + assert!(matches!(s.mode, FalVeo3p1Mode::TextToVideo(_))); + } + _ => panic!("expected Request(FalVeo3p1)"), + } + } + } + + mod extend_video { + use super::*; + + #[test] + fn extend_maps_video_url_and_7s_duration() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.duration_seconds = Some(7); + match unwrap_mode(b) { + FalVeo3p1Mode::ExtendVideo(r) => { + assert_eq!(r.video_url, REFERENCE_VIDEO_URL); + assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SevenSeconds))); + } + _ => panic!("expected extend"), + } + } + + #[test] + fn extend_requires_exactly_one_video() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![ + REFERENCE_VIDEO_URL.to_string(), + "https://example.com/other.mp4".to_string(), + ])); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn extend_rejects_start_frame() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn extend_rejects_end_frame() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn extend_rejects_reference_images() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()])); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn extend_duration_5s_pay_more_upgrades_to_6() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + match unwrap_mode(b) { + FalVeo3p1Mode::ExtendVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SixSeconds))); + } + _ => panic!("expected extend"), + } + } + + #[test] + fn extend_4k_errors_with_error_out() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn extend_4k_maps_to_1080p_with_mitigation() { + for strategy in [ + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = strategy; + match unwrap_mode(b) { + FalVeo3p1Mode::ExtendVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1ExtendVideoResolution::TenEightyP))); + } + _ => panic!("expected extend"), + } + } + } + } + + mod reference_to_video { + use super::*; + + #[test] + fn reference_maps_image_urls() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![ + REFERENCE_IMAGE_URL_A.to_string(), + REFERENCE_IMAGE_URL_B.to_string(), + ])); + match unwrap_mode(b) { + FalVeo3p1Mode::ReferenceToVideo(r) => { + assert_eq!(r.image_urls, vec![REFERENCE_IMAGE_URL_A, REFERENCE_IMAGE_URL_B]); + } + _ => panic!("expected reference"), + } + } + + #[test] + fn reference_rejects_start_frame() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()])); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn reference_rejects_end_frame() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()])); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn reference_auto_aspect_ratio_is_omitted() { + // reference-to-video has no `auto` wire value; Auto must map to None. + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()])); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + match unwrap_mode(b) { + FalVeo3p1Mode::ReferenceToVideo(r) => assert!(r.aspect_ratio.is_none()), + _ => panic!("expected reference"), + } + } + + #[test] + fn reference_maps_duration_and_resolution() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()])); + b.duration_seconds = Some(6); + b.resolution = Some(RouterResolution::FourK); + match unwrap_mode(b) { + FalVeo3p1Mode::ReferenceToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1ReferenceToVideoDuration::SixSeconds))); + assert!(matches!(r.resolution, Some(Veo3p1ReferenceToVideoResolution::FourK))); + } + _ => panic!("expected reference"), + } + } + } + + mod duration_conversions { + use super::*; + + #[test] + fn duration_4s() { + let mut b = base_builder(); + b.duration_seconds = Some(4); + match unwrap_mode(b) { + FalVeo3p1Mode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::FourSeconds))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_3p1_state(b).is_err()); + } + + #[test] + fn duration_5s_pay_more_upgrades_to_6() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + match unwrap_mode(b) { + FalVeo3p1Mode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::SixSeconds))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn duration_5s_pay_less_downgrades_to_4() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + match unwrap_mode(b) { + FalVeo3p1Mode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::FourSeconds))); + } + _ => panic!("expected t2v"), + } + } + } + + mod resolution_conversions { + use super::*; + + #[test] + fn t2v_720p() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::SevenTwentyP); + match unwrap_mode(b) { + FalVeo3p1Mode::TextToVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1TextToVideoResolution::SevenTwentyP))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn t2v_4k_maps_to_four_k() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourK); + match unwrap_mode(b) { + FalVeo3p1Mode::TextToVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1TextToVideoResolution::FourK))); + } + _ => panic!("expected t2v"), + } + } + } + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn t2v_auto_aspect_ratio_is_omitted() { + // t2v has no `auto` wire value; Auto must map to None. + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + match unwrap_mode(b) { + FalVeo3p1Mode::TextToVideo(r) => assert!(r.aspect_ratio.is_none()), + _ => panic!("expected t2v"), + } + } + + #[test] + fn i2v_auto_aspect_ratio_maps_to_auto() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + match unwrap_mode(b) { + FalVeo3p1Mode::ImageToVideo(r) => { + assert!(matches!(r.aspect_ratio, Some(Veo3p1ImageToVideoAspectRatio::Auto))); + } + _ => panic!("expected i2v"), + } + } + } + + #[test] + fn full_combinatorial_pass() { + let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), Some(RouterResolution::FourK)]; + let durations = [None, Some(4u16), Some(6), Some(8)]; + let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &resolution in &resolutions { + for &duration in &durations { + for &aspect_ratio in &aspect_ratios { + for &generate_audio in &audios { + for frames in [0, 1, 2] { + let mut b = base_builder(); + b.resolution = resolution; + b.duration_seconds = duration; + b.aspect_ratio = aspect_ratio; + b.generate_audio = generate_audio; + if frames >= 1 { + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + } + if frames == 2 { + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + } + assert!(build_fal_veo_3p1_state(b).is_ok()); + combos += 1; + } + } + } + } + } + assert_eq!(combos, 4 * 4 * 4 * 3 * 3); + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1Mode { + build_fal_veo_3p1_state(b).expect("build").mode + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/cost.rs new file mode 100644 index 0000000000..4b4769acaf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/cost.rs @@ -0,0 +1,239 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::veo_3p1::request::{FalVeo3p1Mode, FalVeo3p1RequestState}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalVeo3p1CostState { + pub cost_in_usd_cents: u64, +} + +impl FalVeo3p1CostState { + pub fn from_request(request: &FalVeo3p1RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalVeo3p1Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1Mode::ExtendVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1_state; + + use super::*; + + // Veo 3.1 pricing: + // 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on) + // 4k: $0.40/sec (audio off), $0.60/sec (audio on) + // Extend-video has no 4k tier and defaults to 7s. + + #[derive(Clone, Copy)] + enum Modality { + TextToVideo, + ImageToVideo, + FirstLastFrame, + Reference, + Extend, + } + + mod text_to_video { + use super::*; + + #[test] + fn t2v_8s_audio_on_720p_is_320() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)), 320); + } + + #[test] + fn t2v_8s_audio_off_720p_is_160() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(false)), 160); + } + + #[test] + fn t2v_4s_audio_on_is_160() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(true)), 160); + } + + #[test] + fn t2v_6s_audio_on_is_240() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(6), None, Some(true)), 240); + } + + #[test] + fn t2v_8s_audio_on_4k_is_480() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(true)), 480); + } + + #[test] + fn t2v_8s_audio_off_4k_is_320() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(false)), 320); + } + + #[test] + fn t2v_defaults_are_8s_720p_audio_on_320() { + // duration=None→8s, resolution=None→720p, audio=None→on. + assert_eq!(cost_cents(Modality::TextToVideo, None, None, None), 320); + } + + #[test] + fn t2v_1080p_bills_same_as_720p() { + assert_eq!( + cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true)), + cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)), + ); + } + } + + mod image_to_video { + use super::*; + + #[test] + fn i2v_6s_audio_on_is_240() { + assert_eq!(cost_cents(Modality::ImageToVideo, Some(6), None, Some(true)), 240); + } + + #[test] + fn i2v_8s_audio_off_1080p_is_160() { + assert_eq!(cost_cents(Modality::ImageToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(false)), 160); + } + } + + mod first_last_frame_to_video { + use super::*; + + #[test] + fn flf_4s_audio_on_is_160() { + assert_eq!(cost_cents(Modality::FirstLastFrame, Some(4), None, Some(true)), 160); + } + + #[test] + fn flf_8s_audio_on_4k_is_480() { + assert_eq!(cost_cents(Modality::FirstLastFrame, Some(8), Some(RouterResolution::FourK), Some(true)), 480); + } + } + + mod reference_to_video { + use super::*; + + #[test] + fn reference_8s_audio_on_1080p_is_320() { + assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::TenEightyP), Some(true)), 320); + } + + #[test] + fn reference_4s_audio_off_720p_is_80() { + assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 80); + } + + #[test] + fn reference_8s_audio_on_4k_is_480() { + assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::FourK), Some(true)), 480); + } + } + + mod extend_video { + use super::*; + + #[test] + fn extend_7s_audio_on_is_280() { + assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(true)), 280); + } + + #[test] + fn extend_7s_audio_off_is_140() { + assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(false)), 140); + } + + #[test] + fn extend_8s_audio_on_is_320() { + assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 320); + } + + #[test] + fn extend_defaults_are_7s_audio_on_280() { + // duration=None→7s (fal's extend default), audio=None→on. + assert_eq!(cost_cents(Modality::Extend, None, None, None), 280); + } + } + + #[test] + fn t2v_i2v_flf_and_reference_price_identically() { + let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true)); + for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] { + assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected); + } + } + + #[test] + fn audio_costs_more_than_no_audio() { + assert!( + cost_cents(Modality::TextToVideo, Some(8), None, Some(false)) + < cost_cents(Modality::TextToVideo, Some(8), None, Some(true)) + ); + } + + fn cost_cents( + modality: Modality, + duration_seconds: Option, + resolution: Option, + generate_audio: Option, + ) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + generate_audio, + ..Default::default() + }; + match modality { + Modality::TextToVideo => {} + Modality::ImageToVideo => { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + Modality::FirstLastFrame => { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string())); + } + Modality::Reference => { + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])); + } + Modality::Extend => { + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/in.mp4".to_string()])); + } + } + let state = build_fal_veo_3p1_state(b).expect("build state"); + FalVeo3p1CostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs new file mode 100644 index 0000000000..c2b43ffbf7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1/request.rs @@ -0,0 +1,186 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoRequest; +use fal_client::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoRequest; +use fal_client::requests::api::video::images::veo_3p1::api::Veo3p1FirstLastFrameToVideoRequest; +use fal_client::requests::api::video::reference::veo_3p1::api::Veo3p1ReferenceToVideoRequest; +use fal_client::requests::api::video::text::veo_3p1::api::Veo3p1TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalVeo3p1Mode { + TextToVideo(Veo3p1TextToVideoRequest), + ImageToVideo(Veo3p1ImageToVideoRequest), + FirstLastFrameToVideo(Veo3p1FirstLastFrameToVideoRequest), + ReferenceToVideo(Veo3p1ReferenceToVideoRequest), + ExtendVideo(Veo3p1ExtendVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalVeo3p1RequestState { + pub mode: FalVeo3p1Mode, +} + +impl FalVeo3p1RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalVeo3p1Mode::TextToVideo(request) => send_request(request, client).await, + FalVeo3p1Mode::ImageToVideo(request) => send_request(request, client).await, + FalVeo3p1Mode::FirstLastFrameToVideo(request) => send_request(request, client).await, + FalVeo3p1Mode::ReferenceToVideo(request) => send_request(request, client).await, + FalVeo3p1Mode::ExtendVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_text_to_video_720p_4s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a quiet meadow with wildflowers".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(4), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_image_to_video_8s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the dog leaps into the lake.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(8), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_first_last_frame() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a smooth transition between scenes".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + end_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(6), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_reference_to_video_4s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the dog runs across a sunlit meadow".to_string()), + reference_images: Some(ImageListRef::Urls(vec![JUNO_AT_LAKE_IMAGE_URL.to_string()])), + duration_seconds: Some(4), + generate_audio: Some(false), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_extend_video_7s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the scene continues naturally, keeping the same motion and style".to_string()), + reference_videos: Some(VideoListRef::Urls(vec![ANGRY_SHIBA_VIDEO_URL.to_string()])), + duration_seconds: Some(7), + generate_audio: Some(false), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + fn builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + async fn run_pipeline(b: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_fal_client(); + let dor = b.build2().expect("build2"); + let req = match dor { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request"), + }; + req.send_request(&client).await.expect("send") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/build.rs new file mode 100644 index 0000000000..38296cfa7e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/build.rs @@ -0,0 +1,746 @@ +use fal_client::requests::api::video::extend::veo_3p1_fast::api::{ + Veo3p1FastExtendVideoAspectRatio, Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest, + Veo3p1FastExtendVideoResolution, +}; +use fal_client::requests::api::video::image::veo_3p1_fast::api::{ + Veo3p1FastImageToVideoAspectRatio, Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest, + Veo3p1FastImageToVideoResolution, +}; +use fal_client::requests::api::video::images::veo_3p1_fast::api::{ + Veo3p1FastFirstLastFrameToVideoAspectRatio, Veo3p1FastFirstLastFrameToVideoDuration, + Veo3p1FastFirstLastFrameToVideoRequest, Veo3p1FastFirstLastFrameToVideoResolution, +}; +use fal_client::requests::api::video::reference::veo_3p1_fast::api::{ + Veo3p1FastReferenceToVideoAspectRatio, Veo3p1FastReferenceToVideoDuration, + Veo3p1FastReferenceToVideoRequest, Veo3p1FastReferenceToVideoResolution, +}; +use fal_client::requests::api::video::text::veo_3p1_fast::api::{ + Veo3p1FastTextToVideoAspectRatio, Veo3p1FastTextToVideoDuration, Veo3p1FastTextToVideoRequest, + Veo3p1FastTextToVideoResolution, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::veo_3p1_fast::request::{ + FalVeo3p1FastMode, FalVeo3p1FastRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Four, + Six, + Eight, +} + +pub fn build_fal_veo_3p1_fast( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_veo_3p1_fast_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(state))) +} + +pub(crate) fn build_fal_veo_3p1_fast_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let start = optional_url(builder.start_frame.clone())?; + let end = optional_url(builder.end_frame.clone())?; + let reference_images = reference_image_urls(builder.reference_images.clone())?; + let reference_videos = reference_video_urls(builder.reference_videos.clone())?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let generate_audio = builder.generate_audio; + + // Modality dispatch, most specific inputs first: + // 1. reference videos → extend-video + // 2. reference images → reference-to-video + // 3. start + end frames → first-last-frame-to-video + // 4. start frame only → image-to-video + // 5. no media → text-to-video + let mode = if let Some(video_urls) = reference_videos { + if reference_images.is_some() { + return Err(unsupported( + "reference_images", + "Veo 3.1 Fast extend-video cannot combine reference_images with a reference video", + )); + } + if start.is_some() { + return Err(unsupported( + "start_frame", + "Veo 3.1 Fast extend-video cannot combine start_frame with a reference video", + )); + } + if end.is_some() { + return Err(unsupported( + "end_frame", + "Veo 3.1 Fast extend-video cannot combine end_frame with a reference video", + )); + } + if video_urls.len() != 1 { + return Err(unsupported( + "reference_videos", + &format!("Veo 3.1 Fast extend-video requires exactly 1 reference video, got {}", video_urls.len()), + )); + } + let video_url = video_urls.into_iter().next().expect("checked len == 1"); + FalVeo3p1FastMode::ExtendVideo(Veo3p1FastExtendVideoRequest { + prompt, + video_url, + aspect_ratio: aspect_ratio.map(to_extend_aspect_ratio), + duration: plan_extend_duration(builder.duration_seconds, strategy)?, + resolution: resolution.map(|r| to_extend_resolution(r, strategy)).transpose()?, + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }) + } else if let Some(image_urls) = reference_images { + if start.is_some() { + return Err(unsupported( + "start_frame", + "Veo 3.1 Fast reference-to-video cannot combine start_frame with reference_images", + )); + } + if end.is_some() { + return Err(unsupported( + "end_frame", + "Veo 3.1 Fast reference-to-video cannot combine end_frame with reference_images", + )); + } + // NB: reference-to-video has no negative_prompt or seed fields on fal's + // schema — those builder inputs are dropped silently. + FalVeo3p1FastMode::ReferenceToVideo(Veo3p1FastReferenceToVideoRequest { + prompt, + image_urls, + aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio), + duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration), + resolution: resolution.map(to_reference_resolution), + generate_audio, + auto_fix: None, + safety_tolerance: None, + }) + } else { + let duration = plan_duration(builder.duration_seconds, strategy)?; + match (start, end) { + (None, None) => FalVeo3p1FastMode::TextToVideo(Veo3p1FastTextToVideoRequest { + prompt, + aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio), + duration: duration.map(to_t2v_duration), + resolution: resolution.map(to_t2v_resolution), + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + (Some(image_url), None) => FalVeo3p1FastMode::ImageToVideo(Veo3p1FastImageToVideoRequest { + prompt, + image_url, + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + duration: duration.map(to_i2v_duration), + resolution: resolution.map(to_i2v_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + (Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1FastMode::FirstLastFrameToVideo( + Veo3p1FastFirstLastFrameToVideoRequest { + prompt, + first_frame_url, + last_frame_url, + aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio), + duration: duration.map(to_flf_duration), + resolution: resolution.map(to_flf_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }, + ), + (None, Some(_)) => { + return Err(unsupported( + "end_frame", + "Veo 3.1 Fast requires a start_frame when end_frame is provided", + )); + } + } + }; + + Ok(FalVeo3p1FastRequestState { mode }) +} + +fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn reference_image_urls(refs: Option) -> Result>, ArtcraftRouterError> { + match refs { + None => Ok(None), + Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None), + Some(ImageListRef::Urls(urls)) => Ok(Some(urls)), + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn reference_video_urls(refs: Option) -> Result>, ArtcraftRouterError> { + match refs { + None => Ok(None), + Some(VideoListRef::Urls(urls)) if urls.is_empty() => Ok(None), + Some(VideoListRef::Urls(urls)) => Ok(Some(urls)), + Some(VideoListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None), + Some(VideoListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanResolution as R; + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)), + Some(RouterResolution::FourK) => Ok(Some(R::FourK)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", &format!("{:?}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(PlanDuration::Four)), + Some(6) => Ok(Some(PlanDuration::Six)), + Some(8) => Ok(Some(PlanDuration::Eight)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + // Nearest supported duration above/below, clamped to the 4s–8s range. + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other { + 0..=4 => PlanDuration::Four, + 5..=6 => PlanDuration::Six, + _ => PlanDuration::Eight, + })), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other { + 0..=5 => PlanDuration::Four, + 6..=7 => PlanDuration::Six, + _ => PlanDuration::Eight, + })), + }, + } +} + +/// Extend-video also supports fal's 7s default in addition to 4/6/8. +fn plan_extend_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use Veo3p1FastExtendVideoDuration as D; + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(D::FourSeconds)), + Some(6) => Ok(Some(D::SixSeconds)), + Some(7) => Ok(Some(D::SevenSeconds)), + Some(8) => Ok(Some(D::EightSeconds)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds })) + } + }, + } +} + +fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +// The text-to-video endpoint has no `auto` aspect ratio; omit the field and +// let fal apply its own default (16:9). +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option { + match a { + PlanAspectRatio::Auto => None, + PlanAspectRatio::SixteenByNine => Some(Veo3p1FastTextToVideoAspectRatio::SixteenByNine), + PlanAspectRatio::NineBySixteen => Some(Veo3p1FastTextToVideoAspectRatio::NineBySixteen), + } +} + +fn to_t2v_duration(d: PlanDuration) -> Veo3p1FastTextToVideoDuration { + match d { + PlanDuration::Four => Veo3p1FastTextToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1FastTextToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1FastTextToVideoDuration::EightSeconds, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> Veo3p1FastTextToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1FastTextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1FastTextToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1FastTextToVideoResolution::FourK, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastImageToVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1FastImageToVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1FastImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1FastImageToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Veo3p1FastImageToVideoDuration { + match d { + PlanDuration::Four => Veo3p1FastImageToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1FastImageToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1FastImageToVideoDuration::EightSeconds, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> Veo3p1FastImageToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1FastImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1FastImageToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1FastImageToVideoResolution::FourK, + } +} + +fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastFirstLastFrameToVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1FastFirstLastFrameToVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1FastFirstLastFrameToVideoAspectRatio::NineBySixteen, + } +} + +fn to_flf_duration(d: PlanDuration) -> Veo3p1FastFirstLastFrameToVideoDuration { + match d { + PlanDuration::Four => Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds, + } +} + +fn to_flf_resolution(r: PlanResolution) -> Veo3p1FastFirstLastFrameToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1FastFirstLastFrameToVideoResolution::FourK, + } +} + +// The reference-to-video endpoint has no `auto` aspect ratio; omit the field +// and let fal apply its own default (16:9). +fn to_reference_aspect_ratio(a: PlanAspectRatio) -> Option { + match a { + PlanAspectRatio::Auto => None, + PlanAspectRatio::SixteenByNine => Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine), + PlanAspectRatio::NineBySixteen => Some(Veo3p1FastReferenceToVideoAspectRatio::NineBySixteen), + } +} + +fn to_reference_duration(d: PlanDuration) -> Veo3p1FastReferenceToVideoDuration { + match d { + PlanDuration::Four => Veo3p1FastReferenceToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1FastReferenceToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1FastReferenceToVideoDuration::EightSeconds, + } +} + +fn to_reference_resolution(r: PlanResolution) -> Veo3p1FastReferenceToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1FastReferenceToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1FastReferenceToVideoResolution::TenEightyP, + PlanResolution::FourK => Veo3p1FastReferenceToVideoResolution::FourK, + } +} + +fn to_extend_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastExtendVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1FastExtendVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1FastExtendVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1FastExtendVideoAspectRatio::NineBySixteen, + } +} + +// Extend-video has no 4k tier; the nearest supported resolution in either +// direction is 1080p. +fn to_extend_resolution( + r: PlanResolution, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match r { + PlanResolution::SevenTwentyP => Ok(Veo3p1FastExtendVideoResolution::SevenTwentyP), + PlanResolution::TenEightyP => Ok(Veo3p1FastExtendVideoResolution::TenEightyP), + PlanResolution::FourK => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", "Veo 3.1 Fast extend-video does not support 4k")) + } + _ => Ok(Veo3p1FastExtendVideoResolution::TenEightyP), + }, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + const START_FRAME_URL: &str = "https://example.com/a.png"; + const END_FRAME_URL: &str = "https://example.com/b.png"; + const REFERENCE_IMAGE_URL: &str = "https://example.com/ref.png"; + const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4"; + + mod mode_selection { + use super::*; + + #[test] + fn no_frames_picks_t2v() { + assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1FastMode::TextToVideo(_))); + } + + #[test] + fn start_only_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_picks_flf() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::FirstLastFrameToVideo(_))); + } + + #[test] + fn end_only_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn reference_images_pick_reference_to_video() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()])); + assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ReferenceToVideo(_))); + } + + #[test] + fn reference_videos_pick_extend_video() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ExtendVideo(_))); + } + + #[test] + fn public_build_fn_wraps_request_enum() { + match build_fal_veo_3p1_fast(base_builder()).expect("build") { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(s)) => { + assert!(matches!(s.mode, FalVeo3p1FastMode::TextToVideo(_))); + } + _ => panic!("expected Request(FalVeo3p1Fast)"), + } + } + } + + mod extend_video { + use super::*; + + #[test] + fn extend_maps_video_url_and_7s_duration() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.duration_seconds = Some(7); + match unwrap_mode(b) { + FalVeo3p1FastMode::ExtendVideo(r) => { + assert_eq!(r.video_url, REFERENCE_VIDEO_URL); + assert!(matches!(r.duration, Some(Veo3p1FastExtendVideoDuration::SevenSeconds))); + } + _ => panic!("expected extend"), + } + } + + #[test] + fn extend_requires_exactly_one_video() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![ + REFERENCE_VIDEO_URL.to_string(), + "https://example.com/other.mp4".to_string(), + ])); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn extend_rejects_start_frame() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn extend_rejects_end_frame() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn extend_rejects_reference_images() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()])); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn extend_4k_errors_with_error_out() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn extend_4k_maps_to_1080p_with_mitigation() { + for strategy in [ + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = strategy; + match unwrap_mode(b) { + FalVeo3p1FastMode::ExtendVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1FastExtendVideoResolution::TenEightyP))); + } + _ => panic!("expected extend"), + } + } + } + } + + mod reference_to_video { + use super::*; + + #[test] + fn reference_maps_image_urls() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()])); + match unwrap_mode(b) { + FalVeo3p1FastMode::ReferenceToVideo(r) => { + assert_eq!(r.image_urls, vec![REFERENCE_IMAGE_URL]); + } + _ => panic!("expected reference"), + } + } + + #[test] + fn reference_rejects_start_frame() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()])); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + + #[test] + fn reference_rejects_end_frame() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()])); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + } + + mod duration_conversions { + use super::*; + + #[test] + fn duration_4s() { + let mut b = base_builder(); + b.duration_seconds = Some(4); + match unwrap_mode(b) { + FalVeo3p1FastMode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1FastTextToVideoDuration::FourSeconds))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_3p1_fast_state(b).is_err()); + } + } + + mod resolution_conversions { + use super::*; + + #[test] + fn t2v_720p() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::SevenTwentyP); + match unwrap_mode(b) { + FalVeo3p1FastMode::TextToVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1FastTextToVideoResolution::SevenTwentyP))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn t2v_4k_maps_to_four_k() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourK); + match unwrap_mode(b) { + FalVeo3p1FastMode::TextToVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1FastTextToVideoResolution::FourK))); + } + _ => panic!("expected t2v"), + } + } + } + + #[test] + fn full_combinatorial_pass() { + let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), Some(RouterResolution::FourK)]; + let durations = [None, Some(4u16), Some(6), Some(8)]; + let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &resolution in &resolutions { + for &duration in &durations { + for &aspect_ratio in &aspect_ratios { + for &generate_audio in &audios { + for frames in [0, 1, 2] { + let mut b = base_builder(); + b.resolution = resolution; + b.duration_seconds = duration; + b.aspect_ratio = aspect_ratio; + b.generate_audio = generate_audio; + if frames >= 1 { + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + } + if frames == 2 { + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + } + assert!(build_fal_veo_3p1_fast_state(b).is_ok()); + combos += 1; + } + } + } + } + } + assert_eq!(combos, 4 * 4 * 4 * 3 * 3); + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Fast, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1FastMode { + build_fal_veo_3p1_fast_state(b).expect("build").mode + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..34e56b288b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/cost.rs @@ -0,0 +1,241 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::veo_3p1_fast::request::{ + FalVeo3p1FastMode, FalVeo3p1FastRequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalVeo3p1FastCostState { + pub cost_in_usd_cents: u64, +} + +impl FalVeo3p1FastCostState { + pub fn from_request(request: &FalVeo3p1FastRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalVeo3p1FastMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1FastMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1FastMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1FastMode::ReferenceToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1FastMode::ExtendVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast_state; + + use super::*; + + // Veo 3.1 Fast pricing: + // 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on) + // 4k: $0.30/sec (audio off), $0.35/sec (audio on) + // Extend-video has no 4k tier and defaults to 7s. + + #[derive(Clone, Copy)] + enum Modality { + TextToVideo, + ImageToVideo, + FirstLastFrame, + Reference, + Extend, + } + + mod text_to_video { + use super::*; + + #[test] + fn t2v_4s_audio_on_is_60() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(true)), 60); + } + + #[test] + fn t2v_6s_audio_on_is_90() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(6), None, Some(true)), 90); + } + + #[test] + fn t2v_8s_audio_on_is_120() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), None, Some(true)), 120); + } + + #[test] + fn t2v_4s_audio_off_is_40() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(false)), 40); + } + + #[test] + fn t2v_8s_audio_off_is_80() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), None, Some(false)), 80); + } + + #[test] + fn t2v_8s_audio_on_4k_is_280() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(true)), 280); + } + + #[test] + fn t2v_8s_audio_off_4k_is_240() { + assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(false)), 240); + } + + #[test] + fn t2v_defaults_are_8s_720p_audio_on_120() { + // duration=None→8s, resolution=None→720p, audio=None→on. + assert_eq!(cost_cents(Modality::TextToVideo, None, None, None), 120); + } + + #[test] + fn t2v_1080p_bills_same_as_720p() { + assert_eq!( + cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true)), + cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)), + ); + } + } + + mod image_to_video { + use super::*; + + #[test] + fn i2v_6s_audio_on_is_90() { + assert_eq!(cost_cents(Modality::ImageToVideo, Some(6), None, Some(true)), 90); + } + + #[test] + fn i2v_8s_audio_off_1080p_is_80() { + assert_eq!(cost_cents(Modality::ImageToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(false)), 80); + } + } + + mod first_last_frame_to_video { + use super::*; + + #[test] + fn flf_6s_audio_on_is_90() { + assert_eq!(cost_cents(Modality::FirstLastFrame, Some(6), None, Some(true)), 90); + } + + #[test] + fn flf_8s_audio_on_4k_is_280() { + assert_eq!(cost_cents(Modality::FirstLastFrame, Some(8), Some(RouterResolution::FourK), Some(true)), 280); + } + } + + mod reference_to_video { + use super::*; + + #[test] + fn reference_8s_audio_on_1080p_is_120() { + assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::TenEightyP), Some(true)), 120); + } + + #[test] + fn reference_4s_audio_off_720p_is_40() { + assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 40); + } + } + + mod extend_video { + use super::*; + + #[test] + fn extend_7s_audio_on_is_105() { + assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(true)), 105); + } + + #[test] + fn extend_7s_audio_off_is_70() { + assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(false)), 70); + } + + #[test] + fn extend_8s_audio_on_is_120() { + assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 120); + } + + #[test] + fn extend_defaults_are_7s_audio_on_105() { + // duration=None→7s (fal's extend default), audio=None→on. + assert_eq!(cost_cents(Modality::Extend, None, None, None), 105); + } + } + + #[test] + fn t2v_i2v_flf_and_reference_price_identically() { + let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true)); + for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] { + assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected); + } + } + + #[test] + fn audio_costs_more_than_no_audio() { + assert!( + cost_cents(Modality::TextToVideo, Some(8), None, Some(false)) + < cost_cents(Modality::TextToVideo, Some(8), None, Some(true)) + ); + } + + fn cost_cents( + modality: Modality, + duration_seconds: Option, + resolution: Option, + generate_audio: Option, + ) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Fast, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + generate_audio, + ..Default::default() + }; + match modality { + Modality::TextToVideo => {} + Modality::ImageToVideo => { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + Modality::FirstLastFrame => { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string())); + } + Modality::Reference => { + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])); + } + Modality::Extend => { + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/in.mp4".to_string()])); + } + } + let state = build_fal_veo_3p1_fast_state(b).expect("build state"); + FalVeo3p1FastCostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs new file mode 100644 index 0000000000..0b9c4c528e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_fast/request.rs @@ -0,0 +1,171 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoRequest; +use fal_client::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoRequest; +use fal_client::requests::api::video::images::veo_3p1_fast::api::Veo3p1FastFirstLastFrameToVideoRequest; +use fal_client::requests::api::video::reference::veo_3p1_fast::api::Veo3p1FastReferenceToVideoRequest; +use fal_client::requests::api::video::text::veo_3p1_fast::api::Veo3p1FastTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalVeo3p1FastMode { + TextToVideo(Veo3p1FastTextToVideoRequest), + ImageToVideo(Veo3p1FastImageToVideoRequest), + FirstLastFrameToVideo(Veo3p1FastFirstLastFrameToVideoRequest), + ReferenceToVideo(Veo3p1FastReferenceToVideoRequest), + ExtendVideo(Veo3p1FastExtendVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalVeo3p1FastRequestState { + pub mode: FalVeo3p1FastMode, +} + +impl FalVeo3p1FastRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalVeo3p1FastMode::TextToVideo(request) => send_request(request, client).await, + FalVeo3p1FastMode::ImageToVideo(request) => send_request(request, client).await, + FalVeo3p1FastMode::FirstLastFrameToVideo(request) => send_request(request, client).await, + FalVeo3p1FastMode::ReferenceToVideo(request) => send_request(request, client).await, + FalVeo3p1FastMode::ExtendVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::test_helpers::get_fal_client; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_text_to_video_4s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("a candle flickering in the dark".to_string()), + duration_seconds: Some(4), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_image_to_video_6s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the dog leaps into the lake.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(6), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_reference_to_video_4s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the dog runs across a sunlit meadow".to_string()), + reference_images: Some(ImageListRef::Urls(vec![JUNO_AT_LAKE_IMAGE_URL.to_string()])), + duration_seconds: Some(4), + generate_audio: Some(false), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_extend_video_7s() { + let r = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("the scene continues naturally, keeping the same motion and style".to_string()), + reference_videos: Some(VideoListRef::Urls(vec![ANGRY_SHIBA_VIDEO_URL.to_string()])), + duration_seconds: Some(7), + generate_audio: Some(false), + ..builder() + }).await; + assert!(matches!(r, GenerateVideoResponse::Fal(_))); + } + + fn builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Fast, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + async fn run_pipeline(b: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_fal_client(); + let dor = b.build2().expect("build2"); + let req = match dor { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request"), + }; + req.send_request(&client).await.expect("send") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/build.rs new file mode 100644 index 0000000000..8ee711eef7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/build.rs @@ -0,0 +1,548 @@ +use fal_client::requests::api::video::image::veo_3p1_lite::api::{ + Veo3p1LiteImageToVideoAspectRatio, Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest, + Veo3p1LiteImageToVideoResolution, +}; +use fal_client::requests::api::video::images::veo_3p1_lite::api::{ + Veo3p1LiteFirstLastFrameToVideoAspectRatio, Veo3p1LiteFirstLastFrameToVideoDuration, + Veo3p1LiteFirstLastFrameToVideoRequest, Veo3p1LiteFirstLastFrameToVideoResolution, +}; +use fal_client::requests::api::video::text::veo_3p1_lite::api::{ + Veo3p1LiteTextToVideoAspectRatio, Veo3p1LiteTextToVideoDuration, Veo3p1LiteTextToVideoRequest, + Veo3p1LiteTextToVideoResolution, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::{ + FalVeo3p1LiteMode, FalVeo3p1LiteRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanDuration { + Four, + Six, + Eight, +} + +pub fn build_fal_veo_3p1_lite( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_veo_3p1_lite_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Lite(state))) +} + +pub(crate) fn build_fal_veo_3p1_lite_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Lite has no reference-to-video or extend-video endpoints on fal. + if has_reference_videos(&builder.reference_videos) { + return Err(unsupported( + "reference_videos", + "Veo 3.1 Lite does not support extend-video (reference videos)", + )); + } + if has_reference_images(&builder.reference_images) { + return Err(unsupported( + "reference_images", + "Veo 3.1 Lite does not support reference-to-video (reference images)", + )); + } + + let start = optional_url(builder.start_frame.clone())?; + let end = optional_url(builder.end_frame.clone())?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let negative_prompt = builder.negative_prompt.clone(); + let generate_audio = builder.generate_audio; + + let mode = match (start, end) { + (None, None) => FalVeo3p1LiteMode::TextToVideo(Veo3p1LiteTextToVideoRequest { + prompt, + aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio), + duration: duration.map(to_t2v_duration), + resolution: resolution.map(to_t2v_resolution), + negative_prompt, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + (Some(image_url), None) => FalVeo3p1LiteMode::ImageToVideo(Veo3p1LiteImageToVideoRequest { + prompt, + image_url, + aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio), + duration: duration.map(to_i2v_duration), + resolution: resolution.map(to_i2v_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }), + (Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1LiteMode::FirstLastFrameToVideo( + Veo3p1LiteFirstLastFrameToVideoRequest { + prompt, + first_frame_url, + last_frame_url, + aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio), + duration: duration.map(to_flf_duration), + resolution: resolution.map(to_flf_resolution), + generate_audio, + negative_prompt, + seed: None, + auto_fix: None, + safety_tolerance: None, + }, + ), + (None, Some(_)) => { + return Err(unsupported( + "end_frame", + "Veo 3.1 Lite requires a start_frame when end_frame is provided", + )); + } + }; + + Ok(FalVeo3p1LiteRequestState { mode }) +} + +fn optional_url(image_ref: Option) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(url)), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn has_reference_images(refs: &Option) -> bool { + match refs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +fn has_reference_videos(refs: &Option) -> bool { + match refs { + None => false, + Some(VideoListRef::Urls(urls)) => !urls.is_empty(), + Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", other))) + } + _ => Ok(Some(Ar::Auto)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanResolution as R; + match resolution { + None => Ok(None), + Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)), + // Lite has no 4k tier; the nearest supported resolution in either + // direction is 1080p. + Some(RouterResolution::FourK) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", "Veo 3.1 Lite does not support 4k")) + } + _ => Ok(Some(R::TenEightyP)), + }, + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("resolution", &format!("{:?}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::SevenTwentyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(4) => Ok(Some(PlanDuration::Four)), + Some(6) => Ok(Some(PlanDuration::Six)), + Some(8) => Ok(Some(PlanDuration::Eight)), + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("duration_seconds", &format!("{}", other))) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other { + 0..=4 => PlanDuration::Four, + 5..=6 => PlanDuration::Six, + _ => PlanDuration::Eight, + })), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other { + 0..=5 => PlanDuration::Four, + 6..=7 => PlanDuration::Six, + _ => PlanDuration::Eight, + })), + }, + } +} + +fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: value.to_string(), + }) +} + +// The Lite text-to-video endpoint has no `auto` aspect ratio; omit the field +// and let fal apply its own default (16:9). +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option { + match a { + PlanAspectRatio::Auto => None, + PlanAspectRatio::SixteenByNine => Some(Veo3p1LiteTextToVideoAspectRatio::SixteenByNine), + PlanAspectRatio::NineBySixteen => Some(Veo3p1LiteTextToVideoAspectRatio::NineBySixteen), + } +} + +fn to_t2v_duration(d: PlanDuration) -> Veo3p1LiteTextToVideoDuration { + match d { + PlanDuration::Four => Veo3p1LiteTextToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1LiteTextToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1LiteTextToVideoDuration::EightSeconds, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> Veo3p1LiteTextToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1LiteTextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1LiteTextToVideoResolution::TenEightyP, + } +} + +fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1LiteImageToVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1LiteImageToVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1LiteImageToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1LiteImageToVideoAspectRatio::NineBySixteen, + } +} + +fn to_i2v_duration(d: PlanDuration) -> Veo3p1LiteImageToVideoDuration { + match d { + PlanDuration::Four => Veo3p1LiteImageToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1LiteImageToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1LiteImageToVideoDuration::EightSeconds, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> Veo3p1LiteImageToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1LiteImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1LiteImageToVideoResolution::TenEightyP, + } +} + +fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1LiteFirstLastFrameToVideoAspectRatio { + match a { + PlanAspectRatio::Auto => Veo3p1LiteFirstLastFrameToVideoAspectRatio::Auto, + PlanAspectRatio::SixteenByNine => Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => Veo3p1LiteFirstLastFrameToVideoAspectRatio::NineBySixteen, + } +} + +fn to_flf_duration(d: PlanDuration) -> Veo3p1LiteFirstLastFrameToVideoDuration { + match d { + PlanDuration::Four => Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds, + PlanDuration::Six => Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds, + PlanDuration::Eight => Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds, + } +} + +fn to_flf_resolution(r: PlanResolution) -> Veo3p1LiteFirstLastFrameToVideoResolution { + match r { + PlanResolution::SevenTwentyP => Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + const START_FRAME_URL: &str = "https://example.com/a.png"; + const END_FRAME_URL: &str = "https://example.com/b.png"; + const REFERENCE_IMAGE_URL: &str = "https://example.com/ref.png"; + const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4"; + + mod mode_selection { + use super::*; + + #[test] + fn no_frames_picks_t2v() { + assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1LiteMode::TextToVideo(_))); + } + + #[test] + fn start_only_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_picks_first_last_frame() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::FirstLastFrameToVideo(_))); + } + + #[test] + fn end_only_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + assert!(build_fal_veo_3p1_lite_state(b).is_err()); + } + } + + mod unsupported_modalities { + use super::*; + + #[test] + fn reference_images_are_rejected() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()])); + assert!(build_fal_veo_3p1_lite_state(b).is_err()); + } + + #[test] + fn reference_videos_are_rejected() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()])); + assert!(build_fal_veo_3p1_lite_state(b).is_err()); + } + + #[test] + fn empty_reference_lists_are_ignored() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec![])); + b.reference_videos = Some(VideoListRef::Urls(vec![])); + assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::TextToVideo(_))); + } + } + + mod duration_conversions { + use super::*; + + #[test] + fn duration_4s() { + let mut b = base_builder(); + b.duration_seconds = Some(4); + match unwrap_mode(b) { + FalVeo3p1LiteMode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::FourSeconds))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn unsupported_duration_errors_with_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_3p1_lite_state(b).is_err()); + } + + #[test] + fn duration_5s_pay_more_upgrades_to_6() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade; + match unwrap_mode(b) { + FalVeo3p1LiteMode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::SixSeconds))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn duration_5s_pay_less_downgrades_to_4() { + let mut b = base_builder(); + b.duration_seconds = Some(5); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + match unwrap_mode(b) { + FalVeo3p1LiteMode::TextToVideo(r) => { + assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::FourSeconds))); + } + _ => panic!("expected t2v"), + } + } + } + + mod resolution_conversions { + use super::*; + + #[test] + fn t2v_720p() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::SevenTwentyP); + match unwrap_mode(b) { + FalVeo3p1LiteMode::TextToVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP))); + } + _ => panic!("expected t2v"), + } + } + + #[test] + fn four_k_errors_with_error_out() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_veo_3p1_lite_state(b).is_err()); + } + + #[test] + fn four_k_maps_to_1080p_with_mitigation() { + for strategy in [ + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = strategy; + match unwrap_mode(b) { + FalVeo3p1LiteMode::TextToVideo(r) => { + assert!(matches!(r.resolution, Some(Veo3p1LiteTextToVideoResolution::TenEightyP))); + } + _ => panic!("expected t2v"), + } + } + } + } + + #[test] + fn t2v_auto_aspect_ratio_is_omitted() { + // Lite t2v has no `auto` wire value; Auto must map to None. + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + match unwrap_mode(b) { + FalVeo3p1LiteMode::TextToVideo(r) => assert!(r.aspect_ratio.is_none()), + _ => panic!("expected t2v"), + } + } + + #[test] + fn i2v_auto_aspect_ratio_maps_to_auto() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + match unwrap_mode(b) { + FalVeo3p1LiteMode::ImageToVideo(r) => { + assert!(matches!(r.aspect_ratio, Some(Veo3p1LiteImageToVideoAspectRatio::Auto))); + } + _ => panic!("expected i2v"), + } + } + + #[test] + fn full_combinatorial_pass() { + let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)]; + let durations = [None, Some(4u16), Some(6), Some(8)]; + let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)]; + let audios = [None, Some(true), Some(false)]; + + let mut combos = 0; + for &resolution in &resolutions { + for &duration in &durations { + for &aspect_ratio in &aspect_ratios { + for &generate_audio in &audios { + for frames in [0, 1, 2] { + let mut b = base_builder(); + b.resolution = resolution; + b.duration_seconds = duration; + b.aspect_ratio = aspect_ratio; + b.generate_audio = generate_audio; + if frames >= 1 { + b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string())); + } + if frames == 2 { + b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string())); + } + assert!(build_fal_veo_3p1_lite_state(b).is_ok()); + combos += 1; + } + } + } + } + } + assert_eq!(combos, 3 * 4 * 4 * 3 * 3); + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Lite, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1LiteMode { + build_fal_veo_3p1_lite_state(b).expect("build").mode + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/cost.rs new file mode 100644 index 0000000000..c6f09adffb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/cost.rs @@ -0,0 +1,174 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::{ + FalVeo3p1LiteMode, FalVeo3p1LiteRequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalVeo3p1LiteCostState { + pub cost_in_usd_cents: u64, +} + +impl FalVeo3p1LiteCostState { + pub fn from_request(request: &FalVeo3p1LiteRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalVeo3p1LiteMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1LiteMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalVeo3p1LiteMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite_state; + + use super::*; + + // Veo 3.1 Lite pricing (both resolution and audio move the rate; no 4k): + // 720p: $0.03/sec (audio off), $0.05/sec (audio on) + // 1080p: $0.05/sec (audio off), $0.08/sec (audio on) + + mod text_to_video { + use super::*; + + #[test] + fn t2v_8s_audio_on_720p_is_40() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0), 40); + } + + #[test] + fn t2v_8s_audio_off_720p_is_24() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 0), 24); + } + + #[test] + fn t2v_8s_audio_on_1080p_is_64() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0), 64); + } + + #[test] + fn t2v_8s_audio_off_1080p_is_40() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false), 0), 40); + } + + #[test] + fn t2v_4s_audio_on_720p_is_20() { + assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(true), 0), 20); + } + + #[test] + fn t2v_6s_audio_on_720p_is_30() { + assert_eq!(cost_cents(Some(6), Some(RouterResolution::SevenTwentyP), Some(true), 0), 30); + } + + #[test] + fn t2v_defaults_are_8s_720p_audio_on_40() { + // duration=None→8s, resolution=None→720p, audio=None→on. + assert_eq!(cost_cents(None, None, None, 0), 40); + } + } + + mod image_to_video { + use super::*; + + #[test] + fn i2v_8s_audio_off_720p_is_24() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 1), 24); + } + + #[test] + fn i2v_6s_audio_on_720p_is_30() { + assert_eq!(cost_cents(Some(6), Some(RouterResolution::SevenTwentyP), Some(true), 1), 30); + } + } + + mod first_last_frame_to_video { + use super::*; + + #[test] + fn flf_8s_audio_on_1080p_is_64() { + assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 2), 64); + } + + #[test] + fn flf_4s_audio_off_720p_is_12() { + assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(false), 2), 12); + } + } + + #[test] + fn all_three_modes_price_identically() { + let t2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 0); + let i2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 1); + let flf = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 2); + assert_eq!(t2v, i2v); + assert_eq!(i2v, flf); + } + + #[test] + fn audio_costs_more_than_no_audio() { + assert!( + cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 0) + < cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0) + ); + } + + #[test] + fn ten_eighty_costs_more_than_seven_twenty() { + assert!( + cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0) + < cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0) + ); + } + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + generate_audio: Option, + frames: u8, + ) -> u64 { + let mut b = GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Lite, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + generate_audio, + ..Default::default() + }; + if frames >= 1 { + b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string())); + } + if frames == 2 { + b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string())); + } + let state = build_fal_veo_3p1_lite_state(b).expect("build state"); + FalVeo3p1LiteCostState::from_request(&state) + .estimate_cost() + .cost_in_usd_cents + .expect("cost") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/request.rs new file mode 100644 index 0000000000..cafc6d8cea --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/veo_3p1_lite/request.rs @@ -0,0 +1,166 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoRequest; +use fal_client::requests::api::video::images::veo_3p1_lite::api::Veo3p1LiteFirstLastFrameToVideoRequest; +use fal_client::requests::api::video::text::veo_3p1_lite::api::Veo3p1LiteTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalVeo3p1LiteMode { + TextToVideo(Veo3p1LiteTextToVideoRequest), + ImageToVideo(Veo3p1LiteImageToVideoRequest), + FirstLastFrameToVideo(Veo3p1LiteFirstLastFrameToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalVeo3p1LiteRequestState { + pub mode: FalVeo3p1LiteMode, +} + +impl FalVeo3p1LiteRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalVeo3p1LiteMode::TextToVideo(request) => send_request(request, client).await, + FalVeo3p1LiteMode::ImageToVideo(request) => send_request(request, client).await, + FalVeo3p1LiteMode::FirstLastFrameToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite_state; + + use super::*; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_text_to_video_4s() { + let state = build_state(GenerateVideoRequestBuilder { + prompt: Some("a candle flickering in the dark".to_string()), + duration_seconds: Some(4), + generate_audio: Some(false), + ..builder() + }); + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_image_to_video_4s() { + let state = build_state(GenerateVideoRequestBuilder { + prompt: Some("the dog leaps into the lake.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(4), + generate_audio: Some(false), + ..builder() + }); + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn live_first_last_frame_4s() { + let state = build_state(GenerateVideoRequestBuilder { + prompt: Some("a smooth transition between scenes".to_string()), + start_frame: Some(ImageRef::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string())), + end_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + duration_seconds: Some(4), + generate_audio: Some(false), + ..builder() + }); + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + fn builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Veo3p1Lite, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn build_state(b: GenerateVideoRequestBuilder) -> FalVeo3p1LiteRequestState { + build_fal_veo_3p1_lite_state(b).expect("build state") + } + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/build.rs new file mode 100644 index 0000000000..97d6f9759f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/build.rs @@ -0,0 +1,514 @@ +use fal_client::requests::api::video::image::vidu_q3::api::{ + ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution, +}; +use fal_client::requests::api::video::reference::vidu_q3_reference_to_video::api::{ + ViduQ3ReferenceToVideoAspectRatio, ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution, +}; +use fal_client::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{ + ViduQ3ReferenceToVideoMixAspectRatio, ViduQ3ReferenceToVideoMixRequest, + ViduQ3ReferenceToVideoMixResolution, +}; +use fal_client::requests::api::video::text::vidu_q3::api::{ + ViduQ3TextToVideoAspectRatio, ViduQ3TextToVideoRequest, ViduQ3TextToVideoResolution, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::vidu_q3::request::{ + FalViduQ3Mode, FalViduQ3RequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +const MAX_REFERENCE_IMAGES: usize = 4; +const MIN_DURATION_SECONDS: u16 = 1; +const MAX_DURATION_SECONDS: u16 = 16; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + SixteenByNine, + NineBySixteen, + FourByThree, + ThreeByFour, + Square, +} + +/// Vidu also offers 360p, but no `RouterResolution` maps to it (fal bills 360p +/// and 540p identically, so 540p is always the better pick). +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +pub fn build_fal_vidu_q3( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_vidu_q3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalViduQ3(state))) +} + +pub(crate) fn build_fal_vidu_q3_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + reject_reference_videos(&builder.reference_videos)?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let audio = builder.generate_audio; + let reference_image_urls = resolve_reference_image_urls(builder.reference_images.clone())?; + let start_image_url = optional_url(builder.start_frame.clone())?; + let end_image_url = optional_url(builder.end_frame.clone())?; + + // Dispatch: reference_images → reference-to-video; start_frame → image; else → text. + let mode = if !reference_image_urls.is_empty() { + if start_image_url.is_some() || end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Vidu Q3 reference-to-video cannot also accept a start_frame or end_frame".to_string(), + })); + } + if reference_image_urls.len() > MAX_REFERENCE_IMAGES { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!( + "Vidu Q3 supports at most {} reference images, got {}", + MAX_REFERENCE_IMAGES, + reference_image_urls.len(), + ), + })); + } + // The plain reference endpoint keeps a single subject consistent; the /mix + // variant blends multiple subjects, so 2+ references route there. + if reference_image_urls.len() == 1 { + FalViduQ3Mode::ReferenceToVideo(ViduQ3ReferenceToVideoRequest { + prompt, + reference_image_urls, + duration, + seed: None, + aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio), + resolution: resolution.map(to_reference_resolution), + audio, + }) + } else { + FalViduQ3Mode::ReferenceToVideoMix(ViduQ3ReferenceToVideoMixRequest { + prompt, + reference_image_urls, + duration, + seed: None, + aspect_ratio: aspect_ratio.map(to_mix_aspect_ratio), + resolution: resolution.map(to_mix_resolution), + audio, + }) + } + } else if let Some(image_url) = start_image_url { + let i2v_resolution = resolution.map(to_i2v_resolution); + check_i2v_end_frame_resolution(i2v_resolution, end_image_url.is_some())?; + // Image-to-video has no aspect_ratio input — the output follows the start + // frame — so any requested aspect ratio is silently dropped here. + FalViduQ3Mode::ImageToVideo(ViduQ3ImageToVideoRequest { + prompt, + image_url, + end_image_url, + duration, + seed: None, + resolution: i2v_resolution, + audio, + }) + } else { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Vidu Q3 requires a start_frame when end_frame is provided".to_string(), + })); + } + FalViduQ3Mode::TextToVideo(ViduQ3TextToVideoRequest { + prompt, + duration, + seed: None, + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + resolution: resolution.map(to_t2v_resolution), + audio, + }) + }; + + Ok(FalViduQ3RequestState { mode }) +} + +// ── Input helpers ── + +fn reject_reference_videos( + reference_videos: &Option, +) -> Result<(), ArtcraftRouterError> { + let has_reference_videos = match reference_videos { + None => false, + Some(VideoListRef::Urls(urls)) => !urls.is_empty(), + Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_reference_videos { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_videos", + value: "Vidu Q3 does not support video references".to_string(), + })); + } + Ok(()) +} + +fn resolve_reference_image_urls( + reference_images: Option, +) -> Result, ArtcraftRouterError> { + match reference_images { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +/// fal rejects 360p image-to-video requests that include an end frame. +/// `plan_resolution` can't currently produce 360p, but guard anyway so a +/// future mapping change can't silently ship the rejected combination. +fn check_i2v_end_frame_resolution( + resolution: Option, + has_end_frame: bool, +) -> Result<(), ArtcraftRouterError> { + if has_end_frame && matches!(resolution, Some(ViduQ3ImageToVideoResolution::ThreeSixtyP)) { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: "Vidu Q3 image-to-video does not support 360p when an end_frame is provided".to_string(), + })); + } + Ok(()) +} + +// ── Plan helpers ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + // Vidu doesn't offer 480p; 540p is the nearest supported size (and bills + // at the same per-second rate as 360p, so nothing cheaper is lost). + Some(RouterResolution::FourEightyP) => Ok(Some(PlanResolution::FiveFortyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::FiveFortyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => { + Ok(Some(seconds as u8)) + } + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8)) + } + }, + } +} + +// ── Leaf converters ── + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> ViduQ3TextToVideoAspectRatio { + match a { + PlanAspectRatio::SixteenByNine => ViduQ3TextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => ViduQ3TextToVideoAspectRatio::NineBySixteen, + PlanAspectRatio::FourByThree => ViduQ3TextToVideoAspectRatio::FourByThree, + PlanAspectRatio::ThreeByFour => ViduQ3TextToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::Square => ViduQ3TextToVideoAspectRatio::Square, + } +} + +fn to_reference_aspect_ratio(a: PlanAspectRatio) -> ViduQ3ReferenceToVideoAspectRatio { + match a { + PlanAspectRatio::SixteenByNine => ViduQ3ReferenceToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => ViduQ3ReferenceToVideoAspectRatio::NineBySixteen, + PlanAspectRatio::FourByThree => ViduQ3ReferenceToVideoAspectRatio::FourByThree, + PlanAspectRatio::ThreeByFour => ViduQ3ReferenceToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::Square => ViduQ3ReferenceToVideoAspectRatio::Square, + } +} + +fn to_mix_aspect_ratio(a: PlanAspectRatio) -> ViduQ3ReferenceToVideoMixAspectRatio { + match a { + PlanAspectRatio::SixteenByNine => ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => ViduQ3ReferenceToVideoMixAspectRatio::NineBySixteen, + PlanAspectRatio::FourByThree => ViduQ3ReferenceToVideoMixAspectRatio::FourByThree, + PlanAspectRatio::ThreeByFour => ViduQ3ReferenceToVideoMixAspectRatio::ThreeByFour, + PlanAspectRatio::Square => ViduQ3ReferenceToVideoMixAspectRatio::Square, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> ViduQ3TextToVideoResolution { + match r { + PlanResolution::FiveFortyP => ViduQ3TextToVideoResolution::FiveFortyP, + PlanResolution::SevenTwentyP => ViduQ3TextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => ViduQ3TextToVideoResolution::TenEightyP, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> ViduQ3ImageToVideoResolution { + match r { + PlanResolution::FiveFortyP => ViduQ3ImageToVideoResolution::FiveFortyP, + PlanResolution::SevenTwentyP => ViduQ3ImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => ViduQ3ImageToVideoResolution::TenEightyP, + } +} + +fn to_reference_resolution(r: PlanResolution) -> ViduQ3ReferenceToVideoResolution { + match r { + PlanResolution::FiveFortyP => ViduQ3ReferenceToVideoResolution::FiveFortyP, + PlanResolution::SevenTwentyP => ViduQ3ReferenceToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => ViduQ3ReferenceToVideoResolution::TenEightyP, + } +} + +fn to_mix_resolution(r: PlanResolution) -> ViduQ3ReferenceToVideoMixResolution { + match r { + PlanResolution::FiveFortyP => ViduQ3ReferenceToVideoMixResolution::FiveFortyP, + PlanResolution::SevenTwentyP => ViduQ3ReferenceToVideoMixResolution::SevenTwentyP, + PlanResolution::TenEightyP => ViduQ3ReferenceToVideoMixResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + const START_URL: &str = "https://example.com/start.png"; + const END_URL: &str = "https://example.com/end.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn no_inputs_picks_t2v() { + let state = build_fal_vidu_q3_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalViduQ3Mode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + let state = build_fal_vidu_q3_state(b).expect("build"); + assert!(matches!(state.mode, FalViduQ3Mode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_frame_picks_i2v_with_end_image_url() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + let state = build_fal_vidu_q3_state(b).expect("build"); + let FalViduQ3Mode::ImageToVideo(req) = state.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.image_url, START_URL); + assert_eq!(req.end_image_url.as_deref(), Some(END_URL)); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + assert!(build_fal_vidu_q3_state(b).is_err()); + } + + #[test] + fn one_reference_image_picks_reference_to_video() { + let state = build_fal_vidu_q3_state(builder_with_references(1)).expect("build"); + assert!(matches!(state.mode, FalViduQ3Mode::ReferenceToVideo(_))); + } + + #[test] + fn three_reference_images_pick_reference_to_video_mix() { + let state = build_fal_vidu_q3_state(builder_with_references(3)).expect("build"); + let FalViduQ3Mode::ReferenceToVideoMix(req) = state.mode else { + panic!("expected ReferenceToVideoMix"); + }; + assert_eq!(req.reference_image_urls.len(), 3); + } + + #[test] + fn five_reference_images_error() { + assert!(build_fal_vidu_q3_state(builder_with_references(5)).is_err()); + } + + #[test] + fn reference_images_with_start_frame_error() { + let mut b = builder_with_references(2); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + assert!(build_fal_vidu_q3_state(b).is_err()); + } + + #[test] + fn reference_videos_error() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()])); + assert!(build_fal_vidu_q3_state(b).is_err()); + } + } + + mod plan_tests { + use super::*; + + #[test] + fn duration_20_errors_under_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_vidu_q3_state(b).is_err()); + } + + #[test] + fn duration_20_clamps_to_16_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_vidu_q3_state(b).expect("build"); + let FalViduQ3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(16)); + } + + #[test] + fn resolution_480p_maps_to_540p() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourEightyP); + let state = build_fal_vidu_q3_state(b).expect("build"); + let FalViduQ3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(ViduQ3TextToVideoResolution::FiveFortyP)); + } + + #[test] + fn i2v_aspect_ratio_is_silently_dropped() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + // Image-to-video has no aspect_ratio field; the request still builds. + let state = build_fal_vidu_q3_state(b).expect("build"); + assert!(matches!(state.mode, FalViduQ3Mode::ImageToVideo(_))); + } + + #[test] + fn i2v_360p_with_end_frame_is_rejected_by_guard() { + // 360p is unreachable from RouterResolution today, so exercise the + // guard directly against the fal-level resolution enum. + let result = check_i2v_end_frame_resolution( + Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), + true, + ); + assert!(result.is_err()); + assert!(check_i2v_end_frame_resolution(Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), false).is_ok()); + assert!(check_i2v_end_frame_resolution(Some(ViduQ3ImageToVideoResolution::SevenTwentyP), true).is_ok()); + } + + #[test] + fn generate_audio_propagates() { + let mut b = base_builder(); + b.generate_audio = Some(false); + let state = build_fal_vidu_q3_state(b).expect("build"); + let FalViduQ3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.audio, Some(false)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn builder_with_references(count: usize) -> GenerateVideoRequestBuilder { + let urls = (0..count) + .map(|i| format!("https://example.com/ref-{}.png", i)) + .collect(); + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(urls)); + b + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/cost.rs new file mode 100644 index 0000000000..3d22cc29a8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/cost.rs @@ -0,0 +1,141 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::vidu_q3::request::{ + FalViduQ3Mode, FalViduQ3RequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalViduQ3CostState { + pub cost_in_usd_cents: u64, +} + +impl FalViduQ3CostState { + pub fn from_request(request: &FalViduQ3RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalViduQ3Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalViduQ3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalViduQ3Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(), + FalViduQ3Mode::ReferenceToVideoMix(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3_state; + + use super::*; + + // Pricing (from fal_client's vidu_q3 cost modules): + // 360p/540p: $0.07/sec → 5s = 35¢ + // 720p/1080p: $0.154/sec → 5s = 77¢, 10s = 154¢ (ceil to whole cents) + // fal defaults when unset: duration = 5s, resolution = 720p. + // All four modes (text/image/reference/mix) bill identically. + // + // NB: `build2()` isn't wired up for Vidu Q3 yet, so these tests go through + // `build_fal_vidu_q3_state()` directly. + + #[test] + fn t2v_default_settings_is_77() { + assert_eq!(cost_cents(base_builder(None, None)), 77); + } + + #[test] + fn t2v_5s_720p_is_77() { + assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 77); + } + + #[test] + fn t2v_5s_540p_is_35() { + // RouterResolution::FourEightyP maps to Vidu's 540p. + assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::FourEightyP))), 35); + } + + #[test] + fn t2v_10s_1080p_is_154() { + assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 154); + } + + #[test] + fn t2v_3s_720p_rounds_up_to_47() { + // 3 × 15.4¢ = 46.2¢ → 47¢. + assert_eq!(cost_cents(base_builder(Some(3), Some(RouterResolution::SevenTwentyP))), 47); + } + + #[test] + fn i2v_5s_720p_is_77() { + let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(cost_cents(b), 77); + } + + #[test] + fn reference_5s_540p_is_35() { + let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP)); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref-0.png".to_string(), + ])); + assert_eq!(cost_cents(b), 35); + } + + #[test] + fn reference_mix_10s_1080p_is_154() { + let mut b = base_builder(Some(10), Some(RouterResolution::TenEightyP)); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref-0.png".to_string(), + "https://example.com/ref-1.png".to_string(), + "https://example.com/ref-2.png".to_string(), + ])); + assert_eq!(cost_cents(b), 154); + } + + #[test] + fn estimate_cost_forwards_cents() { + let estimate = FalViduQ3CostState { cost_in_usd_cents: 77 }.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(77)); + assert_eq!(estimate.cost_in_credits, Some(77)); + assert!(!estimate.is_free); + } + + fn base_builder( + duration_seconds: Option, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + } + } + + fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 { + let state = build_fal_vidu_q3_state(builder).expect("build"); + FalViduQ3CostState::from_request(&state).cost_in_usd_cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/request.rs new file mode 100644 index 0000000000..4833e4bb92 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3/request.rs @@ -0,0 +1,146 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::vidu_q3::api::ViduQ3ImageToVideoRequest; +use fal_client::requests::api::video::reference::vidu_q3_reference_to_video::api::ViduQ3ReferenceToVideoRequest; +use fal_client::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::ViduQ3ReferenceToVideoMixRequest; +use fal_client::requests::api::video::text::vidu_q3::api::ViduQ3TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalViduQ3Mode { + TextToVideo(ViduQ3TextToVideoRequest), + ImageToVideo(ViduQ3ImageToVideoRequest), + ReferenceToVideo(ViduQ3ReferenceToVideoRequest), + ReferenceToVideoMix(ViduQ3ReferenceToVideoMixRequest), +} + +#[derive(Clone, Debug)] +pub struct FalViduQ3RequestState { + pub mode: FalViduQ3Mode, +} + +impl FalViduQ3RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalViduQ3Mode::TextToVideo(request) => send_request(request, client).await, + FalViduQ3Mode::ImageToVideo(request) => send_request(request, client).await, + FalViduQ3Mode::ReferenceToVideo(request) => send_request(request, client).await, + FalViduQ3Mode::ReferenceToVideoMix(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::vidu_q3::api::ViduQ3ImageToVideoResolution; + use fal_client::requests::api::video::text::vidu_q3::api::{ + ViduQ3TextToVideoAspectRatio, ViduQ3TextToVideoResolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalViduQ3RequestState { + mode: FalViduQ3Mode::TextToVideo(ViduQ3TextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + duration: Some(5), + seed: None, + aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3TextToVideoResolution::SevenTwentyP), + audio: Some(false), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalViduQ3RequestState { + mode: FalViduQ3Mode::ImageToVideo(ViduQ3ImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + seed: None, + resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP), + audio: Some(false), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/build.rs new file mode 100644 index 0000000000..b69c459b58 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/build.rs @@ -0,0 +1,407 @@ +use fal_client::requests::api::video::image::vidu_q3_turbo::api::{ + ViduQ3TurboImageToVideoRequest, ViduQ3TurboImageToVideoResolution, +}; +use fal_client::requests::api::video::text::vidu_q3_turbo::api::{ + ViduQ3TurboTextToVideoAspectRatio, ViduQ3TurboTextToVideoRequest, ViduQ3TurboTextToVideoResolution, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::{ + FalViduQ3TurboMode, FalViduQ3TurboRequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +const MIN_DURATION_SECONDS: u16 = 1; +const MAX_DURATION_SECONDS: u16 = 16; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + SixteenByNine, + NineBySixteen, + FourByThree, + ThreeByFour, + Square, +} + +/// Vidu also offers 360p, but no `RouterResolution` maps to it (fal bills 360p +/// and 540p identically, so 540p is always the better pick). +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanResolution { + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +pub fn build_fal_vidu_q3_turbo( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_vidu_q3_turbo_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalViduQ3Turbo(state))) +} + +pub(crate) fn build_fal_vidu_q3_turbo_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + reject_reference_videos(&builder.reference_videos)?; + reject_reference_images(&builder.reference_images)?; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let audio = builder.generate_audio; + let start_image_url = optional_url(builder.start_frame.clone())?; + let end_image_url = optional_url(builder.end_frame.clone())?; + + // Dispatch: start_frame → image; else → text. Turbo has no reference-to-video + // endpoint (that's the non-turbo Vidu Q3 model). + let mode = if let Some(image_url) = start_image_url { + let i2v_resolution = resolution.map(to_i2v_resolution); + check_i2v_end_frame_resolution(i2v_resolution, end_image_url.is_some())?; + // Image-to-video has no aspect_ratio input — the output follows the start + // frame — so any requested aspect ratio is silently dropped here. + FalViduQ3TurboMode::ImageToVideo(ViduQ3TurboImageToVideoRequest { + prompt, + image_url, + end_image_url, + duration, + seed: None, + resolution: i2v_resolution, + audio, + }) + } else { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Vidu Q3 Turbo requires a start_frame when end_frame is provided".to_string(), + })); + } + FalViduQ3TurboMode::TextToVideo(ViduQ3TurboTextToVideoRequest { + prompt, + duration, + seed: None, + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + resolution: resolution.map(to_t2v_resolution), + audio, + }) + }; + + Ok(FalViduQ3TurboRequestState { mode }) +} + +// ── Input helpers ── + +fn reject_reference_videos( + reference_videos: &Option, +) -> Result<(), ArtcraftRouterError> { + let has_reference_videos = match reference_videos { + None => false, + Some(VideoListRef::Urls(urls)) => !urls.is_empty(), + Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_reference_videos { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_videos", + value: "Vidu Q3 Turbo does not support video references".to_string(), + })); + } + Ok(()) +} + +fn reject_reference_images( + reference_images: &Option, +) -> Result<(), ArtcraftRouterError> { + let has_reference_images = match reference_images { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + }; + if has_reference_images { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Vidu Q3 Turbo does not support reference images (use Vidu Q3 for reference-to-video)".to_string(), + })); + } + Ok(()) +} + +/// fal rejects 360p image-to-video requests that include an end frame. +/// `plan_resolution` can't currently produce 360p, but guard anyway so a +/// future mapping change can't silently ship the rejected combination. +fn check_i2v_end_frame_resolution( + resolution: Option, + has_end_frame: bool, +) -> Result<(), ArtcraftRouterError> { + if has_end_frame && matches!(resolution, Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP)) { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: "Vidu Q3 Turbo image-to-video does not support 360p when an end_frame is provided".to_string(), + })); + } + Ok(()) +} + +// ── Plan helpers ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None => Ok(None), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + + Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + // Vidu doesn't offer 480p; 540p is the nearest supported size (and bills + // at the same per-second rate as 360p, so nothing cheaper is lost). + Some(RouterResolution::FourEightyP) => Ok(Some(PlanResolution::FiveFortyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::FiveFortyP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => { + Ok(Some(seconds as u8)) + } + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8)) + } + }, + } +} + +// ── Leaf converters ── + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> ViduQ3TurboTextToVideoAspectRatio { + match a { + PlanAspectRatio::SixteenByNine => ViduQ3TurboTextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::NineBySixteen => ViduQ3TurboTextToVideoAspectRatio::NineBySixteen, + PlanAspectRatio::FourByThree => ViduQ3TurboTextToVideoAspectRatio::FourByThree, + PlanAspectRatio::ThreeByFour => ViduQ3TurboTextToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::Square => ViduQ3TurboTextToVideoAspectRatio::Square, + } +} + +fn to_t2v_resolution(r: PlanResolution) -> ViduQ3TurboTextToVideoResolution { + match r { + PlanResolution::FiveFortyP => ViduQ3TurboTextToVideoResolution::FiveFortyP, + PlanResolution::SevenTwentyP => ViduQ3TurboTextToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => ViduQ3TurboTextToVideoResolution::TenEightyP, + } +} + +fn to_i2v_resolution(r: PlanResolution) -> ViduQ3TurboImageToVideoResolution { + match r { + PlanResolution::FiveFortyP => ViduQ3TurboImageToVideoResolution::FiveFortyP, + PlanResolution::SevenTwentyP => ViduQ3TurboImageToVideoResolution::SevenTwentyP, + PlanResolution::TenEightyP => ViduQ3TurboImageToVideoResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + const START_URL: &str = "https://example.com/start.png"; + const END_URL: &str = "https://example.com/end.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn no_inputs_picks_t2v() { + let state = build_fal_vidu_q3_turbo_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalViduQ3TurboMode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + let state = build_fal_vidu_q3_turbo_state(b).expect("build"); + assert!(matches!(state.mode, FalViduQ3TurboMode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_frame_picks_i2v_with_end_image_url() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + let state = build_fal_vidu_q3_turbo_state(b).expect("build"); + let FalViduQ3TurboMode::ImageToVideo(req) = state.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.image_url, START_URL); + assert_eq!(req.end_image_url.as_deref(), Some(END_URL)); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + assert!(build_fal_vidu_q3_turbo_state(b).is_err()); + } + + #[test] + fn reference_images_error() { + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])); + assert!(build_fal_vidu_q3_turbo_state(b).is_err()); + } + + #[test] + fn reference_videos_error() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()])); + assert!(build_fal_vidu_q3_turbo_state(b).is_err()); + } + } + + mod plan_tests { + use super::*; + + #[test] + fn duration_20_errors_under_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_vidu_q3_turbo_state(b).is_err()); + } + + #[test] + fn duration_20_clamps_to_16_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_vidu_q3_turbo_state(b).expect("build"); + let FalViduQ3TurboMode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(16)); + } + + #[test] + fn resolution_480p_maps_to_540p() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourEightyP); + let state = build_fal_vidu_q3_turbo_state(b).expect("build"); + let FalViduQ3TurboMode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(ViduQ3TurboTextToVideoResolution::FiveFortyP)); + } + + #[test] + fn i2v_aspect_ratio_is_silently_dropped() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + // Image-to-video has no aspect_ratio field; the request still builds. + let state = build_fal_vidu_q3_turbo_state(b).expect("build"); + assert!(matches!(state.mode, FalViduQ3TurboMode::ImageToVideo(_))); + } + + #[test] + fn i2v_360p_with_end_frame_is_rejected_by_guard() { + // 360p is unreachable from RouterResolution today, so exercise the + // guard directly against the fal-level resolution enum. + let result = check_i2v_end_frame_resolution( + Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), + true, + ); + assert!(result.is_err()); + assert!(check_i2v_end_frame_resolution(Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), false).is_ok()); + assert!(check_i2v_end_frame_resolution(Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), true).is_ok()); + } + + #[test] + fn generate_audio_propagates() { + let mut b = base_builder(); + b.generate_audio = Some(false); + let state = build_fal_vidu_q3_turbo_state(b).expect("build"); + let FalViduQ3TurboMode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.audio, Some(false)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3Turbo, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/cost.rs new file mode 100644 index 0000000000..a9d85326d7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/cost.rs @@ -0,0 +1,126 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::{ + FalViduQ3TurboMode, FalViduQ3TurboRequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalViduQ3TurboCostState { + pub cost_in_usd_cents: u64, +} + +impl FalViduQ3TurboCostState { + pub fn from_request(request: &FalViduQ3TurboRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalViduQ3TurboMode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalViduQ3TurboMode::ImageToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo_state; + + use super::*; + + // Pricing (from fal_client's vidu_q3_turbo cost modules; half the Vidu Q3 rate): + // 360p/540p: $0.035/sec → 5s = 17.5¢ → 18¢ + // 720p/1080p: $0.077/sec → 5s = 38.5¢ → 39¢, 10s = 77¢ (ceil to whole cents) + // fal defaults when unset: duration = 5s, resolution = 720p. + // Both modes (text/image) bill identically. + // + // NB: `build2()` isn't wired up for Vidu Q3 Turbo yet, so these tests go + // through `build_fal_vidu_q3_turbo_state()` directly. + + #[test] + fn t2v_default_settings_is_39() { + assert_eq!(cost_cents(base_builder(None, None)), 39); + } + + #[test] + fn t2v_5s_720p_rounds_up_to_39() { + // 5 × 7.7¢ = 38.5¢ → 39¢. + assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 39); + } + + #[test] + fn t2v_5s_540p_rounds_up_to_18() { + // RouterResolution::FourEightyP maps to Vidu's 540p; 5 × 3.5¢ = 17.5¢ → 18¢. + assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::FourEightyP))), 18); + } + + #[test] + fn t2v_10s_1080p_is_77() { + assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 77); + } + + #[test] + fn t2v_3s_720p_rounds_up_to_24() { + // 3 × 7.7¢ = 23.1¢ → 24¢. + assert_eq!(cost_cents(base_builder(Some(3), Some(RouterResolution::SevenTwentyP))), 24); + } + + #[test] + fn i2v_5s_720p_is_39() { + let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(cost_cents(b), 39); + } + + #[test] + fn i2v_10s_540p_is_35() { + let mut b = base_builder(Some(10), Some(RouterResolution::FourEightyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(cost_cents(b), 35); + } + + #[test] + fn estimate_cost_forwards_cents() { + let estimate = FalViduQ3TurboCostState { cost_in_usd_cents: 39 }.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(39)); + assert_eq!(estimate.cost_in_credits, Some(39)); + assert!(!estimate.is_free); + } + + fn base_builder( + duration_seconds: Option, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::ViduQ3Turbo, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + } + } + + fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 { + let state = build_fal_vidu_q3_turbo_state(builder).expect("build"); + FalViduQ3TurboCostState::from_request(&state).cost_in_usd_cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/request.rs new file mode 100644 index 0000000000..c250d946d6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/vidu_q3_turbo/request.rs @@ -0,0 +1,140 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::vidu_q3_turbo::api::ViduQ3TurboImageToVideoRequest; +use fal_client::requests::api::video::text::vidu_q3_turbo::api::ViduQ3TurboTextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalViduQ3TurboMode { + TextToVideo(ViduQ3TurboTextToVideoRequest), + ImageToVideo(ViduQ3TurboImageToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalViduQ3TurboRequestState { + pub mode: FalViduQ3TurboMode, +} + +impl FalViduQ3TurboRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalViduQ3TurboMode::TextToVideo(request) => send_request(request, client).await, + FalViduQ3TurboMode::ImageToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::image::vidu_q3_turbo::api::ViduQ3TurboImageToVideoResolution; + use fal_client::requests::api::video::text::vidu_q3_turbo::api::{ + ViduQ3TurboTextToVideoAspectRatio, ViduQ3TurboTextToVideoResolution, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalViduQ3TurboRequestState { + mode: FalViduQ3TurboMode::TextToVideo(ViduQ3TurboTextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + duration: Some(5), + seed: None, + aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), + audio: Some(false), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalViduQ3TurboRequestState { + mode: FalViduQ3TurboMode::ImageToVideo(ViduQ3TurboImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + seed: None, + resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), + audio: Some(false), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/first_party/minimax_h3_ultra_cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/first_party/minimax_h3_ultra_cost.rs new file mode 100644 index 0000000000..ce9f8ffb01 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/first_party/minimax_h3_ultra_cost.rs @@ -0,0 +1,81 @@ +//! Pricing for first-party Minimax H3 Ultra. +//! +//! TODO(2026-08-06): Placeholder rates copied from the existing Minimax H3 +//! pricing. Finalize once first-party GPU inference costs are known. + +use enums::common::generation::common_resolution::CommonResolution; + +/// Per-second rates in hundredths of a US cent. +const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_840; +const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 2_990; + +/// Minimax H3 defaults None → 5s. +const DEFAULT_DURATION_SECONDS: u64 = 5; + +/// Estimate the user-facing Minimax H3 Ultra cost in USD cents. +/// System credits = cents (same convention as the v2 pipeline). +pub fn estimate_minimax_h3_ultra_cost_usd_cents( + maybe_duration_seconds: Option, + maybe_resolution: Option, +) -> u64 { + let duration_seconds = maybe_duration_seconds + .map(u64::from) + .unwrap_or(DEFAULT_DURATION_SECONDS); + + let rate = if is_2k(maybe_resolution) { + HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC + } else { + LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC + }; + + // Round up to the next whole cent. + (rate * duration_seconds).div_ceil(100) +} + +/// Minimax H3 renders 768P or 2K; 1080p and above land on the 2K tier, while +/// 720p and below render at 768P. The default (unset) resolution is 2K. +fn is_2k(resolution: Option) -> bool { + match resolution { + None => true, // defaults to 2K + Some(CommonResolution::TenEightyP) + | Some(CommonResolution::OneK) + | Some(CommonResolution::TwoK) + | Some(CommonResolution::ThreeK) + | Some(CommonResolution::FourK) => true, + Some(CommonResolution::HalfK) + | Some(CommonResolution::FourEightyP) + | Some(CommonResolution::SevenTwentyP) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_resolution_5s_is_150() { + assert_eq!(estimate_minimax_h3_ultra_cost_usd_cents(None, None), 150); + } + + #[test] + fn low_res_5s_is_92() { + assert_eq!( + estimate_minimax_h3_ultra_cost_usd_cents(Some(5), Some(CommonResolution::SevenTwentyP)), + 92, + ); + } + + #[test] + fn high_res_10s_is_299() { + assert_eq!( + estimate_minimax_h3_ultra_cost_usd_cents(Some(10), Some(CommonResolution::TenEightyP)), + 299, + ); + } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents. + assert_eq!(estimate_minimax_h3_ultra_cost_usd_cents(Some(7), None), 210); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/first_party/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/first_party/mod.rs new file mode 100644 index 0000000000..ad6b7ce5b4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/first_party/mod.rs @@ -0,0 +1,4 @@ +//! Models fulfilled on first-party infrastructure; enqueued directly by +//! storyteller-web rather than routed. + +pub mod minimax_h3_ultra_cost; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/build.rs new file mode 100644 index 0000000000..c4d517c01f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/build.rs @@ -0,0 +1,553 @@ +use log::info; + +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + VideoGenerationRequest as GrokVideoGenerationRequest, + VideoImageSource as GrokVideoImageSource, +}; +use grok_api_client::api::types::video_types::video_aspect_ratio::VideoAspectRatio as GrokAspectRatio; +use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Builds a Grok Imagine Video request from the generic GenerateVideoRequestBuilder. +/// +/// xAI's grok-imagine-video accepts: +/// - `image` (single source image, image-to-video mode) OR `reference_images` (multi-image +/// reference-to-video). These two are mutually exclusive per xAI's API. +/// - Aspect ratio, resolution (480p/720p), duration (1–15s), prompt. +/// +/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`, `reference_audio`, +/// and `MediaFileToken`-style image refs) are silently dropped with an info-level log. +/// Same goes for the `start_frame` + `reference_images` conflict — we prefer +/// `start_frame` (image-to-video) and drop `reference_images`. This keeps the +/// pipeline tolerant when callers fan a single builder out to multiple providers +/// with different capabilities. +pub fn build_grok_api_grok_imagine_video( + mut builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Scalar fields. + let prompt = builder.prompt.take().unwrap_or_default(); + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy); + let resolution = plan_resolution(builder.resolution.take(), strategy); + let duration = builder.duration_seconds.take().map(|d| (d as u32).clamp(1, 15)); + + // Wholly-unsupported fields → drop with a log so it shows up in operator traces. + log_and_drop_end_frame(builder.end_frame.take()); + log_and_drop_reference_videos(builder.reference_videos.take()); + log_and_drop_reference_audio(builder.reference_audio.take()); + + // Image sources: prefer start_frame (image-to-video). If both start_frame and + // reference_images are supplied, drop reference_images (xAI rejects the + // combination on the wire). + let start_frame = resolve_url_to_image_source(builder.start_frame.take()); + let reference_images_supplied = builder.reference_images.take(); + + let (image, reference_images) = match (start_frame, reference_images_supplied) { + (Some(img), Some(refs)) => { + info!( + "grok_imagine_video: both start_frame and reference_images supplied — \ + preferring start_frame (image-to-video). Dropping {} reference images.", + count_image_list_ref(&refs), + ); + (Some(img), None) + } + (Some(img), None) => (Some(img), None), + (None, Some(refs)) => (None, resolve_url_list_to_image_sources(refs)), + (None, None) => (None, None), + }; + + let request = GrokVideoGenerationRequest { + prompt, + model: None, // defaults to grok-imagine-video in the client + image, + reference_images, + aspect_ratio, + duration, + resolution, + user: None, + }; + + let state = GrokApiGrokImagineVideoRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::GrokApiGrokImagineVideo(state), + )) +} + +// ── Field planners ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Option { + // xAI supports exactly: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3. + // Auto / unsupported ratios fall back to the closest match (or None → xAI default 16:9). + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => None, + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Some(GrokAspectRatio::Square) + } + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Some(GrokAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Some(GrokAspectRatio::Portrait9x16) + } + + Some(RouterAspectRatio::WideFourByThree) => Some(GrokAspectRatio::Landscape4x3), + Some(RouterAspectRatio::TallThreeByFour) => Some(GrokAspectRatio::Portrait3x4), + + Some(RouterAspectRatio::WideThreeByTwo) => Some(GrokAspectRatio::Landscape3x2), + Some(RouterAspectRatio::TallTwoByThree) => Some(GrokAspectRatio::Portrait2x3), + + // No exact xAI match — pick the closest cardinal direction. + Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideTwentyOneByNine) => Some(GrokAspectRatio::Landscape16x9), + Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallNineByTwentyOne) => Some(GrokAspectRatio::Portrait9x16), + } +} + +fn plan_resolution( + resolution: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Option { + // Grok supports 480p and 720p only (1080p is downsized to 720p per xAI docs). + match resolution { + None => None, + Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP), + // Higher-than-720p requests get clamped to 720p (Grok's cap). + Some(RouterResolution::TenEightyP) + | Some(RouterResolution::TwoK) + | Some(RouterResolution::ThreeK) + | Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP), + // Lower-than-480p requests get bumped to 480p (Grok's floor). + Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => { + Some(GrokResolution::FourEightyP) + } + } +} + +// ── Image source resolvers (silent drops for unsupported variants) ── + +fn resolve_url_to_image_source(image_ref: Option) -> Option { + match image_ref { + None => None, + Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)), + Some(ImageRef::MediaFileToken(token)) => { + info!( + "grok_imagine_video: dropping MediaFileToken {:?} for start_frame — \ + Grok only accepts public URLs. Resolve the token to a URL upstream if needed.", + token, + ); + None + } + } +} + +fn resolve_url_list_to_image_sources(list_ref: ImageListRef) -> Option> { + match list_ref { + ImageListRef::Urls(urls) if urls.is_empty() => None, + ImageListRef::Urls(urls) => Some(urls.into_iter().map(GrokVideoImageSource::Url).collect()), + ImageListRef::MediaFileTokens(tokens) => { + info!( + "grok_imagine_video: dropping {} MediaFileToken reference image(s) — \ + Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.", + tokens.len(), + ); + None + } + } +} + +fn count_image_list_ref(refs: &ImageListRef) -> usize { + match refs { + ImageListRef::Urls(v) => v.len(), + ImageListRef::MediaFileTokens(v) => v.len(), + } +} + +// ── Drop-with-log helpers for fields Grok doesn't accept at all ── + +fn log_and_drop_end_frame(end_frame: Option) { + if end_frame.is_some() { + info!("grok_imagine_video: dropping end_frame — Grok doesn't support end-frame keyframes."); + } +} + +fn log_and_drop_reference_videos(refs: Option) { + if let Some(refs) = refs { + let count = match refs { + VideoListRef::Urls(v) => v.len(), + VideoListRef::MediaFileTokens(v) => v.len(), + }; + info!( + "grok_imagine_video: dropping {} reference video(s) — Grok doesn't accept reference videos.", + count, + ); + } +} + +fn log_and_drop_reference_audio(refs: Option) { + if let Some(refs) = refs { + let count = match refs { + AudioListRef::Urls(v) => v.len(), + AudioListRef::MediaFileTokens(v) => v.len(), + }; + info!( + "grok_imagine_video: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.", + count, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::api::audio_list_ref::AudioListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + // ── Field passthrough ── + + mod field_conversions { + use super::*; + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); })); + assert_eq!(req.request.prompt, "test prompt"); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); })); + assert_eq!(req.request.duration, Some(8)); + } + + #[test] + fn duration_clamped_to_min() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(0); })); + assert_eq!(req.request.duration, Some(1)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration, Some(15)); + } + + #[test] + fn duration_none_stays_none() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; })); + assert_eq!(req.request.duration, None); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn square() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Square)); + } + + #[test] + fn landscape_16x9() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + } + + #[test] + fn portrait_9x16() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16)); + } + + #[test] + fn landscape_4x3() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape4x3)); + } + + #[test] + fn portrait_3x4() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallThreeByFour); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait3x4)); + } + + #[test] + fn landscape_3x2() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape3x2)); + } + + #[test] + fn portrait_2x3() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallTwoByThree); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait2x3)); + } + + #[test] + fn auto_maps_to_none() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); })); + assert_eq!(req.request.aspect_ratio, None); + } + + #[test] + fn unsupported_wide_falls_back_to_16x9() { + let req = unwrap_request(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); + })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + } + + #[test] + fn unsupported_tall_falls_back_to_9x16() { + let req = unwrap_request(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::TallNineByTwentyOne); + })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_1080p_clamps_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_4k_clamps_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_1k_bumps_to_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::OneK); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Image source plumbing (graceful — never errors) ── + + mod image_tests { + use super::*; + + #[test] + fn start_frame_url_becomes_image() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + })); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"), + other => panic!("expected Url variant, got {:?}", other), + } + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn start_frame_media_file_token_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))); + })); + // Dropped, request still builds successfully with no image. + assert!(req.request.image.is_none()); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn reference_image_urls_passed_through() { + let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(urls.clone())); + })); + let refs = req.request.reference_images.expect("reference_images should be set"); + assert_eq!(refs.len(), 2); + assert!(req.request.image.is_none()); + } + + #[test] + fn reference_image_tokens_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ])); + })); + // All tokens dropped; reference_images becomes None. + assert!(req.request.reference_images.is_none()); + assert!(req.request.image.is_none()); + } + + #[test] + fn start_frame_and_reference_images_together_prefers_start_frame() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref1.png".to_string(), + "https://example.com/ref2.png".to_string(), + ])); + })); + // start_frame wins; reference_images dropped (mutually exclusive on the wire). + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"), + other => panic!("expected start_frame URL, got {:?}", other), + } + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn empty_reference_image_url_list_becomes_none() { + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(vec![])); + })); + assert!(req.request.reference_images.is_none()); + } + } + + // ── Unsupported fields are silently dropped, not rejected ── + + mod unsupported_fields_graceful { + use super::*; + + #[test] + fn end_frame_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + })); + // Request still builds; Grok's request body has no end_frame field. + assert!(req.request.image.is_none()); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn reference_videos_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + })); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn reference_audio_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()])); + })); + // No audio field on Grok; just gone. + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn kitchen_sink_with_every_unsupported_field_still_succeeds() { + // Worst-case builder fanned out from a multi-provider context. Grok + // should keep what it can and drop the rest without erroring. + let req = unwrap_request(make_builder(|b| { + b.prompt = Some("kitchen sink".to_string()); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref.png".to_string(), + ])); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()])); + b.resolution = Some(RouterResolution::SevenTwentyP); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + b.duration_seconds = Some(8); + })); + // Kept: prompt + start_frame + resolution + aspect_ratio + duration. + assert_eq!(req.request.prompt, "kitchen sink"); + assert!(matches!( + req.request.image, + Some(GrokVideoImageSource::Url(_)) + )); + // Dropped because start_frame won the image-source contest. + assert!(req.request.reference_images.is_none()); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + assert_eq!(req.request.duration, Some(8)); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo, + provider: RouterProvider::GrokApi, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut b = base_builder(); + f(&mut b); + b + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideoRequestState { + let result = build_grok_api_grok_imagine_video(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GrokApiGrokImagineVideo(s)) => s, + _ => panic!("expected GrokApiGrokImagineVideo request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/cost.rs new file mode 100644 index 0000000000..79c9381988 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/cost.rs @@ -0,0 +1,188 @@ +use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState; + +pub struct GrokApiGrokImagineVideoCostState { + request: GrokApiGrokImagineVideoRequestState, +} + +impl GrokApiGrokImagineVideoCostState { + pub fn from_request(request: &GrokApiGrokImagineVideoRequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // grok_api_client's GrokRequestCostCalculator does the math: + // - Output: resolution × duration (50 mills/s @ 480p, 70 mills/s @ 720p) + // - Input: 2 mills × source image count + // No source-video duration to factor in for `video_generation` — it's + // text/image → video, not edit/extension. + let cost_in_usd_cents = self.request.request.calculate_cost_in_cents(); + + VideoGenerationCostEstimate { + cost_in_credits: None, + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // Pricing reference (from grok_api_client/cost.rs, mirrors xAI's table): + // Output: 480p 50 mills/s ($0.05/s) + // 720p 70 mills/s ($0.07/s) + // Input: 2 mills per source image ($0.002/img) + // + // Cost in cents = ceil(mills / 10). + + mod pricing_720p { + use super::*; + + #[test] + fn five_s_no_image_ref() { + // 70 × 5 = 350 mills → 35¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None), 35); + } + + #[test] + fn ten_s_no_image_ref() { + // 70 × 10 = 700 mills → 70¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None), 70); + } + + #[test] + fn fifteen_s_no_image_ref() { + // 70 × 15 = 1050 mills → 105¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None), 105); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn five_s_no_image_ref() { + // 50 × 5 = 250 mills → 25¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, None, None), 25); + } + + #[test] + fn ten_s_no_image_ref() { + // 50 × 10 = 500 mills → 50¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, None, None), 50); + } + + #[test] + fn fifteen_s_no_image_ref() { + // 50 × 15 = 750 mills → 75¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, None, None), 75); + } + } + + mod image_inputs_add_to_cost { + use super::*; + + #[test] + fn single_start_frame_image_adds_2_mills() { + // 5s @ 720p output 350 mills + 1 input image 2 mills = 352 mills → 36¢ (ceil) + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + Some(ImageRef::Url("https://example.com/a.png".to_string())), + None, + ); + assert_eq!(cents, 36); + } + + #[test] + fn three_reference_images_adds_6_mills() { + // 5s @ 720p output 350 mills + 3 input images 6 mills = 356 mills → 36¢ (ceil) + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + None, + Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ])), + ); + assert_eq!(cents, 36); + } + } + + mod relative_pricing { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})"); + } + + #[test] + fn longer_duration_costs_more() { + let c5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn one_k_request_clamps_to_480p_pricing() { + // OneK gets bumped to 480p in plan_resolution, so cost equals 480p cost. + let one_k = cost_cents(Some(RouterResolution::OneK), 10, None, None); + let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None); + assert_eq!(one_k, p480); + } + + #[test] + fn ten_eighty_p_request_clamps_to_720p_pricing() { + // TenEightyP gets clamped to 720p in plan_resolution. + let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + assert_eq!(p1080, p720); + } + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo, + provider: RouterProvider::GrokApi, + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(1), + start_frame, + reference_images, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/request.rs new file mode 100644 index 0000000000..7ed4d6c629 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video/request.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + video_generation, VideoGenerationArgs, VideoGenerationRequest as GrokVideoGenerationRequest, +}; + +use crate::client::router_grok_api_client::RouterGrokApiClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + GenerateVideoResponse, GrokVideoResponsePayload, +}; + +#[derive(Clone, Debug)] +pub struct GrokApiGrokImagineVideoRequestState { + /// The fully-resolved Grok request body. Doesn't carry the API key — that + /// gets borrowed at send time from the router client. + pub request: GrokVideoGenerationRequest, +} + +impl GrokApiGrokImagineVideoRequestState { + pub async fn send(&self, client: &RouterGrokApiClient) -> Result { + let outbound_request = Arc::new(self.request.clone()); + + let response = video_generation(VideoGenerationArgs { + api_key: &client.api_key, + request: self.request.clone(), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Grok(err)))?; + + Ok(GenerateVideoResponse::Grok(GrokVideoResponsePayload { + request_id: response.request_id, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +#[cfg(test)] +mod tests { + use grok_api_client::creds::grok_api_key::GrokApiKey; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_grok_api_client::RouterGrokApiClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A glowing crystal rocket launching from Mars.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog leaps into the lake and splashes around.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + resolution: Some(RouterResolution::FourEightyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + fn grok_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo, + provider: RouterProvider::GrokApi, + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_grok_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/grok_api_key.txt") + .expect("Failed to read Grok API key"); + let api_key = GrokApiKey::new(secret.trim().to_string()); + RouterClient::GrokApi(RouterGrokApiClient::new(api_key)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_grok_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Grok skips draft)"), + }; + let response = request.send_request(&client).await.expect("send_request should succeed"); + match &response { + GenerateVideoResponse::Grok(p) => println!("grok request_id={}", p.request_id), + other => println!("unexpected response: {:?}", other), + } + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/build.rs new file mode 100644 index 0000000000..e87ebf1230 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/build.rs @@ -0,0 +1,700 @@ +use log::info; + +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + VideoGenerationRequest as GrokVideoGenerationRequest, + VideoImageSource as GrokVideoImageSource, +}; +use grok_api_client::api::types::video_types::video_aspect_ratio::VideoAspectRatio as GrokAspectRatio; +use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel; +use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/// Builds a Grok Imagine Video 1.5 preview request from the generic +/// GenerateVideoRequestBuilder. Mirrors the v1 builder's capabilities and +/// graceful-drop policy (the 1.5 model accepts the same wire shape as v1); +/// the only material differences are the `model` identifier on the wire and +/// the steeper pricing in the cost calculator (see `cost.rs`). +/// +/// xAI's grok-imagine-video-1.5 accepts: +/// - `image` (single source image, image-to-video mode) OR `reference_images` +/// (multi-image reference-to-video). These two are mutually exclusive per +/// xAI's API. +/// - Aspect ratio, resolution (480p/720p/1080p), duration (1–15s), prompt. +/// +/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`, +/// `reference_audio`, and `MediaFileToken`-style image refs) are silently +/// dropped with an info-level log. +pub fn build_grok_api_grok_imagine_video_1p5( + mut builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Scalar fields. + let prompt = builder.prompt.take().unwrap_or_default(); + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy); + let resolution = plan_resolution(builder.resolution.take(), strategy); + let duration = builder.duration_seconds.take().map(|d| (d as u32).clamp(1, 15)); + + log_and_drop_end_frame(builder.end_frame.take()); + log_and_drop_reference_videos(builder.reference_videos.take()); + log_and_drop_reference_audio(builder.reference_audio.take()); + + let start_frame = resolve_url_to_image_source(builder.start_frame.take()); + let reference_images_supplied = builder.reference_images.take(); + + // xAI's v1.5 model explicitly does NOT accept `reference_images` (it only + // supports text-to-video and single-image-to-video). If the caller supplies + // reference images and no start_frame, promote the first reference image + // into `image` (image-to-video) and drop the rest. + let (image, reference_images) = match (start_frame, reference_images_supplied) { + (Some(img), Some(refs)) => { + info!( + "grok_imagine_video_1p5: both start_frame and reference_images supplied — \ + preferring start_frame (image-to-video). Dropping {} reference image(s).", + count_image_list_ref(&refs), + ); + (Some(img), None) + } + (Some(img), None) => (Some(img), None), + (None, Some(refs)) => promote_first_reference_to_image(refs), + (None, None) => (None, None), + }; + + // xAI's v1.5 model rejects text-to-video at the server. Bounce the request + // here rather than spending an API round-trip to learn the same thing. + if image.is_none() && reference_images.is_none() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(), + })); + } + + let request = GrokVideoGenerationRequest { + prompt, + // Pin to the 1.5 preview identifier so the grok_api_client cost + // calculator picks the v1.5 pricing tier (see VideoModel::pricing_tier). + model: Some(GrokVideoModel::GrokImagineVideo1p5), + image, + reference_images, + aspect_ratio, + duration, + resolution, + user: None, + }; + + let state = GrokApiGrokImagineVideo1p5RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::GrokApiGrokImagineVideo1p5(state), + )) +} + +// ── Field planners ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Option { + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => None, + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Some(GrokAspectRatio::Square) + } + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Some(GrokAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Some(GrokAspectRatio::Portrait9x16) + } + + Some(RouterAspectRatio::WideFourByThree) => Some(GrokAspectRatio::Landscape4x3), + Some(RouterAspectRatio::TallThreeByFour) => Some(GrokAspectRatio::Portrait3x4), + + Some(RouterAspectRatio::WideThreeByTwo) => Some(GrokAspectRatio::Landscape3x2), + Some(RouterAspectRatio::TallTwoByThree) => Some(GrokAspectRatio::Portrait2x3), + + Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideTwentyOneByNine) => Some(GrokAspectRatio::Landscape16x9), + Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallNineByTwentyOne) => Some(GrokAspectRatio::Portrait9x16), + } +} + +fn plan_resolution( + resolution: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Option { + match resolution { + None => None, + Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP) => Some(GrokResolution::TenEightyP), + // 2K/3K/4K aren't produced — cap at the model's max of 1080p. + Some(RouterResolution::TwoK) + | Some(RouterResolution::ThreeK) + | Some(RouterResolution::FourK) => Some(GrokResolution::TenEightyP), + Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => { + Some(GrokResolution::FourEightyP) + } + } +} + +// ── Image source resolvers (silent drops for unsupported variants) ── + +fn resolve_url_to_image_source(image_ref: Option) -> Option { + match image_ref { + None => None, + Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)), + Some(ImageRef::MediaFileToken(token)) => { + info!( + "grok_imagine_video_1p5: dropping MediaFileToken {:?} for start_frame — \ + Grok only accepts public URLs. Resolve the token to a URL upstream if needed.", + token, + ); + None + } + } +} + +/// xAI's v1.5 model rejects `reference_images` entirely. When a caller has +/// only supplied reference images (no start_frame), promote the first one +/// to `image` so they at least get image-to-video, and drop the remainder +/// with a log so it's visible in operator traces. +fn promote_first_reference_to_image( + list_ref: ImageListRef, +) -> (Option, Option>) { + match list_ref { + ImageListRef::Urls(urls) if urls.is_empty() => (None, None), + ImageListRef::Urls(mut urls) => { + let first_url = urls.remove(0); + let dropped = urls.len(); + if dropped > 0 { + info!( + "grok_imagine_video_1p5: v1.5 doesn't accept reference_images; promoting \ + the first reference image to start_frame (image-to-video). Dropping {} \ + additional reference image(s).", + dropped, + ); + } else { + info!( + "grok_imagine_video_1p5: v1.5 doesn't accept reference_images; promoting \ + the single reference image to start_frame (image-to-video).", + ); + } + (Some(GrokVideoImageSource::Url(first_url)), None) + } + ImageListRef::MediaFileTokens(tokens) => { + info!( + "grok_imagine_video_1p5: dropping {} MediaFileToken reference image(s) — \ + Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.", + tokens.len(), + ); + (None, None) + } + } +} + +fn count_image_list_ref(refs: &ImageListRef) -> usize { + match refs { + ImageListRef::Urls(v) => v.len(), + ImageListRef::MediaFileTokens(v) => v.len(), + } +} + +// ── Drop-with-log helpers for fields Grok doesn't accept at all ── + +fn log_and_drop_end_frame(end_frame: Option) { + if end_frame.is_some() { + info!("grok_imagine_video_1p5: dropping end_frame — Grok doesn't support end-frame keyframes."); + } +} + +fn log_and_drop_reference_videos(refs: Option) { + if let Some(refs) = refs { + let count = match refs { + VideoListRef::Urls(v) => v.len(), + VideoListRef::MediaFileTokens(v) => v.len(), + }; + info!( + "grok_imagine_video_1p5: dropping {} reference video(s) — Grok doesn't accept reference videos.", + count, + ); + } +} + +fn log_and_drop_reference_audio(refs: Option) { + if let Some(refs) = refs { + let count = match refs { + AudioListRef::Urls(v) => v.len(), + AudioListRef::MediaFileTokens(v) => v.len(), + }; + info!( + "grok_imagine_video_1p5: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.", + count, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::api::audio_list_ref::AudioListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + // ── Wire-shape: the built request always carries the 1.5 preview model id ── + + mod model_id { + use super::*; + + #[test] + fn built_request_pins_grok_imagine_video_1p5_preview() { + let req = unwrap_request(make_builder(|_| {})); + assert_eq!( + req.request.model, + Some(GrokVideoModel::GrokImagineVideo1p5), + ); + } + + #[test] + fn built_request_serializes_canonical_wire_name() { + // The on-wire model string is the canonical name; the v1.5 cost + // calculator in grok_api_client keys off this string. + let req = unwrap_request(make_builder(|_| {})); + let model = req.request.model.as_ref().expect("model should be set"); + assert_eq!(model.as_str(), "grok-imagine-video-1.5"); + } + } + + // ── Field passthrough ── + + mod field_conversions { + use super::*; + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); })); + assert_eq!(req.request.prompt, "test prompt"); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); })); + assert_eq!(req.request.duration, Some(8)); + } + + #[test] + fn duration_clamped_to_min() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(0); })); + assert_eq!(req.request.duration, Some(1)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration, Some(15)); + } + + #[test] + fn duration_none_stays_none() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; })); + assert_eq!(req.request.duration, None); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn square() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Square)); + } + + #[test] + fn landscape_16x9() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + } + + #[test] + fn portrait_9x16() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16)); + } + + #[test] + fn landscape_4x3() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape4x3)); + } + + #[test] + fn portrait_3x4() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallThreeByFour); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait3x4)); + } + + #[test] + fn landscape_3x2() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape3x2)); + } + + #[test] + fn portrait_2x3() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallTwoByThree); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait2x3)); + } + + #[test] + fn auto_maps_to_none() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); })); + assert_eq!(req.request.aspect_ratio, None); + } + + #[test] + fn unsupported_wide_falls_back_to_16x9() { + let req = unwrap_request(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); + })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + } + + #[test] + fn unsupported_tall_falls_back_to_9x16() { + let req = unwrap_request(make_builder(|b| { + b.aspect_ratio = Some(RouterAspectRatio::TallNineByTwentyOne); + })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_1080p_maps_to_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP)); + } + + #[test] + fn res_4k_caps_to_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); })); + assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP)); + } + + #[test] + fn res_1k_bumps_to_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::OneK); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Image source plumbing (graceful — never errors) ── + + mod image_tests { + use super::*; + + #[test] + fn start_frame_url_becomes_image() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + })); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"), + other => panic!("expected Url variant, got {:?}", other), + } + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn start_frame_media_file_token_dropped_then_rejected_as_text_only() { + // MediaFileTokens get silently dropped (Grok only accepts URLs), but + // with no usable image left the no-image guard rejects the build. + let result = build_grok_api_grok_imagine_video_1p5(GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::GrokApi, + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))), + ..Default::default() + }); + assert!(matches!( + result, + Err(crate::errors::artcraft_router_error::ArtcraftRouterError::Client( + crate::errors::client_error::ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. } + )), + )); + } + + #[test] + fn multiple_reference_image_urls_promote_first_and_drop_rest() { + // xAI's v1.5 model rejects `reference_images`. The builder promotes the + // first reference to `image` and drops the remainder. + let urls = vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(urls.clone())); + })); + assert!( + req.request.reference_images.is_none(), + "reference_images must be None (xAI v1.5 rejects them)", + ); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/a.png"), + other => panic!("expected first reference image promoted to `image`, got {:?}", other), + } + } + + #[test] + fn single_reference_image_url_promotes_to_image() { + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/only.png".to_string(), + ])); + })); + assert!(req.request.reference_images.is_none()); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/only.png"), + other => panic!("expected single reference promoted to `image`, got {:?}", other), + } + } + + /// Replicates the production failure from 2026-05-31: storyteller-web + /// received `reference_image_media_tokens: [..., ...]` and routed it to + /// the v1.5 model, which rejected the request with + /// `reference_images is not supported for this model`. After the + /// promote-first fix, the resolved URLs go into `image` instead. + #[test] + fn reproduces_2026_05_31_two_ref_image_urls_no_start_frame() { + let req = unwrap_request(make_builder(|b| { + // Two URLs (the storyteller-web pipeline resolves media tokens to URLs + // before calling build2() on the GrokApi provider). + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://pub.example.com/media/a.png".to_string(), + "https://pub.example.com/media/b.png".to_string(), + ])); + b.start_frame = None; + b.resolution = Some(RouterResolution::SevenTwentyP); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + b.duration_seconds = Some(5); + })); + // The fix: no `reference_images` ever reach xAI for v1.5. + assert!( + req.request.reference_images.is_none(), + "v1.5 must never send reference_images to xAI", + ); + // The first reference is preserved as image-to-video so the user still + // gets a sensible result. + assert!(matches!(req.request.image, Some(GrokVideoImageSource::Url(_)))); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + assert_eq!(req.request.duration, Some(5)); + assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5)); + } + + #[test] + fn reference_image_tokens_dropped_then_rejected_as_text_only() { + // MediaFileTokens get silently dropped; with no usable image left the + // no-image guard rejects the build. + let result = build_grok_api_grok_imagine_video_1p5(GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::GrokApi, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ])), + ..Default::default() + }); + assert!(matches!( + result, + Err(crate::errors::artcraft_router_error::ArtcraftRouterError::Client( + crate::errors::client_error::ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. } + )), + )); + } + + #[test] + fn start_frame_and_reference_images_together_prefers_start_frame() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref1.png".to_string(), + "https://example.com/ref2.png".to_string(), + ])); + })); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"), + other => panic!("expected start_frame URL, got {:?}", other), + } + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn empty_reference_image_url_list_rejected_as_text_only() { + // Empty list resolves to None for both image and reference_images; + // the no-image guard then rejects the build. + let result = build_grok_api_grok_imagine_video_1p5(GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::GrokApi, + reference_images: Some(ImageListRef::Urls(vec![])), + ..Default::default() + }); + assert!(matches!( + result, + Err(crate::errors::artcraft_router_error::ArtcraftRouterError::Client( + crate::errors::client_error::ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. } + )), + )); + } + } + + // ── Unsupported fields are silently dropped, not rejected ── + + mod unsupported_fields_graceful { + use super::*; + + #[test] + fn end_frame_silently_dropped() { + // end_frame isn't on Grok's wire body at all, so the only thing to + // verify is that supplying it doesn't break the build. `make_builder` + // injects a default start_frame to satisfy the v1.5 no-image guard. + let req = unwrap_request(make_builder(|b| { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + })); + // image comes from the default start_frame; reference_images stays None. + assert!(req.request.reference_images.is_none()); + assert!(matches!(req.request.image, Some(GrokVideoImageSource::Url(_)))); + } + + #[test] + fn reference_videos_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + })); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn reference_audio_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()])); + })); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn kitchen_sink_with_every_unsupported_field_still_succeeds() { + let req = unwrap_request(make_builder(|b| { + b.prompt = Some("kitchen sink".to_string()); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref.png".to_string(), + ])); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()])); + b.resolution = Some(RouterResolution::SevenTwentyP); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + b.duration_seconds = Some(8); + })); + assert_eq!(req.request.prompt, "kitchen sink"); + assert!(matches!( + req.request.image, + Some(GrokVideoImageSource::Url(_)) + )); + assert!(req.request.reference_images.is_none()); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + assert_eq!(req.request.duration, Some(8)); + // Still pinned to the 1.5 model regardless of kitchen-sink inputs. + assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5)); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::GrokApi, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut b = base_builder(); + f(&mut b); + // v1.5 requires an input image; tests that don't explicitly exercise the + // image-handling paths get a default start_frame so they sail past the + // no-image guard in `build()`. Tests that set start_frame or + // reference_images themselves keep their override. + if b.start_frame.is_none() && b.reference_images.is_none() { + b.start_frame = Some(ImageRef::Url("https://example.com/default.png".to_string())); + } + b + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideo1p5RequestState { + let result = build_grok_api_grok_imagine_video_1p5(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GrokApiGrokImagineVideo1p5(s)) => s, + _ => panic!("expected GrokApiGrokImagineVideo1p5 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/cost.rs new file mode 100644 index 0000000000..6230b02a07 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/cost.rs @@ -0,0 +1,386 @@ +use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState; + +pub struct GrokApiGrokImagineVideo1p5CostState { + request: GrokApiGrokImagineVideo1p5RequestState, +} + +impl GrokApiGrokImagineVideo1p5CostState { + pub fn from_request(request: &GrokApiGrokImagineVideo1p5RequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> Result { + // Defense in depth: `build()` already enforces this, but a request state + // constructed by hand must not get a cost quote for an operation the + // model will reject. + if self.request.request.image.is_none() && self.request.request.reference_images.is_none() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(), + })); + } + + // grok_api_client's calculator picks the v1.5 pricing tier from the + // request's `model` field (see VideoModel::pricing_tier). The build step + // sets `model = Some(GrokImagineVideo1p5)`, so this returns the + // v1.5 rates: + // Output: 80 mills/s @ 480p, 140 mills/s @ 720p + // Input: 10 mills per source image + let cost_in_usd_cents = self.request.request.calculate_cost_in_cents(); + + Ok(VideoGenerationCostEstimate { + cost_in_credits: None, + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // v1.5 pricing reference (from grok_api_client/video_generation/cost.rs): + // Output: 480p 80 mills/s ($0.08/s), 720p 140 mills/s ($0.14/s) + // Input: 10 mills per source image ($0.01/img) + // + // Cost in cents = ceil(mills / 10). + + // ── 720p pricing ── + + mod pricing_720p { + use super::*; + + // Note: build_for() injects a default start_frame when no image source + // is given (v1.5 requires an image), so every "no explicit image" case + // here actually includes 1 image worth of input surcharge (+10 mills = +1¢). + + #[test] + fn five_s_with_default_image() { + // 140 × 5 + 10 = 710 mills → 71¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None), 71); + } + + #[test] + fn ten_s_with_default_image() { + // 140 × 10 + 10 = 1410 mills → 141¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None), 141); + } + + #[test] + fn fifteen_s_with_default_image() { + // 140 × 15 + 10 = 2110 mills → 211¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None), 211); + } + } + + // ── 480p pricing ── + + mod pricing_480p { + use super::*; + + #[test] + fn five_s_with_default_image() { + // 80 × 5 + 10 = 410 mills → 41¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, None, None), 41); + } + + #[test] + fn ten_s_with_default_image() { + // 80 × 10 + 10 = 810 mills → 81¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, None, None), 81); + } + + #[test] + fn fifteen_s_with_default_image() { + // 80 × 15 + 10 = 1210 mills → 121¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, None, None), 121); + } + } + + // ── Image input pricing (10 mills/image, NOT 2 mills as in v1) ── + + mod image_inputs_add_to_cost { + use super::*; + + #[test] + fn single_start_frame_image_adds_10_mills() { + // 5s @ 720p output 700 mills + 1 input image 10 mills = 710 mills → 71¢ + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + Some(ImageRef::Url("https://example.com/a.png".to_string())), + None, + ); + assert_eq!(cents, 71); + } + + #[test] + fn three_reference_images_collapse_to_one_image() { + // v1.5 rejects reference_images, so the build step promotes the first + // reference to `image` and drops the rest (see promote_first_reference_to_image + // in build.rs). Effective input image count = 1 regardless of how + // many were submitted as references. + // + // 5s @ 720p output 700 mills + 1 effective input image 10 mills = 710 mills → 71¢ + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + None, + Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ])), + ); + assert_eq!(cents, 71); + } + + #[test] + fn reference_image_count_does_not_scale_cost() { + // Promote-first collapses any number of reference_images down to one + // `image` on the wire, so the cost stays flat at the 1-image surcharge. + let baseline = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + None, + Some(ImageListRef::Urls(vec!["https://example.com/0.png".to_string()])), + ); + for n in 1u64..=5 { + let images: Vec = (0..n) + .map(|i| format!("https://example.com/{i}.png")) + .collect(); + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + None, + Some(ImageListRef::Urls(images)), + ); + assert_eq!(cents, baseline, "n={n} should still cost the same after promote-first"); + } + } + } + + // ── Relative pricing sanity ── + + mod relative_pricing { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})"); + } + + #[test] + fn longer_duration_costs_more() { + let c5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn one_k_request_clamps_to_480p_pricing() { + let one_k = cost_cents(Some(RouterResolution::OneK), 10, None, None); + let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None); + assert_eq!(one_k, p480); + } + + #[test] + fn ten_eighty_p_prices_higher_than_720p() { + // Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p). + let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})"); + } + } + + // ── Cross-tier sanity: 1.5 must cost more than v1 at same settings ── + + mod cross_tier { + use super::*; + + #[test] + fn v1p5_costs_more_than_v1_at_720p() { + let v1p5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + let v1 = v1_cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}"); + } + + #[test] + fn v1p5_costs_more_than_v1_at_480p() { + let v1p5 = cost_cents(Some(RouterResolution::FourEightyP), 5, None, None); + let v1 = v1_cost_cents(Some(RouterResolution::FourEightyP), 5, None, None); + assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}"); + } + } + + // ── Exhaustive matrix ── + + mod exhaustive_matrix { + use super::*; + + // (resolution, duration_s, has_image, ref_count, expected_cents) + // + // Mills math: + // 480p output: 80 mills/s 720p output: 140 mills/s + // per input image: 10 mills + // cents = ceil(mills / 10) + // + // v1.5 requires at least one image, so every "has_image=false, + // ref_count=0" row would be rejected. Those rows are gone; everything + // else uses an explicit image source. + const CASES: &[(RouterResolution, u16, bool, usize, u64)] = &[ + // 480p with image-to-video (1 input image, +10 mills) + (RouterResolution::FourEightyP, 1, true, 0, 9), // 80 + 10 + (RouterResolution::FourEightyP, 5, true, 0, 41), // 400 + 10 + (RouterResolution::FourEightyP, 8, true, 0, 65), // 640 + 10 + (RouterResolution::FourEightyP, 15, true, 0, 121), // 1200 + 10 + // 720p with image-to-video (1 input image, +10 mills) + (RouterResolution::SevenTwentyP, 1, true, 0, 15), // 140 + 10 + (RouterResolution::SevenTwentyP, 5, true, 0, 71), // 700 + 10 + (RouterResolution::SevenTwentyP, 8, true, 0, 113), // 1120 + 10 + (RouterResolution::SevenTwentyP,15, true, 0, 211), // 2100 + 10 + (RouterResolution::SevenTwentyP,10, true, 0, 141), // 1400 + 10 + // 720p reference-to-video. Promote-first collapses any non-empty refs + // list down to ONE effective image on the wire, so all of these cost + // the same regardless of ref_count. + (RouterResolution::SevenTwentyP, 5, false, 1, 71), // 700 + 10 + (RouterResolution::SevenTwentyP, 5, false, 2, 71), // 700 + 10 (promote-first) + (RouterResolution::SevenTwentyP, 5, false, 3, 71), // 700 + 10 (promote-first) + ]; + + #[test] + fn all_matrix_cases() { + for &(res, duration, has_image, ref_count, expected_cents) in CASES { + let start_frame = if has_image { + Some(ImageRef::Url("https://example.com/start.png".to_string())) + } else { None }; + let reference_images = if ref_count > 0 { + Some(ImageListRef::Urls( + (0..ref_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(), + )) + } else { None }; + let cents = cost_cents(Some(res), duration, start_frame, reference_images); + assert_eq!( + cents, expected_cents, + "res={res:?} dur={duration} has_image={has_image} ref_count={ref_count}", + ); + } + } + } + + // ── No-image rejection (defense in depth) ───────────────────────────── + + mod rejects_text_only { + use super::*; + use crate::errors::artcraft_router_error::ArtcraftRouterError; + use crate::errors::client_error::ClientError; + use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState; + use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState; + use grok_api_client::api::requests::videos::video_generation::video_generation::VideoGenerationRequest as GrokVideoGenerationRequest; + use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel; + + /// A cost-state hand-built around a text-only Grok request must return + /// Err when estimate_cost is called. This guards against a state + /// constructed outside of `build()` (e.g. in a misuse). + #[test] + fn estimate_cost_returns_err_when_no_image_sources() { + let request = GrokVideoGenerationRequest { + prompt: "text only".to_string(), + model: Some(GrokVideoModel::GrokImagineVideo1p5), + image: None, + reference_images: None, + aspect_ratio: None, + duration: Some(5), + resolution: None, + user: None, + }; + let state = GrokApiGrokImagineVideo1p5CostState::from_request( + &GrokApiGrokImagineVideo1p5RequestState { request }, + ); + let err = state.estimate_cost().expect_err("text-only must be rejected"); + match err { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => { + assert_eq!(field, "image_inputs"); + } + other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other), + } + } + } + + // ── Helpers ── + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + build_for(RouterVideoModel::GrokImagineVideo1p5, resolution, duration_seconds, start_frame, reference_images) + } + + fn v1_cost_cents( + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + build_for(RouterVideoModel::GrokImagineVideo, resolution, duration_seconds, start_frame, reference_images) + } + + fn build_for( + model: RouterVideoModel, + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + // v1.5 rejects text-to-video at build time. To keep pricing tests + // focused on duration/resolution/batch scaling (rather than constantly + // duplicating an image input in every call site), the helper injects a + // single default start_frame URL when neither image source is supplied. + // That adds a 10-mill / 1¢ input-image surcharge to every "no-image" + // case — all the expected values below account for it. + let start_frame = match (start_frame, &reference_images) { + (Some(sf), _) => Some(sf), + (None, Some(_)) => None, + (None, None) => Some(ImageRef::Url("https://example.com/default.png".to_string())), + }; + let builder = GenerateVideoRequestBuilder { + model, + provider: RouterProvider::GrokApi, + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(1), + start_frame, + reference_images, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/request.rs new file mode 100644 index 0000000000..feddf35f92 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/grok_imagine_video_1p5/request.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + video_generation, VideoGenerationArgs, VideoGenerationRequest as GrokVideoGenerationRequest, +}; + +use crate::client::router_grok_api_client::RouterGrokApiClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + GenerateVideoResponse, GrokVideoResponsePayload, +}; + +#[derive(Clone, Debug)] +pub struct GrokApiGrokImagineVideo1p5RequestState { + /// The fully-resolved Grok request body, with `model` set to + /// `VideoModel::GrokImagineVideo1p5`. Doesn't carry the API key — + /// that gets borrowed at send time from the router client. + pub request: GrokVideoGenerationRequest, +} + +impl GrokApiGrokImagineVideo1p5RequestState { + pub async fn send(&self, client: &RouterGrokApiClient) -> Result { + // Defense in depth: `build()` already enforces this. Bouncing the + // request here costs nothing and avoids an HTTP call we know will fail. + if self.request.image.is_none() && self.request.reference_images.is_none() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(), + })); + } + + let outbound_request = Arc::new(self.request.clone()); + + let response = video_generation(VideoGenerationArgs { + api_key: &client.api_key, + request: self.request.clone(), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Grok(err)))?; + + Ok(GenerateVideoResponse::Grok(GrokVideoResponsePayload { + request_id: response.request_id, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use grok_api_client::api::types::video_types::video_model::VideoModel; + use grok_api_client::creds::grok_api_key::GrokApiKey; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_grok_api_client::RouterGrokApiClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + // ── Wire-shape sanity: the built request always carries the 1.5 model id ── + + #[test] + fn built_request_targets_grok_imagine_video_1p5_preview() { + let builder = GenerateVideoRequestBuilder { + prompt: Some("test".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + // v1.5 requires an input image (no T2V). + start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())), + ..grok_builder() + }; + let request = unwrap_request(builder); + assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5)); + } + + // ── Live API tests (manual) ── + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A glowing crystal rocket launching from Mars.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog leaps into the lake and splashes around.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + resolution: Some(RouterResolution::FourEightyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog leaps into the lake and splashes around.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + fn grok_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::GrokApi, + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_grok_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/grok_api_key.txt") + .expect("Failed to read Grok API key"); + let api_key = GrokApiKey::new(secret.trim().to_string()); + RouterClient::GrokApi(RouterGrokApiClient::new(api_key)) + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideo1p5RequestState { + let result = builder.build2().expect("build2 should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + crate::generate::generate_video::video_generation_request::VideoGenerationRequest::GrokApiGrokImagineVideo1p5(s) + ) => s, + _ => panic!("expected GrokApiGrokImagineVideo1p5 request"), + } + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_grok_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Grok skips draft)"), + }; + let response = request.send_request(&client).await.expect("send_request should succeed"); + match &response { + GenerateVideoResponse::Grok(p) => println!("grok request_id={}", p.request_id), + other => println!("unexpected response: {:?}", other), + } + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/mod.rs new file mode 100644 index 0000000000..3368e9b140 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/grok_api/mod.rs @@ -0,0 +1,2 @@ +pub mod grok_imagine_video; +pub mod grok_imagine_video_1p5; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/build.rs new file mode 100644 index 0000000000..11b8d18f3f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/build.rs @@ -0,0 +1,463 @@ +use kinovi_web_client::generate::video::generate_happy_horse_1p0::{ + KinoviHappyHorse1p0AspectRatio, KinoviHappyHorse1p0BatchCount, + KinoviHappyHorse1p0OutputResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::draft::{ + KinoviHappyHorse1p0DraftState, KinoviHappyHorse1p0RemainingItems, +}; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_happy_horse_1p0(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft))) +} + +fn do_build(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + + let unhandled_request_state = KinoviHappyHorse1p0RemainingItems { + start_frame: builder.start_frame.take(), + }; + + Ok(KinoviHappyHorse1p0DraftState { + prompt, + aspect_ratio, + resolution, + duration_seconds, + batch_count, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── Plan helpers ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(None), + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9)) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16)) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(Some(KinoviHappyHorse1p0AspectRatio::Square1x1)) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(KinoviHappyHorse1p0AspectRatio::Portrait3x4)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(nearest_aspect_ratio(unsupported))) + } + }, + } +} + +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviHappyHorse1p0AspectRatio { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => KinoviHappyHorse1p0AspectRatio::Landscape4x3, + RouterAspectRatio::WideThreeByTwo => KinoviHappyHorse1p0AspectRatio::Landscape4x3, + RouterAspectRatio::WideTwentyOneByNine => KinoviHappyHorse1p0AspectRatio::Landscape16x9, + RouterAspectRatio::TallFourByFive => KinoviHappyHorse1p0AspectRatio::Portrait3x4, + RouterAspectRatio::TallTwoByThree => KinoviHappyHorse1p0AspectRatio::Portrait3x4, + RouterAspectRatio::TallNineByTwentyOne => KinoviHappyHorse1p0AspectRatio::Portrait9x16, + _ => KinoviHappyHorse1p0AspectRatio::Square1x1, + } +} + +// Happy Horse supports 720p and 1080p only (no 480p). +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + // Unset defaults to explicit 720p. Cost estimation prices unset as 720p, + // so the request must pin 720p too — never leave the resolution up to the + // provider's server-side default, or billing and supplier cost can diverge. + None => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)), + + Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)) + } + }, + } +} + +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(None), + 2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)), + 4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four })) + } + }, + } +} + +// Happy Horse supports 4–15 seconds, defaults to 5. +fn plan_duration( + duration_seconds: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Result { + let seconds = duration_seconds.unwrap_or(5); + Ok(seconds.clamp(4, 15) as u8) +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::artcraft_router_error::ArtcraftRouterError; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + // ── Materialized field conversions ── + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateVideoRequestBuilder { prompt: None, ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert_eq!(draft.prompt, ""); + } + + #[test] + fn duration_seconds_converted() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert_eq!(draft.duration_seconds, 10); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_max() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert_eq!(draft.duration_seconds, 15); + } + + #[test] + fn batch_count_none_for_one() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(draft.batch_count.is_none()); + } + + #[test] + fn batch_count_two() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Two))); + } + + #[test] + fn batch_count_four() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Four))); + } + } + + // ── Aspect ratio conversions ── + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn aspect_ratio_wide() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9))); + } + + #[test] + fn aspect_ratio_tall() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16))); + } + + #[test] + fn aspect_ratio_square() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Square1x1))); + } + + #[test] + fn aspect_ratio_none_defaults_to_none() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(draft.aspect_ratio.is_none()); + } + + #[test] + fn aspect_ratio_auto_defaults_to_none() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Auto), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(draft.aspect_ratio.is_none()); + } + + #[test] + fn unsupported_aspect_ratio_falls_back() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideFiveByFour), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3))); + } + + #[test] + fn unsupported_aspect_ratio_errors_out() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideFiveByFour), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..happy_horse_builder() + }; + assert!(build_kinovi_happy_horse_1p0(builder).is_err()); + } + } + + // ── Resolution conversions ── + + mod resolution_conversions { + use super::*; + + #[test] + fn resolution_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::SevenTwentyP), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP))); + } + + #[test] + fn resolution_none_defaults_to_720p() { + let builder = GenerateVideoRequestBuilder { resolution: None, ..happy_horse_builder() }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP))); + } + + #[test] + fn unsupported_resolution_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..happy_horse_builder() + }; + assert!(build_kinovi_happy_horse_1p0(builder).is_err()); + } + + #[test] + fn unsupported_resolution_upgrades() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP))); + } + + #[test] + fn unsupported_resolution_downgrades() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP))); + } + } + + // ── unhandled_request_state ── + + mod unhandled_request_state { + use super::*; + + #[test] + fn unhandled_state_is_present() { + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder())); + assert!(draft.unhandled_request_state.is_some()); + } + + #[test] + fn start_frame_url_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg")); + } + + #[test] + fn start_frame_media_token_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123")); + } + + #[test] + fn empty_refs_are_none_in_unhandled() { + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder())); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_none()); + } + } + + // ── Full combination ── + + #[test] + fn full_request_all_fields() { + let builder = GenerateVideoRequestBuilder { + prompt: Some("full test".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + resolution: Some(RouterResolution::TenEightyP), + duration_seconds: Some(10), + video_batch_count: Some(4), + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..happy_horse_builder() + }; + let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder)); + + assert_eq!(draft.prompt, "full test"); + assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16))); + assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP))); + assert_eq!(draft.duration_seconds, 10); + assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Four))); + + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_some()); + } + + // ── Helpers ── + + fn happy_horse_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::KinoviWeb, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviHappyHorse1p0DraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) + ) => draft, + _ => panic!("expected KinoviHappyHorse1p0 draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/cost.rs new file mode 100644 index 0000000000..0432960dc4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/cost.rs @@ -0,0 +1,344 @@ +use kinovi_web_client::generate::video::generate_happy_horse_1p0::{ + GenerateHappyHorse1p0Request, KinoviHappyHorse1p0BatchCount, + KinoviHappyHorse1p0OutputResolution, +}; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::draft::KinoviHappyHorse1p0DraftState; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState; + +pub struct KinoviHappyHorse1p0CostState { + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, +} + +impl KinoviHappyHorse1p0CostState { + pub fn from_request(request: &KinoviHappyHorse1p0RequestState) -> Self { + Self { + resolution: request.request.output_resolution, + duration_seconds: request.request.duration_seconds, + batch_count: request.request.batch_count, + } + } + + pub fn from_draft(draft: &KinoviHappyHorse1p0DraftState) -> Self { + Self { + resolution: draft.resolution, + duration_seconds: draft.duration_seconds, + batch_count: draft.batch_count, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let pricing_request = GenerateHappyHorse1p0Request { + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: self.batch_count, + + // No impact on price + prompt: String::new(), + aspect_ratio: None, + start_frame_url: None, + }; + + let costs = pricing_request.calculate_costs(); + let cost_in_credits = costs.kinovi_credits; + let cost_in_usd_cents = costs.usd_cents_rounded_up; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + // ── 720p pricing (33 credits/sec) ── + + mod pricing_720p { + use super::*; + + #[test] + fn credits_720p_every_duration() { + assert_eq!(credits(None, 3), 99); + assert_eq!(credits(None, 4), 132); + assert_eq!(credits(None, 5), 165); + assert_eq!(credits(None, 6), 198); + assert_eq!(credits(None, 7), 231); + assert_eq!(credits(None, 8), 264); + assert_eq!(credits(None, 9), 297); + assert_eq!(credits(None, 10), 330); + assert_eq!(credits(None, 11), 363); + assert_eq!(credits(None, 12), 396); + assert_eq!(credits(None, 13), 429); + assert_eq!(credits(None, 14), 462); + assert_eq!(credits(None, 15), 495); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = credits(None, 5); + let explicit = credits(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), 5); + assert_eq!(default, explicit); + } + + #[test] + fn usd_cents_720p() { + assert_eq!(usd_cents(None, 5, None), 68); // 16500/243 = 67.90 -> rounds UP + assert_eq!(usd_cents(None, 15, None), 204); // 49500/243 = 203.70 -> rounds UP + } + } + + // ── 1080p pricing (66 credits/sec) ── + + mod pricing_1080p { + use super::*; + + #[test] + fn credits_1080p_every_duration() { + let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP); + assert_eq!(credits(r, 3), 198); + assert_eq!(credits(r, 4), 264); + assert_eq!(credits(r, 5), 330); + assert_eq!(credits(r, 6), 396); + assert_eq!(credits(r, 7), 462); + assert_eq!(credits(r, 8), 528); + assert_eq!(credits(r, 9), 594); + assert_eq!(credits(r, 10), 660); + assert_eq!(credits(r, 11), 726); + assert_eq!(credits(r, 12), 792); + assert_eq!(credits(r, 13), 858); + assert_eq!(credits(r, 14), 924); + assert_eq!(credits(r, 15), 990); + } + + #[test] + fn usd_cents_1080p() { + let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP); + // 330 credits; 33000/243 = 135.80 → rounds UP to 136¢ + assert_eq!(usd_cents(r, 5, None), 136); + // 990 credits; 99000/243 = 407.41 → rounds UP to 408¢ + assert_eq!(usd_cents(r, 15, None), 408); + } + } + + // ── Batch multiplier ── + + mod batch_tests { + use super::*; + + #[test] + fn batch_2_doubles_credits() { + let base = credits(None, 5); + let batch2 = credits_with_batch(None, 5, Some(KinoviHappyHorse1p0BatchCount::Two)); + assert_eq!(batch2, base * 2); + } + + #[test] + fn batch_4_quadruples_credits() { + let base = credits(None, 5); + let batch4 = credits_with_batch(None, 5, Some(KinoviHappyHorse1p0BatchCount::Four)); + assert_eq!(batch4, base * 4); + } + + #[test] + fn batch_applies_to_1080p() { + let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP); + let base = credits(r, 5); + let batch4 = credits_with_batch(r, 5, Some(KinoviHappyHorse1p0BatchCount::Four)); + assert_eq!(batch4, base * 4); + } + } + + // ── Relative pricing ── + + mod relative_pricing { + use super::*; + + #[test] + fn r1080p_is_double_720p() { + for dur in 3..=15u8 { + let c720 = credits(None, dur); + let c1080 = credits(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), dur); + assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur); + } + } + + #[test] + fn cost_scales_with_duration() { + let c3 = credits(None, 3); + let c10 = credits(None, 10); + let c15 = credits(None, 15); + assert!(c3 < c10); + assert!(c10 < c15); + } + + #[test] + fn not_free() { + let est = KinoviHappyHorse1p0CostState { + resolution: None, duration_seconds: 5, batch_count: None, + }.estimate_cost(); + assert!(!est.is_free); + } + } + + // ── from_draft() ── + + mod from_draft_tests { + use super::*; + + #[test] + fn from_draft_720p_default() { + let draft = make_draft(5, 1, None); + let cost = KinoviHappyHorse1p0CostState::from_draft(&draft); + assert_eq!(cost.duration_seconds, 5); + assert_eq!(cost.estimate_cost().cost_in_credits, Some(165)); + } + + #[test] + fn from_draft_1080p_batch_4() { + let draft = make_draft(5, 4, Some(RouterResolution::TenEightyP)); + let cost = KinoviHappyHorse1p0CostState::from_draft(&draft); + assert_eq!(cost.estimate_cost().cost_in_credits, Some(330 * 4)); + } + } + + // ── from_request() ── + + mod from_request_tests { + use super::*; + + #[test] + fn from_request_default() { + let req = make_request_state(None, 5, None); + let cost = KinoviHappyHorse1p0CostState::from_request(&req); + assert_eq!(cost.estimate_cost().cost_in_credits, Some(165)); + } + + #[test] + fn from_request_1080p() { + let req = make_request_state( + Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), 5, None, + ); + let cost = KinoviHappyHorse1p0CostState::from_request(&req); + assert_eq!(cost.estimate_cost().cost_in_credits, Some(330)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(136)); + } + } + + // ── Cross-check: draft matches request ── + + mod cross_check { + use super::*; + + #[test] + fn draft_and_request_produce_same_cost() { + let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP)); + let req = make_request_state( + Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), 5, None, + ); + let draft_cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviHappyHorse1p0CostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + + #[test] + fn draft_and_request_produce_same_cost_1080p_batch_2() { + let draft = make_draft(10, 2, Some(RouterResolution::TenEightyP)); + let req = make_request_state( + Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), 10, + Some(KinoviHappyHorse1p0BatchCount::Two), + ); + let draft_cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviHappyHorse1p0CostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + } + + // ── Helpers ── + + fn credits( + resolution: Option, + duration_seconds: u8, + ) -> u64 { + credits_with_batch(resolution, duration_seconds, None) + } + + fn credits_with_batch( + resolution: Option, + duration_seconds: u8, + batch_count: Option, + ) -> u64 { + KinoviHappyHorse1p0CostState { resolution, duration_seconds, batch_count } + .estimate_cost() + .cost_in_credits + .unwrap() + } + + fn usd_cents( + resolution: Option, + duration_seconds: u8, + batch_count: Option, + ) -> u64 { + KinoviHappyHorse1p0CostState { resolution, duration_seconds, batch_count } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + fn make_draft( + duration_seconds: u16, + video_batch_count: u16, + resolution: Option, + ) -> KinoviHappyHorse1p0DraftState { + use crate::api::router_video_model::RouterVideoModel; + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::HappyHorse1p0, + provider: RouterProvider::KinoviWeb, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + + match builder.build2().expect("build2 should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) + ) => draft, + _ => panic!("expected KinoviHappyHorse1p0 draft"), + } + } + + fn make_request_state( + resolution: Option, + duration_seconds: u8, + batch_count: Option, + ) -> KinoviHappyHorse1p0RequestState { + KinoviHappyHorse1p0RequestState { + request: GenerateHappyHorse1p0Request { + prompt: "test".to_string(), + aspect_ratio: None, + output_resolution: resolution, + batch_count, + duration_seconds, + start_frame_url: None, + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/draft.rs new file mode 100644 index 0000000000..bc8957f89c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/draft.rs @@ -0,0 +1,55 @@ +use kinovi_web_client::generate::video::generate_happy_horse_1p0::{ + GenerateHappyHorse1p0Request, KinoviHappyHorse1p0AspectRatio, + KinoviHappyHorse1p0BatchCount, KinoviHappyHorse1p0OutputResolution, +}; + +use crate::api::image_ref::ImageRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::providers::kinovi::resolve::resolve_and_upload_single; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviHappyHorse1p0DraftState { + pub prompt: String, + pub aspect_ratio: Option, + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviHappyHorse1p0RemainingItems { + pub start_frame: Option, +} + +impl KinoviHappyHorse1p0DraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut start_frame_url = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + let predownloaded = draft_context.predownloaded_media_paths; + start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?; + } + + let request = GenerateHappyHorse1p0Request { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + output_resolution: self.resolution, + batch_count: self.batch_count, + duration_seconds: self.duration_seconds, + start_frame_url, + }; + + Ok(KinoviHappyHorse1p0RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/request.rs new file mode 100644 index 0000000000..2ccf6b4a40 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/happy_horse_1p0/request.rs @@ -0,0 +1,256 @@ +use kinovi_web_client::generate::video::generate_happy_horse_1p0::{ + generate_happy_horse_1p0, GenerateHappyHorse1p0Args, GenerateHappyHorse1p0Request, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload}; + +#[derive(Debug, Clone)] +pub struct KinoviHappyHorse1p0RequestState { + pub request: GenerateHappyHorse1p0Request, +} + +impl KinoviHappyHorse1p0RequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateHappyHorse1p0Args { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_happy_horse_1p0(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_kinovi_web_client::RouterKinoviWebClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + mod aspect_ratio_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn landscape() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..happy_horse_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn portrait() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..happy_horse_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn square() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A hummingbird hovering near a flower.".to_string()), + aspect_ratio: Some(RouterAspectRatio::Square), + ..happy_horse_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + mod resolution_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn res_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + ..happy_horse_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn res_1080p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A fox walking through a snowy forest.".to_string()), + resolution: Some(RouterResolution::TenEightyP), + ..happy_horse_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + mod modality_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..happy_horse_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn keyframe_start_frame() { + let start_token = MediaFileToken::new("mf_start".to_string()); + + let mut media_map = HashMap::new(); + media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder { + prompt: Some("The dog watches the sunset over the lake.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(start_token)), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..happy_horse_builder() + }, media_map).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] + async fn keyframe_1080p_square() { + let start_token = MediaFileToken::new("mf_start".to_string()); + + let mut media_map = HashMap::new(); + media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder { + prompt: Some("A dragon soaring over the mountains.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(start_token)), + aspect_ratio: Some(RouterAspectRatio::Square), + resolution: Some(RouterResolution::TenEightyP), + duration_seconds: Some(15), + ..happy_horse_builder() + }, media_map).await; + assert!(matches!(response, GenerateVideoResponse::KinoviWeb(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // ── Helpers ── + + fn happy_horse_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::HappyHorse1p0, + provider: RouterProvider::KinoviWeb, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_kinovi_web_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read kinovi_web cookies"); + let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_kinovi_web_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let draft = match draft_or_request { + VideoGenerationDraftOrRequest::Draft(d) => d, + _ => panic!("expected Draft variant"), + }; + + let draft_context = VideoGenerationDraftContext { + client: Some(&client), + ..Default::default() + }; + + let request = draft.finalize(draft_context).await.expect("finalize should succeed"); + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::KinoviWeb(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } + + async fn run_pipeline_with_media_map( + builder: GenerateVideoRequestBuilder, + media_map: HashMap, + ) -> GenerateVideoResponse { + let client = get_kinovi_web_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let draft = match draft_or_request { + VideoGenerationDraftOrRequest::Draft(d) => d, + _ => panic!("expected Draft variant"), + }; + + let draft_context = VideoGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&media_map), + ..Default::default() + }; + + let request = draft.finalize(draft_context).await.expect("finalize should succeed"); + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::KinoviWeb(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/mod.rs new file mode 100644 index 0000000000..5e06be3815 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/mod.rs @@ -0,0 +1,8 @@ +pub mod happy_horse_1p0; +pub mod seedance_2p0; +pub mod seedance_2p0_fast; +pub mod seedance_2p0_mini; +pub mod seedance_2p5; +pub mod seedance_2p5_preview; +pub(crate) mod resolve; +pub(crate) mod upload; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/resolve.rs new file mode 100644 index 0000000000..fe64757d59 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/resolve.rs @@ -0,0 +1,142 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::providers::kinovi::upload::upload_to_kinovi_web; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +/// Either resolved URLs or unresolved media file tokens. +pub(crate) enum UrlsOrTokens { + Urls(Vec), + Tokens(Vec), +} + +pub(crate) fn image_list_ref_into_urls_or_tokens(list: ImageListRef) -> UrlsOrTokens { + match list { + ImageListRef::Urls(urls) => UrlsOrTokens::Urls(urls), + ImageListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), + } +} + +pub(crate) fn video_list_ref_into_urls_or_tokens(list: VideoListRef) -> UrlsOrTokens { + match list { + VideoListRef::Urls(urls) => UrlsOrTokens::Urls(urls), + VideoListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), + } +} + +pub(crate) fn audio_list_ref_into_urls_or_tokens(list: AudioListRef) -> UrlsOrTokens { + match list { + AudioListRef::Urls(urls) => UrlsOrTokens::Urls(urls), + AudioListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), + } +} + +/// Resolve a single ImageRef and upload to KinoviWeb CDN. +pub(crate) async fn resolve_and_upload_single( + session: &KinoviWebSession, + image_ref: Option, + maybe_map: Option<&HashMap>, + maybe_predownloaded: Option<&HashMap>, +) -> Result, ArtcraftRouterError> { + let source_url = match image_ref { + None => return Ok(None), + Some(ImageRef::Url(url)) => url, + Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?, + }; + let local_path = predownloaded_path(maybe_predownloaded, &source_url); + Ok(Some(upload_to_kinovi_web(session, &source_url, local_path).await?)) +} + +/// Resolve a list of refs to URLs and upload each to KinoviWeb CDN. +/// Order is preserved. +pub(crate) async fn resolve_and_upload_list( + session: &KinoviWebSession, + urls_or_tokens: Option, + maybe_map: Option<&HashMap>, + maybe_predownloaded: Option<&HashMap>, +) -> Result>, ArtcraftRouterError> { + let source_urls = match urls_or_tokens { + None => return Ok(None), + Some(UrlsOrTokens::Urls(urls)) if urls.is_empty() => return Ok(None), + Some(UrlsOrTokens::Urls(urls)) => urls, + Some(UrlsOrTokens::Tokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(UrlsOrTokens::Tokens(tokens)) => resolve_tokens(maybe_map, &tokens)?, + }; + + let mut uploaded = Vec::with_capacity(source_urls.len()); + for url in &source_urls { + let local_path = predownloaded_path(maybe_predownloaded, url); + uploaded.push(upload_to_kinovi_web(session, url, local_path).await?); + } + Ok(Some(uploaded)) +} + +/// Look up a caller-predownloaded local copy of the source file (see +/// `VideoGenerationDraftContext::predownloaded_media_paths`). +fn predownloaded_path<'a>( + maybe_predownloaded: Option<&'a HashMap>, + source_url: &str, +) -> Option<&'a Path> { + maybe_predownloaded + .and_then(|paths| paths.get(source_url)) + .map(PathBuf::as_path) +} + +/// Map character tokens to their Kinovi character IDs, preserving order. +pub(crate) fn resolve_character_tokens( + character_list_ref: Option<&CharacterListRef>, + draft_context: &VideoGenerationDraftContext<'_>, +) -> Result>, ArtcraftRouterError> { + let list = match character_list_ref { + None => return Ok(None), + Some(r) => r, + }; + + let tokens = match list { + CharacterListRef::CharacterTokens(tokens) if tokens.is_empty() => return Ok(None), + CharacterListRef::CharacterTokens(tokens) => tokens, + }; + + let map = draft_context.get_character_token_to_kinovi_map()?; + + let ids: Result, _> = tokens.iter() + .map(|token| { + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::CharacterTokenNotFoundInMap { + token: token.clone(), + }) + }) + }) + .collect(); + + ids.map(Some) +} + +fn resolve_token( + maybe_map: Option<&HashMap>, + token: &MediaFileToken, +) -> Result { + let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + }) +} + +fn resolve_tokens( + maybe_map: Option<&HashMap>, + tokens: &[MediaFileToken], +) -> Result, ArtcraftRouterError> { + tokens.iter().map(|t| resolve_token(maybe_map, t)).collect() +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs new file mode 100644 index 0000000000..96b76fc220 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/build.rs @@ -0,0 +1,642 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0::{ + KinoviSeedance2p0AspectRatio as KinoviAspectRatio, + KinoviSeedance2p0Bitrate as KinoviBitrate, + KinoviSeedance2p0BatchCount as KinoviBatchCount, + KinoviSeedance2p0OutputResolution as KinoviOutputResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::{KinoviSeedance2p0DraftState, KinoviSeedance2p0RemainingItems}; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build_kinovi_seedance_2p0(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0(draft))) +} + +fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); + let prompt = builder.prompt.take().unwrap_or_default(); + + let unhandled_request_state = KinoviSeedance2p0RemainingItems { + start_frame: builder.start_frame.take(), + end_frame: builder.end_frame.take(), + reference_images: builder.reference_images.take(), + reference_videos: builder.reference_videos.take(), + reference_audio: builder.reference_audio.take(), + reference_character_tokens: builder.reference_character_tokens.take(), + }; + + Ok(KinoviSeedance2p0DraftState { + aspect_ratio, + resolution, + batch_count, + duration_seconds, + bitrate, + prompt, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── Plan helpers ── + +// Supported aspect ratios and their AR values (width / height): +// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78 +// +// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both +// select the nearest match rather than rounding in a specific direction. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match aspect_ratio { + // No preference or auto — default to landscape + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9), + + // Direct mappings + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(KinoviAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(KinoviAspectRatio::Portrait9x16) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(KinoviAspectRatio::Square1x1) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3), + Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9), + + // Mismatches — apply strategy + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(nearest_aspect_ratio(unsupported)) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33 + RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33 + RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75 + RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75 + RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56 + _ => KinoviAspectRatio::Square1x1, + } +} + +// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p, 4K. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + // Unset defaults to explicit 720p. Cost estimation prices unset as 720p, + // so the request must pin 720p too — never leave the resolution up to the + // provider's server-side default, or billing and supplier cost can diverge. + None => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // Direct mappings + Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(KinoviOutputResolution::TenEightyP)), + Some(RouterResolution::FourK) => Ok(Some(KinoviOutputResolution::FourK)), + + // Mismatches + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::TenEightyP, + })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::TenEightyP, + })) + } + }, + } +} + +// Seedance2p0 supports batch counts of 1 through 4. +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviBatchCount::One), + 2 => Ok(KinoviBatchCount::Two), + 3 => Ok(KinoviBatchCount::Three), + 4 => Ok(KinoviBatchCount::Four), + // 5 and above: + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four), + }, + } +} + +// Seedance2p0 supports duration of 4–15 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + const MIN: u16 = 4; + const MAX: u16 = 15; + const DEFAULT: u8 = 5; + match duration_seconds { + None => Ok(DEFAULT), + Some(d) if d >= MIN && d <= MAX => Ok(d as u8), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(d.clamp(MIN, MAX) as u8), + }, + } +} + +// Seedance 2.0 Pro bitrate: "standard" (default, field omitted) or "high". +// Bitrate does not affect cost. `Normal` and an unset value both map to the +// standard bitrate (None); only `High` requests the higher bitrate. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(RouterBitrate::High) => Some(KinoviBitrate::High), + Some(RouterBitrate::Normal) | None => None, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p0::{ + KinoviSeedance2p0AspectRatio as KinoviAspectRatio, + KinoviSeedance2p0BatchCount as KinoviBatchCount, + KinoviSeedance2p0OutputResolution as KinoviOutputResolution, + }; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + // ── Materialized field conversions ── + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_seedance_2p0(kinovi_web_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateVideoRequestBuilder { prompt: None, ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert_eq!(draft.prompt, ""); + } + + #[test] + fn duration_seconds_converted() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert_eq!(draft.duration_seconds, 10); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_max() { + let builder = GenerateVideoRequestBuilder { + duration_seconds: Some(99), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert_eq!(draft.duration_seconds, 15); + } + + #[test] + fn batch_count_one() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::One)); + } + + #[test] + fn batch_count_two() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Two)); + } + + #[test] + fn batch_count_three() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Three)); + } + + #[test] + fn batch_count_four() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Four)); + } + } + + // ── Aspect ratio conversions ── + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn aspect_ratio_wide() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + + #[test] + fn aspect_ratio_tall() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + } + + #[test] + fn aspect_ratio_square() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1)); + } + + #[test] + fn aspect_ratio_defaults_to_landscape() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + } + + // ── Resolution conversions ── + + mod resolution_conversions { + use super::*; + + #[test] + fn resolution_480p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourEightyP), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP))); + } + + #[test] + fn resolution_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::SevenTwentyP), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP))); + } + + #[test] + fn resolution_4k() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourK))); + } + + #[test] + fn resolution_none_defaults_to_720p() { + let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn unsupported_resolution_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::ThreeK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..kinovi_web_builder() + }; + assert!(build_kinovi_seedance_2p0(builder).is_err()); + } + + #[test] + fn unsupported_resolution_rounds_up() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::ThreeK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP))); + } + } + + // ── Bitrate conversions ── + + mod bitrate_conversions { + use super::*; + + #[test] + fn bitrate_high() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::High), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(matches!(draft.bitrate, Some(KinoviBitrate::High))); + } + + #[test] + fn bitrate_normal_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::Normal), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(draft.bitrate.is_none()); + } + + #[test] + fn bitrate_none_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { bitrate: None, ..kinovi_web_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + assert!(draft.bitrate.is_none()); + } + } + + // ── unhandled_request_state: media refs are placed there ── + + mod unhandled_request_state { + use super::*; + + #[test] + fn unhandled_state_is_present() { + let draft = unwrap_draft(build_kinovi_seedance_2p0(kinovi_web_builder())); + assert!(draft.unhandled_request_state.is_some()); + } + + #[test] + fn start_frame_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg")); + } + + #[test] + fn end_frame_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.end_frame, Some(ImageRef::Url(url)) if url == "https://example.com/end.jpg")); + } + + #[test] + fn start_frame_media_token_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123")); + } + + #[test] + fn reference_images_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + "https://example.com/ref1.jpg".to_string(), + "https://example.com/ref2.jpg".to_string(), + ])), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + match remaining.reference_images { + Some(ImageListRef::Urls(urls)) => { + assert_eq!(urls.len(), 2); + assert_eq!(urls[0], "https://example.com/ref1.jpg"); + assert_eq!(urls[1], "https://example.com/ref2.jpg"); + } + _ => panic!("expected Urls variant"), + } + } + + #[test] + fn reference_videos_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_videos: Some(VideoListRef::Urls(vec![ + "https://example.com/vid.mp4".to_string(), + ])), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.reference_videos, Some(VideoListRef::Urls(urls)) if urls.len() == 1)); + } + + #[test] + fn reference_audio_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_audio: Some(AudioListRef::Urls(vec![ + "https://example.com/audio.mp3".to_string(), + ])), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.reference_audio, Some(AudioListRef::Urls(urls)) if urls.len() == 1)); + } + + #[test] + fn character_tokens_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + CharacterToken::new("char_def".to_string()), + ])), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + match remaining.reference_character_tokens { + Some(CharacterListRef::CharacterTokens(tokens)) => { + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[0].as_str(), "char_abc"); + assert_eq!(tokens[1].as_str(), "char_def"); + } + _ => panic!("expected CharacterTokens variant"), + } + } + + #[test] + fn empty_refs_are_none_in_unhandled() { + let draft = unwrap_draft(build_kinovi_seedance_2p0(kinovi_web_builder())); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_none()); + assert!(remaining.end_frame.is_none()); + assert!(remaining.reference_images.is_none()); + assert!(remaining.reference_videos.is_none()); + assert!(remaining.reference_audio.is_none()); + assert!(remaining.reference_character_tokens.is_none()); + } + } + + // ── Full combination ── + + #[test] + fn full_request_all_fields() { + let builder = GenerateVideoRequestBuilder { + prompt: Some("full test".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + resolution: Some(RouterResolution::TenEightyP), + duration_seconds: Some(10), + video_batch_count: Some(4), + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])), + reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_xyz".to_string()), + ])), + ..kinovi_web_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0(builder)); + + assert_eq!(draft.prompt, "full test"); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP))); + assert_eq!(draft.duration_seconds, 10); + assert!(matches!(draft.batch_count, KinoviBatchCount::Four)); + + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_some()); + assert!(remaining.end_frame.is_some()); + assert!(remaining.reference_images.is_some()); + assert!(remaining.reference_videos.is_some()); + assert!(remaining.reference_audio.is_some()); + assert!(remaining.reference_character_tokens.is_some()); + } + + // ── Helpers ── + + fn kinovi_web_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::KinoviWeb, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviSeedance2p0DraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0 draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs new file mode 100644 index 0000000000..d1ff43ac9b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/cost.rs @@ -0,0 +1,660 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0::{ + GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount, + KinoviSeedance2p0OutputResolution, +}; +use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState; + +pub struct KinoviSeedance2p0CostState { + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + pub has_video_reference: bool, +} + +impl KinoviSeedance2p0CostState { + pub fn from_request(request: &KinoviSeedance2p0RequestState) -> Self { + Self { + resolution: request.request.output_resolution, + duration_seconds: request.request.duration_seconds, + batch_count: request.request.batch_count, + has_video_reference: request.request.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()), + } + } + + pub fn from_draft(draft: &KinoviSeedance2p0DraftState) -> Self { + let has_video_reference = draft.unhandled_request_state + .as_ref() + .and_then(|rem| rem.reference_videos.as_ref()) + .is_some(); + + Self { + resolution: draft.resolution, + duration_seconds: draft.duration_seconds, + batch_count: Some(draft.batch_count), + has_video_reference, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let pricing_request = GenerateSeedance2p0Request { + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: self.batch_count, + + // PRESENCE of reference videos changes the price (per-second + // surcharge); the URL contents don't. + reference_video_urls: if self.has_video_reference { + Some(vec!["pricing-placeholder".to_string()]) + } else { + None + }, + + // No impact on price + prompt: String::new(), + aspect_ratio: None, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }; + + // Enterprise tier: what generations actually cost us (our discounted + // per-model credit rate at our bulk credit purchase rate). + let costs = pricing_request.calculate_enterprise_costs(); + // Enterprise 720p bills fractional credits (37.9/sec). The router's + // credit field is an integer, so round to the nearest credit; the USD + // cents (the authoritative charge) are already rounded up. + let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64; + let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kinovi_web_client::generate::video::generate_seedance_2p0::{ + KinoviSeedance2p0OutputResolution as KinoviOutputResolution, + KinoviSeedance2p0BatchCount as KinoviBatchCount, + }; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + // ── 720p pricing (enterprise discount: 37.9 credits/sec) ── + + mod pricing_720p { + use super::*; + + #[test] + fn cost_720p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 63); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 78); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 94); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 110); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 156); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 234); + } + + #[test] + fn cost_720p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 125); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 156); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 468); + } + + #[test] + fn cost_720p_batch_3() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Three), 188); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 234); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Three), 702); + } + + #[test] + fn cost_720p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 250); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 312); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 936); + } + } + + // ── 480p pricing ── + + mod pricing_480p { + use super::*; + + #[test] + fn cost_480p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 25); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 31); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 62); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 93); + } + + #[test] + fn cost_480p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62); + } + + #[test] + fn cost_480p_batch_3() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 93); + } + + #[test] + fn cost_480p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124); + } + } + + // ── 1080p pricing ── + + mod pricing_1080p { + use super::*; + + #[test] + fn cost_1080p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 149); + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 186); + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 371); + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 556); + } + + #[test] + fn cost_1080p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371); + } + + #[test] + fn cost_1080p_batch_3() { + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Three), 556); + } + + #[test] + fn cost_1080p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741); + } + } + + // ── 4K pricing ── + // + // Routes to the kinovi_web_client: 4K base is 200 credits/s (vs 90 at 1080p). + // Credits → cents at the enterprise rate of 243.16 credits/$1, rounded up. + + mod pricing_4k { + use super::*; + + #[test] + fn cost_4k_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 4, KinoviBatchCount::One), 330); + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 412); + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 823); + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1234); + } + + #[test] + fn cost_4k_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 823); + } + + #[test] + fn cost_4k_batch_3() { + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Three), 1234); + } + + #[test] + fn cost_4k_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1646); + } + } + + // ── 4K: router cost must match the kinovi_web_client binding ── + // + // The router delegates to GenerateSeedance2p0Request::calculate_enterprise_costs(), + // so these assert the exact 4K numbers AND cross-check the router against the + // binding for every duration (4/5/10/15s) with and without a video reference. + // + // 4K base = 200 credits/s; with a video reference = 240 credits/s (+40 surcharge). + // USD cents = ceil(total_credits / 243.16 * 100). + + mod four_k_matches_binding { + use super::*; + use kinovi_web_client::generate::video::generate_seedance_2p0::GenerateSeedance2p0Request; + + /// Router-side 4K cost (batch 1) for a duration / video-reference combo. + fn router(duration_seconds: u8, has_video_reference: bool) -> VideoGenerationCostEstimate { + KinoviSeedance2p0CostState { + resolution: Some(KinoviOutputResolution::FourK), + duration_seconds, + batch_count: Some(KinoviBatchCount::One), + has_video_reference, + } + .estimate_cost() + } + + /// The kinovi_web_client binding's own 4K cost for the same inputs. + /// Returns (kinovi_credits, usd_cents_rounded_up). + fn binding(duration_seconds: u8, has_video_reference: bool) -> (u64, u64) { + let reference_video_urls = if has_video_reference { + Some(vec!["pricing-placeholder".to_string()]) + } else { + None + }; + let costs = GenerateSeedance2p0Request { + output_resolution: Some(KinoviOutputResolution::FourK), + duration_seconds, + batch_count: Some(KinoviBatchCount::One), + reference_video_urls, + prompt: String::new(), + aspect_ratio: None, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + .calculate_enterprise_costs(); + (costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up) + } + + #[test] + fn explicit_values_without_video_reference() { + // 200 credits/s. + assert_eq!((router(4, false).cost_in_credits, router(4, false).cost_in_usd_cents), (Some(800), Some(330))); + assert_eq!((router(5, false).cost_in_credits, router(5, false).cost_in_usd_cents), (Some(1000), Some(412))); + assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(823))); + assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1234))); + } + + #[test] + fn explicit_values_with_video_reference() { + // 240 credits/s (200 base + 40 surcharge). + assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(395))); + assert_eq!((router(5, true).cost_in_credits, router(5, true).cost_in_usd_cents), (Some(1200), Some(494))); + assert_eq!((router(10, true).cost_in_credits, router(10, true).cost_in_usd_cents), (Some(2400), Some(988))); + assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1481))); + } + + #[test] + fn router_matches_binding_for_every_duration_and_video_ref() { + for &duration in &[4u8, 5, 10, 15] { + for &has_ref in &[false, true] { + let r = router(duration, has_ref); + let (b_credits, b_cents) = binding(duration, has_ref); + assert_eq!( + r.cost_in_credits, Some(b_credits), + "credits differ at {duration}s has_video_reference={has_ref}", + ); + assert_eq!( + r.cost_in_usd_cents, Some(b_cents), + "usd cents differ at {duration}s has_video_reference={has_ref}", + ); + } + } + } + } + + // ── Relative pricing ── + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p_cheaper_than_1080p() { + let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One); + let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One); + let c1080 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One); + assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720); + assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080); + } + + #[test] + fn cost_1080p_cheaper_than_4k() { + let c1080 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One); + let c4k = usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One); + assert!(c1080 < c4k, "1080p ({}) should be cheaper than 4K ({})", c1080, c4k); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One); + let c10 = usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One); + let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One); + let b2 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two); + let b4 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // -- Video reference does NOT affect cost (yet) -- + + #[test] + fn video_reference_adds_surcharge() { + let base = KinoviSeedance2p0CostState { + resolution: Some(KinoviOutputResolution::SevenTwentyP), + duration_seconds: 5, + batch_count: Some(KinoviBatchCount::One), + has_video_reference: false, + }; + let without = base.estimate_cost(); + let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost(); + // Enterprise 720p surcharge is +7.58 credits/s: 189.5 -> 227.4 credits + // (22740/243.16 = 93.52 -> 94 cents). + assert_eq!(without.cost_in_credits, Some(190)); + assert_eq!(with.cost_in_credits, Some(227)); + assert_eq!(with.cost_in_usd_cents, Some(94)); + } + + // ── from_request() tests ── + + mod from_request_tests { + use super::*; + + #[test] + fn from_request_720p() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0CostState::from_request(&req); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(cost.duration_seconds, 5); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One))); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78)); + } + + #[test] + fn from_request_none_defaults_to_720p() { + let req = make_request_state(None, 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0CostState::from_request(&req); + assert!(cost.resolution.is_none()); // None defaults to 720p pricing + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78)); + } + + #[test] + fn from_request_480p() { + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0CostState::from_request(&req); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31)); + } + + #[test] + fn from_request_1080p_batch_2() { + let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false); + let cost = KinoviSeedance2p0CostState::from_request(&req); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(371)); + } + + #[test] + fn from_request_with_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true); + let cost = KinoviSeedance2p0CostState::from_request(&req); + assert!(cost.has_video_reference); + // Enterprise video refs add +7.58 credits/s at 720p: 227.4 credits -> 94 cents. + assert_eq!(cost.estimate_cost().cost_in_credits, Some(227)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(94)); + } + + #[test] + fn from_request_without_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0CostState::from_request(&req); + assert!(!cost.has_video_reference); + } + } + + // ── from_draft() tests ── + + mod from_draft_tests { + use super::*; + + #[test] + fn from_draft_720p_default() { + let draft = make_draft(5, 1, None, false); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + // Unset resolution is pinned to explicit 720p at build time. + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(cost.duration_seconds, 5); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One))); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78)); + } + + #[test] + fn from_draft_480p() { + let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31)); + } + + #[test] + fn from_draft_4k() { + let draft = make_draft(5, 1, Some(RouterResolution::FourK), false); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourK))); + assert_eq!(cost.estimate_cost().cost_in_credits, Some(1000)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(412)); + } + + #[test] + fn from_draft_4k_with_video_reference() { + let draft = make_draft(5, 1, Some(RouterResolution::FourK), true); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + // 4K base 200 + video-ref surcharge 40 = 240 credits/s -> 1200 credits for 5s. + assert_eq!(cost.estimate_cost().cost_in_credits, Some(1200)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494)); + } + + #[test] + fn from_draft_1080p_batch_4() { + let draft = make_draft(5, 4, Some(RouterResolution::TenEightyP), false); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP))); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(741)); + } + + #[test] + fn from_draft_with_video_reference() { + let draft = make_draft(5, 1, None, true); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + assert!(cost.has_video_reference); + // Enterprise video refs add +7.58 credits/s at 720p: 227.4 credits -> 94 cents. + assert_eq!(cost.estimate_cost().cost_in_credits, Some(227)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(94)); + } + + #[test] + fn from_draft_without_video_reference() { + let draft = make_draft(5, 1, None, false); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + assert!(!cost.has_video_reference); + } + + #[test] + fn from_draft_duration_15_batch_2() { + let draft = make_draft(15, 2, Some(RouterResolution::SevenTwentyP), false); + let cost = KinoviSeedance2p0CostState::from_draft(&draft); + assert_eq!(cost.duration_seconds, 15); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(468)); + } + } + + // ── Cross-check: from_draft matches from_request ── + + mod cross_check_tests { + use super::*; + + #[test] + fn draft_and_request_produce_same_cost() { + // 720p, 5s, batch 1 + let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), false); + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let draft_cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0CostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + + #[test] + fn draft_and_request_produce_same_cost_1080p() { + let draft = make_draft(10, 2, Some(RouterResolution::TenEightyP), false); + let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 10, KinoviBatchCount::Two, false); + let draft_cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0CostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + } + + // ── Credits spot checks ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_720p() { + // Enterprise 37.9 credits/s × 5s = 189.5, rounded to 190. + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 190); + } + + #[test] + fn credits_480p() { + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 75); + } + + #[test] + fn credits_1080p() { + assert_eq!(credits(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 450); + } + + #[test] + fn credits_4k() { + assert_eq!(credits(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 1000); + } + } + + // ── Helpers ── + + fn usd_cents( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + ) -> u64 { + KinoviSeedance2p0CostState { + resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count), + has_video_reference: false, + } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + fn credits( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + ) -> u64 { + KinoviSeedance2p0CostState { + resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count), + has_video_reference: false, + } + .estimate_cost() + .cost_in_credits + .unwrap() + } + + /// Build a draft via the builder to test from_draft(). + fn make_draft( + duration_seconds: u16, + video_batch_count: u16, + resolution: Option, + with_video_ref: bool, + ) -> KinoviSeedance2p0DraftState { + let reference_videos = if with_video_ref { + Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()])) + } else { + None + }; + + let builder = GenerateVideoRequestBuilder { + provider: RouterProvider::KinoviWeb, + resolution, + reference_videos, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + + match builder.build2().expect("build2 should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0 draft"), + } + } + + /// Build a request state for from_request() tests. + fn make_request_state( + resolution: Option, + duration_seconds: u8, + batch_count: KinoviBatchCount, + with_video_ref: bool, + ) -> KinoviSeedance2p0RequestState { + let reference_video_urls = if with_video_ref { + Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()]) + } else { + None + }; + + KinoviSeedance2p0RequestState { + request: GenerateSeedance2p0Request { + prompt: "test".to_string(), + aspect_ratio: None, + output_resolution: resolution, + duration_seconds, + batch_count: Some(batch_count), + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs new file mode 100644 index 0000000000..bcd0999f8d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/draft.rs @@ -0,0 +1,103 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0::{ + GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio, KinoviSeedance2p0Bitrate, + KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens, + video_list_ref_into_urls_or_tokens, +}; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0DraftState { + // Materialized / finalized types + + pub prompt: String, + pub aspect_ratio: KinoviSeedance2p0AspectRatio, + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: KinoviSeedance2p0BatchCount, + pub bitrate: Option, + + // Pending types that need to be queried. + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0RemainingItems { + pub start_frame: Option, + pub end_frame: Option, + pub reference_images: Option, + pub reference_videos: Option, + pub reference_audio: Option, + pub reference_character_tokens: Option, +} + +impl KinoviSeedance2p0DraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut start_frame_url = None; + let mut end_frame_url = None; + let mut reference_image_urls = None; + let mut reference_video_urls = None; + let mut reference_audio_urls = None; + let mut character_ids = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + let predownloaded = draft_context.predownloaded_media_paths; + + start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?; + end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?; + + reference_image_urls = resolve_and_upload_list( + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_video_urls = resolve_and_upload_list( + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_audio_urls = resolve_and_upload_list( + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + character_ids = resolve_character_tokens( + remaining.reference_character_tokens.as_ref(), + draft_context, + )?; + } + + let request = GenerateSeedance2p0Request { + prompt: self.prompt.clone(), + aspect_ratio: Some(self.aspect_ratio), + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: Some(self.batch_count), + start_frame_url, + end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + character_ids, + use_face_blur_hack: None, + bitrate: self.bitrate, + }; + + Ok(KinoviSeedance2p0RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/mod.rs new file mode 100644 index 0000000000..9ebe3f9c54 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod draft; +pub mod request; +pub mod cost; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/request.rs new file mode 100644 index 0000000000..67ae006ea6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0/request.rs @@ -0,0 +1,37 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0::{ + generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0RequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSeedance2p0Request, +} + +impl KinoviSeedance2p0RequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSeedance2p0Args { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_seedance_2p0(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs new file mode 100644 index 0000000000..98cae90bdb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/build.rs @@ -0,0 +1,671 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{ + KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio, + KinoviSeedance2p0FastBitrate as KinoviBitrate, + KinoviSeedance2p0FastBatchCount as KinoviBatchCount, + KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::{KinoviSeedance2p0FastDraftState, KinoviSeedance2p0FastRemainingItems}; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_seedance_2p0_fast(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build_kinovi_seedance_2p0_fast(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft))) +} + +fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); + let prompt = builder.prompt.take().unwrap_or_default(); + + let unhandled_request_state = KinoviSeedance2p0FastRemainingItems { + start_frame: builder.start_frame.take(), + end_frame: builder.end_frame.take(), + reference_images: builder.reference_images.take(), + reference_videos: builder.reference_videos.take(), + reference_audio: builder.reference_audio.take(), + reference_character_tokens: builder.reference_character_tokens.take(), + }; + + Ok(KinoviSeedance2p0FastDraftState { + aspect_ratio, + resolution, + batch_count, + duration_seconds, + bitrate, + prompt, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── Plan helpers ── + +// Supported aspect ratios and their AR values (width / height): +// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78 +// +// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both +// select the nearest match rather than rounding in a specific direction. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match aspect_ratio { + // No preference or auto — default to landscape + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9), + + // Direct mappings + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(KinoviAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(KinoviAspectRatio::Portrait9x16) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(KinoviAspectRatio::Square1x1) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3), + Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9), + + // Mismatches — apply strategy + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(nearest_aspect_ratio(unsupported)) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33 + RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33 + RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75 + RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75 + RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56 + _ => KinoviAspectRatio::Square1x1, + } +} + +// Seedance 2.0 Fast supports output resolutions: 480p and 720p only. +// 1080p is NOT supported — downgrade to 720p or error based on strategy. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + // Unset defaults to explicit 720p. Cost estimation prices unset as 720p, + // so the request must pin 720p too — never leave the resolution up to the + // provider's server-side default, or billing and supplier cost can diverge. + None => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // Direct mappings + Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // 1080p is not supported for Fast — handle via strategy + Some(RouterResolution::TenEightyP) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::TenEightyP), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + // 1080p not available — downgrade to 720p (highest supported) + Ok(Some(KinoviOutputResolution::SevenTwentyP)) + } + }, + + // Other unsupported resolutions + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::SevenTwentyP, + })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::SevenTwentyP, + })) + } + }, + } +} + +// Seedance2p0Fast supports batch counts of 1 through 4. +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviBatchCount::One), + 2 => Ok(KinoviBatchCount::Two), + 3 => Ok(KinoviBatchCount::Three), + 4 => Ok(KinoviBatchCount::Four), + // 5 and above: + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four), + }, + } +} + +// Seedance2p0Fast supports duration of 4–15 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + const MIN: u16 = 4; + const MAX: u16 = 15; + const DEFAULT: u8 = 5; + match duration_seconds { + None => Ok(DEFAULT), + Some(d) if d >= MIN && d <= MAX => Ok(d as u8), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(d.clamp(MIN, MAX) as u8), + }, + } +} + +// Seedance 2.0 Fast bitrate: "standard" (default, field omitted) or "high". +// Bitrate does not affect cost. `Normal` and an unset value both map to the +// standard bitrate (None); only `High` requests the higher bitrate. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(RouterBitrate::High) => Some(KinoviBitrate::High), + Some(RouterBitrate::Normal) | None => None, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{ + KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio, + KinoviSeedance2p0FastBatchCount as KinoviBatchCount, + KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution, + }; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + // ── Materialized field conversions ── + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(kinovi_web_fast_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateVideoRequestBuilder { prompt: None, ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.prompt, ""); + } + + #[test] + fn duration_seconds_converted() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.duration_seconds, 10); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_max() { + let builder = GenerateVideoRequestBuilder { + duration_seconds: Some(99), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.duration_seconds, 15); + } + + #[test] + fn batch_count_one() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::One)); + } + + #[test] + fn batch_count_two() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Two)); + } + + #[test] + fn batch_count_three() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Three)); + } + + #[test] + fn batch_count_four() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Four)); + } + } + + // ── Aspect ratio conversions ── + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn aspect_ratio_wide() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + + #[test] + fn aspect_ratio_tall() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + } + + #[test] + fn aspect_ratio_square() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::Square), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1)); + } + + #[test] + fn aspect_ratio_defaults_to_landscape() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + } + + // ── Resolution conversions ── + + mod resolution_conversions { + use super::*; + + #[test] + fn resolution_480p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourEightyP), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP))); + } + + #[test] + fn resolution_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::SevenTwentyP), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_downgrades_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_upgrade_also_downgrades_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..kinovi_web_fast_builder() + }; + assert!(build_kinovi_seedance_2p0_fast(builder).is_err()); + } + + #[test] + fn resolution_none_defaults_to_720p() { + let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn unsupported_resolution_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..kinovi_web_fast_builder() + }; + assert!(build_kinovi_seedance_2p0_fast(builder).is_err()); + } + + #[test] + fn unsupported_resolution_rounds_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + } + + // ── Bitrate conversions ── + + mod bitrate_conversions { + use super::*; + + #[test] + fn bitrate_high() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::High), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.bitrate, Some(KinoviBitrate::High))); + } + + #[test] + fn bitrate_normal_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { + bitrate: Some(RouterBitrate::Normal), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(draft.bitrate.is_none()); + } + + #[test] + fn bitrate_none_maps_to_standard() { + let builder = GenerateVideoRequestBuilder { bitrate: None, ..kinovi_web_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(draft.bitrate.is_none()); + } + } + + // ── unhandled_request_state: media refs are placed there ── + + mod unhandled_request_state { + use super::*; + + #[test] + fn unhandled_state_is_present() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(kinovi_web_fast_builder())); + assert!(draft.unhandled_request_state.is_some()); + } + + #[test] + fn start_frame_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg")); + } + + #[test] + fn end_frame_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.end_frame, Some(ImageRef::Url(url)) if url == "https://example.com/end.jpg")); + } + + #[test] + fn start_frame_media_token_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123")); + } + + #[test] + fn reference_images_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + "https://example.com/ref1.jpg".to_string(), + "https://example.com/ref2.jpg".to_string(), + ])), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + match remaining.reference_images { + Some(ImageListRef::Urls(urls)) => { + assert_eq!(urls.len(), 2); + assert_eq!(urls[0], "https://example.com/ref1.jpg"); + assert_eq!(urls[1], "https://example.com/ref2.jpg"); + } + _ => panic!("expected Urls variant"), + } + } + + #[test] + fn reference_videos_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_videos: Some(VideoListRef::Urls(vec![ + "https://example.com/vid.mp4".to_string(), + ])), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.reference_videos, Some(VideoListRef::Urls(urls)) if urls.len() == 1)); + } + + #[test] + fn reference_audio_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_audio: Some(AudioListRef::Urls(vec![ + "https://example.com/audio.mp3".to_string(), + ])), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.reference_audio, Some(AudioListRef::Urls(urls)) if urls.len() == 1)); + } + + #[test] + fn character_tokens_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + CharacterToken::new("char_def".to_string()), + ])), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + match remaining.reference_character_tokens { + Some(CharacterListRef::CharacterTokens(tokens)) => { + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[0].as_str(), "char_abc"); + assert_eq!(tokens[1].as_str(), "char_def"); + } + _ => panic!("expected CharacterTokens variant"), + } + } + + #[test] + fn empty_refs_are_none_in_unhandled() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(kinovi_web_fast_builder())); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_none()); + assert!(remaining.end_frame.is_none()); + assert!(remaining.reference_images.is_none()); + assert!(remaining.reference_videos.is_none()); + assert!(remaining.reference_audio.is_none()); + assert!(remaining.reference_character_tokens.is_none()); + } + } + + // ── Full combination ── + + #[test] + fn full_request_all_fields() { + let builder = GenerateVideoRequestBuilder { + prompt: Some("full test".to_string()), + aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), + resolution: Some(RouterResolution::SevenTwentyP), + duration_seconds: Some(10), + video_batch_count: Some(4), + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])), + reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_xyz".to_string()), + ])), + ..kinovi_web_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + + assert_eq!(draft.prompt, "full test"); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(draft.duration_seconds, 10); + assert!(matches!(draft.batch_count, KinoviBatchCount::Four)); + + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_some()); + assert!(remaining.end_frame.is_some()); + assert!(remaining.reference_images.is_some()); + assert!(remaining.reference_videos.is_some()); + assert!(remaining.reference_audio.is_some()); + assert!(remaining.reference_character_tokens.is_some()); + } + + // ── Helpers ── + + fn kinovi_web_fast_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::KinoviWeb, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviSeedance2p0FastDraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0Fast draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs new file mode 100644 index 0000000000..6f046d67dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/cost.rs @@ -0,0 +1,456 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{ + GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount, + KinoviSeedance2p0FastOutputResolution, +}; +use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState; + +pub struct KinoviSeedance2p0FastCostState { + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + pub has_video_reference: bool, +} + +impl KinoviSeedance2p0FastCostState { + pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self { + Self { + resolution: request.request.output_resolution, + duration_seconds: request.request.duration_seconds, + batch_count: request.request.batch_count, + has_video_reference: request.request.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()), + } + } + + pub fn from_draft(draft: &KinoviSeedance2p0FastDraftState) -> Self { + let has_video_reference = draft.unhandled_request_state + .as_ref() + .and_then(|rem| rem.reference_videos.as_ref()) + .is_some(); + + Self { + resolution: draft.resolution, + duration_seconds: draft.duration_seconds, + batch_count: Some(draft.batch_count), + has_video_reference, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let pricing_request = GenerateSeedance2p0FastRequest { + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: self.batch_count, + + // PRESENCE of reference videos changes the price (per-second + // surcharge); the URL contents don't. + reference_video_urls: if self.has_video_reference { + Some(vec!["pricing-placeholder".to_string()]) + } else { + None + }, + + // No impact on price + prompt: String::new(), + aspect_ratio: None, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }; + + // Enterprise tier: what generations actually cost us (our discounted + // per-model credit rate at our bulk credit purchase rate). + let costs = pricing_request.calculate_enterprise_costs(); + // Enterprise Fast credits can be fractional (e.g. 480p 5s = 52.5). The + // router's credit field is an integer, so round to the nearest credit; + // the USD cents (the authoritative charge) are already rounded up. + let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64; + let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_resolution::RouterResolution; + use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{ + KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution, + KinoviSeedance2p0FastBatchCount as KinoviBatchCount, + }; + + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + // ── Direct estimate_cost() tests ── + // These must match the reference implementation values exactly. + + // -- 720p (enterprise 21 credits/sec, 243.16 credits/$1) -- + + mod pricing_720p { + use super::*; + + #[test] + fn cost_720p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 35); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 44); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 87); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 130); + } + + #[test] + fn cost_720p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 87); + } + + #[test] + fn cost_720p_batch_3() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 130); + } + + #[test] + fn cost_720p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 173); + } + } + + // -- 480p (enterprise 10.5 credits/sec, 243.16 credits/$1) -- + + mod pricing_480p { + use super::*; + + #[test] + fn cost_480p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 18); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 22); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 44); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 65); + } + + #[test] + fn cost_480p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 44); + } + + #[test] + fn cost_480p_batch_3() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 65); + } + + #[test] + fn cost_480p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 87); + } + } + + // -- Relative pricing -- + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One); + let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One); + assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One); + let c10 = usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One); + let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One); + let b2 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two); + let b4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // -- Video reference does NOT affect cost (yet) -- + + #[test] + fn video_reference_adds_surcharge() { + let base = KinoviSeedance2p0FastCostState { + resolution: Some(KinoviOutputResolution::SevenTwentyP), + duration_seconds: 5, + batch_count: Some(KinoviBatchCount::One), + has_video_reference: false, + }; + let without = base.estimate_cost(); + let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost(); + // Enterprise Fast 720p surcharge is +4.5 credits/s: 105 -> 127.5 credits + // (12750/243.16 = 52.43 -> 53 cents; credits round to 128). + assert_eq!(without.cost_in_credits, Some(105)); + assert_eq!(with.cost_in_credits, Some(128)); + assert_eq!(with.cost_in_usd_cents, Some(53)); + } + + // ── from_request() tests ── + + mod from_request_tests { + use super::*; + + #[test] + fn from_request_720p() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(cost.duration_seconds, 5); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One))); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44)); + } + + #[test] + fn from_request_none_defaults_to_720p() { + let req = make_request_state(None, 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(cost.resolution.is_none()); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44)); + } + + #[test] + fn from_request_480p() { + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(22)); + } + + #[test] + fn from_request_with_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(cost.has_video_reference); + // Enterprise video refs add +4.5 credits/s at 720p: 127.5 credits -> 53 cents. + assert_eq!(cost.estimate_cost().cost_in_credits, Some(128)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(53)); + } + + #[test] + fn from_request_without_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(!cost.has_video_reference); + } + } + + // ── from_draft() tests ── + + mod from_draft_tests { + use super::*; + + #[test] + fn from_draft_720p_default() { + let draft = make_draft(5, 1, None, false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + // Unset resolution is pinned to explicit 720p at build time. + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(cost.duration_seconds, 5); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One))); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44)); + } + + #[test] + fn from_draft_480p() { + let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(22)); + } + + #[test] + fn from_draft_with_video_reference() { + let draft = make_draft(5, 1, None, true); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(cost.has_video_reference); + // Enterprise video refs add +4.5 credits/s at 720p: 127.5 credits -> 53 cents. + assert_eq!(cost.estimate_cost().cost_in_credits, Some(128)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(53)); + } + + #[test] + fn from_draft_without_video_reference() { + let draft = make_draft(5, 1, None, false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(!cost.has_video_reference); + } + + #[test] + fn from_draft_duration_15_batch_2() { + let draft = make_draft(15, 2, Some(RouterResolution::SevenTwentyP), false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert_eq!(cost.duration_seconds, 15); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(260)); + } + } + + // ── Cross-check: from_draft matches from_request ── + + mod cross_check_tests { + use super::*; + + #[test] + fn draft_and_request_produce_same_cost() { + // 720p, 5s, batch 1 + let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), false); + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let draft_cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0FastCostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + + #[test] + fn draft_and_request_produce_same_cost_480p() { + let draft = make_draft(10, 2, Some(RouterResolution::FourEightyP), false); + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, false); + let draft_cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0FastCostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + } + + // ── Credits spot checks ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_720p() { + // Enterprise 21 credits/s × 5s = 105. + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 105); + } + + #[test] + fn credits_480p() { + // Enterprise 10.5 credits/s × 5s = 52.5, rounded to 53. + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 53); + } + } + + // ── Helpers ── + + fn usd_cents( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + ) -> u64 { + KinoviSeedance2p0FastCostState { + resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count), + has_video_reference: false, + } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + fn credits( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + ) -> u64 { + KinoviSeedance2p0FastCostState { + resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count), + has_video_reference: false, + } + .estimate_cost() + .cost_in_credits + .unwrap() + } + + /// Build a draft via the builder to test from_draft(). + fn make_draft( + duration_seconds: u16, + video_batch_count: u16, + resolution: Option, + with_video_ref: bool, + ) -> KinoviSeedance2p0FastDraftState { + let reference_videos = if with_video_ref { + Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()])) + } else { + None + }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Fast, + provider: RouterProvider::KinoviWeb, + resolution, + reference_videos, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + + match builder.build2().expect("build2 should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0Fast draft"), + } + } + + /// Build a request state for from_request() tests. + fn make_request_state( + resolution: Option, + duration_seconds: u8, + batch_count: KinoviBatchCount, + with_video_ref: bool, + ) -> KinoviSeedance2p0FastRequestState { + let reference_video_urls = if with_video_ref { + Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()]) + } else { + None + }; + + KinoviSeedance2p0FastRequestState { + request: GenerateSeedance2p0FastRequest { + prompt: "test".to_string(), + aspect_ratio: None, + output_resolution: resolution, + duration_seconds, + batch_count: Some(batch_count), + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs new file mode 100644 index 0000000000..f5b3b10f39 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/draft.rs @@ -0,0 +1,100 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{ + GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio, KinoviSeedance2p0FastBitrate, + KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens, + video_list_ref_into_urls_or_tokens, +}; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0FastDraftState { + pub prompt: String, + pub aspect_ratio: KinoviSeedance2p0FastAspectRatio, + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: KinoviSeedance2p0FastBatchCount, + pub bitrate: Option, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0FastRemainingItems { + pub start_frame: Option, + pub end_frame: Option, + pub reference_images: Option, + pub reference_videos: Option, + pub reference_audio: Option, + pub reference_character_tokens: Option, +} + +impl KinoviSeedance2p0FastDraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut start_frame_url = None; + let mut end_frame_url = None; + let mut reference_image_urls = None; + let mut reference_video_urls = None; + let mut reference_audio_urls = None; + let mut character_ids = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + let predownloaded = draft_context.predownloaded_media_paths; + + start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?; + end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?; + + reference_image_urls = resolve_and_upload_list( + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_video_urls = resolve_and_upload_list( + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_audio_urls = resolve_and_upload_list( + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + character_ids = resolve_character_tokens( + remaining.reference_character_tokens.as_ref(), + draft_context, + )?; + } + + let request = GenerateSeedance2p0FastRequest { + prompt: self.prompt.clone(), + aspect_ratio: Some(self.aspect_ratio), + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: Some(self.batch_count), + start_frame_url, + end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + character_ids, + use_face_blur_hack: None, + bitrate: self.bitrate, + }; + + Ok(KinoviSeedance2p0FastRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/mod.rs new file mode 100644 index 0000000000..fcfc9ddce8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod draft; +pub mod request; +pub mod cost; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/request.rs new file mode 100644 index 0000000000..56263818ae --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_fast/request.rs @@ -0,0 +1,37 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{ + generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0FastRequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSeedance2p0FastRequest, +} + +impl KinoviSeedance2p0FastRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSeedance2p0FastArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_seedance_2p0_fast(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/build.rs new file mode 100644 index 0000000000..59dfeffcb6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/build.rs @@ -0,0 +1,513 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ + KinoviSeedance2p0MiniAspectRatio as KinoviAspectRatio, + KinoviSeedance2p0MiniBitrate as KinoviBitrate, + KinoviSeedance2p0MiniBatchCount as KinoviBatchCount, + KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_bitrate::RouterBitrate; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::{KinoviSeedance2p0MiniDraftState, KinoviSeedance2p0MiniRemainingItems}; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build_kinovi_seedance_2p0_mini(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft))) +} + +fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let bitrate = plan_bitrate(builder.bitrate.take()); + let prompt = builder.prompt.take().unwrap_or_default(); + + let unhandled_request_state = KinoviSeedance2p0MiniRemainingItems { + start_frame: builder.start_frame.take(), + end_frame: builder.end_frame.take(), + reference_images: builder.reference_images.take(), + reference_videos: builder.reference_videos.take(), + reference_audio: builder.reference_audio.take(), + reference_character_tokens: builder.reference_character_tokens.take(), + }; + + Ok(KinoviSeedance2p0MiniDraftState { + aspect_ratio, + resolution, + batch_count, + duration_seconds, + bitrate, + prompt, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── Plan helpers ── + +// Seedance 2.0 Mini supports all six aspect ratios: +// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so +// both upgrade and downgrade pick the nearest match. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match aspect_ratio { + // No preference or auto — default to landscape + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9), + + // Direct mappings + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(KinoviAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(KinoviAspectRatio::Portrait9x16) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(KinoviAspectRatio::Square1x1) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3), + Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9), + + // Mismatches — apply strategy + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(nearest_aspect_ratio(unsupported)) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33 + RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33 + RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75 + RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75 + RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56 + _ => KinoviAspectRatio::Square1x1, + } +} + +// Seedance 2.0 Mini supports output resolutions: 480p and 720p only. +// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + // Unset defaults to explicit 720p. Cost estimation prices unset as 720p, + // so the request must pin 720p too — never leave the resolution up to the + // provider's server-side default, or billing and supplier cost can diverge. + None => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // Direct mappings + Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // 1080p is not supported for Mini — handle via strategy + Some(RouterResolution::TenEightyP) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::TenEightyP), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + // 1080p not available — downgrade to 720p (highest supported) + Ok(Some(KinoviOutputResolution::SevenTwentyP)) + } + }, + + // Other unsupported resolutions + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::SevenTwentyP, + })) + } + }, + } +} + +// Seedance 2.0 Mini supports batch counts of 1–8. +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviBatchCount::One), + 2 => Ok(KinoviBatchCount::Two), + 3 => Ok(KinoviBatchCount::Three), + 4 => Ok(KinoviBatchCount::Four), + 5 => Ok(KinoviBatchCount::Five), + 6 => Ok(KinoviBatchCount::Six), + 7 => Ok(KinoviBatchCount::Seven), + 8 => Ok(KinoviBatchCount::Eight), + // Over the maximum of 8. + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight), + }, + } +} + +// Seedance 2.0 Mini supports duration of 4–15 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + const MIN: u16 = 4; + const MAX: u16 = 15; + const DEFAULT: u8 = 5; + match duration_seconds { + None => Ok(DEFAULT), + Some(d) if d >= MIN && d <= MAX => Ok(d as u8), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(d.clamp(MIN, MAX) as u8), + }, + } +} + +// Seedance 2.0 Mini bitrate: "standard" (default, field omitted) or "high". +// Bitrate does not affect cost. `Normal` and unset both map to standard (None); +// only `High` requests the higher bitrate. +fn plan_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(RouterBitrate::High) => Some(KinoviBitrate::High), + Some(RouterBitrate::Normal) | None => None, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ + KinoviSeedance2p0MiniAspectRatio as KinoviAspectRatio, + KinoviSeedance2p0MiniBatchCount as KinoviBatchCount, + KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution, + }; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(mini_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateVideoRequestBuilder { prompt: None, ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert_eq!(draft.prompt, ""); + } + + #[test] + fn duration_seconds_converted() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert_eq!(draft.duration_seconds, 10); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_max() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert_eq!(draft.duration_seconds, 15); + } + } + + mod batch_count_conversions { + use super::*; + + #[test] + fn batch_counts_one_through_eight() { + let expected = [ + (1u16, KinoviBatchCount::One), + (2, KinoviBatchCount::Two), + (3, KinoviBatchCount::Three), + (4, KinoviBatchCount::Four), + (5, KinoviBatchCount::Five), + (6, KinoviBatchCount::Six), + (7, KinoviBatchCount::Seven), + (8, KinoviBatchCount::Eight), + ]; + for (count, variant) in expected { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(count), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!( + std::mem::discriminant(&draft.batch_count) == std::mem::discriminant(&variant), + "batch {count} mapped wrong", + ); + } + } + + #[test] + fn batch_over_max_downgrades_to_eight() { + let builder = GenerateVideoRequestBuilder { + video_batch_count: Some(12), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..mini_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Eight)); + } + + #[test] + fn batch_over_max_error_out() { + let builder = GenerateVideoRequestBuilder { + video_batch_count: Some(12), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..mini_builder() + }; + assert!(build_kinovi_seedance_2p0_mini(builder).is_err()); + } + + #[test] + fn batch_zero_errors() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(0), ..mini_builder() }; + assert!(build_kinovi_seedance_2p0_mini(builder).is_err()); + } + } + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn aspect_ratio_wide() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + + #[test] + fn aspect_ratio_ultra_wide() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::UltraWide21x9)); + } + + #[test] + fn aspect_ratio_tall() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + } + + #[test] + fn aspect_ratio_square() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::Square), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1)); + } + + #[test] + fn aspect_ratio_four_by_three() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideFourByThree), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Standard4x3)); + } + + #[test] + fn aspect_ratio_three_by_four() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::TallThreeByFour), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait3x4)); + } + + #[test] + fn aspect_ratio_defaults_to_landscape() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + } + + mod resolution_conversions { + use super::*; + + #[test] + fn resolution_480p() { + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP))); + } + + #[test] + fn resolution_720p() { + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_downgrades_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..mini_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..mini_builder() + }; + assert!(build_kinovi_seedance_2p0_mini(builder).is_err()); + } + + #[test] + fn resolution_none_defaults_to_720p() { + let builder = GenerateVideoRequestBuilder { resolution: None, ..mini_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + } + + mod unhandled_request_state { + use super::*; + + #[test] + fn media_refs_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])), + reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + ])), + ..mini_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_some()); + assert!(remaining.end_frame.is_some()); + assert!(remaining.reference_images.is_some()); + assert!(remaining.reference_videos.is_some()); + assert!(remaining.reference_audio.is_some()); + assert!(remaining.reference_character_tokens.is_some()); + } + + #[test] + fn start_frame_media_token_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))), + ..mini_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123")); + } + + #[test] + fn empty_refs_are_none_in_unhandled() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(mini_builder())); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_none()); + assert!(remaining.end_frame.is_none()); + assert!(remaining.reference_images.is_none()); + assert!(remaining.reference_videos.is_none()); + assert!(remaining.reference_audio.is_none()); + assert!(remaining.reference_character_tokens.is_none()); + } + } + + // ── Helpers ── + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::KinoviWeb, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviSeedance2p0MiniDraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0Mini draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs new file mode 100644 index 0000000000..dfc4e62703 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/cost.rs @@ -0,0 +1,386 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ + GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniBatchCount, + KinoviSeedance2p0MiniOutputResolution, +}; +use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState; + +pub struct KinoviSeedance2p0MiniCostState { + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + pub has_video_reference: bool, +} + +impl KinoviSeedance2p0MiniCostState { + pub fn from_request(request: &KinoviSeedance2p0MiniRequestState) -> Self { + Self { + resolution: request.request.output_resolution, + duration_seconds: request.request.duration_seconds, + batch_count: request.request.batch_count, + has_video_reference: request.request.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()), + } + } + + pub fn from_draft(draft: &KinoviSeedance2p0MiniDraftState) -> Self { + let has_video_reference = draft.unhandled_request_state + .as_ref() + .and_then(|rem| rem.reference_videos.as_ref()) + .is_some(); + + Self { + resolution: draft.resolution, + duration_seconds: draft.duration_seconds, + batch_count: Some(draft.batch_count), + has_video_reference, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let pricing_request = GenerateSeedance2p0MiniRequest { + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: self.batch_count, + + // PRESENCE of reference videos changes the price (per-second + // surcharge); the URL contents don't. + reference_video_urls: if self.has_video_reference { + Some(vec!["pricing-placeholder".to_string()]) + } else { + None + }, + + // No impact on price + prompt: String::new(), + aspect_ratio: None, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }; + + // Enterprise tier: what generations actually cost us (our discounted + // per-model credit rate at our bulk credit purchase rate). + let costs = pricing_request.calculate_enterprise_costs(); + // Mini credits can be fractional (e.g. 480p + video ref at 4s = 15.2). + // The router's credit field is an integer, so round to the nearest credit; + // the USD cents (the authoritative charge) are already rounded up. + let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64; + let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::router_resolution::RouterResolution; + use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ + KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution, + KinoviSeedance2p0MiniBatchCount as KinoviBatchCount, + }; + + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + // ── USD cents: every combination (480p/720p × with/without video ref) ── + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + // Enterprise 3 credits/s. + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 5); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 7); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 13); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 19); + } + + #[test] + fn with_video_reference() { + // Enterprise 3.8 credits/s (3 base + 0.8 surcharge). + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 7); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 8); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 16); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 24); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + // Enterprise 8 credits/s. + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 14); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 17); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 33); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 50); + } + + #[test] + fn with_video_reference() { + // Enterprise 9.6 credits/s (8 base + 1.6 surcharge). + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 16); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 20); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 40); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 60); + } + } + + // ── Credits (mini bills fractional credits, rounded for the integer field) ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_480p() { + // Enterprise 3 credits/s (whole at every duration). + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 12); + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 15); + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 30); + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 45); + } + + #[test] + fn credits_720p() { + // Enterprise 8 credits/s. + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 40); + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 120); + } + } + + // ── Relative pricing ── + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false); + let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false); + assert!(c480 < c720, "480p ({c480}) should be cheaper than 720p ({c720})"); + } + + #[test] + fn video_reference_costs_more() { + for res in [KinoviOutputResolution::FourEightyP, KinoviOutputResolution::SevenTwentyP] { + for dur in [4u8, 5, 10, 15] { + let without = usd_cents(res, dur, KinoviBatchCount::One, false); + let with = usd_cents(res, dur, KinoviBatchCount::One, true); + assert!(with > without, "video ref should cost more at {res:?} {dur}s"); + } + } + } + + #[test] + fn cost_scales_with_duration_and_batch() { + let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false); + let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false); + assert!(c4 < c15); + let b1 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false); + let b4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four, false); + assert!(b1 < b4); + } + } + + // ── from_request() ── + + mod from_request_tests { + use super::*; + + #[test] + fn from_request_720p() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0MiniCostState::from_request(&req); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(cost.duration_seconds, 5); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(17)); + } + + #[test] + fn from_request_480p_with_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, true); + let cost = KinoviSeedance2p0MiniCostState::from_request(&req); + assert!(cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(8)); + } + + #[test] + fn from_request_none_defaults_to_720p() { + let req = make_request_state(None, 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0MiniCostState::from_request(&req); + assert!(cost.resolution.is_none()); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(17)); + } + } + + // ── from_draft() ── + + mod from_draft_tests { + use super::*; + + #[test] + fn from_draft_480p() { + let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false); + let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft); + assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(7)); + } + + #[test] + fn from_draft_with_video_reference() { + let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), true); + let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft); + assert!(cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20)); + } + + #[test] + fn from_draft_batch_8() { + // Mini supports up to batch 8: 720p 5s × 8 = 320 credits → 32000/243.16 → 132¢. + let draft = make_draft(5, 8, Some(RouterResolution::SevenTwentyP), false); + let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft); + assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Eight))); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(132)); + } + } + + // ── Cross-check: from_draft matches from_request ── + + mod cross_check_tests { + use super::*; + + #[test] + fn draft_and_request_produce_same_cost() { + let draft = make_draft(10, 2, Some(RouterResolution::FourEightyP), true); + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, true); + let draft_cost = KinoviSeedance2p0MiniCostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0MiniCostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + } + + // ── Helpers ── + + fn usd_cents( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + has_video_reference: bool, + ) -> u64 { + KinoviSeedance2p0MiniCostState { + resolution: Some(resolution), + duration_seconds, + batch_count: Some(batch_count), + has_video_reference, + } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + fn credits( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + has_video_reference: bool, + ) -> u64 { + KinoviSeedance2p0MiniCostState { + resolution: Some(resolution), + duration_seconds, + batch_count: Some(batch_count), + has_video_reference, + } + .estimate_cost() + .cost_in_credits + .unwrap() + } + + /// Build a draft via the builder to test from_draft(). + fn make_draft( + duration_seconds: u16, + video_batch_count: u16, + resolution: Option, + with_video_ref: bool, + ) -> KinoviSeedance2p0MiniDraftState { + let reference_videos = if with_video_ref { + Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()])) + } else { + None + }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::KinoviWeb, + resolution, + reference_videos, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + + match builder.build2().expect("build2 should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0Mini draft"), + } + } + + /// Build a request state for from_request() tests. + fn make_request_state( + resolution: Option, + duration_seconds: u8, + batch_count: KinoviBatchCount, + with_video_ref: bool, + ) -> KinoviSeedance2p0MiniRequestState { + let reference_video_urls = if with_video_ref { + Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()]) + } else { + None + }; + + KinoviSeedance2p0MiniRequestState { + request: GenerateSeedance2p0MiniRequest { + prompt: "test".to_string(), + aspect_ratio: None, + output_resolution: resolution, + duration_seconds, + batch_count: Some(batch_count), + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/draft.rs new file mode 100644 index 0000000000..5a44f667a5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/draft.rs @@ -0,0 +1,100 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ + GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniAspectRatio, KinoviSeedance2p0MiniBitrate, + KinoviSeedance2p0MiniBatchCount, KinoviSeedance2p0MiniOutputResolution, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens, + video_list_ref_into_urls_or_tokens, +}; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0MiniDraftState { + pub prompt: String, + pub aspect_ratio: KinoviSeedance2p0MiniAspectRatio, + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: KinoviSeedance2p0MiniBatchCount, + pub bitrate: Option, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0MiniRemainingItems { + pub start_frame: Option, + pub end_frame: Option, + pub reference_images: Option, + pub reference_videos: Option, + pub reference_audio: Option, + pub reference_character_tokens: Option, +} + +impl KinoviSeedance2p0MiniDraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut start_frame_url = None; + let mut end_frame_url = None; + let mut reference_image_urls = None; + let mut reference_video_urls = None; + let mut reference_audio_urls = None; + let mut character_ids = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + let predownloaded = draft_context.predownloaded_media_paths; + + start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?; + end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?; + + reference_image_urls = resolve_and_upload_list( + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_video_urls = resolve_and_upload_list( + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_audio_urls = resolve_and_upload_list( + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + character_ids = resolve_character_tokens( + remaining.reference_character_tokens.as_ref(), + draft_context, + )?; + } + + let request = GenerateSeedance2p0MiniRequest { + prompt: self.prompt.clone(), + aspect_ratio: Some(self.aspect_ratio), + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: Some(self.batch_count), + start_frame_url, + end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + character_ids, + use_face_blur_hack: None, + bitrate: self.bitrate, + }; + + Ok(KinoviSeedance2p0MiniRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/mod.rs new file mode 100644 index 0000000000..fcfc9ddce8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod draft; +pub mod request; +pub mod cost; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/request.rs new file mode 100644 index 0000000000..a1699bf7d9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p0_mini/request.rs @@ -0,0 +1,37 @@ +use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{ + generate_seedance_2p0_mini, GenerateSeedance2p0MiniArgs, GenerateSeedance2p0MiniRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0MiniRequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSeedance2p0MiniRequest, +} + +impl KinoviSeedance2p0MiniRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSeedance2p0MiniArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_seedance_2p0_mini(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/build.rs new file mode 100644 index 0000000000..bd80c8979f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/build.rs @@ -0,0 +1,473 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5::{ + KinoviSeedance2p5AspectRatio as KinoviAspectRatio, + KinoviSeedance2p5OutputResolution as KinoviOutputResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::{KinoviSeedance2p5DraftState, KinoviSeedance2p5RemainingItems}; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_seedance_2p5(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build_kinovi_seedance_2p5(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p5(draft))) +} + +fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some(); + let has_references = builder.reference_images.is_some() + || builder.reference_videos.is_some() + || builder.reference_audio.is_some(); + + // Keyframe (image-to-video) and reference modes are mutually exclusive on + // the Seedance 2.5 API. Dropping either set would change the meaning of + // the request, so this errors regardless of strategy. + if has_keyframes && has_references { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(), + })); + } + + // An end frame requires a start frame. + if builder.end_frame.is_some() && builder.start_frame.is_none() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(), + })); + } + + // Kinovi character references are not supported by the 2.5 API. + if builder.reference_character_tokens.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_character_tokens", + value: "Seedance 2.5 does not support character references".to_string(), + })); + } + + // Keyframe mode only supports "Adaptive" (the aspect ratio follows the + // input frames), so any explicit aspect ratio preference is ignored there. + // The planned value below only applies to reference mode. + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + + let total_input_seconds = builder.total_reference_video_input_seconds + .map(|seconds| u8::try_from(seconds).unwrap_or(u8::MAX)); + + // NB: `builder.bitrate` is intentionally ignored — 2.5 has no bitrate + // option (and bitrate never affects cost). + + let unhandled_request_state = KinoviSeedance2p5RemainingItems { + start_frame: builder.start_frame.take(), + end_frame: builder.end_frame.take(), + reference_images: builder.reference_images.take(), + reference_videos: builder.reference_videos.take(), + reference_audio: builder.reference_audio.take(), + }; + + Ok(KinoviSeedance2p5DraftState { + aspect_ratio, + resolution, + duration_seconds, + prompt, + total_input_seconds, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── Plan helpers ── + +// Seedance 2.5 reference mode supports all six aspect ratios: +// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so +// both upgrade and downgrade pick the nearest match. (Keyframe mode is +// always Adaptive and ignores this entirely.) +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match aspect_ratio { + // No preference or auto — default to landscape + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9), + + // Direct mappings + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(KinoviAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(KinoviAspectRatio::Portrait9x16) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(KinoviAspectRatio::Square1x1) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3), + Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9), + + // Mismatches — apply strategy + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(nearest_aspect_ratio(unsupported)) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33 + RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33 + RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75 + RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75 + RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56 + _ => KinoviAspectRatio::Square1x1, + } +} + +// Seedance 2.5 supports output resolutions: 480p, 720p, and 1080p. +// 4K (and other resolutions) are NOT supported — they map to the nearest +// offered resolution or error based on strategy. This planning MUST stay in +// lockstep with the Artcraft billing twin (`SupportedResolutions::Full` in +// build_common) so billing and execution always land on the same resolution. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + // Unset defaults to explicit 720p. Cost estimation prices unset as 720p, + // so the request must pin 720p too — never leave the resolution up to the + // provider's server-side default, or billing and supplier cost can diverge. + None => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // Direct mappings + Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + Some(RouterResolution::TenEightyP) => Ok(Some(KinoviOutputResolution::TenEightyP)), + + // 4K is not supported — handle via strategy + Some(RouterResolution::FourK) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::FourK), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + // 4K not available — downgrade to 1080p (highest supported) + Ok(Some(KinoviOutputResolution::TenEightyP)) + } + }, + + // Other unsupported resolutions + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::TenEightyP, + })) + } + }, + } +} + +// Seedance 2.5 generates a single video per request (no batching). +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(()), + // Over the maximum of 1. + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + }, + } +} + +// Seedance 2.5 supports durations of 4–30 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + const MIN: u16 = 4; + const MAX: u16 = 30; + const DEFAULT: u8 = 5; + match duration_seconds { + None => Ok(DEFAULT), + Some(d) if d >= MIN && d <= MAX => Ok(d as u8), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(d.clamp(MIN, MAX) as u8), + }, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p5::{ + KinoviSeedance2p5AspectRatio as KinoviAspectRatio, + KinoviSeedance2p5OutputResolution as KinoviOutputResolution, + }; + use tokens::tokens::characters::CharacterToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_seedance_2p5(base_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..base_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_range() { + let over = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_builder() }; + assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(over)).duration_seconds, 30); + + let under = GenerateVideoRequestBuilder { duration_seconds: Some(2), ..base_builder() }; + assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(under)).duration_seconds, 4); + } + + #[test] + fn total_input_seconds_is_carried_through() { + let builder = GenerateVideoRequestBuilder { + total_reference_video_input_seconds: Some(14), + ..base_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p5(builder)); + assert_eq!(draft.total_input_seconds, Some(14)); + } + + #[test] + fn total_input_seconds_saturates_at_u8_max() { + let builder = GenerateVideoRequestBuilder { + total_reference_video_input_seconds: Some(9_999), + ..base_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p5(builder)); + assert_eq!(draft.total_input_seconds, Some(u8::MAX)); + } + } + + mod modality_rules { + use super::*; + + #[test] + fn keyframes_are_accepted() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + ..base_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p5(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_some()); + assert!(remaining.end_frame.is_some()); + } + + #[test] + fn start_frame_alone_is_accepted() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..base_builder() + }; + assert!(build_kinovi_seedance_2p5(builder).is_ok()); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let builder = GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + ..base_builder() + }; + assert!(build_kinovi_seedance_2p5(builder).is_err()); + } + + #[test] + fn keyframes_plus_references_error() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + ..base_builder() + }; + assert!(build_kinovi_seedance_2p5(builder).is_err()); + } + + #[test] + fn character_references_error() { + let builder = GenerateVideoRequestBuilder { + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + ])), + ..base_builder() + }; + assert!(build_kinovi_seedance_2p5(builder).is_err()); + } + + #[test] + fn reference_media_lands_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])), + ..base_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p5(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.reference_images.is_some()); + assert!(remaining.reference_videos.is_some()); + } + } + + mod aspect_ratio_and_resolution { + use super::*; + + #[test] + fn direct_aspect_ratio_mappings() { + let expected = [ + (RouterAspectRatio::WideSixteenByNine, KinoviAspectRatio::Landscape16x9), + (RouterAspectRatio::WideTwentyOneByNine, KinoviAspectRatio::UltraWide21x9), + (RouterAspectRatio::TallNineBySixteen, KinoviAspectRatio::Portrait9x16), + (RouterAspectRatio::Square, KinoviAspectRatio::Square1x1), + (RouterAspectRatio::WideFourByThree, KinoviAspectRatio::Standard4x3), + (RouterAspectRatio::TallThreeByFour, KinoviAspectRatio::Portrait3x4), + ]; + for (input, output) in expected { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(input), ..base_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5(builder)); + assert!( + std::mem::discriminant(&draft.aspect_ratio) == std::mem::discriminant(&output), + "aspect ratio {input:?} mapped wrong", + ); + } + } + + #[test] + fn resolution_480p_720p_and_1080p() { + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..base_builder() }; + assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::FourEightyP))); + + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..base_builder() }; + assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP))); + + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::TenEightyP), ..base_builder() }; + assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::TenEightyP))); + } + + #[test] + fn resolution_1080p_error_out_strategy_still_maps_directly() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::TenEightyP))); + } + + #[test] + fn resolution_4k_downgrades_to_1080p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..base_builder() + }; + assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::TenEightyP))); + } + + #[test] + fn resolution_4k_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_kinovi_seedance_2p5(builder).is_err()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5, + provider: RouterProvider::KinoviWeb, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviSeedance2p5DraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p5(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p5 draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs new file mode 100644 index 0000000000..867274966b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs @@ -0,0 +1,244 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5::{ + GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution, + MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS, +}; +use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; + +use crate::api::video_list_ref::VideoListRef; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState; + +/// Seedance 2.5 pricing depends on the resolution, the output duration, and +/// — when reference videos are attached — the total seconds of reference +/// video input, which are billed on top of the output duration (at a lower +/// per-second rate). Input seconds are clamped to +/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of +/// video. +pub struct KinoviSeedance2p5CostState { + pub resolution: Option, + pub duration_seconds: u8, + pub has_video_references: bool, + pub total_input_seconds: Option, +} + +impl KinoviSeedance2p5CostState { + pub fn from_request(request: &KinoviSeedance2p5RequestState) -> Self { + let has_video_references = matches!( + &request.request.modality, + KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty() + ); + + Self { + resolution: request.request.output_resolution, + duration_seconds: request.request.duration_seconds, + has_video_references, + total_input_seconds: request.request.total_input_seconds, + } + } + + pub fn from_draft(draft: &KinoviSeedance2p5DraftState) -> Self { + let has_video_references = draft.unhandled_request_state + .as_ref() + .and_then(|remaining| remaining.reference_videos.as_ref()) + .is_some_and(|videos| match videos { + VideoListRef::MediaFileTokens(tokens) => !tokens.is_empty(), + VideoListRef::Urls(urls) => !urls.is_empty(), + }); + + Self { + resolution: draft.resolution, + duration_seconds: draft.duration_seconds, + has_video_references, + total_input_seconds: draft.total_input_seconds, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Delegate to the kinovi_web_client calculator via a pricing-only + // request. Only the modality's video references, the resolution, the + // duration, and the input seconds affect the price. + let modality = if self.has_video_references { + KinoviSeedance2p5Modality::Reference { + aspect_ratio: None, + reference_image_urls: None, + reference_video_urls: Some(vec!["https://pricing-only.invalid/ref.mp4".to_string()]), + reference_audio_urls: None, + } + } else { + KinoviSeedance2p5Modality::Reference { + aspect_ratio: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + } + }; + + // NB: The kinovi calculator clamps measured values and bills the + // worst-case maximum for unknown (None) or zero inputs. Zero passes + // through unclamped so that worst-case fallback applies — clamping it up + // to the minimum here would understate the cost. + let total_input_seconds = self.total_input_seconds + .map(|seconds| match seconds { + 0 => 0, + measured => measured.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS), + }); + + let pricing_request = GenerateSeedance2p5Request { + prompt: String::new(), + modality, + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + total_input_seconds, + use_face_blur_hack: None, + }; + + // Enterprise tier: what generations actually cost us (our discounted + // per-model credit rate at our bulk credit purchase rate). + let costs = pricing_request.calculate_enterprise_costs(); + // 2.5 bills whole credits at 480p/720p, so the rounding below is exact + // there; 1080p bills fractional credits (103.25/sec) and rounds to the + // nearest credit. The USD cents (the authoritative charge) are rounded + // up. + let cost_in_credits = costs.kinovi_credits.round() as u64; + let cost_in_usd_cents = costs.usd_cents_rounded_up; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputResolution as KinoviOutputResolution; + + use super::*; + + // ── Credits without video references (26/sec at 480p, 59/sec at 720p, + // 103.25/sec at 1080p enterprise) ── + + mod credits_without_video_references { + use super::*; + + #[test] + fn credits_480p() { + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 5, false, None), 130); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, None), 260); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, false, None), 780); + } + + #[test] + fn credits_720p() { + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 5, false, None), 295); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, false, None), 1770); + } + + #[test] + fn credits_1080p() { + // 103.25/sec enterprise; fractional totals round to the nearest credit + // (516.25 → 516, 1032.5 → 1033, 3097.5 → 3098). + assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 516); + assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1033); + assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 3098); + } + + #[test] + fn default_resolution_is_720p() { + assert_eq!(credits(None, 10, false, None), credits(Some(KinoviOutputResolution::SevenTwentyP), 10, false, None)); + } + + #[test] + fn input_seconds_ignored_without_video_references() { + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, Some(60)), 260); + } + } + + // ── Credits with video references (16/sec at 480p, 35/sec at 720p, + // 61.69/sec at 1080p enterprise, over output duration + input seconds) ── + + mod credits_with_video_references { + use super::*; + + #[test] + fn thirty_second_output_with_ten_input_seconds_bills_forty() { + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40); + // 61.69/sec enterprise × 40 = 2467.6 → 2468 credits. + assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468); + } + + #[test] + fn fourteen_input_seconds_bill_forty_four() { + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(14)), 16 * 44); + } + + #[test] + fn missing_input_seconds_bill_the_worst_case_maximum() { + // Unknown input duration with video references attached: the kinovi + // estimator assumes the 30-second maximum so the provider-cost + // estimate never undershoots the actual charge. + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, true, None), 16 * 40); + } + + #[test] + fn input_seconds_clamp_to_max_billed_input_seconds() { + // 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds. + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)), 16 * 60); + assert_eq!( + credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)), + credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(30)), + ); + } + } + + // ── USD cents (rounded up) ── + + #[test] + fn usd_cents_are_rounded_up() { + // 130 credits → 13000/243.16 = 53.4627 → 54¢. + let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(54)); + } + + // ── Estimate flags ── + + #[test] + fn estimate_flags() { + let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost(); + assert!(!estimate.is_free); + assert!(!estimate.is_unlimited); + assert!(!estimate.is_rate_limited); + assert!(!estimate.has_watermark); + assert!(estimate.failures_are_refunded.is_none()); + } + + // ── Helpers ── + + fn cost_state( + resolution: Option, + duration_seconds: u8, + has_video_references: bool, + total_input_seconds: Option, + ) -> KinoviSeedance2p5CostState { + KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds } + } + + fn credits( + resolution: Option, + duration_seconds: u8, + has_video_references: bool, + total_input_seconds: Option, + ) -> u64 { + cost_state(resolution, duration_seconds, has_video_references, total_input_seconds) + .estimate_cost() + .cost_in_credits + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/draft.rs new file mode 100644 index 0000000000..dcafeb6059 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/draft.rs @@ -0,0 +1,118 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5::{ + GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Modality, + KinoviSeedance2p5OutputResolution, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, resolve_and_upload_single, video_list_ref_into_urls_or_tokens, +}; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p5DraftState { + pub prompt: String, + + /// Aspect ratio for the reference (text/reference-to-video) modality. + /// Keyframe mode is always "Adaptive" and ignores this. + pub aspect_ratio: KinoviSeedance2p5AspectRatio, + + pub resolution: Option, + + pub duration_seconds: u8, + + /// Calculation-only: total seconds of reference video input, summed across + /// all reference videos (each rounded up to a whole second). + pub total_input_seconds: Option, + + pub unhandled_request_state: Option, +} + +/// Media inputs not yet resolved/uploaded. The build step guarantees that +/// keyframes and reference media are mutually exclusive. +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p5RemainingItems { + pub start_frame: Option, + pub end_frame: Option, + pub reference_images: Option, + pub reference_videos: Option, + pub reference_audio: Option, +} + +impl KinoviSeedance2p5DraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut start_frame_url = None; + let mut end_frame_url = None; + let mut reference_image_urls = None; + let mut reference_video_urls = None; + let mut reference_audio_urls = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + let predownloaded = draft_context.predownloaded_media_paths; + + start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?; + end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?; + + reference_image_urls = resolve_and_upload_list( + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_video_urls = resolve_and_upload_list( + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_audio_urls = resolve_and_upload_list( + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + } + + let modality = match start_frame_url { + Some(start_frame_url) => KinoviSeedance2p5Modality::Keyframe { + start_frame_url, + end_frame_url, + }, + None => { + // The build step rejects an end frame without a start frame, but a + // failed resolution could recreate the situation — never send an + // orphaned end frame as a reference request. + if end_frame_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(), + })); + } + KinoviSeedance2p5Modality::Reference { + aspect_ratio: Some(self.aspect_ratio), + reference_image_urls, + reference_video_urls, + reference_audio_urls, + } + } + }; + + let request = GenerateSeedance2p5Request { + prompt: self.prompt.clone(), + modality, + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + total_input_seconds: self.total_input_seconds, + use_face_blur_hack: None, + }; + + Ok(KinoviSeedance2p5RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/mod.rs new file mode 100644 index 0000000000..fcfc9ddce8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod draft; +pub mod request; +pub mod cost; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/request.rs new file mode 100644 index 0000000000..7ad3ba9f71 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/request.rs @@ -0,0 +1,37 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5::{ + generate_seedance_2p5, GenerateSeedance2p5Args, GenerateSeedance2p5Request, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p5RequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSeedance2p5Request, +} + +impl KinoviSeedance2p5RequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSeedance2p5Args { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_seedance_2p5(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/build.rs new file mode 100644 index 0000000000..a222d3f1ff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/build.rs @@ -0,0 +1,501 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{ + KinoviSeedance2p5PreviewAspectRatio as KinoviAspectRatio, + KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution, +}; + +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::{KinoviSeedance2p5PreviewDraftState, KinoviSeedance2p5PreviewRemainingItems}; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_seedance_2p5_preview(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build_kinovi_seedance_2p5_preview(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p5Preview(draft))) +} + +fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Seedance 2.5 Preview only operates in reference mode — there is no + // keyframe (start/end frame) mode. Dropping a caller's frames would change + // the meaning of the request, so this errors regardless of strategy. + if builder.start_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "start_frame", + value: "Seedance 2.5 Preview has no keyframe mode; use reference_images".to_string(), + })); + } + if builder.end_frame.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Seedance 2.5 Preview has no keyframe mode; use reference_images".to_string(), + })); + } + + // Kinovi character references are not supported by the 2.5 Preview API. + if builder.reference_character_tokens.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_character_tokens", + value: "Seedance 2.5 Preview does not support character references".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + + // NB: `builder.bitrate` is intentionally ignored — 2.5 Preview has no + // bitrate option (and bitrate never affects cost). + + let unhandled_request_state = KinoviSeedance2p5PreviewRemainingItems { + reference_images: builder.reference_images.take(), + reference_videos: builder.reference_videos.take(), + reference_audio: builder.reference_audio.take(), + }; + + Ok(KinoviSeedance2p5PreviewDraftState { + aspect_ratio, + resolution, + duration_seconds, + prompt, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── Plan helpers ── + +// Seedance 2.5 Preview supports all six aspect ratios: +// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so +// both upgrade and downgrade pick the nearest match. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + match aspect_ratio { + // No preference or auto — default to landscape + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9), + + // Direct mappings + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Ok(KinoviAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Ok(KinoviAspectRatio::Portrait9x16) + } + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Ok(KinoviAspectRatio::Square1x1) + } + Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3), + Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9), + + // Mismatches — apply strategy + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(nearest_aspect_ratio(unsupported)) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio { + match aspect_ratio { + RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33 + RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33 + RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75 + RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75 + RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56 + _ => KinoviAspectRatio::Square1x1, + } +} + +// Seedance 2.5 Preview supports output resolutions: 480p and 720p only. +// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + // Unset defaults to explicit 720p. Cost estimation prices unset as 720p, + // so the request must pin 720p too — never leave the resolution up to the + // provider's server-side default, or billing and supplier cost can diverge. + None => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // Direct mappings + Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)), + Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // 1080p is not supported — handle via strategy + Some(RouterResolution::TenEightyP) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", RouterResolution::TenEightyP), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + // 1080p not available — downgrade to 720p (highest supported) + Ok(Some(KinoviOutputResolution::SevenTwentyP)) + } + }, + + // Other unsupported resolutions + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + RouterResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::SevenTwentyP, + })) + } + }, + } +} + +// Seedance 2.5 Preview generates a single video per request (no batching). +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(()), + // Over the maximum of 1. + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + }, + } +} + +// Seedance 2.5 Preview supports durations of 4–30 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + const MIN: u16 = 4; + const MAX: u16 = 30; + const DEFAULT: u8 = 5; + match duration_seconds { + None => Ok(DEFAULT), + Some(d) if d >= MIN && d <= MAX => Ok(d as u8), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(d.clamp(MIN, MAX) as u8), + }, + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{ + KinoviSeedance2p5PreviewAspectRatio as KinoviAspectRatio, + KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution, + }; + use tokens::tokens::characters::CharacterToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::character_list_ref::CharacterListRef; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState; + use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + mod materialized_field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(preview_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateVideoRequestBuilder { prompt: None, ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert_eq!(draft.prompt, ""); + } + + #[test] + fn duration_seconds_converted() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(30), ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert_eq!(draft.duration_seconds, 30); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_max_30() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert_eq!(draft.duration_seconds, 30); + } + + #[test] + fn duration_clamped_to_min_4() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(2), ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert_eq!(draft.duration_seconds, 4); + } + + #[test] + fn duration_out_of_range_error_out() { + let builder = GenerateVideoRequestBuilder { + duration_seconds: Some(99), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + } + + mod unsupported_option_errors { + use super::*; + + #[test] + fn start_frame_errors() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + + #[test] + fn end_frame_errors() { + let builder = GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + + #[test] + fn character_references_error() { + let builder = GenerateVideoRequestBuilder { + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + ])), + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + } + + mod batch_count_conversions { + use super::*; + + #[test] + fn batch_one_is_accepted() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..preview_builder() }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_ok()); + } + + #[test] + fn batch_over_one_downgrades_to_single() { + let builder = GenerateVideoRequestBuilder { + video_batch_count: Some(4), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_ok()); + } + + #[test] + fn batch_over_one_error_out() { + let builder = GenerateVideoRequestBuilder { + video_batch_count: Some(4), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + + #[test] + fn batch_zero_errors() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(0), ..preview_builder() }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + } + + mod aspect_ratio_conversions { + use super::*; + + #[test] + fn direct_mappings() { + let expected = [ + (RouterAspectRatio::WideSixteenByNine, KinoviAspectRatio::Landscape16x9), + (RouterAspectRatio::WideTwentyOneByNine, KinoviAspectRatio::UltraWide21x9), + (RouterAspectRatio::TallNineBySixteen, KinoviAspectRatio::Portrait9x16), + (RouterAspectRatio::Square, KinoviAspectRatio::Square1x1), + (RouterAspectRatio::WideFourByThree, KinoviAspectRatio::Standard4x3), + (RouterAspectRatio::TallThreeByFour, KinoviAspectRatio::Portrait3x4), + ]; + for (input, output) in expected { + let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(input), ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert!( + std::mem::discriminant(&draft.aspect_ratio) == std::mem::discriminant(&output), + "aspect ratio {input:?} mapped wrong", + ); + } + } + + #[test] + fn aspect_ratio_defaults_to_landscape() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + } + + mod resolution_conversions { + use super::*; + + #[test] + fn resolution_480p() { + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP))); + } + + #[test] + fn resolution_720p() { + let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_downgrades_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..preview_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..preview_builder() + }; + assert!(build_kinovi_seedance_2p5_preview(builder).is_err()); + } + + #[test] + fn resolution_none_defaults_to_720p() { + let builder = GenerateVideoRequestBuilder { resolution: None, ..preview_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + } + + mod unhandled_request_state { + use super::*; + + #[test] + fn media_refs_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])), + reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])), + ..preview_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.reference_images.is_some()); + assert!(remaining.reference_videos.is_some()); + assert!(remaining.reference_audio.is_some()); + } + + #[test] + fn empty_refs_are_none_in_unhandled() { + let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(preview_builder())); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.reference_images.is_none()); + assert!(remaining.reference_videos.is_none()); + assert!(remaining.reference_audio.is_none()); + } + } + + // ── Helpers ── + + fn preview_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p5Preview, + provider: RouterProvider::KinoviWeb, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviSeedance2p5PreviewDraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p5Preview(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p5Preview draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/cost.rs new file mode 100644 index 0000000000..f6770103c0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/cost.rs @@ -0,0 +1,152 @@ +use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{ + GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewOutputResolution, +}; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState; + +/// Only the resolution and duration matter — Seedance 2.5 Preview has no +/// video-reference surcharge and no batching, so references never change the +/// price. +pub struct KinoviSeedance2p5PreviewCostState { + pub resolution: Option, + pub duration_seconds: u8, +} + +impl KinoviSeedance2p5PreviewCostState { + pub fn from_request(request: &KinoviSeedance2p5PreviewRequestState) -> Self { + Self { + resolution: request.request.output_resolution, + duration_seconds: request.request.duration_seconds, + } + } + + pub fn from_draft(draft: &KinoviSeedance2p5PreviewDraftState) -> Self { + Self { + resolution: draft.resolution, + duration_seconds: draft.duration_seconds, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let pricing_request = GenerateSeedance2p5PreviewRequest { + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + + // No impact on price (references never affect 2.5 Preview pricing) + prompt: String::new(), + aspect_ratio: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + use_face_blur_hack: None, + }; + + // Enterprise tier: what generations actually cost us (our discounted + // per-model credit rate at our bulk credit purchase rate). + let costs = pricing_request.calculate_enterprise_costs(); + // 2.5 Preview bills fractional credits (42.13/sec at 480p). The router's + // credit field is an integer, so round to the nearest credit; the USD + // cents (the authoritative charge) are already rounded up. + let cost_in_credits = costs.kinovi_credits.round() as u64; + let cost_in_usd_cents = costs.usd_cents_rounded_up; + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use kinovi_web_client::generate::video::generate_seedance_2p5_preview::KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution; + + use super::*; + + // ── Credits (rounded to the nearest whole credit for the integer field) ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_480p() { + // Enterprise 42.13 credits/s: 168.52 → 169, 631.95 → 632, 1053.25 → 1053. + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 4), 169); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10), 421); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 15), 632); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 20), 843); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 25), 1053); + assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30), 1264); + } + + #[test] + fn credits_720p() { + // Enterprise 84.26 credits/s: 337.04 → 337, 1263.9 → 1264, 2106.5 → 2107. + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 4), 337); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 10), 843); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 15), 1264); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 20), 1685); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 25), 2107); + assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30), 2528); + } + + #[test] + fn default_resolution_is_720p() { + assert_eq!(credits(None, 10), credits(Some(KinoviOutputResolution::SevenTwentyP), 10)); + } + } + + // ── USD cents (rounded up) ── + + mod usd_cents_tests { + use super::*; + + #[test] + fn cents_480p() { + // 168.52 credits → 16852/243.16 = 69.30 → 70¢; 631.95 → 259.89 → 260¢. + assert_eq!(usd_cents(Some(KinoviOutputResolution::FourEightyP), 4), 70); + assert_eq!(usd_cents(Some(KinoviOutputResolution::FourEightyP), 15), 260); + } + + #[test] + fn cents_720p() { + // 337.04 credits → 33704/243.16 = 138.61 → 139¢; 2527.8 → 1039.56 → 1040¢. + assert_eq!(usd_cents(Some(KinoviOutputResolution::SevenTwentyP), 4), 139); + assert_eq!(usd_cents(Some(KinoviOutputResolution::SevenTwentyP), 30), 1040); + } + } + + // ── Estimate flags ── + + #[test] + fn estimate_flags() { + let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 4).estimate_cost(); + assert!(!estimate.is_free); + assert!(!estimate.is_unlimited); + assert!(!estimate.is_rate_limited); + assert!(!estimate.has_watermark); + assert!(estimate.failures_are_refunded.is_none()); + } + + // ── Helpers ── + + fn cost_state(resolution: Option, duration_seconds: u8) -> KinoviSeedance2p5PreviewCostState { + KinoviSeedance2p5PreviewCostState { resolution, duration_seconds } + } + + fn credits(resolution: Option, duration_seconds: u8) -> u64 { + cost_state(resolution, duration_seconds).estimate_cost().cost_in_credits.unwrap() + } + + fn usd_cents(resolution: Option, duration_seconds: u8) -> u64 { + cost_state(resolution, duration_seconds).estimate_cost().cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/draft.rs new file mode 100644 index 0000000000..9632193f54 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/draft.rs @@ -0,0 +1,78 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{ + GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewAspectRatio, + KinoviSeedance2p5PreviewOutputResolution, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, video_list_ref_into_urls_or_tokens, +}; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p5PreviewDraftState { + pub prompt: String, + pub aspect_ratio: KinoviSeedance2p5PreviewAspectRatio, + pub resolution: Option, + pub duration_seconds: u8, + + pub unhandled_request_state: Option, +} + +/// 2.5 Preview only supports reference media — no start/end frames and no +/// character references (the builder errors on those). +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p5PreviewRemainingItems { + pub reference_images: Option, + pub reference_videos: Option, + pub reference_audio: Option, +} + +impl KinoviSeedance2p5PreviewDraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_kinovi_web_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + let mut reference_video_urls = None; + let mut reference_audio_urls = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + let predownloaded = draft_context.predownloaded_media_paths; + + reference_image_urls = resolve_and_upload_list( + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_video_urls = resolve_and_upload_list( + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + + reference_audio_urls = resolve_and_upload_list( + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded, + ).await?; + } + + let request = GenerateSeedance2p5PreviewRequest { + prompt: self.prompt.clone(), + aspect_ratio: Some(self.aspect_ratio), + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + use_face_blur_hack: None, + }; + + Ok(KinoviSeedance2p5PreviewRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/mod.rs new file mode 100644 index 0000000000..fcfc9ddce8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod draft; +pub mod request; +pub mod cost; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/request.rs new file mode 100644 index 0000000000..5872ca97ae --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5_preview/request.rs @@ -0,0 +1,37 @@ +use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{ + generate_seedance_2p5_preview, GenerateSeedance2p5PreviewArgs, GenerateSeedance2p5PreviewRequest, +}; + +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p5PreviewRequestState { + /// Final materialized request; ready to fire. + pub request: GenerateSeedance2p5PreviewRequest, +} + +impl KinoviSeedance2p5PreviewRequestState { + pub async fn send(&self, client: &RouterKinoviWebClient) -> Result { + let session = &client.session; + + let args = GenerateSeedance2p5PreviewArgs { + session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_seedance_2p5_preview(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/upload.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/upload.rs new file mode 100644 index 0000000000..ae878a16a3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/upload.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use log::{info, warn}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::utils::download_file::download_file; +use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession; +use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; +use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; +use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions}; + +/// Re-uploads a file to the kinovi_web CDN, reading it from +/// `maybe_local_path` when the caller already downloaded it (e.g. reference +/// videos probed for billing), or downloading it from the source URL +/// otherwise. +pub(crate) async fn upload_to_kinovi_web( + session: &KinoviWebSession, + source_url: &str, + maybe_local_path: Option<&Path>, +) -> Result { + let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All) + .map(|ext| ext.without_period().to_string()) + .unwrap_or_else(|| "jpg".to_string()); + + let file_bytes = match maybe_local_path { + Some(local_path) => match std::fs::read(local_path) { + Ok(bytes) => { + info!("Reusing predownloaded file for kinovi upload: {}", source_url); + bytes + } + Err(err) => { + // The file should exist; fall back to downloading rather than fail. + warn!("Failed to read predownloaded file {:?} ({:?}); re-downloading {}", + local_path, err, source_url); + download_file(source_url).await? + } + }, + None => download_file(source_url).await?, + }; + + let prepare_response = prepare_file_upload(PrepareFileUploadArgs { + session, + extension, + host_override: None, + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + let upload_response = upload_file(UploadFileArgs { + upload_url: prepare_response.upload_url, + file_bytes, + host_override: None, + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?; + + Ok(upload_response.public_url) +} + diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/mod.rs new file mode 100644 index 0000000000..80bd2abb40 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/mod.rs @@ -0,0 +1,5 @@ +pub mod artcraft; +pub mod fal; +pub mod first_party; +pub mod grok_api; +pub mod kinovi; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_cost_estimate.rs new file mode 100644 index 0000000000..adaf5364b4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_cost_estimate.rs @@ -0,0 +1,16 @@ +/// The result of estimating the cost of a video generation plan. +#[derive(Clone, Debug)] +pub struct VideoGenerationCostEstimate { + pub cost_in_credits: Option, + pub cost_in_usd_cents: Option, + pub is_free: bool, + pub is_unlimited: bool, + pub is_rate_limited: bool, + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft.rs new file mode 100644 index 0000000000..cf31d89bff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft.rs @@ -0,0 +1,87 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::draft::KinoviHappyHorse1p0DraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState; +use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +/** + * Wrapper for all video generation draft requests. + */ +#[derive(Clone, Debug)] +pub enum VideoGenerationDraftRequest { + KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState), + KinoviSeedance2p0(KinoviSeedance2p0DraftState), + KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState), + KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState), + KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewDraftState), + KinoviSeedance2p5(KinoviSeedance2p5DraftState), +} + +impl VideoGenerationDraftRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()), + VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()), + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()), + VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) => Ok(KinoviSeedance2p0MiniCostState::from_draft(draft).estimate_cost()), + VideoGenerationDraftRequest::KinoviSeedance2p5Preview(draft) => Ok(KinoviSeedance2p5PreviewCostState::from_draft(draft).estimate_cost()), + VideoGenerationDraftRequest::KinoviSeedance2p5(draft) => Ok(KinoviSeedance2p5CostState::from_draft(draft).estimate_cost()), + } + } + + /// Finalize the draft request before generation + /// This may involve uploading media to the provider. + pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result { + match self { + VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result)) + }, + VideoGenerationDraftRequest::KinoviSeedance2p0(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviSeedance2p0(result)) + }, + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviSeedance2p0Fast(result)) + }, + VideoGenerationDraftRequest::KinoviSeedance2p0Mini(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviSeedance2p0Mini(result)) + }, + VideoGenerationDraftRequest::KinoviSeedance2p5Preview(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviSeedance2p5Preview(result)) + }, + VideoGenerationDraftRequest::KinoviSeedance2p5(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviSeedance2p5(result)) + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft_context.rs new file mode 100644 index 0000000000..b8d70dda5d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft_context.rs @@ -0,0 +1,58 @@ +use crate::client::router_client::RouterClient; +use crate::client::router_kinovi_web_client::RouterKinoviWebClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use std::collections::HashMap; +use std::fmt::Debug; +use std::path::PathBuf; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Default)] +pub struct VideoGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require authentication. + pub client: Option<&'a RouterClient>, + + /// Optional context: a map of Media File Tokens to their ArtCraft URLs + /// Only needed if we have to fetch these assets and upload them to another provider. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, + + /// Optional context: a map of Character Tokens to their respective Kinovi IDs + /// Only necessary if using Kinovi characters + pub character_token_to_kinovi_id_map: Option<&'a HashMap>, + + /// Optional context: source URL → local file path for media the caller has + /// already downloaded (e.g. reference videos probed for billing). Uploads + /// read these files instead of downloading the same bytes again. The + /// caller must keep the files alive until the upload completes. + pub predownloaded_media_paths: Option<&'a HashMap>, +} + +impl <'a> VideoGenerationDraftContext<'a> { + pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> { + let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?; + client.get_kinovi_web_client_ref() + .map_err(|err| ArtcraftRouterError::Client(err)) + } + + pub fn get_media_file_to_artcraft_url_map(&self) -> Result<&HashMap, ArtcraftRouterError> { + self.media_file_to_artcraft_url_map + .ok_or_else(|| ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided)) + } + + pub fn get_character_token_to_kinovi_map(&self) -> Result<&HashMap, ArtcraftRouterError> { + self.character_token_to_kinovi_id_map + .ok_or_else(|| ArtcraftRouterError::Client(ClientError::CharacterTokenToKinoviCharacterIdNotProvided)) + } +} + +impl Debug for VideoGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VideoGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map) + .field("character_token_to_kinovi_id_map", &self.character_token_to_kinovi_id_map) + .field("predownloaded_media_paths", &self.predownloaded_media_paths) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft_or_request.rs new file mode 100644 index 0000000000..5906f5ccf3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_draft_or_request.rs @@ -0,0 +1,28 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +#[derive(Debug, Clone)] +pub enum VideoGenerationDraftOrRequest { + Draft(VideoGenerationDraftRequest), + Request(VideoGenerationRequest), +} + +impl VideoGenerationDraftOrRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + VideoGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), + VideoGenerationDraftOrRequest::Request(request) => request.estimate_cost(), + } + } +} \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs new file mode 100644 index 0000000000..eb8b02dfa7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs @@ -0,0 +1,651 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video::cost::ArtcraftGrokImagineVideoCostState; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::cost::ArtcraftGrokImagineVideo1p5CostState; +use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState; +use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState; +use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState; +use crate::generate::generate_video::providers::artcraft::kling_1_6_pro::cost::ArtcraftKling16ProCostState; +use crate::generate::generate_video::providers::artcraft::kling_1_6_pro::request::ArtcraftKling16ProRequestState; +use crate::generate::generate_video::providers::artcraft::kling_2_1_master::cost::ArtcraftKling21MasterCostState; +use crate::generate::generate_video::providers::artcraft::kling_2_1_master::request::ArtcraftKling21MasterRequestState; +use crate::generate::generate_video::providers::artcraft::kling_2_1_pro::cost::ArtcraftKling21ProCostState; +use crate::generate::generate_video::providers::artcraft::kling_2_1_pro::request::ArtcraftKling21ProRequestState; +use crate::generate::generate_video::providers::artcraft::kling_2_5_turbo_pro::cost::ArtcraftKling2p5TurboProCostState; +use crate::generate::generate_video::providers::artcraft::kling_2_5_turbo_pro::request::ArtcraftKling2p5TurboProRequestState; +use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::cost::ArtcraftKling2p6ProCostState; +use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::request::ArtcraftKling2p6ProRequestState; +use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::cost::ArtcraftKling3p0ProCostState; +use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState; +use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState; +use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState; +use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState; +use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState; +use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::cost::ArtcraftKling3p0StandardCostState; +use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState; +use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState; +use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState; +use crate::generate::generate_video::providers::artcraft::preview_model::cost::ArtcraftPreviewModelCostState; +use crate::generate::generate_video::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState; +use crate::generate::generate_video::providers::artcraft::preview_model_fast::cost::ArtcraftPreviewModelFastCostState; +use crate::generate::generate_video::providers::artcraft::preview_model_fast::request::ArtcraftPreviewModelFastRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::cost::ArtcraftSeedance10LiteCostState; +use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::request::ArtcraftSeedance10LiteRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::cost::ArtcraftSeedance1p5ProCostState; +use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0::cost::ArtcraftSeedance2p0CostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::cost::ArtcraftSeedance2p0BytePlusCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::cost::ArtcraftSeedance2p0BytePlusFastCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::cost::ArtcraftSeedance2p0BytePlusUltraCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::cost::ArtcraftSeedance2p0BytePlusUltraFastCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p5::cost::ArtcraftSeedance2p5CostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::cost::ArtcraftSeedance2p5UltraCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::cost::ArtcraftSeedance2p5PreviewCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::cost::ArtcraftSeedance2p0BytePlusUltraMiniCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState; +use crate::generate::generate_video::providers::artcraft::sora_2::cost::ArtcraftSora2CostState; +use crate::generate::generate_video::providers::artcraft::sora_2::request::ArtcraftSora2RequestState; +use crate::generate::generate_video::providers::artcraft::sora_2_pro::cost::ArtcraftSora2ProCostState; +use crate::generate::generate_video::providers::artcraft::sora_2_pro::request::ArtcraftSora2ProRequestState; +use crate::generate::generate_video::providers::artcraft::veo_2::cost::ArtcraftVeo2CostState; +use crate::generate::generate_video::providers::artcraft::veo_2::request::ArtcraftVeo2RequestState; +use crate::generate::generate_video::providers::artcraft::veo_3::cost::ArtcraftVeo3CostState; +use crate::generate::generate_video::providers::artcraft::veo_3::request::ArtcraftVeo3RequestState; +use crate::generate::generate_video::providers::artcraft::veo_3_fast::cost::ArtcraftVeo3FastCostState; +use crate::generate::generate_video::providers::artcraft::veo_3_fast::request::ArtcraftVeo3FastRequestState; +use crate::generate::generate_video::providers::artcraft::veo_3p1::cost::ArtcraftVeo3p1CostState; +use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState; +use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::cost::ArtcraftVeo3p1FastCostState; +use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState; +use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::cost::ArtcraftVeo3p1LiteCostState; +use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState; +use crate::generate::generate_video::providers::artcraft::vidu_q3::cost::ArtcraftViduQ3CostState; +use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState; +use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::cost::ArtcraftViduQ3TurboCostState; +use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::cost::FalKling16ProCostState; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::FalKling16ProRequestState; +use crate::generate::generate_video::providers::fal::kling_2_1_master::cost::FalKling21MasterCostState; +use crate::generate::generate_video::providers::fal::kling_2_1_master::request::FalKling21MasterRequestState; +use crate::generate::generate_video::providers::fal::kling_2_1_pro::cost::FalKling21ProCostState; +use crate::generate::generate_video::providers::fal::kling_2_1_pro::request::FalKling21ProRequestState; +use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::cost::FalKling2p5TurboProCostState; +use crate::generate::generate_video::providers::fal::kling_2_5_turbo_pro::request::FalKling2p5TurboProRequestState; +use crate::generate::generate_video::providers::fal::kling_2_6_pro::cost::FalKling2p6ProCostState; +use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::FalKling2p6ProRequestState; +use crate::generate::generate_video::providers::fal::kling_3p0_pro::cost::FalKling3p0ProCostState; +use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::FalKling3p0ProRequestState; +use crate::generate::generate_video::providers::fal::flux_3::cost::FalFlux3CostState; +use crate::generate::generate_video::providers::fal::flux_3::request::FalFlux3RequestState; +use crate::generate::generate_video::providers::fal::flux_3_draft::cost::FalFlux3DraftCostState; +use crate::generate::generate_video::providers::fal::flux_3_draft::request::FalFlux3DraftRequestState; +use crate::generate::generate_video::providers::fal::kling_3p0_standard::cost::FalKling3p0StandardCostState; +use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::FalKling3p0StandardRequestState; +use crate::generate::generate_video::providers::fal::minimax_h3::cost::FalMinimaxH3CostState; +use crate::generate::generate_video::providers::fal::minimax_h3::request::FalMinimaxH3RequestState; +use crate::generate::generate_video::providers::fal::seedance_1p0_lite::cost::FalSeedance10LiteCostState; +use crate::generate::generate_video::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState; +use crate::generate::generate_video::providers::fal::sora_2::cost::FalSora2CostState; +use crate::generate::generate_video::providers::fal::sora_2::request::FalSora2RequestState; +use crate::generate::generate_video::providers::fal::sora_2_pro::cost::FalSora2ProCostState; +use crate::generate::generate_video::providers::fal::sora_2_pro::request::FalSora2ProRequestState; +use crate::generate::generate_video::providers::fal::seedance_1p5_pro::cost::FalSeedance1p5ProCostState; +use crate::generate::generate_video::providers::fal::seedance_1p5_pro::request::FalSeedance1p5ProRequestState; +use crate::generate::generate_video::providers::fal::veo_2::cost::FalVeo2CostState; +use crate::generate::generate_video::providers::fal::veo_2::request::FalVeo2RequestState; +use crate::generate::generate_video::providers::fal::veo_3::cost::FalVeo3CostState; +use crate::generate::generate_video::providers::fal::veo_3::request::FalVeo3RequestState; +use crate::generate::generate_video::providers::fal::veo_3_fast::cost::FalVeo3FastCostState; +use crate::generate::generate_video::providers::fal::veo_3_fast::request::FalVeo3FastRequestState; +use crate::generate::generate_video::providers::fal::veo_3p1::cost::FalVeo3p1CostState; +use crate::generate::generate_video::providers::fal::veo_3p1::request::FalVeo3p1RequestState; +use crate::generate::generate_video::providers::fal::veo_3p1_fast::cost::FalVeo3p1FastCostState; +use crate::generate::generate_video::providers::fal::veo_3p1_fast::request::FalVeo3p1FastRequestState; +use crate::generate::generate_video::providers::fal::veo_3p1_lite::cost::FalVeo3p1LiteCostState; +use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::FalVeo3p1LiteRequestState; +use crate::generate::generate_video::providers::fal::vidu_q3::cost::FalViduQ3CostState; +use crate::generate::generate_video::providers::fal::vidu_q3::request::FalViduQ3RequestState; +use crate::generate::generate_video::providers::fal::vidu_q3_turbo::cost::FalViduQ3TurboCostState; +use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::FalViduQ3TurboRequestState; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState; +use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState; +use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState; +use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState; + +#[derive(Clone, Debug)] +pub enum VideoGenerationRequest { + ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState), + ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState), + ArtcraftFlux3(ArtcraftFlux3RequestState), + ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState), + ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState), + ArtcraftKling16Pro(ArtcraftKling16ProRequestState), + ArtcraftKling21Master(ArtcraftKling21MasterRequestState), + ArtcraftKling21Pro(ArtcraftKling21ProRequestState), + ArtcraftKling2p5TurboPro(ArtcraftKling2p5TurboProRequestState), + ArtcraftKling2p6Pro(ArtcraftKling2p6ProRequestState), + ArtcraftKling3p0Pro(ArtcraftKling3p0ProRequestState), + ArtcraftKling3p0Standard(ArtcraftKling3p0StandardRequestState), + ArtcraftMinimaxH3(ArtcraftMinimaxH3RequestState), + ArtcraftPreviewModel(ArtcraftPreviewModelRequestState), + ArtcraftPreviewModelFast(ArtcraftPreviewModelFastRequestState), + ArtcraftSeedance10Lite(ArtcraftSeedance10LiteRequestState), + ArtcraftSeedance1p5Pro(ArtcraftSeedance1p5ProRequestState), + ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState), + ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState), + ArtcraftSeedance2p0BytePlus(ArtcraftSeedance2p0BytePlusRequestState), + ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState), + ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState), + ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState), + ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState), + ArtcraftSeedance2p5Preview(ArtcraftSeedance2p5PreviewRequestState), + ArtcraftSeedance2p5(ArtcraftSeedance2p5RequestState), + ArtcraftSeedance2p5Ultra(ArtcraftSeedance2p5UltraRequestState), + ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState), + ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState), + ArtcraftSora2(ArtcraftSora2RequestState), + ArtcraftSora2Pro(ArtcraftSora2ProRequestState), + ArtcraftVeo2(ArtcraftVeo2RequestState), + ArtcraftVeo3(ArtcraftVeo3RequestState), + ArtcraftVeo3Fast(ArtcraftVeo3FastRequestState), + ArtcraftVeo3p1(ArtcraftVeo3p1RequestState), + ArtcraftVeo3p1Fast(ArtcraftVeo3p1FastRequestState), + ArtcraftVeo3p1Lite(ArtcraftVeo3p1LiteRequestState), + ArtcraftViduQ3(ArtcraftViduQ3RequestState), + ArtcraftViduQ3Turbo(ArtcraftViduQ3TurboRequestState), + FalFlux3(FalFlux3RequestState), + FalFlux3Draft(FalFlux3DraftRequestState), + FalKling16Pro(FalKling16ProRequestState), + FalKling21Master(FalKling21MasterRequestState), + FalKling21Pro(FalKling21ProRequestState), + FalKling2p5TurboPro(FalKling2p5TurboProRequestState), + FalKling2p6Pro(FalKling2p6ProRequestState), + FalKling3p0Pro(FalKling3p0ProRequestState), + FalKling3p0Standard(FalKling3p0StandardRequestState), + FalMinimaxH3(FalMinimaxH3RequestState), + FalSeedance10Lite(FalSeedance10LiteRequestState), + FalSeedance1p5Pro(FalSeedance1p5ProRequestState), + FalSora2(FalSora2RequestState), + FalSora2Pro(FalSora2ProRequestState), + FalVeo2(FalVeo2RequestState), + FalVeo3(FalVeo3RequestState), + FalVeo3Fast(FalVeo3FastRequestState), + FalVeo3p1(FalVeo3p1RequestState), + FalVeo3p1Fast(FalVeo3p1FastRequestState), + FalVeo3p1Lite(FalVeo3p1LiteRequestState), + FalViduQ3(FalViduQ3RequestState), + FalViduQ3Turbo(FalViduQ3TurboRequestState), + GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState), + GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState), + KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState), + KinoviSeedance2p0(KinoviSeedance2p0RequestState), + KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState), + KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState), + KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewRequestState), + KinoviSeedance2p5(KinoviSeedance2p5RequestState), +} + +impl VideoGenerationRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft, + Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft, + Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft, + Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft, + Self::ArtcraftKling21Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftKling2p5TurboPro(_) => RouterProvider::Artcraft, + Self::ArtcraftKling2p6Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftKling3p0Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftFlux3(_) => RouterProvider::Artcraft, + Self::ArtcraftFlux3Draft(_) => RouterProvider::Artcraft, + Self::ArtcraftKling3p0Standard(_) => RouterProvider::Artcraft, + Self::ArtcraftMinimaxH3(_) => RouterProvider::Artcraft, + Self::ArtcraftPreviewModel(_) => RouterProvider::Artcraft, + Self::ArtcraftPreviewModelFast(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance10Lite(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance1p5Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0Fast(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlus(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p5Preview(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p5(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p5Ultra(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft, + Self::ArtcraftSora2(_) => RouterProvider::Artcraft, + Self::ArtcraftSora2Pro(_) => RouterProvider::Artcraft, + Self::ArtcraftVeo2(_) => RouterProvider::Artcraft, + Self::ArtcraftVeo3(_) => RouterProvider::Artcraft, + Self::ArtcraftVeo3Fast(_) => RouterProvider::Artcraft, + Self::ArtcraftVeo3p1(_) => RouterProvider::Artcraft, + Self::ArtcraftVeo3p1Fast(_) => RouterProvider::Artcraft, + Self::ArtcraftVeo3p1Lite(_) => RouterProvider::Artcraft, + Self::ArtcraftViduQ3(_) => RouterProvider::Artcraft, + Self::ArtcraftViduQ3Turbo(_) => RouterProvider::Artcraft, + Self::FalKling16Pro(_) => RouterProvider::Fal, + Self::FalKling21Master(_) => RouterProvider::Fal, + Self::FalKling21Pro(_) => RouterProvider::Fal, + Self::FalKling2p5TurboPro(_) => RouterProvider::Fal, + Self::FalKling2p6Pro(_) => RouterProvider::Fal, + Self::FalKling3p0Pro(_) => RouterProvider::Fal, + Self::FalFlux3(_) => RouterProvider::Fal, + Self::FalFlux3Draft(_) => RouterProvider::Fal, + Self::FalKling3p0Standard(_) => RouterProvider::Fal, + Self::FalMinimaxH3(_) => RouterProvider::Fal, + Self::FalSeedance10Lite(_) => RouterProvider::Fal, + Self::FalSeedance1p5Pro(_) => RouterProvider::Fal, + Self::FalSora2(_) => RouterProvider::Fal, + Self::FalSora2Pro(_) => RouterProvider::Fal, + Self::FalVeo2(_) => RouterProvider::Fal, + Self::FalVeo3(_) => RouterProvider::Fal, + Self::FalVeo3Fast(_) => RouterProvider::Fal, + Self::FalVeo3p1(_) => RouterProvider::Fal, + Self::FalVeo3p1Fast(_) => RouterProvider::Fal, + Self::FalVeo3p1Lite(_) => RouterProvider::Fal, + Self::FalViduQ3(_) => RouterProvider::Fal, + Self::FalViduQ3Turbo(_) => RouterProvider::Fal, + Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi, + Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi, + Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb, + Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost(), + VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling21Pro(request) => Ok(ArtcraftKling21ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling2p5TurboPro(request) => Ok(ArtcraftKling2p5TurboProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling2p6Pro(request) => Ok(ArtcraftKling2p6ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling3p0Pro(request) => Ok(ArtcraftKling3p0ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftFlux3(request) => Ok(ArtcraftFlux3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftFlux3Draft(request) => Ok(ArtcraftFlux3DraftCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftKling3p0Standard(request) => Ok(ArtcraftKling3p0StandardCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftMinimaxH3(request) => Ok(ArtcraftMinimaxH3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftPreviewModelFast(request) => Ok(ArtcraftPreviewModelFastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance10Lite(request) => Ok(ArtcraftSeedance10LiteCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance1p5Pro(request) => Ok(ArtcraftSeedance1p5ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => Ok(ArtcraftSeedance2p0BytePlusCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p5Preview(request) => Ok(ArtcraftSeedance2p5PreviewCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p5(request) => Ok(ArtcraftSeedance2p5CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p5Ultra(request) => Ok(ArtcraftSeedance2p5UltraCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSora2Pro(request) => Ok(ArtcraftSora2ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftVeo2(request) => Ok(ArtcraftVeo2CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftVeo3(request) => Ok(ArtcraftVeo3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftVeo3Fast(request) => Ok(ArtcraftVeo3FastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftVeo3p1(request) => Ok(ArtcraftVeo3p1CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftVeo3p1Fast(request) => Ok(ArtcraftVeo3p1FastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftVeo3p1Lite(request) => Ok(ArtcraftVeo3p1LiteCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftViduQ3(request) => Ok(ArtcraftViduQ3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftViduQ3Turbo(request) => Ok(ArtcraftViduQ3TurboCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling16Pro(request) => Ok(FalKling16ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling21Master(request) => Ok(FalKling21MasterCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling21Pro(request) => Ok(FalKling21ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling2p5TurboPro(request) => Ok(FalKling2p5TurboProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling2p6Pro(request) => Ok(FalKling2p6ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling3p0Pro(request) => Ok(FalKling3p0ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalFlux3(request) => Ok(FalFlux3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalFlux3Draft(request) => Ok(FalFlux3DraftCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalKling3p0Standard(request) => Ok(FalKling3p0StandardCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalMinimaxH3(request) => Ok(FalMinimaxH3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalSeedance10Lite(request) => Ok(FalSeedance10LiteCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalSeedance1p5Pro(request) => Ok(FalSeedance1p5ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalSora2(request) => Ok(FalSora2CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalSora2Pro(request) => Ok(FalSora2ProCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalVeo2(request) => Ok(FalVeo2CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalVeo3(request) => Ok(FalVeo3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalVeo3Fast(request) => Ok(FalVeo3FastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalVeo3p1(request) => Ok(FalVeo3p1CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalVeo3p1Fast(request) => Ok(FalVeo3p1FastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalVeo3p1Lite(request) => Ok(FalVeo3p1LiteCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalViduQ3(request) => Ok(FalViduQ3CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(), + VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::KinoviSeedance2p0Mini(request) => Ok(KinoviSeedance2p0MiniCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::KinoviSeedance2p5Preview(request) => Ok(KinoviSeedance2p5PreviewCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::KinoviSeedance2p5(request) => Ok(KinoviSeedance2p5CostState::from_request(request).estimate_cost()), + } + } + + /// Send the video generation request + /// If successful, returns the job IDs. + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling16Pro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling21Master(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling21Pro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling2p5TurboPro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling2p6Pro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling3p0Pro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftKling3p0Standard(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftMinimaxH3(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftFlux3(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftFlux3Draft(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftPreviewModel(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftPreviewModelFast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance10Lite(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance1p5Pro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p5Preview(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p5(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p5Ultra(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSora2(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSora2Pro(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftVeo2(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftVeo3(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftVeo3Fast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftVeo3p1(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftVeo3p1Fast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftVeo3p1Lite(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftViduQ3(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftViduQ3Turbo(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling16Pro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling21Master(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling21Pro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling2p5TurboPro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling2p6Pro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling3p0Pro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalKling3p0Standard(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalMinimaxH3(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalFlux3(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalFlux3Draft(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalSeedance10Lite(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalSeedance1p5Pro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalSora2(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalSora2Pro(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalVeo2(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalVeo3(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalVeo3Fast(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalVeo3p1(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalVeo3p1Fast(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalVeo3p1Lite(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalViduQ3(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::FalViduQ3Turbo(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::GrokApiGrokImagineVideo(request) => { + let client_ref = client.get_grok_api_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => { + let client_ref = client.get_grok_api_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::KinoviHappyHorse1p0(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::KinoviSeedance2p0(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::KinoviSeedance2p0Fast(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::KinoviSeedance2p0Mini(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::KinoviSeedance2p5Preview(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::KinoviSeedance2p5(request) => { + let client_ref = client.get_kinovi_web_client_ref()?; + request.send(client_ref).await + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs new file mode 100644 index 0000000000..e6b63cc290 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs @@ -0,0 +1,5 @@ +pub mod generate_audio; +pub mod generate_image; +pub mod generate_mesh; +pub mod generate_splat; +pub mod generate_video; diff --git a/crates/api_clients/artcraft/artcraft_router/src/lib.rs b/crates/api_clients/artcraft/artcraft_router/src/lib.rs new file mode 100644 index 0000000000..b7ace7c4b0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/lib.rs @@ -0,0 +1,8 @@ +pub mod api; +pub mod client; +pub mod errors; +pub mod generate; +pub mod utils; + +#[cfg(test)] +pub mod test_helpers; diff --git a/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs new file mode 100644 index 0000000000..9aad59b4c3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/test_helpers.rs @@ -0,0 +1,362 @@ +use crate::api::router_image_model::RouterImageModel; +use crate::api::router_video_model::RouterVideoModel; +use crate::api::router_provider::RouterProvider; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::client::router_client::RouterClient; +use crate::client::router_fal_client::RouterFalClient; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::utils::api_host::ApiHost; +use fal_client::creds::fal_api_key::FalApiKey; + +pub fn get_artcraft_client_for_env(host: ApiHost) -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read /Users/bt/Artcraft/credentials/artcraft_cookies.txt"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(host, credentials)) +} + +pub fn get_artcraft_client() -> RouterClient { + get_artcraft_client_for_env(ApiHost::Storyteller) +} + +pub fn base_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBananaPro, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read /Users/bt/Artcraft/credentials/fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) +} + +pub fn base_fal_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBananaPro, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_seedream_4_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream4, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_seedream_4p5_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream4p5, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_seedream_5_lite_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Seedream5Lite, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_nano_banana_2_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBanana2, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_nano_banana_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBanana, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_gpt_image_1_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage1, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_gpt_image_2_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage2, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_gpt_image_1p5_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::GptImage1p5, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_flux_1_dev_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Flux1Dev, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_flux_1_schnell_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Flux1Schnell, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_flux_pro_1p1_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::FluxPro11, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_qwen_edit_2511_angles_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::QwenEdit2511Angles, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_flux_2_lora_angles_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Flux2LoraAngles, + provider: RouterProvider::Artcraft, + prompt: None, + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_flux_pro_1p1_ultra_image_request() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::FluxPro11Ultra, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } +} + +pub fn base_video_request() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0, + provider: RouterProvider::Artcraft, + prompt: Some("a cat in space".to_string()), + negative_prompt: None, + start_frame: None, + end_frame: None, + reference_images: None, + reference_videos: None, + reference_audio: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + bitrate: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + total_reference_video_input_seconds: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + idempotency_token: None, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/utils/download_file.rs b/crates/api_clients/artcraft/artcraft_router/src/utils/download_file.rs new file mode 100644 index 0000000000..e71b39582b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/utils/download_file.rs @@ -0,0 +1,30 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::download_error::DownloadError; + +/// Download a file from a URL, returning its bytes. +pub async fn download_file(url: &str) -> Result, ArtcraftRouterError> { + let response = reqwest::get(url) + .await + .map_err(|err| DownloadError::ReqwestDownload { + url: url.to_string(), + maybe_status: err.status(), + error: err, + })?; + + let status = response.status(); + if !status.is_success() { + return Err(DownloadError::BadStatus { + url: url.to_string(), + status_code: status.as_u16(), + }.into()); + } + + response.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|err| DownloadError::ReqwestDownload { + url: url.to_string(), + maybe_status: err.status(), + error: err, + }.into()) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/utils/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/utils/mod.rs new file mode 100644 index 0000000000..db2beffde9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod download_file; diff --git a/crates/api_clients/fal_client/Cargo.toml b/crates/api_clients/fal_client/Cargo.toml new file mode 100644 index 0000000000..be9f9a3a28 --- /dev/null +++ b/crates/api_clients/fal_client/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "fal_client" +edition = "2021" +rust-version = "1.75" # MSRV: async traits +version = "0.0.1" +publish = false + +[lib] +name = "fal_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true +file_to_base64_url.workspace = true + +# External +anyhow.workspace = true +futures.workspace = true +log.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +strum.workspace = true +thiserror.workspace = true +tokio.workspace = true +url.workspace = true + +[dev-dependencies] +# Internal Dev +test_utils.workspace = true +test_data.workspace = true + +# External Dev +tokio = { workspace = true, features = ["rt", "macros", "net", "io-util"] } diff --git a/crates/api_clients/fal_client/src/creds/fal_api_key.rs b/crates/api_clients/fal_client/src/creds/fal_api_key.rs new file mode 100644 index 0000000000..2e1e9cc723 --- /dev/null +++ b/crates/api_clients/fal_client/src/creds/fal_api_key.rs @@ -0,0 +1,14 @@ + +#[derive(Clone)] +pub struct FalApiKey(pub String); + +impl FalApiKey { + pub fn new(api_key: String) -> Self { + Self(api_key) + } + + pub fn from_str(api_key: &str) -> Self { + let api_key = api_key.trim().to_string(); + Self(api_key) + } +} diff --git a/crates/api_clients/fal_client/src/creds/mod.rs b/crates/api_clients/fal_client/src/creds/mod.rs new file mode 100644 index 0000000000..092bf41316 --- /dev/null +++ b/crates/api_clients/fal_client/src/creds/mod.rs @@ -0,0 +1,2 @@ +pub mod fal_api_key; +pub mod open_ai_api_key; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/creds/open_ai_api_key.rs b/crates/api_clients/fal_client/src/creds/open_ai_api_key.rs new file mode 100644 index 0000000000..92b9d16c71 --- /dev/null +++ b/crates/api_clients/fal_client/src/creds/open_ai_api_key.rs @@ -0,0 +1,15 @@ + +/// This is for Fal's OpenAI "Bring Your Own Key" (BYOK) endpoints. +#[derive(Clone)] +pub struct OpenAiApiKey(pub String); + +impl OpenAiApiKey { + pub fn new(api_key: String) -> Self { + Self(api_key) + } + + pub fn from_str(api_key: &str) -> Self { + let api_key = api_key.trim().to_string(); + Self(api_key) + } +} diff --git a/crates/api_clients/fal_client/src/error/api_generic_error.rs b/crates/api_clients/fal_client/src/error/api_generic_error.rs new file mode 100644 index 0000000000..fbb7c67867 --- /dev/null +++ b/crates/api_clients/fal_client/src/error/api_generic_error.rs @@ -0,0 +1,34 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use reqwest::StatusCode; + +#[derive(Debug)] +pub enum FalGenericApiError { + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody { + error: serde_json::Error, + body: String, + }, + + /// An uncategorized bad HTTP response. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, +} + +impl Error for FalGenericApiError {} + +impl Display for FalGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::SerdeResponseParseErrorWithBody { error, body } => { + write!(f, "Failed to parse response body: {:?}. Body: {}", error, body) + } + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => { + write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body) + } + } + } +} diff --git a/crates/api_clients/fal_client/src/error/api_specific_error.rs b/crates/api_clients/fal_client/src/error/api_specific_error.rs new file mode 100644 index 0000000000..7b2fa608c1 --- /dev/null +++ b/crates/api_clients/fal_client/src/error/api_specific_error.rs @@ -0,0 +1,19 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum FalSpecificApiError { + /// The job is still in progress. The response URL is not ready yet. + /// Contains the detail message from the FAL API (e.g. "Request is still in progress"). + IncompleteJob(String), +} + +impl Error for FalSpecificApiError {} + +impl Display for FalSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::IncompleteJob(detail) => write!(f, "Job is incomplete: {}", detail), + } + } +} diff --git a/crates/api_clients/fal_client/src/error/classify_fal_error.rs b/crates/api_clients/fal_client/src/error/classify_fal_error.rs new file mode 100644 index 0000000000..f473618db9 --- /dev/null +++ b/crates/api_clients/fal_client/src/error/classify_fal_error.rs @@ -0,0 +1,27 @@ +use crate::error::fal_error::FalError; +use crate::error::fal_error_plus::FalErrorPlus; + +const BILLING_ERROR : &str = "{\"detail\": \"User is locked. Reason: Exhausted balance. Top up your balance at fal.ai/dashboard/billing.\"}"; + +/// Better classify the `FalError` into a more user-friendly `FalErrorPlus`. +pub fn classify_fal_error(err: FalError) -> FalErrorPlus { + match err { + FalError::RequestError(_) => FalErrorPlus::FalError(err), + FalError::SerializeError(_) => FalErrorPlus::FalError(err), + FalError::Other(ref inner) => { + if inner == BILLING_ERROR { + FalErrorPlus::FalBillingError(inner.to_string()) + } else if inner.contains("billing") { + FalErrorPlus::FalBillingError(inner.to_string()) + } else if inner.contains("balance") { + FalErrorPlus::FalBillingError(inner.to_string()) + } else if inner.contains("Invalid Key Authorization header format") { + FalErrorPlus::FalApiKeyError(inner.to_string()) + } else if inner.contains("No user found for Key ID and Secret") { + FalErrorPlus::FalApiKeyError(inner.to_string()) + } else { + FalErrorPlus::FalError(err) + } + } + } +} diff --git a/crates/api_clients/fal_client/src/error/client_error.rs b/crates/api_clients/fal_client/src/error/client_error.rs new file mode 100644 index 0000000000..8439c58b06 --- /dev/null +++ b/crates/api_clients/fal_client/src/error/client_error.rs @@ -0,0 +1,18 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum FalClientError { + /// The URL provided was invalid or pointed to an unexpected host. + InvalidUrl(String), +} + +impl Error for FalClientError {} + +impl Display for FalClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg), + } + } +} diff --git a/crates/api_clients/fal_client/src/error/fal_error.rs b/crates/api_clients/fal_client/src/error/fal_error.rs new file mode 100644 index 0000000000..4a3521f9cb --- /dev/null +++ b/crates/api_clients/fal_client/src/error/fal_error.rs @@ -0,0 +1,21 @@ +/// Error type for Fal API interactions. +/// +/// Originally from the vendored `fal` crate (`fal::FalError`). +/// Copied here so `fal_client` can be independent of the vendored crate. +#[derive(Debug, thiserror::Error)] +pub enum FalError { + #[error("fal request failed: {0}")] + RequestError(#[from] reqwest::Error), + + #[error("serialization error: {0}")] + SerializeError(#[from] serde_json::Error), + + #[error("error: {0}")] + Other(String), +} + +impl From for FalError { + fn from(s: String) -> Self { + FalError::Other(s) + } +} diff --git a/crates/api_clients/fal_client/src/error/fal_error_plus.rs b/crates/api_clients/fal_client/src/error/fal_error_plus.rs new file mode 100644 index 0000000000..506ba35c96 --- /dev/null +++ b/crates/api_clients/fal_client/src/error/fal_error_plus.rs @@ -0,0 +1,85 @@ +use crate::error::api_generic_error::FalGenericApiError; +use crate::error::api_specific_error::FalSpecificApiError; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::client_error::FalClientError; +use crate::error::fal_error::FalError; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Additional errors that aren't included in `crate::error::fal_error::FalError`. +#[derive(Debug)] +pub enum FalErrorPlus { + // =============== Newer error types =============== + + /// A client-side error (invalid input, bad URL, etc.). + ClientError(FalClientError), + + /// A generic API error (bad response, parse failure, etc.). + ApiGeneric(FalGenericApiError), + + /// A specific, categorized API error (incomplete job, etc.). + ApiSpecific(FalSpecificApiError), + + // =============== Older error types (gradually replace these) =============== + + /// An error arising in the `fal` crate. + FalError(crate::error::fal_error::FalError), + /// The fal API key is invalid. + FalApiKeyError(String), + /// The fal account has a billing issue + FalBillingError(String), + /// Error with an invalid polling URL. + InvalidPollingUrl(String), + /// Another error we didn't handle. + AnyhowError(anyhow::Error), + /// URL parse errors. + UrlParseError(url::ParseError), + /// An endpoint we don't support yet. + UnhandledEndpoint(String), + /// Error from the `reqwest` crate. + ReqwestError(reqwest::Error), +} + +impl Display for FalErrorPlus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ClientError(err) => write!(f, "FalErrorPlus::ClientError: {}", err), + Self::ApiGeneric(err) => write!(f, "FalErrorPlus::ApiGeneric: {}", err), + Self::ApiSpecific(err) => write!(f, "FalErrorPlus::ApiSpecific: {}", err), + Self::FalError(err) => write!(f, "FalErrorPlus::FalError: {:?}", err), + Self::FalApiKeyError(reason) => write!(f, "FalErrorPlus::FalApiKeyError: {}", reason), + Self::FalBillingError(reason) => write!(f, "FalErrorPlus::FalBillingError: {}", reason), + Self::InvalidPollingUrl(url) => write!(f, "FalErrorPlus::InvalidPollingUrl: {}", url), + Self::AnyhowError(err) => write!(f, "FalErrorPlus::AnyhowError: {:?}", err), + Self::UrlParseError(err) => write!(f, "FalErrorPlus::UrlParseError: {:?}", err), + Self::UnhandledEndpoint(endpoint) => write!(f, "FalErrorPlus::UnhandledEndpoint: {:?}", endpoint), + Self::ReqwestError(err) => write!(f, "FalErrorPlus::ReqwestError: {:?}", err), + } + } +} + +impl Error for FalErrorPlus {} + +impl From for FalErrorPlus { + fn from(err: FalError) -> Self { + classify_fal_error(err) + } +} + +impl From for FalErrorPlus { + fn from(err: anyhow::Error) -> Self { + FalErrorPlus::AnyhowError(err) + } +} + +impl From for FalErrorPlus { + fn from(err: url::ParseError) -> Self { + FalErrorPlus::UrlParseError(err) + } +} + +impl From for FalErrorPlus { + fn from(err: reqwest::Error) -> Self { + FalErrorPlus::ReqwestError(err) + } +} diff --git a/crates/api_clients/fal_client/src/error/mod.rs b/crates/api_clients/fal_client/src/error/mod.rs new file mode 100644 index 0000000000..261dd15597 --- /dev/null +++ b/crates/api_clients/fal_client/src/error/mod.rs @@ -0,0 +1,6 @@ +pub mod api_generic_error; +pub mod api_specific_error; +pub mod classify_fal_error; +pub mod client_error; +pub mod fal_error; +pub mod fal_error_plus; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/lib.rs b/crates/api_clients/fal_client/src/lib.rs new file mode 100644 index 0000000000..132dd21f76 --- /dev/null +++ b/crates/api_clients/fal_client/src/lib.rs @@ -0,0 +1,9 @@ +//! A fal wrapper + +pub mod creds; +pub mod error; +pub mod polling; +pub mod requests; +pub mod requests_old; +pub mod utils; +pub mod webhook_payload; diff --git a/crates/api_clients/fal_client/src/polling/mod.rs b/crates/api_clients/fal_client/src/polling/mod.rs new file mode 100644 index 0000000000..8f6960df75 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/mod.rs @@ -0,0 +1,2 @@ +pub mod poll_job_status; +pub mod poll_job_response; diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/mod.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/mod.rs new file mode 100644 index 0000000000..06d719e6ba --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/mod.rs @@ -0,0 +1,3 @@ +pub mod poll_job_response; +pub mod raw_response; +pub mod success_case_extractors; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/poll_job_response.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/poll_job_response.rs new file mode 100644 index 0000000000..83fd1c48fd --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/poll_job_response.rs @@ -0,0 +1,428 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::api_generic_error::FalGenericApiError; +use crate::error::api_specific_error::FalSpecificApiError; +use crate::error::client_error::FalClientError; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::polling::poll_job_response::raw_response::RawIncompleteJobResponse; +use crate::polling::poll_job_response::success_case_extractors::{ + extract_contents_from_response, PollResponseExtractedContents, +}; +use log::info; +use serde_json::Value; +use url::Url; + +const EXPECTED_HOST: &str = "queue.fal.run"; + +pub struct PollJobResponseArgs<'a> { + /// This is the "response" URL (not the "status" URL). + /// This fetches the actual results of a completed job. + /// If the job is not complete, this call will fail in error. + pub response_url: &'a str, + + pub api_key: &'a FalApiKey, +} + +/// Parsed response from fetching a completed FAL job's results. +#[derive(Debug)] +pub struct PollJobResponse { + /// The raw JSON payload as a serde_json::Value, for full access. + pub payload: Value, + + /// Extracted known content fields (images, video, model_glb, thumbnail). + /// `None` if no recognized content keys were found in the payload. + pub extracted_contents: Option, + + /// The raw JSON response body, preserved for debugging. + pub raw_body: String, +} + +/// Fetch the results of a completed FAL job. +/// +/// The `response_url` must point to `queue.fal.run`. +/// +/// Returns `FalErrorPlus::ApiSpecific(IncompleteJob)` if the job is still +/// in progress (FAL returns HTTP 400 with `"detail": "Request is still in progress"`). +pub async fn poll_job_response(args: PollJobResponseArgs<'_>) -> Result { + let parsed = Url::parse(args.response_url)?; + + let host = parsed.host_str().unwrap_or(""); + + if host != EXPECTED_HOST { + return Err(FalErrorPlus::ClientError(FalClientError::InvalidUrl(format!( + "Expected host '{}' but got '{}' in response URL: {}", + EXPECTED_HOST, + host, + args.response_url, + )))); + } + + info!("Polling FAL job response: {}", args.response_url); + + let response = reqwest::Client::new() + .get(args.response_url) + .header("Authorization", format!("Key {}", args.api_key.0)) + .send() + .await?; + + let http_status = response.status(); + let body = response.text().await?; + + if !http_status.is_success() { + return Err(classify_error_response(http_status, &body)); + } + + let payload: Value = serde_json::from_str(&body) + .map_err(|err| FalErrorPlus::ApiGeneric( + FalGenericApiError::SerdeResponseParseErrorWithBody { + error: err, + body: body.clone(), + }, + ))?; + + let extracted_contents = extract_contents_from_response(&payload); + + Ok(PollJobResponse { + payload, + extracted_contents, + raw_body: body, + }) +} + +// ── Helpers ── + +/// Classify a non-2xx response. If it's a 400 with "Request is still in progress", +/// return the specific `IncompleteJob` error. Otherwise fall through to a generic error. +fn classify_error_response( + status_code: reqwest::StatusCode, + body: &str, +) -> FalErrorPlus { + if status_code == reqwest::StatusCode::BAD_REQUEST { + if let Ok(raw) = serde_json::from_str::(body) { + if let Some(detail) = raw.detail { + if detail.contains("still in progress") { + return FalErrorPlus::ApiSpecific(FalSpecificApiError::IncompleteJob(detail)); + } + } + } + } + + FalErrorPlus::ApiGeneric( + FalGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: body.to_string(), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Unit tests (no network) ── + + #[tokio::test] + async fn rejects_wrong_host() { + let api_key = FalApiKey::from_str("test-key"); + let args = PollJobResponseArgs { + response_url: "https://evil.example.com/fal-ai/flux/requests/abc123", + api_key: &api_key, + }; + let result = poll_job_response(args).await; + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("evil.example.com"), "error should mention the bad host: {}", err); + } + + #[tokio::test] + async fn rejects_invalid_url() { + let api_key = FalApiKey::from_str("test-key"); + let args = PollJobResponseArgs { + response_url: "not a url at all", + api_key: &api_key, + }; + let result = poll_job_response(args).await; + assert!(result.is_err()); + } + + mod classify_error { + use super::*; + + #[test] + fn incomplete_job_400() { + let body = r#"{"cancel_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/abc/cancel","detail":"Request is still in progress","request_id":"abc","response_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/abc","status_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/abc/status"}"#; + let err = classify_error_response(reqwest::StatusCode::BAD_REQUEST, body); + assert!(matches!(err, FalErrorPlus::ApiSpecific(FalSpecificApiError::IncompleteJob(_)))); + } + + #[test] + fn other_400_is_generic() { + let body = r#"{"detail":"Some other error"}"#; + let err = classify_error_response(reqwest::StatusCode::BAD_REQUEST, body); + assert!(matches!(err, FalErrorPlus::ApiGeneric(FalGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }))); + } + + #[test] + fn non_400_is_generic() { + let body = "Internal Server Error"; + let err = classify_error_response(reqwest::StatusCode::INTERNAL_SERVER_ERROR, body); + assert!(matches!(err, FalErrorPlus::ApiGeneric(FalGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }))); + } + } + + mod extract_payloads { + use super::*; + + #[test] + fn extract_image_payload() { + let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/img.jpg","width":1024,"height":768,"content_type":"image/jpeg"}],"timings":{"inference":1.19},"seed":1248735483,"has_nsfw_concepts":[false],"prompt":"a giant robot"}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).unwrap(); + let images = extracted.images.unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/img.jpg")); + assert_eq!(images[0].width, Some(1024)); + assert_eq!(images[0].height, Some(768)); + } + + #[test] + fn extract_glb_mesh_payload() { + let json = r#"{"model_glb":{"url":"https://v3b.fal.media/files/b/model.glb","content_type":"model/gltf-binary","file_name":"model.glb","file_size":33352724},"thumbnail":{"url":"https://v3b.fal.media/files/b/preview.png","content_type":"image/png","file_name":"preview.png","file_size":99797},"model_urls":{"glb":{"url":"https://v3b.fal.media/files/b/model.glb"},"fbx":null,"obj":{"url":"https://v3b.fal.media/files/b/model.obj"},"usdz":null},"seed":null}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).unwrap(); + + let glb = extracted.model_glb.unwrap(); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/model.glb")); + assert_eq!(glb.file_size, Some(33352724)); + + let thumb = extracted.thumbnail.unwrap(); + assert_eq!(thumb.url.as_deref(), Some("https://v3b.fal.media/files/b/preview.png")); + + assert!(extracted.images.is_none()); + assert!(extracted.video.is_none()); + } + + #[test] + fn no_known_keys_returns_none() { + let json = r#"{"some_unknown_field": "value"}"#; + let value: Value = serde_json::from_str(json).unwrap(); + assert!(extract_contents_from_response(&value).is_none()); + } + + #[test] + fn parse_real_image_response_offline() { + let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/0a99d392/qzx8aXgWTbf5tVcYEF0S8.jpg","width":1024,"height":768,"content_type":"image/jpeg"}],"timings":{"inference":1.1978995269964798},"seed":1248735483,"has_nsfw_concepts":[false],"prompt":"a giant robot fighting a dragon in a futuristic city"}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).expect("should extract contents"); + + // Should have images, not video or mesh + assert!(extracted.video.is_none()); + assert!(extracted.model_glb.is_none()); + assert!(extracted.thumbnail.is_none()); + + let images = extracted.images.expect("should have images"); + assert_eq!(images.len(), 1); + + let image = &images[0]; + assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d392/qzx8aXgWTbf5tVcYEF0S8.jpg")); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(768)); + assert_eq!(image.content_type.as_deref(), Some("image/jpeg")); + + // Verify raw payload also has seed, prompt, timings + assert_eq!(value.get("seed").and_then(|v| v.as_u64()), Some(1248735483)); + assert_eq!(value.get("prompt").and_then(|v| v.as_str()), Some("a giant robot fighting a dragon in a futuristic city")); + let inference_time = value.get("timings").and_then(|v| v.get("inference")).and_then(|v| v.as_f64()).unwrap(); + assert!((inference_time - 1.198).abs() < 0.001); + } + + #[test] + fn parse_real_mesh_response_offline() { + let json = r#"{"model_glb":{"url":"https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb","content_type":"model/gltf-binary","file_name":"model.glb","file_size":33352724},"thumbnail":{"url":"https://v3b.fal.media/files/b/0a99d693/_wB78l-a_WmrlAzaKyTWy_preview.png","content_type":"image/png","file_name":"preview.png","file_size":99797},"model_urls":{"glb":{"url":"https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb","content_type":"model/gltf-binary","file_name":"model.glb","file_size":33352724},"fbx":null,"obj":{"url":"https://v3b.fal.media/files/b/0a99d693/_sxg6RiOiz_N9Eut1uFQo_model.obj","content_type":"text/plain","file_name":"model.obj","file_size":26194790},"usdz":null},"seed":null}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).expect("should extract contents"); + + // Should have model_glb + thumbnail, not images or video + assert!(extracted.images.is_none()); + assert!(extracted.video.is_none()); + + let glb = extracted.model_glb.expect("should have model_glb"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("model.glb")); + assert_eq!(glb.file_size, Some(33352724)); + + let thumbnail = extracted.thumbnail.expect("should have thumbnail"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/_wB78l-a_WmrlAzaKyTWy_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(99797)); + + // Verify raw payload also has model_urls with OBJ + let obj = value.get("model_urls").and_then(|v| v.get("obj")).unwrap(); + assert_eq!(obj.get("url").and_then(|v| v.as_str()), Some("https://v3b.fal.media/files/b/0a99d693/_sxg6RiOiz_N9Eut1uFQo_model.obj")); + assert_eq!(obj.get("content_type").and_then(|v| v.as_str()), Some("text/plain")); + assert_eq!(obj.get("file_name").and_then(|v| v.as_str()), Some("model.obj")); + assert_eq!(obj.get("file_size").and_then(|v| v.as_u64()), Some(26194790)); + + // FBX and USDZ are null + assert!(value.get("model_urls").and_then(|v| v.get("fbx")).and_then(|v| v.as_object()).is_none()); + assert!(value.get("model_urls").and_then(|v| v.get("usdz")).and_then(|v| v.as_object()).is_none()); + } + + #[test] + fn parse_real_kling3_video_response_offline() { + let json = r#"{"video":{"url":"https://v3b.fal.media/files/b/0a99d90a/v1tQEfvnY2bVcn38IvmfD_output.mp4","content_type":"video/mp4","file_name":"output.mp4","file_size":3096422}}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).expect("should extract contents"); + + // Should have video, not images or mesh + assert!(extracted.image.is_none()); + assert!(extracted.images.is_none()); + assert!(extracted.model_glb.is_none()); + assert!(extracted.thumbnail.is_none()); + + let video = extracted.video.expect("should have video"); + assert_eq!(video.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d90a/v1tQEfvnY2bVcn38IvmfD_output.mp4")); + assert_eq!(video.content_type.as_deref(), Some("video/mp4")); + assert_eq!(video.file_name.as_deref(), Some("output.mp4")); + assert_eq!(video.file_size, Some(3096422)); + } + + #[test] + fn parse_real_nano_banana_pro_edit_response_offline() { + let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/0a99d90e/IXN1B_4MHnozvltxEHfvX_BwTMO9xM.png","content_type":"image/png","file_name":"IXN1B_4MHnozvltxEHfvX_BwTMO9xM.png","file_size":null,"width":null,"height":null},{"url":"https://v3b.fal.media/files/b/0a99d90e/aj_Xu0cIUoVSZ6qSSxaZf_wy3l60NO.png","content_type":"image/png","file_name":"aj_Xu0cIUoVSZ6qSSxaZf_wy3l60NO.png","file_size":null,"width":null,"height":null}],"description":"**Developing Spooky Atmosphere**\n\ntest description"}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).expect("should extract contents"); + + // Should have images, not video or mesh + assert!(extracted.image.is_none()); + assert!(extracted.video.is_none()); + assert!(extracted.model_glb.is_none()); + assert!(extracted.thumbnail.is_none()); + + let images = extracted.images.expect("should have images"); + assert_eq!(images.len(), 2); + + assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d90e/IXN1B_4MHnozvltxEHfvX_BwTMO9xM.png")); + assert_eq!(images[0].content_type.as_deref(), Some("image/png")); + assert!(images[0].width.is_none(), "nano banana pro edit returns null width"); + assert!(images[0].height.is_none(), "nano banana pro edit returns null height"); + assert!(images[0].file_size.is_none(), "nano banana pro edit returns null file_size"); + + assert_eq!(images[1].url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d90e/aj_Xu0cIUoVSZ6qSSxaZf_wy3l60NO.png")); + assert_eq!(images[1].content_type.as_deref(), Some("image/png")); + } + + #[test] + fn parse_real_flux_angle_lora_response_offline() { + let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/0a99d915/_61CGILQXk8A7A6FrAbdu_uytUbw2B.png","content_type":"image/png","file_name":"_61CGILQXk8A7A6FrAbdu_uytUbw2B.png","file_size":null,"width":1024,"height":1024}],"seed":225652535,"prompt":" front-right quarter view elevated shot medium shot"}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).expect("should extract contents"); + + // Should have images, not video or mesh + assert!(extracted.image.is_none()); + assert!(extracted.video.is_none()); + assert!(extracted.model_glb.is_none()); + assert!(extracted.thumbnail.is_none()); + + let images = extracted.images.expect("should have images"); + assert_eq!(images.len(), 1); + + let image = &images[0]; + assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d915/_61CGILQXk8A7A6FrAbdu_uytUbw2B.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(1024)); + assert!(image.file_size.is_none()); + + // Verify raw payload fields + assert_eq!(value.get("seed").and_then(|v| v.as_u64()), Some(225652535)); + assert_eq!(value.get("prompt").and_then(|v| v.as_str()), Some(" front-right quarter view elevated shot medium shot")); + } + + #[test] + fn parse_real_remove_bg_response_offline() { + let json = r#"{"image":{"url":"https://v3b.fal.media/files/b/0a99d916/J7OyBamMKRI_RUbkUxnJE_f5b8faa705534b19ad87bc3d48b5c6c5.png","content_type":"image/png","file_name":"f5b8faa705534b19ad87bc3d48b5c6c5.png","file_size":496291,"width":1000,"height":664}}"#; + let value: Value = serde_json::from_str(json).unwrap(); + let extracted = extract_contents_from_response(&value).expect("should extract contents"); + + // Should have single image, not images (plural), video, or mesh + assert!(extracted.images.is_none()); + assert!(extracted.video.is_none()); + assert!(extracted.model_glb.is_none()); + assert!(extracted.thumbnail.is_none()); + + let image = extracted.image.expect("should have single image"); + assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d916/J7OyBamMKRI_RUbkUxnJE_f5b8faa705534b19ad87bc3d48b5c6c5.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("f5b8faa705534b19ad87bc3d48b5c6c5.png")); + assert_eq!(image.file_size, Some(496291)); + assert_eq!(image.width, Some(1000)); + assert_eq!(image.height, Some(664)); + } + } + + // ── Live tests ── + + #[tokio::test] + #[ignore] // requires real API key + async fn poll_completed_image_job() { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal.api_key.txt") + .expect("Failed to read fal.api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + + let args = PollJobResponseArgs { + response_url: "https://queue.fal.run/fal-ai/flux/requests/019e18d8-8c36-7bc1-aa77-2bc2f70268c6", + api_key: &api_key, + }; + + let result = poll_job_response(args).await.expect("poll should succeed"); + let extracted = result.extracted_contents.expect("should have extracted contents"); + + // Should have images, not video or mesh + assert!(extracted.video.is_none()); + assert!(extracted.model_glb.is_none()); + assert!(extracted.thumbnail.is_none()); + + let images = extracted.images.expect("should have images"); + assert_eq!(images.len(), 1); + + let image = &images[0]; + assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d392/qzx8aXgWTbf5tVcYEF0S8.jpg")); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(768)); + assert_eq!(image.content_type.as_deref(), Some("image/jpeg")); + } + + #[tokio::test] + #[ignore] // requires real API key + async fn poll_completed_mesh_job() { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal.api_key.txt") + .expect("Failed to read fal.api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + + let args = PollJobResponseArgs { + response_url: "https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d", + api_key: &api_key, + }; + + let result = poll_job_response(args).await.expect("poll should succeed"); + let extracted = result.extracted_contents.expect("should have extracted contents"); + + // Should have model_glb + thumbnail, not images or video + assert!(extracted.images.is_none()); + assert!(extracted.video.is_none()); + + let glb = extracted.model_glb.expect("should have model_glb"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("model.glb")); + assert_eq!(glb.file_size, Some(33352724)); + + let thumbnail = extracted.thumbnail.expect("should have thumbnail"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/_wB78l-a_WmrlAzaKyTWy_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(99797)); + } +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/raw_response.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/raw_response.rs new file mode 100644 index 0000000000..11b81fb8f3 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/raw_response.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; + +/// Raw JSON response returned by the FAL queue response endpoint +/// when the job is still in progress (HTTP 400). +#[derive(Debug, Deserialize)] +pub struct RawIncompleteJobResponse { + pub detail: Option, + pub request_id: Option, + pub response_url: Option, + pub status_url: Option, + pub cancel_url: Option, +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_image.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_image.rs new file mode 100644 index 0000000000..a49e9647b1 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_image.rs @@ -0,0 +1,17 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct PollResponseSingleImageData { + pub url: Option, + pub width: Option, + pub height: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, +} + +pub fn extract_image(value: &Value) -> Option { + let image = value.get("image")?; + serde_json::from_value(image.clone()).ok() +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_images.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_images.rs new file mode 100644 index 0000000000..7619129581 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_images.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct PollResponseImageEntry { + pub url: Option, + pub width: Option, + pub height: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, +} + +pub fn extract_images(value: &Value) -> Option> { + let arr = value.get("images")?.as_array()?; + let images: Vec = arr.iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect(); + if images.is_empty() { None } else { Some(images) } +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_model_glb.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_model_glb.rs new file mode 100644 index 0000000000..4b1211532a --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_model_glb.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct PollResponseModelGlbData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, +} + +pub fn extract_model_glb(value: &Value) -> Option { + let model_glb = value.get("model_glb")?; + serde_json::from_value(model_glb.clone()).ok() +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_thumbnail.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_thumbnail.rs new file mode 100644 index 0000000000..0d643c1379 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_thumbnail.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct PollResponseThumbnailData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, +} + +pub fn extract_thumbnail(value: &Value) -> Option { + let thumbnail = value.get("thumbnail")?; + serde_json::from_value(thumbnail.clone()).ok() +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_video.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_video.rs new file mode 100644 index 0000000000..326c60d13a --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/extract_video.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct PollResponseVideoData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, +} + +pub fn extract_video(value: &Value) -> Option { + let video = value.get("video")?; + serde_json::from_value(video.clone()).ok() +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/mod.rs b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/mod.rs new file mode 100644 index 0000000000..260eccfbe5 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_response/success_case_extractors/mod.rs @@ -0,0 +1,49 @@ +pub mod extract_image; +pub mod extract_images; +pub mod extract_model_glb; +pub mod extract_thumbnail; +pub mod extract_video; + +use serde_json::Value; + +use crate::polling::poll_job_response::success_case_extractors::extract_image::PollResponseSingleImageData; +use crate::polling::poll_job_response::success_case_extractors::extract_images::PollResponseImageEntry; +use crate::polling::poll_job_response::success_case_extractors::extract_model_glb::PollResponseModelGlbData; +use crate::polling::poll_job_response::success_case_extractors::extract_thumbnail::PollResponseThumbnailData; +use crate::polling::poll_job_response::success_case_extractors::extract_video::PollResponseVideoData; + +/// Extracted content fields from a successful job response. +/// Multiple fields can be populated simultaneously (e.g. model_glb + thumbnail). +#[derive(Debug, Clone)] +pub struct PollResponseExtractedContents { + /// Parsed from `payload.image` (single image result, e.g. background removal). + pub image: Option, + /// Parsed from `payload.images` (batch image results). + pub images: Option>, + pub video: Option, + pub model_glb: Option, + pub thumbnail: Option, +} + +/// Try to extract known content keys from a success response payload. +pub fn extract_contents_from_response(value: &Value) -> Option { + let image = extract_image::extract_image(value); + let images = extract_images::extract_images(value); + let video = extract_video::extract_video(value); + let model_glb = extract_model_glb::extract_model_glb(value); + let thumbnail = extract_thumbnail::extract_thumbnail(value); + + if image.is_none() && images.is_none() && video.is_none() + && model_glb.is_none() && thumbnail.is_none() + { + return None; + } + + Some(PollResponseExtractedContents { + image, + images, + video, + model_glb, + thumbnail, + }) +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_status/mod.rs b/crates/api_clients/fal_client/src/polling/poll_job_status/mod.rs new file mode 100644 index 0000000000..5d53f26e28 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_status/mod.rs @@ -0,0 +1,2 @@ +pub mod poll_job_status; +pub mod raw_response; diff --git a/crates/api_clients/fal_client/src/polling/poll_job_status/poll_job_status.rs b/crates/api_clients/fal_client/src/polling/poll_job_status/poll_job_status.rs new file mode 100644 index 0000000000..5e11495607 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_status/poll_job_status.rs @@ -0,0 +1,245 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::api_generic_error::FalGenericApiError; +use crate::error::client_error::FalClientError; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::polling::poll_job_status::raw_response::RawPollJobStatusResponse; +use log::info; +use url::Url; + +const EXPECTED_HOST: &str = "queue.fal.run"; + +pub struct PollJobStatusArgs<'a> { + /// This is the "status" URL, not the "response" URL. + /// This only gives us the progress of the job, not details of the final results upon completion. + pub status_url: &'a str, + + pub api_key: &'a FalApiKey, +} + +/// The parsed status of a FAL queue job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FalJobStatus { + /// Request is queued, waiting for a runner. + InQueue, + /// A runner is actively processing the request. + InProgress, + /// Processing is complete; results are available at the response URL. + Completed, + /// A status value we haven't mapped yet. + Unknown(String), +} + +/// Parsed response from polling a FAL job's status. +#[derive(Debug)] +pub struct PollJobStatusResponse { + /// The job's current status. + pub status: FalJobStatus, + /// The request ID for this job. + pub request_id: Option, + /// URL to retrieve the completed result. + pub response_url: Option, + /// Queue position (only present when status is InQueue). + pub maybe_queue_position: Option, + /// Inference time in seconds (only present when status is Completed). + pub maybe_inference_time: Option, + /// The raw JSON response body, preserved for debugging or further parsing. + pub raw_body: String, +} + +/// Poll the status of a queued FAL job. +/// +/// The `status_url` must point to `queue.fal.run`. +pub async fn poll_job_status(args: PollJobStatusArgs<'_>) -> Result { + let parsed = Url::parse(args.status_url)?; + + let host = parsed.host_str().unwrap_or(""); + + if host != EXPECTED_HOST { + return Err(FalErrorPlus::ClientError(FalClientError::InvalidUrl(format!( + "Expected host '{}' but got '{}' in status URL: {}", + EXPECTED_HOST, + host, + args.status_url, + )))); + } + + info!("Polling FAL job status: {}", args.status_url); + + let response = reqwest::Client::new() + .get(args.status_url) + .header("Authorization", format!("Key {}", args.api_key.0)) + .send() + .await?; + + let http_status = response.status(); + let body = response.text().await?; + + if !http_status.is_success() { + return Err(FalErrorPlus::ApiGeneric( + FalGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: http_status, + body, + }, + )); + } + + let raw: RawPollJobStatusResponse = serde_json::from_str(&body) + .map_err(|err| FalErrorPlus::ApiGeneric( + FalGenericApiError::SerdeResponseParseErrorWithBody { + error: err, + body: body.clone(), + }, + ))?; + + let status = match raw.status.as_str() { + "IN_QUEUE" => FalJobStatus::InQueue, + "IN_PROGRESS" => FalJobStatus::InProgress, + "COMPLETED" => FalJobStatus::Completed, + other => FalJobStatus::Unknown(other.to_string()), + }; + + let maybe_inference_time = raw.metrics + .and_then(|m| m.inference_time); + + Ok(PollJobStatusResponse { + status, + request_id: raw.request_id, + response_url: raw.response_url, + maybe_queue_position: raw.queue_position, + maybe_inference_time, + raw_body: body, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Unit tests (no network) ── + + #[tokio::test] + async fn rejects_wrong_host() { + let api_key = FalApiKey::from_str("test-key"); + let args = PollJobStatusArgs { + status_url: "https://evil.example.com/fal-ai/flux/requests/abc123", + api_key: &api_key, + }; + let result = poll_job_status(args).await; + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("evil.example.com"), "error should mention the bad host: {}", err); + } + + #[tokio::test] + async fn rejects_invalid_url() { + let api_key = FalApiKey::from_str("test-key"); + let args = PollJobStatusArgs { + status_url: "not a url at all", + api_key: &api_key, + }; + let result = poll_job_status(args).await; + assert!(result.is_err()); + } + + #[test] + fn accepts_valid_host() { + let parsed = Url::parse("https://queue.fal.run/fal-ai/flux/requests/019e18d8-8c36-7bc1-aa77-2bc2f70268c6").unwrap(); + assert_eq!(parsed.host_str(), Some(EXPECTED_HOST)); + } + + // ── Parsing tests ── + + mod parsing { + use super::*; + use crate::polling::poll_job_status::raw_response::RawPollJobStatusResponse; + + #[test] + fn parse_completed_response() { + let json = r#"{"status":"COMPLETED","request_id":"019e194b-f69a-77b1-bada-3f56d7d3c87d","response_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d","status_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d/status","cancel_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d/cancel","logs":null,"metrics":{"inference_time":126.701406955719}}"#; + let raw: RawPollJobStatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(raw.status, "COMPLETED"); + assert_eq!(raw.request_id.as_deref(), Some("019e194b-f69a-77b1-bada-3f56d7d3c87d")); + assert!(raw.response_url.is_some()); + assert!(raw.queue_position.is_none()); + assert!((raw.metrics.unwrap().inference_time.unwrap() - 126.7).abs() < 1.0); + } + + #[test] + fn parse_in_queue_response() { + let json = r#"{"status":"IN_QUEUE","request_id":"019e1951-c778-7120-9c82-16efcf40ec82","response_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e1951-c778-7120-9c82-16efcf40ec82","status_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e1951-c778-7120-9c82-16efcf40ec82/status","cancel_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e1951-c778-7120-9c82-16efcf40ec82/cancel","logs":null,"metrics":{},"queue_position":0}"#; + let raw: RawPollJobStatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(raw.status, "IN_QUEUE"); + assert_eq!(raw.queue_position, Some(0)); + assert!(raw.metrics.unwrap().inference_time.is_none()); + } + + #[test] + fn status_enum_mapping() { + assert_eq!( + match "IN_QUEUE" { "IN_QUEUE" => FalJobStatus::InQueue, _ => unreachable!() }, + FalJobStatus::InQueue + ); + assert_eq!( + match "IN_PROGRESS" { "IN_PROGRESS" => FalJobStatus::InProgress, _ => unreachable!() }, + FalJobStatus::InProgress + ); + assert_eq!( + match "COMPLETED" { "COMPLETED" => FalJobStatus::Completed, _ => unreachable!() }, + FalJobStatus::Completed + ); + } + + #[test] + fn unknown_status_preserved() { + let status = match "SOMETHING_NEW" { + "IN_QUEUE" => FalJobStatus::InQueue, + "IN_PROGRESS" => FalJobStatus::InProgress, + "COMPLETED" => FalJobStatus::Completed, + other => FalJobStatus::Unknown(other.to_string()), + }; + assert_eq!(status, FalJobStatus::Unknown("SOMETHING_NEW".to_string())); + } + } + + // ── Live tests ── + + #[tokio::test] + #[ignore] // requires real API key + async fn poll_single_image_job() { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal.api_key.txt") + .expect("Failed to read fal.api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + + let args = PollJobStatusArgs { + status_url: "https://queue.fal.run/fal-ai/flux/requests/019e18d8-8c36-7bc1-aa77-2bc2f70268c6", + api_key: &api_key, + }; + + let result = poll_job_status(args).await.expect("poll should succeed"); + println!("Status: {:?}", result.status); + println!("Response URL: {:?}", result.response_url); + println!("Inference time: {:?}", result.maybe_inference_time); + assert_eq!(result.status, FalJobStatus::Completed); + assert!(result.response_url.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key + async fn poll_single_mesh_status_job() { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal.api_key.txt") + .expect("Failed to read fal.api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + + let args = PollJobStatusArgs { + status_url: "https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d/status", + api_key: &api_key, + }; + + let result = poll_job_status(args).await.expect("poll should succeed"); + println!("Status: {:?}", result.status); + println!("Response URL: {:?}", result.response_url); + println!("Inference time: {:?}", result.maybe_inference_time); + println!("Raw body: {}", result.raw_body); + assert_eq!(result.status, FalJobStatus::Completed); + } +} diff --git a/crates/api_clients/fal_client/src/polling/poll_job_status/raw_response.rs b/crates/api_clients/fal_client/src/polling/poll_job_status/raw_response.rs new file mode 100644 index 0000000000..8266709f35 --- /dev/null +++ b/crates/api_clients/fal_client/src/polling/poll_job_status/raw_response.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +/// Raw JSON response from the FAL queue status endpoint. +/// +/// See: https://fal.ai/docs/documentation/model-apis/inference/queue +#[derive(Debug, Deserialize)] +pub struct RawPollJobStatusResponse { + pub status: String, + pub request_id: Option, + pub response_url: Option, + pub status_url: Option, + pub cancel_url: Option, + pub queue_position: Option, + pub logs: Option>, + pub metrics: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RawPollJobStatusLog { + pub message: Option, + pub level: Option, + pub source: Option, + pub timestamp: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RawPollJobStatusMetrics { + pub inference_time: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/audio/mod.rs b/crates/api_clients/fal_client/src/requests/api/audio/mod.rs new file mode 100644 index 0000000000..603cfcb38e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/audio/mod.rs @@ -0,0 +1 @@ +pub mod omni; diff --git a/crates/api_clients/fal_client/src/requests/api/audio/omni/mod.rs b/crates/api_clients/fal_client/src/requests/api/audio/omni/mod.rs new file mode 100644 index 0000000000..b93956a4ca --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/audio/omni/mod.rs @@ -0,0 +1 @@ +pub mod seed_audio_1p0; diff --git a/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/api.rs b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/api.rs new file mode 100644 index 0000000000..a05c9a14a3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/api.rs @@ -0,0 +1,459 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::audio::omni::seed_audio_1p0::raw_request::{ + SeedAudio1p0Input, SeedAudio1p0Output, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// ByteDance Seed Audio 1.0: omni audio generation (speech, drama, ambience) +/// with voice presets, voice cloning, and audio/image references. +#[derive(Clone, Debug)] +pub struct SeedAudio1p0Request { + /// Text to synthesize. Reference audio clips supplied via `audio_urls` are + /// cited in the prompt as `@Audio1`, `@Audio2`, `@Audio3`. + pub prompt: String, + + /// Voice to synthesize with: a preset or a cloned voice id. + /// The model picks a voice when `None`. + pub voice: Option, + + /// Up to 3 reference audio URLs, cited in the prompt as `@Audio1`–`@Audio3`. + /// Each clip: ≤30 seconds, ≤10MB, wav/mp3/pcm/ogg_opus. + pub audio_urls: Option>, + + /// A single reference image URL (jpeg/png/webp, ≤10MB) to condition the + /// generation on (e.g. a scene to voice over). + /// NB: fal rejects requests that combine an image reference with audio + /// references — set `image_url` or `audio_urls`, not both. + pub image_url: Option, + + /// Output container. fal's default is mp3 when `None`. + pub output_format: Option, + + /// Output sample rate. fal's default is 24 kHz when `None`. + pub sample_rate: Option, + + /// Speech speed multiplier. Valid range 0.5–2.0; fal's default is 1.0 + /// when `None`. + pub speed: Option, + + /// Volume multiplier. Valid range 0.5–2.0; fal's default is 1.0 when `None`. + pub volume: Option, + + /// Pitch shift in semitones. Valid range −12 to +12; fal's default is 0 + /// when `None`. + pub pitch: Option, +} + +/// A voice for Seed Audio: either one of fal's preset voices or a cloned +/// voice id. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SeedAudio1p0Voice { + Preset(SeedAudio1p0VoicePreset), + /// The id of a cloned voice. + Custom(String), +} + +impl SeedAudio1p0Voice { + fn to_wire_string(&self) -> String { + match self { + Self::Preset(preset) => preset.to_str().to_string(), + Self::Custom(voice_id) => voice_id.clone(), + } + } +} + +/// fal's preset voices. The suffixes name the languages each voice supports +/// (en = English, zh = Chinese, ja = Japanese, es = Spanish, id = Indonesian, +/// pt = Portuguese). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SeedAudio1p0VoicePreset { + ViviMixedEnZhJaEsId, + MindyEnEsIdPtZh, + KianEnZh, + CedricEnZh, + SophieEnZh, + JeanEnZh, + MagnusEnZh, + MabelEnZh, + NadiaEnZh, + OpalEnZh, + PearlEnZh, + QuentinEnZh, + CorinneMixedEnZh, + EstherMixedEnZh, + LylaMixedEnZh, + TracyEsZh, + SandyEsMixedEnZh, + FelixZh, + CelesteZh, + MonkeyKingZh, +} + +impl SeedAudio1p0VoicePreset { + pub fn to_str(&self) -> &'static str { + match self { + Self::ViviMixedEnZhJaEsId => "vivi_mixed_en_zh_ja_es_id", + Self::MindyEnEsIdPtZh => "mindy_en_es_id_pt_zh", + Self::KianEnZh => "kian_en_zh", + Self::CedricEnZh => "cedric_en_zh", + Self::SophieEnZh => "sophie_en_zh", + Self::JeanEnZh => "jean_en_zh", + Self::MagnusEnZh => "magnus_en_zh", + Self::MabelEnZh => "mabel_en_zh", + Self::NadiaEnZh => "nadia_en_zh", + Self::OpalEnZh => "opal_en_zh", + Self::PearlEnZh => "pearl_en_zh", + Self::QuentinEnZh => "quentin_en_zh", + Self::CorinneMixedEnZh => "corinne_mixed_en_zh", + Self::EstherMixedEnZh => "esther_mixed_en_zh", + Self::LylaMixedEnZh => "lyla_mixed_en_zh", + Self::TracyEsZh => "tracy_es_zh", + Self::SandyEsMixedEnZh => "sandy_es_mixed_en_zh", + Self::FelixZh => "felix_zh", + Self::CelesteZh => "celeste_zh", + Self::MonkeyKingZh => "monkey_king_zh", + } + } + + pub fn all() -> [Self; 20] { + [ + Self::ViviMixedEnZhJaEsId, + Self::MindyEnEsIdPtZh, + Self::KianEnZh, + Self::CedricEnZh, + Self::SophieEnZh, + Self::JeanEnZh, + Self::MagnusEnZh, + Self::MabelEnZh, + Self::NadiaEnZh, + Self::OpalEnZh, + Self::PearlEnZh, + Self::QuentinEnZh, + Self::CorinneMixedEnZh, + Self::EstherMixedEnZh, + Self::LylaMixedEnZh, + Self::TracyEsZh, + Self::SandyEsMixedEnZh, + Self::FelixZh, + Self::CelesteZh, + Self::MonkeyKingZh, + ] + } +} + +/// Output audio container. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SeedAudio1p0OutputFormat { + Wav, + /// fal's default. + Mp3, + Pcm, + OggOpus, +} + +impl SeedAudio1p0OutputFormat { + fn to_str(&self) -> &'static str { + match self { + Self::Wav => "wav", + Self::Mp3 => "mp3", + Self::Pcm => "pcm", + Self::OggOpus => "ogg_opus", + } + } +} + +/// Output sample rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SeedAudio1p0SampleRate { + Hz8000, + Hz16000, + /// fal's default. + Hz24000, + Hz32000, + Hz44100, + Hz48000, +} + +impl SeedAudio1p0SampleRate { + fn as_hz(&self) -> u32 { + match self { + Self::Hz8000 => 8_000, + Self::Hz16000 => 16_000, + Self::Hz24000 => 24_000, + Self::Hz32000 => 32_000, + Self::Hz44100 => 44_100, + Self::Hz48000 => 48_000, + } + } +} + +impl FalEndpoint for SeedAudio1p0Request { + const ENDPOINT: &str = "bytedance/seed-audio-1.0"; + + type RawRequest = SeedAudio1p0Input; + type RawResponse = SeedAudio1p0Output; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + voice: self.voice.as_ref().map(|voice| voice.to_wire_string()), + audio_urls: self.audio_urls.clone(), + image_url: self.image_url.clone(), + output_format: self.output_format.map(|format| format.to_str().to_string()), + sample_rate: self.sample_rate.map(|rate| rate.as_hz()), + speed: self.speed, + volume: self.volume, + pitch: self.pitch, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_seed_audio_queue_minimal() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = SeedAudio1p0Request { + prompt: "Generate a short suspense radio drama in a late-night convenience store.".to_string(), + voice: None, + audio_urls: None, + image_url: None, + output_format: None, + sample_rate: None, + speed: None, + volume: None, + pitch: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_seed_audio_queue_preset_voice_tuned() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = SeedAudio1p0Request { + prompt: "Welcome aboard the midnight express. Please keep your arms inside the train at all times.".to_string(), + voice: Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::MagnusEnZh)), + audio_urls: None, + image_url: None, + output_format: Some(SeedAudio1p0OutputFormat::Wav), + sample_rate: Some(SeedAudio1p0SampleRate::Hz44100), + speed: Some(0.9), + volume: Some(1.2), + pitch: Some(-2), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_seed_audio_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = SeedAudio1p0Request { + prompt: "A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string(), + voice: Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::SophieEnZh)), + audio_urls: None, + image_url: None, + output_format: Some(SeedAudio1p0OutputFormat::Mp3), + sample_rate: None, + speed: None, + volume: None, + pitch: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = SeedAudio1p0Request { + prompt: "Read @Audio1 in this voice.".to_string(), + voice: Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::ViviMixedEnZhJaEsId)), + audio_urls: Some(vec![ + "https://example.com/a1.mp3".to_string(), + "https://example.com/a2.wav".to_string(), + "https://example.com/a3.ogg".to_string(), + ]), + image_url: None, + output_format: Some(SeedAudio1p0OutputFormat::OggOpus), + sample_rate: Some(SeedAudio1p0SampleRate::Hz48000), + speed: Some(1.5), + volume: Some(0.5), + pitch: Some(12), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "Read @Audio1 in this voice."); + assert_eq!(raw.voice.as_deref(), Some("vivi_mixed_en_zh_ja_es_id")); + assert_eq!(raw.audio_urls.as_ref().map(|urls| urls.len()), Some(3)); + assert_eq!(raw.image_url, None); + assert_eq!(raw.output_format.as_deref(), Some("ogg_opus")); + assert_eq!(raw.sample_rate, Some(48_000)); + assert_eq!(raw.speed, Some(1.5)); + assert_eq!(raw.volume, Some(0.5)); + assert_eq!(raw.pitch, Some(12)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = SeedAudio1p0Request { + prompt: "minimal".to_string(), + voice: None, + audio_urls: None, + image_url: None, + output_format: None, + sample_rate: None, + speed: None, + volume: None, + pitch: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn custom_voice_id_passes_through_verbatim() { + let request = SeedAudio1p0Request { + prompt: "p".to_string(), + voice: Some(SeedAudio1p0Voice::Custom("my_cloned_voice_id_123".to_string())), + audio_urls: None, + image_url: None, + output_format: None, + sample_rate: None, + speed: None, + volume: None, + pitch: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.voice.as_deref(), Some("my_cloned_voice_id_123")); + } + + #[test] + fn image_url_serializes_when_set() { + let request = SeedAudio1p0Request { + prompt: "Narrate this scene.".to_string(), + voice: None, + audio_urls: None, + image_url: Some("https://example.com/scene.png".to_string()), + output_format: None, + sample_rate: None, + speed: None, + volume: None, + pitch: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "Narrate this scene.", + "image_url": "https://example.com/scene.png", + }), + ); + } + + #[test] + fn negative_pitch_serializes() { + let request = SeedAudio1p0Request { + prompt: "p".to_string(), + voice: None, + audio_urls: None, + image_url: None, + output_format: None, + sample_rate: None, + speed: None, + volume: None, + pitch: Some(-12), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("pitch").and_then(|p| p.as_i64()), Some(-12)); + } + + #[test] + fn every_voice_preset_maps_to_wire_string() { + let expected = [ + "vivi_mixed_en_zh_ja_es_id", + "mindy_en_es_id_pt_zh", + "kian_en_zh", + "cedric_en_zh", + "sophie_en_zh", + "jean_en_zh", + "magnus_en_zh", + "mabel_en_zh", + "nadia_en_zh", + "opal_en_zh", + "pearl_en_zh", + "quentin_en_zh", + "corinne_mixed_en_zh", + "esther_mixed_en_zh", + "lyla_mixed_en_zh", + "tracy_es_zh", + "sandy_es_mixed_en_zh", + "felix_zh", + "celeste_zh", + "monkey_king_zh", + ]; + for (preset, expected) in SeedAudio1p0VoicePreset::all().iter().zip(expected) { + assert_eq!(preset.to_str(), expected, "wire value for {preset:?}"); + } + } + + #[test] + fn every_output_format_maps_to_wire_string() { + for (variant, expected) in [ + (SeedAudio1p0OutputFormat::Wav, "wav"), + (SeedAudio1p0OutputFormat::Mp3, "mp3"), + (SeedAudio1p0OutputFormat::Pcm, "pcm"), + (SeedAudio1p0OutputFormat::OggOpus, "ogg_opus"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn every_sample_rate_maps_to_hz() { + for (variant, expected) in [ + (SeedAudio1p0SampleRate::Hz8000, 8_000), + (SeedAudio1p0SampleRate::Hz16000, 16_000), + (SeedAudio1p0SampleRate::Hz24000, 24_000), + (SeedAudio1p0SampleRate::Hz32000, 32_000), + (SeedAudio1p0SampleRate::Hz44100, 44_100), + (SeedAudio1p0SampleRate::Hz48000, 48_000), + ] { + assert_eq!(variant.as_hz(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(SeedAudio1p0Request::ENDPOINT, "bytedance/seed-audio-1.0"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/cost.rs b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/cost.rs new file mode 100644 index 0000000000..1213a3dbbb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/cost.rs @@ -0,0 +1,128 @@ +use crate::requests::api::audio::omni::seed_audio_1p0::api::SeedAudio1p0Request; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Seed Audio 1.0 pricing (see https://fal.ai/models/bytedance/seed-audio-1.0): +// "Your request will cost $0.1875 per minute." +// +// fal bills per minute of GENERATED audio, and no request parameter fixes the +// output duration — it depends on the prompt. The rate is stored in +// hundredths-of-a-cent per minute (exact), and per-duration costs are rounded +// UP to whole cents so the user is never undercharged. + +/// $0.1875 per minute, in hundredths of a cent. +pub const SEED_AUDIO_1P0_RATE_HUNDREDTH_CENTS_PER_MINUTE: u64 = 1_875; + +/// The duration assumed by the parameterless [`FalRequestCostCalculator`] +/// estimate (the output length isn't knowable at request time). +pub const SEED_AUDIO_1P0_ESTIMATED_DURATION_SECONDS: u64 = 60; + +/// Cost in whole cents for a known output duration: +/// ceil(1875 hundredth-cents/min × seconds / 60 / 100). +pub fn seed_audio_1p0_cost_cents_for_duration_seconds(duration_seconds: u64) -> UsdCents { + (SEED_AUDIO_1P0_RATE_HUNDREDTH_CENTS_PER_MINUTE * duration_seconds).div_ceil(6_000) +} + +impl FalRequestCostCalculator for SeedAudio1p0Request { + /// The output duration isn't known until the audio is generated, so this + /// estimates one minute of output. Use + /// [`seed_audio_1p0_cost_cents_for_duration_seconds`] to price the actual + /// generated duration once it is known. + fn calculate_cost_in_cents(&self) -> UsdCents { + seed_audio_1p0_cost_cents_for_duration_seconds(SEED_AUDIO_1P0_ESTIMATED_DURATION_SECONDS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod duration_cost_table { + use super::*; + + // (duration_seconds, expected_cents) + // Math: ceil($0.1875/min × seconds), i.e. ceil(18.75¢ × secs / 60). + const COST_TABLE: &[(u64, u64)] = &[ + (0, 0), + (1, 1), // 0.3125 → 1 + (10, 4), // 3.125 → 4 + (30, 10), // 9.375 → 10 + (32, 10), // exactly 10.0 + (60, 19), // 18.75 → 19 + (61, 20), // 19.0625 → 20 + (90, 29), // 28.125 → 29 + (96, 30), // exactly 30.0 + (120, 38), // 37.5 → 38 + (300, 94), // 93.75 → 94 + (600, 188), // 187.5 → 188 + ]; + + #[test] + fn matches_cost_table() { + for &(duration_seconds, expected) in COST_TABLE { + let got = seed_audio_1p0_cost_cents_for_duration_seconds(duration_seconds); + assert_eq!(got, expected, "duration_seconds={duration_seconds}"); + } + } + + #[test] + fn cost_scales_with_duration() { + let c30 = seed_audio_1p0_cost_cents_for_duration_seconds(30); + let c60 = seed_audio_1p0_cost_cents_for_duration_seconds(60); + let c120 = seed_audio_1p0_cost_cents_for_duration_seconds(120); + assert!(c30 < c60); + assert!(c60 < c120); + } + + #[test] + fn exact_minute_multiples_never_round() { + // 16 minutes = exactly 300¢ ($3.00): 1875 × 960 / 6000 = 300. + assert_eq!(seed_audio_1p0_cost_cents_for_duration_seconds(960), 300); + } + } + + mod request_estimate { + use super::*; + + fn make_request() -> SeedAudio1p0Request { + SeedAudio1p0Request { + prompt: "test".to_string(), + voice: None, + audio_urls: None, + image_url: None, + output_format: None, + sample_rate: None, + speed: None, + volume: None, + pitch: None, + } + } + + #[test] + fn estimates_one_minute() { + // 60s at $0.1875/min = 18.75¢ → rounds up to 19¢. + assert_eq!(make_request().calculate_cost_in_cents(), 19); + assert_eq!( + make_request().calculate_cost_in_cents(), + seed_audio_1p0_cost_cents_for_duration_seconds(SEED_AUDIO_1P0_ESTIMATED_DURATION_SECONDS), + ); + } + + #[test] + fn request_options_do_not_affect_the_estimate() { + use crate::requests::api::audio::omni::seed_audio_1p0::api::{ + SeedAudio1p0OutputFormat, SeedAudio1p0SampleRate, SeedAudio1p0Voice, + SeedAudio1p0VoicePreset, + }; + + let mut tuned = make_request(); + tuned.voice = Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::FelixZh)); + tuned.output_format = Some(SeedAudio1p0OutputFormat::Wav); + tuned.sample_rate = Some(SeedAudio1p0SampleRate::Hz48000); + tuned.speed = Some(2.0); + tuned.volume = Some(0.5); + tuned.pitch = Some(-12); + + assert_eq!(tuned.calculate_cost_in_cents(), make_request().calculate_cost_in_cents()); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/mod.rs b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/raw_request.rs new file mode 100644 index 0000000000..e763c50b2f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/audio/omni/seed_audio_1p0/raw_request.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `bytedance/seed-audio-1.0`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedAudio1p0Input { + /// Text to synthesize. Reference audio clips are cited in the prompt as + /// `@Audio1`, `@Audio2`, `@Audio3`. + pub prompt: String, + + /// Voice to use: a preset voice name or a cloned voice id. + #[serde(skip_serializing_if = "Option::is_none")] + pub voice: Option, + + /// Up to 3 reference audio URLs (each ≤30s and ≤10MB; wav/mp3/pcm/ogg_opus). + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_urls: Option>, + + /// A single reference image URL (jpeg/png/webp, ≤10MB). + /// Cannot be combined with audio references. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + + /// Output container. + /// Possible values: "wav", "mp3", "pcm", "ogg_opus". fal default: "mp3". + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// Output sample rate in Hz. + /// Possible values: 8000, 16000, 24000, 32000, 44100, 48000. fal default: 24000. + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_rate: Option, + + /// Speech speed multiplier. Range 0.5–2.0. fal default: 1.0. + #[serde(skip_serializing_if = "Option::is_none")] + pub speed: Option, + + /// Volume multiplier. Range 0.5–2.0. fal default: 1.0. + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + + /// Pitch shift in semitones. Range −12 to +12. fal default: 0. + #[serde(skip_serializing_if = "Option::is_none")] + pub pitch: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedAudio1p0AudioFile { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_size: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedAudio1p0Output { + pub audio: SeedAudio1p0AudioFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/api.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/api.rs new file mode 100644 index 0000000000..6ddb7d7127 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/api.rs @@ -0,0 +1,156 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::angle::flux_2_lora_edit_image_angle::raw_request::{Flux2LoraEditImageAngleInput, Flux2LoraEditImageAngleOutput}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + + +#[derive(Clone, Debug)] +pub struct Flux2LoraEditImageAngleRequest { + // Request required + pub image_urls: Vec, + + // Camera parameters + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + + // Optional args + pub num_images: Option, + pub image_size: Option, + pub lora_scale: Option, + pub guidance_scale: Option, + pub num_inference_steps: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux2LoraAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux2LoraAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +impl FalEndpoint for Flux2LoraEditImageAngleRequest { + const ENDPOINT : &str = "fal-ai/flux-2-lora-gallery/multiple-angles"; + + type RawRequest = Flux2LoraEditImageAngleInput; + type RawResponse = Flux2LoraEditImageAngleOutput; + + fn to_raw_request(&self) -> Result { + let num_images = self.num_images + .map(|n| match n { + Flux2LoraAngleNumImages::One => 1, + Flux2LoraAngleNumImages::Two => 2, + Flux2LoraAngleNumImages::Three => 3, + Flux2LoraAngleNumImages::Four => 4, + }); + + let image_size = self.image_size + .map(|s| match s { + Flux2LoraAngleImageSize::Square => "square", + Flux2LoraAngleImageSize::SquareHd => "square_hd", + Flux2LoraAngleImageSize::PortraitFourThree => "portrait_4_3", + Flux2LoraAngleImageSize::PortraitSixteenNine => "portrait_16_9", + Flux2LoraAngleImageSize::LandscapeFourThree => "landscape_4_3", + Flux2LoraAngleImageSize::LandscapeSixteenNine => "landscape_16_9", + }) + .map(|s| s.to_string()); + + Ok(Self::RawRequest { + image_urls: self.image_urls.clone(), + horizontal_angle: self.horizontal_angle, + vertical_angle: self.vertical_angle, + zoom: self.zoom, + lora_scale: self.lora_scale, + guidance_scale: self.guidance_scale, + num_inference_steps: self.num_inference_steps, + image_size, + num_images, + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Unused + seed: None, + }) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test_single() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux2LoraEditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + num_images: Some(Flux2LoraAngleNumImages::One), + image_size: Some(Flux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {:?}", result.request_id); + Ok(()) + } + + /// Enqueues 27 angle combinations: 3 horizontal x 3 vertical x 3 zoom levels. + #[tokio::test] + #[ignore] + async fn test_angle_grid() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0]; + let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0]; + let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0]; + + for &h in &horizontal_angles { + for &v in &vertical_angles { + for &z in &zoom_levels { + let request = Flux2LoraEditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(h), + vertical_angle: Some(v), + zoom: Some(z), + num_images: Some(Flux2LoraAngleNumImages::One), + image_size: Some(Flux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Enqueued: h={}, v={}, z={}, request_id={:?}", h, v, z, result.request_id); + } + } + } + + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/cost.rs new file mode 100644 index 0000000000..8f2df6bc9f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/cost.rs @@ -0,0 +1,62 @@ +use crate::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Flux2LoraEditImageAngleRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.021 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~2 cents per image. + let unit_cost = 2; + let cost = match self.num_images { + None => unit_cost, + Some(Flux2LoraAngleNumImages::One) => unit_cost, + Some(Flux2LoraAngleNumImages::Two) => unit_cost * 2, + Some(Flux2LoraAngleNumImages::Three) => unit_cost * 3, + Some(Flux2LoraAngleNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(num_images: Option) -> Flux2LoraEditImageAngleRequest { + Flux2LoraEditImageAngleRequest { + image_urls: vec!["https://example.com/image.jpg".to_string()], + horizontal_angle: None, + vertical_angle: None, + zoom: None, + num_images, + image_size: None, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + } + } + + #[test] + fn cost_default_one_image() { + assert_eq!(make_request(None).calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_one_image() { + assert_eq!(make_request(Some(Flux2LoraAngleNumImages::One)).calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_two_images() { + assert_eq!(make_request(Some(Flux2LoraAngleNumImages::Two)).calculate_cost_in_cents(), 4); + } + + #[test] + fn cost_three_images() { + assert_eq!(make_request(Some(Flux2LoraAngleNumImages::Three)).calculate_cost_in_cents(), 6); + } + + #[test] + fn cost_four_images() { + assert_eq!(make_request(Some(Flux2LoraAngleNumImages::Four)).calculate_cost_in_cents(), 8); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/mod.rs new file mode 100644 index 0000000000..acd14d2fb4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/mod.rs @@ -0,0 +1,3 @@ +mod raw_request; +pub mod api; +pub mod cost; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/raw_request.rs new file mode 100644 index 0000000000..116d841e12 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/flux_2_lora_edit_image_angle/raw_request.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux2LoraEditImageAngleInput { + /// The URL of the image to adjust camera angle for. + pub image_urls: Vec, + + /// Horizontal rotation angle around the object in degrees. + /// 0°=front view, 90°=right side, 180°=back view, 270°=left side. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub horizontal_angle: Option, + + /// Vertical camera angle in degrees. + /// 0°=eye-level shot, 30°=elevated shot, 60°=high-angle shot. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub vertical_angle: Option, + + /// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up. + /// Default: 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, + + /// The strength of the multiple angles effect. + /// Default: 1, range: 0-2 + #[serde(skip_serializing_if = "Option::is_none")] + pub lora_scale: Option, + + /// The size of the generated image. + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default: 2.5, range: 0-20 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// The number of inference steps to perform. + /// Default: 40, range: 1-50 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Random seed for reproducibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Number of images to generate. + /// Default: 1, max: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Whether to enable the safety checker. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Output format: png, jpeg, webp. + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux2LoraEditImageAngleFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux2LoraEditImageAngleOutput { + pub images: Vec, + pub seed: u64, + pub prompt: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/mod.rs new file mode 100644 index 0000000000..bcb20e27da --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_2_lora_edit_image_angle; +pub mod qwen_edit_2511_edit_image_angle; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/api.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/api.rs new file mode 100644 index 0000000000..b77b53bd75 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/api.rs @@ -0,0 +1,162 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::angle::qwen_edit_2511_edit_image_angle::raw_request::{ + QwenEdit2511EditImageAngleInput, QwenEdit2511EditImageAngleOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct QwenEdit2511EditImageAngleRequest { + // Request required + pub image_urls: Vec, + + // Camera parameters + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + + // Optional args + pub additional_prompt: Option, + pub num_images: Option, + pub image_size: Option, + pub lora_scale: Option, + pub guidance_scale: Option, + pub num_inference_steps: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum QwenEdit2511AngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum QwenEdit2511AngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +impl FalEndpoint for QwenEdit2511EditImageAngleRequest { + const ENDPOINT: &str = "fal-ai/qwen-image-edit-2511-multiple-angles"; + + type RawRequest = QwenEdit2511EditImageAngleInput; + type RawResponse = QwenEdit2511EditImageAngleOutput; + + fn to_raw_request(&self) -> Result { + let num_images = self.num_images + .map(|n| match n { + QwenEdit2511AngleNumImages::One => 1, + QwenEdit2511AngleNumImages::Two => 2, + QwenEdit2511AngleNumImages::Three => 3, + QwenEdit2511AngleNumImages::Four => 4, + }); + + let image_size = self.image_size + .map(|s| match s { + QwenEdit2511AngleImageSize::Square => "square", + QwenEdit2511AngleImageSize::SquareHd => "square_hd", + QwenEdit2511AngleImageSize::PortraitFourThree => "portrait_4_3", + QwenEdit2511AngleImageSize::PortraitSixteenNine => "portrait_16_9", + QwenEdit2511AngleImageSize::LandscapeFourThree => "landscape_4_3", + QwenEdit2511AngleImageSize::LandscapeSixteenNine => "landscape_16_9", + }) + .map(|s| s.to_string()); + + Ok(Self::RawRequest { + image_urls: self.image_urls.clone(), + horizontal_angle: self.horizontal_angle, + vertical_angle: self.vertical_angle, + zoom: self.zoom, + additional_prompt: self.additional_prompt.clone(), + lora_scale: self.lora_scale, + guidance_scale: self.guidance_scale, + num_inference_steps: self.num_inference_steps, + image_size, + num_images, + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Unused + negative_prompt: None, + seed: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_single() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = QwenEdit2511EditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + additional_prompt: Some("cinematic lighting".to_string()), + num_images: Some(QwenEdit2511AngleNumImages::One), + image_size: Some(QwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {:?}", result.request_id); + Ok(()) + } + + /// Enqueues 27 angle combinations: 3 horizontal x 3 vertical x 3 zoom levels. + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_angle_grid() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0]; + let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0]; + let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0]; + + for &h in &horizontal_angles { + for &v in &vertical_angles { + for &z in &zoom_levels { + let request = QwenEdit2511EditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(h), + vertical_angle: Some(v), + zoom: Some(z), + additional_prompt: None, + num_images: Some(QwenEdit2511AngleNumImages::One), + image_size: Some(QwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Enqueued: h={}, v={}, z={}, request_id={:?}", h, v, z, result.request_id); + } + } + } + + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/cost.rs new file mode 100644 index 0000000000..a9f90bd395 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/cost.rs @@ -0,0 +1,76 @@ +use crate::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{ + QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for QwenEdit2511EditImageAngleRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.035 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~4 cents per image. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(QwenEdit2511AngleNumImages::One) => unit_cost, + Some(QwenEdit2511AngleNumImages::Two) => unit_cost * 2, + Some(QwenEdit2511AngleNumImages::Three) => unit_cost * 3, + Some(QwenEdit2511AngleNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cost_default_one_image() { + let request = QwenEdit2511EditImageAngleRequest { + image_urls: vec!["https://example.com/image.jpg".to_string()], + horizontal_angle: None, + vertical_angle: None, + zoom: None, + additional_prompt: None, + num_images: None, + image_size: None, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 4); + } + + #[test] + fn cost_one_image() { + let request = QwenEdit2511EditImageAngleRequest { + image_urls: vec!["https://example.com/image.jpg".to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + additional_prompt: None, + num_images: Some(QwenEdit2511AngleNumImages::One), + image_size: None, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 4); + } + + #[test] + fn cost_four_images() { + let request = QwenEdit2511EditImageAngleRequest { + image_urls: vec!["https://example.com/image.jpg".to_string()], + horizontal_angle: None, + vertical_angle: None, + zoom: None, + additional_prompt: None, + num_images: Some(QwenEdit2511AngleNumImages::Four), + image_size: None, + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 16); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/raw_request.rs new file mode 100644 index 0000000000..2d92733447 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/angle/qwen_edit_2511_edit_image_angle/raw_request.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct QwenEdit2511EditImageAngleInput { + /// The URL(s) of the image(s) to adjust camera angle for. + pub image_urls: Vec, + + /// Horizontal rotation angle around the object in degrees. 0°=front view. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub horizontal_angle: Option, + + /// Vertical camera angle in degrees. -30°=low-angle shot, 30°=high-angle shot. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub vertical_angle: Option, + + /// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up. + /// Default: 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, + + /// Additional text to append to the automatically generated prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_prompt: Option, + + /// The scale factor for the LoRA model. Controls the strength of the effect. + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub lora_scale: Option, + + /// The size of the generated image. + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default: 4.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// The number of inference steps to perform. + /// Default: 28 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// The negative prompt for the generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Random seed for reproducibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Number of images to generate. + /// Default: 1, max: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Whether to enable the safety checker. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Output format: png, jpeg, webp. + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenEdit2511EditImageAngleFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenEdit2511EditImageAngleOutput { + pub images: Vec, + pub seed: u64, + pub prompt: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/background/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/background/mod.rs new file mode 100644 index 0000000000..791f160fd8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/background/mod.rs @@ -0,0 +1 @@ +pub mod rembg_remove_background; diff --git a/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/api.rs b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/api.rs new file mode 100644 index 0000000000..487e264539 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/api.rs @@ -0,0 +1,53 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::background::rembg_remove_background::raw_request::{ + RembgRemoveBackgroundInput, RembgRemoveBackgroundOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct RembgRemoveBackgroundRequest { + /// URL of the image to remove the background from. + pub image_url: String, +} + +impl FalEndpoint for RembgRemoveBackgroundRequest { + const ENDPOINT: &str = "fal-ai/imageutils/rembg"; + + type RawRequest = RembgRemoveBackgroundInput; + type RawResponse = RembgRemoveBackgroundOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + image_url: self.image_url.clone(), + crop_to_bbox: None, + sync_mode: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_remove_background() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = RembgRemoveBackgroundRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {:?}", result.request_id); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/cost.rs new file mode 100644 index 0000000000..c55615074d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/cost.rs @@ -0,0 +1,23 @@ +use crate::requests::api::image::background::rembg_remove_background::api::RembgRemoveBackgroundRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for RembgRemoveBackgroundRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.004 per image (less than 1 cent). + // We round up to 0 cents for now + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cost_is_one_cent() { + let request = RembgRemoveBackgroundRequest { + image_url: "https://example.com/image.jpg".to_string(), + }; + assert_eq!(request.calculate_cost_in_cents(), 0); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/raw_request.rs new file mode 100644 index 0000000000..282c58e42a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/background/rembg_remove_background/raw_request.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct RembgRemoveBackgroundInput { + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub crop_to_bbox: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RembgRemoveBackgroundFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RembgRemoveBackgroundOutput { + pub image: RembgRemoveBackgroundFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2_resolution.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2_resolution.rs new file mode 100644 index 0000000000..a8d483406c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2_resolution.rs @@ -0,0 +1,206 @@ +use serde::{Deserialize, Serialize}; + +/// Our synthetic resolution tiers for GPT Image 2. When combined with an +/// aspect ratio (image_size), we compute a concrete width x height that +/// satisfies all GPT Image 2 constraints: +/// +/// - Maximum edge length <= 3840px +/// - Both edges must be multiples of 16px +/// - Long-to-short ratio must not exceed 3:1 +/// - Total pixels: 655,360 <= w*h <= 8,294,400 +#[derive(Copy, Clone, Debug)] +pub enum GptImage2Resolution { + /// ~1K on the long edge (1024px baseline) + OneK, + /// ~2K on the long edge (2048px baseline) + TwoK, + /// ~3K on the long edge (3072px baseline) + ThreeK, + /// ~4K on the long edge (max edge, up to 3840px) + FourK, +} + +/// The aspect ratios supported by GPT Image 2 (the standard image_size enum +/// values that carry aspect-ratio semantics). +#[derive(Copy, Clone, Debug)] +pub enum GptImage2AspectRatio { + /// 1:1 + Square, + /// 1:1 (HD variant — same ratio, higher default res) + SquareHd, + /// 4:3 + Landscape4x3, + /// 16:9 + Landscape16x9, + /// 3:4 + Portrait4x3, + /// 9:16 + Portrait16x9, +} + +/// A custom image size with explicit width and height, serialisable as the +/// JSON object `{"width": N, "height": N}` that Fal accepts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomImageSize { + pub width: u32, + pub height: u32, +} + +const MIN_PIXELS: u64 = 655_360; +const MAX_PIXELS: u64 = 8_294_400; +const MAX_EDGE: u32 = 3840; + +/// Given an aspect ratio and a resolution tier, compute a concrete +/// `CustomImageSize`. The returned dimensions are guaranteed to satisfy all +/// GPT Image 2 constraints. +pub fn compute_custom_image_size(aspect: GptImage2AspectRatio, resolution: GptImage2Resolution) -> CustomImageSize { + // Aspect ratio as (w_ratio, h_ratio) + let (w_ratio, h_ratio): (u32, u32) = match aspect { + GptImage2AspectRatio::Square | GptImage2AspectRatio::SquareHd => (1, 1), + GptImage2AspectRatio::Landscape4x3 => (4, 3), + GptImage2AspectRatio::Landscape16x9 => (16, 9), + GptImage2AspectRatio::Portrait4x3 => (3, 4), + GptImage2AspectRatio::Portrait16x9 => (9, 16), + }; + + // Target long-edge pixels for each tier + let target_long_edge: u32 = match resolution { + GptImage2Resolution::OneK => 1024, + GptImage2Resolution::TwoK => 2048, + GptImage2Resolution::ThreeK => 3072, + GptImage2Resolution::FourK => 3840, + }; + + // Compute raw dimensions from the target long edge and the aspect ratio. + // The long edge gets `target_long_edge`; the short edge is derived. + let (raw_w, raw_h) = if w_ratio >= h_ratio { + // Landscape or square: width is the long edge + let w = target_long_edge; + let h = (w as u64 * h_ratio as u64 / w_ratio as u64) as u32; + (w, h) + } else { + // Portrait: height is the long edge + let h = target_long_edge; + let w = (h as u64 * w_ratio as u64 / h_ratio as u64) as u32; + (w, h) + }; + + // Round both dimensions down to nearest multiple of 16 + let mut w = (raw_w / 16) * 16; + let mut h = (raw_h / 16) * 16; + + // Clamp each edge to MAX_EDGE + w = w.min(MAX_EDGE); + h = h.min(MAX_EDGE); + + // Ensure minimum pixels by scaling up if needed + let pixels = w as u64 * h as u64; + if pixels < MIN_PIXELS { + // Scale up uniformly + let scale = ((MIN_PIXELS as f64) / (pixels as f64)).sqrt(); + w = (((w as f64 * scale).ceil() as u32 + 15) / 16) * 16; + h = (((h as f64 * scale).ceil() as u32 + 15) / 16) * 16; + } + + // Ensure maximum pixels by scaling down if needed + let pixels = w as u64 * h as u64; + if pixels > MAX_PIXELS { + let scale = ((MAX_PIXELS as f64) / (pixels as f64)).sqrt(); + w = ((w as f64 * scale).floor() as u32 / 16) * 16; + h = ((h as f64 * scale).floor() as u32 / 16) * 16; + } + + // Final clamp + w = w.min(MAX_EDGE); + h = h.min(MAX_EDGE); + + CustomImageSize { width: w, height: h } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL_ASPECTS: &[GptImage2AspectRatio] = &[ + GptImage2AspectRatio::Square, + GptImage2AspectRatio::SquareHd, + GptImage2AspectRatio::Landscape4x3, + GptImage2AspectRatio::Landscape16x9, + GptImage2AspectRatio::Portrait4x3, + GptImage2AspectRatio::Portrait16x9, + ]; + + const ALL_RESOLUTIONS: &[GptImage2Resolution] = &[ + GptImage2Resolution::OneK, + GptImage2Resolution::TwoK, + GptImage2Resolution::ThreeK, + GptImage2Resolution::FourK, + ]; + + #[test] + fn all_combinations_satisfy_constraints() { + for &aspect in ALL_ASPECTS { + for &resolution in ALL_RESOLUTIONS { + let size = compute_custom_image_size(aspect, resolution); + let pixels = size.width as u64 * size.height as u64; + let max_edge = size.width.max(size.height); + let min_edge = size.width.min(size.height); + + assert!(size.width % 16 == 0, "width {} not multiple of 16 for {:?} {:?}", size.width, aspect, resolution); + assert!(size.height % 16 == 0, "height {} not multiple of 16 for {:?} {:?}", size.height, aspect, resolution); + assert!(max_edge <= MAX_EDGE, "max edge {} exceeds 3840 for {:?} {:?}", max_edge, aspect, resolution); + assert!(max_edge as f64 / min_edge as f64 <= 3.0, "ratio {:.2} exceeds 3:1 for {:?} {:?} ({}x{})", max_edge as f64 / min_edge as f64, aspect, resolution, size.width, size.height); + assert!(pixels >= MIN_PIXELS, "pixels {} < 655360 for {:?} {:?} ({}x{})", pixels, aspect, resolution, size.width, size.height); + assert!(pixels <= MAX_PIXELS, "pixels {} > 8294400 for {:?} {:?} ({}x{})", pixels, aspect, resolution, size.width, size.height); + } + } + } + + #[test] + fn all_combinations_dimensions_snapshot() { + // Print all dimensions for manual review / documentation + for &aspect in ALL_ASPECTS { + for &resolution in ALL_RESOLUTIONS { + let size = compute_custom_image_size(aspect, resolution); + let pixels = size.width as u64 * size.height as u64; + println!("{:?} + {:?} => {}x{} ({} pixels)", aspect, resolution, size.width, size.height, pixels); + } + } + } + + mod specific_dimensions { + use super::*; + + #[test] + fn square_1k() { + let s = compute_custom_image_size(GptImage2AspectRatio::Square, GptImage2Resolution::OneK); + assert_eq!(s.width, 1024); + assert_eq!(s.height, 1024); + } + + #[test] + fn square_4k() { + let s = compute_custom_image_size(GptImage2AspectRatio::Square, GptImage2Resolution::FourK); + // sqrt(8294400) ~= 2880, but 3840 * 3840 = 14745600 > MAX, so it gets clamped + assert!(s.width <= MAX_EDGE); + assert!(s.height <= MAX_EDGE); + assert!(s.width as u64 * s.height as u64 <= MAX_PIXELS); + } + + #[test] + fn landscape_16x9_1k() { + // 1024x576 = 589,824 < MIN_PIXELS, so gets scaled up to 1088x608 + let s = compute_custom_image_size(GptImage2AspectRatio::Landscape16x9, GptImage2Resolution::OneK); + assert_eq!(s.width, 1088); + assert_eq!(s.height, 608); + } + + #[test] + fn portrait_16x9_4k() { + let s = compute_custom_image_size(GptImage2AspectRatio::Portrait16x9, GptImage2Resolution::FourK); + assert!(s.width <= MAX_EDGE); + assert!(s.height <= MAX_EDGE); + assert!(s.width as u64 * s.height as u64 <= MAX_PIXELS); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_background.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_background.rs new file mode 100644 index 0000000000..c064c27426 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_background.rs @@ -0,0 +1,29 @@ +/// Background treatment for the generated image. Fal's default is `Auto`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GptImage2p5Background { + Auto, + Transparent, + Opaque, +} + +impl GptImage2p5Background { + pub fn to_str(self) -> &'static str { + match self { + GptImage2p5Background::Auto => "auto", + GptImage2p5Background::Transparent => "transparent", + GptImage2p5Background::Opaque => "opaque", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_strings_match_fal_schema() { + assert_eq!(GptImage2p5Background::Auto.to_str(), "auto"); + assert_eq!(GptImage2p5Background::Transparent.to_str(), "transparent"); + assert_eq!(GptImage2p5Background::Opaque.to_str(), "opaque"); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_edit_image_params.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_edit_image_params.rs new file mode 100644 index 0000000000..107a7011cb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_edit_image_params.rs @@ -0,0 +1,299 @@ +use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_background::GptImage2p5Background; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_pricing::{ + estimate_output_image_cost, estimate_reference_images_cost, hundredths_of_cent_to_cents, +}; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_edit_image_input::GptImage2p5EditImageInput; +use crate::requests::traits::fal_request_cost_calculator_trait::UsdCents; + +/// Fal rejects more than this many reference images. +pub const MAX_REFERENCE_IMAGES: usize = 16; + +/// Parameters for a GPT Image 2.5 edit request. Identical for Flare and Sunburst; the +/// per-endpoint request types wrap this. +#[derive(Clone, Debug)] +pub struct GptImage2p5EditImageParams { + /// Text prompt describing the edit. 1 to 32,000 characters. + pub prompt: String, + + /// One to sixteen reference image URLs. + pub image_urls: Vec, + + /// Optional mask URL indicating which part of the image to edit. + pub mask_url: Option, + + /// Number of images to generate. + pub num_images: GptImage2p5NumImages, + + /// Output image size preset. `None` lets Fal default to `auto` (inferred from the input + /// images). + pub image_size: Option, + + /// Optional resolution tier. When present (and the size is not `Auto`), a custom + /// width x height is computed from the preset's aspect ratio and sent instead of the + /// preset name. + pub resolution: Option, + + /// Quality tier. `None` lets Fal default to `high`. + pub quality: Option, + + /// Background treatment. `None` lets Fal default to `auto`. + pub background: Option, + + /// Output format. `None` sends `png`. + pub output_format: Option, + + /// Compression level 0 to 100. Only sent for `jpeg` and `webp`. + pub output_compression: Option, +} + +impl GptImage2p5EditImageParams { + /// Fal's default when `image_size` is omitted. + pub const DEFAULT_IMAGE_SIZE: GptImage2p5ImageSize = GptImage2p5ImageSize::Auto; + + /// Fal's default when `quality` is omitted. + pub const DEFAULT_QUALITY: GptImage2p5Quality = GptImage2p5Quality::High; + + pub fn to_raw_input(&self) -> GptImage2p5EditImageInput { + let output_format = self.output_format.unwrap_or(GptImage2p5OutputFormat::Png); + let output_compression = if output_format.supports_compression() { + self.output_compression.map(|c| c.min(100)) + } else { + None + }; + + GptImage2p5EditImageInput { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + mask_url: self.mask_url.clone(), + image_size: self.image_size.map(|size| size.to_raw_param(self.resolution)), + background: self.background.map(|b| b.to_str().to_string()), + quality: self.quality.map(|q| q.to_str().to_string()), + num_images: Some(self.num_images.to_u8()), + output_format: Some(output_format.to_str().to_string()), + output_compression, + } + } + + /// Estimated cost in whole cents, rounded up per image. + /// + /// Each output image is charged for rendering plus an allowance for reading the reference + /// images (the mask is not counted). With no explicit size the estimate uses the + /// conservative `Auto` dimensions. + pub fn estimate_cost_in_cents(&self) -> UsdCents { + let quality = self.quality.unwrap_or(Self::DEFAULT_QUALITY); + let (width, height) = self.image_size + .unwrap_or(Self::DEFAULT_IMAGE_SIZE) + .estimated_output_dimensions(self.resolution); + let hundredths_per_image = estimate_output_image_cost(quality, width, height) + + estimate_reference_images_cost(self.image_urls.len()); + hundredths_of_cent_to_cents(hundredths_per_image) * self.num_images.to_u64() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + use GptImage2Resolution::*; + use GptImage2p5ImageSize::*; + use GptImage2p5NumImages::*; + use GptImage2p5Quality::{Low, Medium, High, XHigh, Max}; + + const IMAGE_URL: &str = "https://example.com/image.png"; + + mod to_raw_input_tests { + use super::*; + + #[test] + fn minimal_request_serializes_to_expected_json() { + let raw = make_params(1, One, None, None, None).to_raw_input(); + let json = serde_json::to_value(&raw).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "test", + "image_urls": [IMAGE_URL], + "num_images": 1, + "output_format": "png", + })); + } + + #[test] + fn full_request_serializes_to_expected_json() { + let params = GptImage2p5EditImageParams { + prompt: "add a hat".to_string(), + image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()], + mask_url: Some("https://example.com/mask.png".to_string()), + num_images: Two, + image_size: Some(Portrait4x3), + resolution: Some(OneK), + quality: Some(Max), + background: Some(GptImage2p5Background::Opaque), + output_format: Some(GptImage2p5OutputFormat::Jpeg), + output_compression: Some(90), + }; + let json = serde_json::to_value(params.to_raw_input()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "add a hat", + "image_urls": ["https://example.com/a.png", "https://example.com/b.png"], + "mask_url": "https://example.com/mask.png", + "image_size": { "width": 768, "height": 1024 }, + "background": "opaque", + "quality": "max", + "num_images": 2, + "output_format": "jpeg", + "output_compression": 90, + })); + } + + #[test] + fn auto_size_sends_auto_preset_even_with_resolution() { + let raw = make_params(1, One, None, Some(Auto), Some(TwoK)).to_raw_input(); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "auto"), + GptImage2p5ImageSizeParam::Custom(_) => panic!("expected preset"), + } + } + + #[test] + fn resolution_without_size_is_not_sent() { + let raw = make_params(1, One, None, None, Some(TwoK)).to_raw_input(); + assert!(raw.image_size.is_none()); + } + + #[test] + fn compression_dropped_for_png() { + let mut params = make_params(1, One, None, None, None); + params.output_compression = Some(50); + assert!(params.to_raw_input().output_compression.is_none()); + } + } + + mod cost_tests { + use super::*; + + // (size, resolution, [low, medium, high, xhigh, max]) — expected cents per image with + // one reference image. Pinned from the model in `gpt_image_2p5_pricing`. + const GRID_ONE_REFERENCE: &[(GptImage2p5ImageSize, Option, [u64; 5])] = &[ + // 1:1 + (Square, None, [2, 3, 7, 11, 22]), // 1024x1024 + (Square, Some(OneK), [2, 3, 7, 11, 22]), // 1024x1024 + (Square, Some(TwoK), [2, 3, 10, 16, 35]), // 2048x2048 + (Square, Some(ThreeK), [3, 4, 14, 23, 51]), // 2880x2880 + (Square, Some(FourK), [3, 4, 14, 23, 51]), // 2880x2880 (pixel cap) + (SquareHd, None, [2, 3, 10, 16, 35]), // 2048x2048 + (SquareHd, Some(OneK), [2, 3, 7, 11, 22]), // 1024x1024 + (SquareHd, Some(TwoK), [2, 3, 10, 16, 35]), // 2048x2048 + (SquareHd, Some(ThreeK), [3, 4, 14, 23, 51]), // 2880x2880 + (SquareHd, Some(FourK), [3, 4, 14, 23, 51]), // 2880x2880 (pixel cap) + // 4:3 + (Landscape4x3, None, [2, 2, 5, 8, 16]), // 1024x768 + (Landscape4x3, Some(OneK), [2, 2, 5, 8, 16]), // 1024x768 + (Landscape4x3, Some(TwoK), [2, 3, 7, 12, 25]), // 2048x1536 + (Landscape4x3, Some(ThreeK), [2, 4, 11, 19, 40]), // 3072x2304 + (Landscape4x3, Some(FourK), [3, 4, 12, 21, 45]), // 3312x2480 (pixel cap) + (Portrait4x3, None, [2, 2, 5, 8, 16]), // 768x1024 + (Portrait4x3, Some(OneK), [2, 2, 5, 8, 16]), // 768x1024 + (Portrait4x3, Some(TwoK), [2, 3, 7, 12, 25]), // 1536x2048 + (Portrait4x3, Some(ThreeK), [2, 4, 11, 19, 40]), // 2304x3072 + (Portrait4x3, Some(FourK), [3, 4, 12, 21, 45]), // 2480x3312 (pixel cap) + // 16:9 + (Landscape16x9, None, [2, 2, 5, 8, 17]), // 1920x1080 + (Landscape16x9, Some(OneK), [2, 2, 4, 6, 12]), // 1088x608 + (Landscape16x9, Some(TwoK), [2, 2, 6, 9, 18]), // 2048x1152 + (Landscape16x9, Some(ThreeK), [2, 3, 8, 14, 30]), // 3072x1728 + (Landscape16x9, Some(FourK), [2, 4, 11, 19, 41]), // 3840x2160 + (Portrait16x9, None, [2, 2, 5, 8, 17]), // 1080x1920 + (Portrait16x9, Some(OneK), [2, 2, 4, 6, 12]), // 608x1088 + (Portrait16x9, Some(TwoK), [2, 2, 6, 9, 18]), // 1152x2048 + (Portrait16x9, Some(ThreeK), [2, 3, 8, 14, 30]), // 1728x3072 + (Portrait16x9, Some(FourK), [2, 4, 11, 19, 41]), // 2160x3840 + // Auto always prices at the conservative 3840x2160 + (Auto, None, [2, 4, 11, 19, 41]), + (Auto, Some(OneK), [2, 4, 11, 19, 41]), + (Auto, Some(FourK), [2, 4, 11, 19, 41]), + ]; + + const ALL_QUALITIES: [GptImage2p5Quality; 5] = [Low, Medium, High, XHigh, Max]; + + #[test] + fn exhaustive_grid_with_one_reference_image() { + for &(size, resolution, expected) in GRID_ONE_REFERENCE { + for (quality, expected) in ALL_QUALITIES.iter().zip(expected) { + let actual = make_params(1, One, Some(*quality), Some(size), resolution).estimate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {resolution:?} {quality:?}"); + } + } + } + + #[test] + fn batch_scales_linearly_across_grid() { + for &(size, resolution, _) in GRID_ONE_REFERENCE { + let per_image = make_params(1, One, Some(High), Some(size), resolution).estimate_cost_in_cents(); + for (num, n) in [(Two, 2), (Three, 3), (Four, 4)] { + let actual = make_params(1, num, Some(High), Some(size), resolution).estimate_cost_in_cents(); + assert_eq!(actual, per_image * n, "{size:?} {resolution:?} x{n}"); + } + } + } + + #[test] + fn more_reference_images_never_cost_less() { + for &(size, resolution, _) in GRID_ONE_REFERENCE { + let mut previous = 0; + for refs in 1..=16 { + let cost = make_params(refs, One, Some(High), Some(size), resolution).estimate_cost_in_cents(); + assert!(cost >= previous, "{size:?} {resolution:?} {refs} refs: {cost} < {previous}"); + previous = cost; + } + } + } + + #[test] + fn defaults_to_high_quality_auto_size() { + // 3840x2160 high = 1001 + one reference image 80 = 1081 hundredths => 11 cents + assert_eq!(make_params(1, One, None, None, None).estimate_cost_in_cents(), 11); + assert_eq!(make_params(1, One, Some(High), Some(Auto), None).estimate_cost_in_cents(), 11); + } + + #[test] + fn reference_images_add_to_cost() { + // Square high = 529 hundredths; each reference image adds 80. + assert_eq!(make_params(1, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 7); // 609 + assert_eq!(make_params(3, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 8); // 769 + assert_eq!(make_params(16, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 19); // 1809 + } + + #[test] + fn batch_multiplies_per_image_cents() { + assert_eq!(make_params(1, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 7); + assert_eq!(make_params(1, Two, Some(High), Some(Square), None).estimate_cost_in_cents(), 14); + assert_eq!(make_params(1, Three, Some(High), Some(Square), None).estimate_cost_in_cents(), 21); + assert_eq!(make_params(1, Four, Some(High), Some(Square), None).estimate_cost_in_cents(), 28); + } + } + + fn make_params( + num_reference_images: usize, + num_images: GptImage2p5NumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2p5EditImageParams { + GptImage2p5EditImageParams { + prompt: "test".to_string(), + image_urls: vec![IMAGE_URL.to_string(); num_reference_images], + mask_url: None, + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_image_size.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_image_size.rs new file mode 100644 index 0000000000..76f96bc77b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_image_size.rs @@ -0,0 +1,169 @@ +use crate::requests::api::image::common::gpt_image_2_resolution::{ + compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution, +}; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + +/// Output image size preset. Both text-to-image and edit accept the same set, including +/// `Auto` (text-to-image: model picks; edit: inferred from the input images). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GptImage2p5ImageSize { + SquareHd, + Square, + Portrait4x3, + Portrait16x9, + Landscape4x3, + Landscape16x9, + Auto, +} + +/// Conservative dimensions assumed for `Auto` when estimating cost: the largest canonical +/// size Fal publishes pricing for. +pub const AUTO_SIZE_ESTIMATED_DIMENSIONS: (u32, u32) = (3840, 2160); + +impl GptImage2p5ImageSize { + /// Build the wire `image_size` value. + /// + /// With a resolution tier, a concrete `{width, height}` is computed from the preset's + /// aspect ratio. `Auto` has no aspect ratio, so it is always sent as the `"auto"` preset + /// and any resolution tier is ignored. + pub fn to_raw_param(self, resolution: Option) -> GptImage2p5ImageSizeParam { + match (self.to_aspect_ratio(), resolution) { + (Some(aspect), Some(resolution)) => { + GptImage2p5ImageSizeParam::Custom(compute_custom_image_size(aspect, resolution)) + } + _ => GptImage2p5ImageSizeParam::Preset(self.to_preset_str().to_string()), + } + } + + /// The output dimensions we expect Fal to render, used for cost estimation. + pub fn estimated_output_dimensions(self, resolution: Option) -> (u32, u32) { + match (self.to_aspect_ratio(), resolution) { + (Some(aspect), Some(resolution)) => { + let custom = compute_custom_image_size(aspect, resolution); + (custom.width, custom.height) + } + _ => self.preset_dimensions(), + } + } + + pub fn to_preset_str(self) -> &'static str { + match self { + GptImage2p5ImageSize::SquareHd => "square_hd", + GptImage2p5ImageSize::Square => "square", + GptImage2p5ImageSize::Portrait4x3 => "portrait_4_3", + GptImage2p5ImageSize::Portrait16x9 => "portrait_16_9", + GptImage2p5ImageSize::Landscape4x3 => "landscape_4_3", + GptImage2p5ImageSize::Landscape16x9 => "landscape_16_9", + GptImage2p5ImageSize::Auto => "auto", + } + } + + /// `None` for `Auto`, which carries no aspect ratio. + pub fn to_aspect_ratio(self) -> Option { + match self { + GptImage2p5ImageSize::SquareHd => Some(GptImage2AspectRatio::SquareHd), + GptImage2p5ImageSize::Square => Some(GptImage2AspectRatio::Square), + GptImage2p5ImageSize::Portrait4x3 => Some(GptImage2AspectRatio::Portrait4x3), + GptImage2p5ImageSize::Portrait16x9 => Some(GptImage2AspectRatio::Portrait16x9), + GptImage2p5ImageSize::Landscape4x3 => Some(GptImage2AspectRatio::Landscape4x3), + GptImage2p5ImageSize::Landscape16x9 => Some(GptImage2AspectRatio::Landscape16x9), + GptImage2p5ImageSize::Auto => None, + } + } + + /// Dimensions assumed for a preset sent without a resolution tier. These match the + /// assumptions used by the GPT Image 2 bindings; `Auto` uses the conservative maximum. + fn preset_dimensions(self) -> (u32, u32) { + match self { + GptImage2p5ImageSize::SquareHd => (2048, 2048), + GptImage2p5ImageSize::Square => (1024, 1024), + GptImage2p5ImageSize::Portrait4x3 => (768, 1024), + GptImage2p5ImageSize::Portrait16x9 => (1080, 1920), + GptImage2p5ImageSize::Landscape4x3 => (1024, 768), + GptImage2p5ImageSize::Landscape16x9 => (1920, 1080), + GptImage2p5ImageSize::Auto => AUTO_SIZE_ESTIMATED_DIMENSIONS, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use GptImage2p5ImageSize::*; + + const ALL_SIZES: &[GptImage2p5ImageSize] = &[ + SquareHd, Square, Portrait4x3, Portrait16x9, Landscape4x3, Landscape16x9, Auto, + ]; + + #[test] + fn wire_strings_match_fal_schema() { + assert_eq!(SquareHd.to_preset_str(), "square_hd"); + assert_eq!(Square.to_preset_str(), "square"); + assert_eq!(Portrait4x3.to_preset_str(), "portrait_4_3"); + assert_eq!(Portrait16x9.to_preset_str(), "portrait_16_9"); + assert_eq!(Landscape4x3.to_preset_str(), "landscape_4_3"); + assert_eq!(Landscape16x9.to_preset_str(), "landscape_16_9"); + assert_eq!(Auto.to_preset_str(), "auto"); + } + + #[test] + fn preset_without_resolution_sends_preset_string() { + for &size in ALL_SIZES { + match size.to_raw_param(None) { + GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, size.to_preset_str()), + GptImage2p5ImageSizeParam::Custom(_) => panic!("{size:?} should be a preset"), + } + } + } + + #[test] + fn preset_with_resolution_sends_custom_dimensions() { + match Landscape16x9.to_raw_param(Some(GptImage2Resolution::TwoK)) { + GptImage2p5ImageSizeParam::Custom(c) => { + assert_eq!(c.width, 2048); + assert_eq!(c.height, 1152); + } + GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[test] + fn auto_with_resolution_falls_back_to_auto_preset() { + match Auto.to_raw_param(Some(GptImage2Resolution::TwoK)) { + GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "auto"), + GptImage2p5ImageSizeParam::Custom(_) => panic!("Auto must never send custom dimensions"), + } + } + + #[test] + fn estimated_dimensions_without_resolution() { + assert_eq!(Square.estimated_output_dimensions(None), (1024, 1024)); + assert_eq!(SquareHd.estimated_output_dimensions(None), (2048, 2048)); + assert_eq!(Landscape4x3.estimated_output_dimensions(None), (1024, 768)); + assert_eq!(Portrait4x3.estimated_output_dimensions(None), (768, 1024)); + assert_eq!(Landscape16x9.estimated_output_dimensions(None), (1920, 1080)); + assert_eq!(Portrait16x9.estimated_output_dimensions(None), (1080, 1920)); + assert_eq!(Auto.estimated_output_dimensions(None), (3840, 2160)); + } + + #[test] + fn estimated_dimensions_with_resolution_match_wire_dimensions() { + for &size in ALL_SIZES { + if size == Auto { + continue; + } + for res in [GptImage2Resolution::OneK, GptImage2Resolution::TwoK, GptImage2Resolution::ThreeK, GptImage2Resolution::FourK] { + let (w, h) = size.estimated_output_dimensions(Some(res)); + match size.to_raw_param(Some(res)) { + GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (w, h), "{size:?} {res:?}"), + GptImage2p5ImageSizeParam::Preset(_) => panic!("{size:?} {res:?} should be custom"), + } + } + } + } + + #[test] + fn auto_ignores_resolution_for_estimation() { + assert_eq!(Auto.estimated_output_dimensions(Some(GptImage2Resolution::OneK)), (3840, 2160)); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_num_images.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_num_images.rs new file mode 100644 index 0000000000..1b4c1a4156 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_num_images.rs @@ -0,0 +1,40 @@ +/// Number of images to generate per request. +/// +/// Fal accepts 1 to 10; we expose the same 1 to 4 range as the other image bindings, which +/// is what the ArtCraft batch UI offers. Add variants here if a larger batch is ever needed. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GptImage2p5NumImages { + One, + Two, + Three, + Four, +} + +impl GptImage2p5NumImages { + pub fn to_u8(self) -> u8 { + match self { + GptImage2p5NumImages::One => 1, + GptImage2p5NumImages::Two => 2, + GptImage2p5NumImages::Three => 3, + GptImage2p5NumImages::Four => 4, + } + } + + pub fn to_u64(self) -> u64 { + self.to_u8() as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts() { + assert_eq!(GptImage2p5NumImages::One.to_u8(), 1); + assert_eq!(GptImage2p5NumImages::Two.to_u8(), 2); + assert_eq!(GptImage2p5NumImages::Three.to_u8(), 3); + assert_eq!(GptImage2p5NumImages::Four.to_u8(), 4); + assert_eq!(GptImage2p5NumImages::Four.to_u64(), 4); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_output_format.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_output_format.rs new file mode 100644 index 0000000000..86beb9e57d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_output_format.rs @@ -0,0 +1,44 @@ +/// Output file format. Fal's default is `Png`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GptImage2p5OutputFormat { + Jpeg, + Png, + Webp, +} + +impl GptImage2p5OutputFormat { + pub fn to_str(self) -> &'static str { + match self { + GptImage2p5OutputFormat::Jpeg => "jpeg", + GptImage2p5OutputFormat::Png => "png", + GptImage2p5OutputFormat::Webp => "webp", + } + } + + /// Fal only honours `output_compression` for lossy formats. + pub fn supports_compression(self) -> bool { + match self { + GptImage2p5OutputFormat::Jpeg | GptImage2p5OutputFormat::Webp => true, + GptImage2p5OutputFormat::Png => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_strings_match_fal_schema() { + assert_eq!(GptImage2p5OutputFormat::Jpeg.to_str(), "jpeg"); + assert_eq!(GptImage2p5OutputFormat::Png.to_str(), "png"); + assert_eq!(GptImage2p5OutputFormat::Webp.to_str(), "webp"); + } + + #[test] + fn only_lossy_formats_support_compression() { + assert!(GptImage2p5OutputFormat::Jpeg.supports_compression()); + assert!(GptImage2p5OutputFormat::Webp.supports_compression()); + assert!(!GptImage2p5OutputFormat::Png.supports_compression()); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_pricing.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_pricing.rs new file mode 100644 index 0000000000..5f46c46c45 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_pricing.rs @@ -0,0 +1,275 @@ +//! Cost model for GPT Image 2.5 (Flare and Sunburst share it). +//! +//! Fal bills by token: output image tokens at $30.00 / 1M, input image tokens at $8.00 / 1M, +//! text tokens at $5.00 / 1M input, rounded up to $0.0001. Rather than count tokens, we fit +//! the canonical per-image price table Fal publishes: +//! +//! | Size | low | medium | high | xhigh | max | +//! |-----------|----------|----------|----------|----------|----------| +//! | 1024x768 | $0.00402 | $0.00903 | $0.03612 | $0.06420 | $0.14445 | +//! | 1024x1024 | $0.00588 | $0.01317 | $0.05268 | $0.09366 | $0.21072 | +//! | 1024x1536 | $0.00474 | $0.01029 | $0.04116 | $0.07377 | $0.16464 | +//! | 1920x1080 | $0.00441 | $0.01029 | $0.03960 | $0.07041 | $0.15840 | +//! | 2560x1440 | $0.00615 | $0.01434 | $0.05529 | $0.09828 | $0.22110 | +//! | 3840x2160 | $0.01113 | $0.02595 | $0.10008 | $0.17790 | $0.40026 | +//! +//! Dividing by the output token price shows the structure: at `low` quality an image costs a +//! per-aspect-ratio base (~158 tokens for 1:1, ~106 for 4:3, ~101 for 3:2, ~72 for 16:9) plus +//! 36 tokens per megapixel, and each higher quality tier multiplies the token count by a +//! constant (medium 7/3, high 9, xhigh 16, max 36). The three 16:9 rows pin the slope and the +//! multipliers exactly; the other rows pin their bases. +//! +//! The estimate never undershoots the published table and is within 8% above it at every +//! canonical size (see tests). Prompt text tokens are not modelled: a few hundred tokens at +//! $5 / 1M is well under $0.002. + +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; +use crate::requests::traits::fal_request_cost_calculator_trait::UsdCents; + +/// Cost in hundredths of a US cent ($0.0001), the granularity Fal rounds GPT Image 2.5 to. +pub type UsdHundredthsOfCent = u64; + +/// $30.00 per 1M output image tokens. +const OUTPUT_IMAGE_TOKEN_USD: f64 = 30.0 / 1_000_000.0; + +/// $8.00 per 1M input image tokens. +const INPUT_IMAGE_TOKEN_USD: f64 = 8.0 / 1_000_000.0; + +/// Output tokens per megapixel at `low` quality, fitted from the 16:9 rows of the table. +const LOW_QUALITY_OUTPUT_TOKENS_PER_MEGAPIXEL: f64 = 36.0; + +/// Fal does not publish input image token counts. This is a conservative allowance per +/// reference image on edit requests, comparable to a high-detail 1024x1024 vision input. +const ESTIMATED_INPUT_TOKENS_PER_REFERENCE_IMAGE: f64 = 1_000.0; + +/// Estimated cost of rendering one output image of the given size at the given quality. +pub fn estimate_output_image_cost(quality: GptImage2p5Quality, width: u32, height: u32) -> UsdHundredthsOfCent { + let megapixels = width as f64 * height as f64 / 1_000_000.0; + let low_quality_tokens = low_quality_base_tokens(width, height) + + LOW_QUALITY_OUTPUT_TOKENS_PER_MEGAPIXEL * megapixels; + let tokens = low_quality_tokens * quality_token_multiplier(quality); + usd_to_hundredths_of_cent(tokens * OUTPUT_IMAGE_TOKEN_USD) +} + +/// Estimated cost of the reference (input) images attached to an edit request. +pub fn estimate_reference_images_cost(num_reference_images: usize) -> UsdHundredthsOfCent { + let tokens = num_reference_images as f64 * ESTIMATED_INPUT_TOKENS_PER_REFERENCE_IMAGE; + usd_to_hundredths_of_cent(tokens * INPUT_IMAGE_TOKEN_USD) +} + +/// Round up to whole cents. +pub fn hundredths_of_cent_to_cents(hundredths: UsdHundredthsOfCent) -> UsdCents { + hundredths.div_ceil(100) +} + +/// Token count multiplier for each quality tier relative to `low`. +fn quality_token_multiplier(quality: GptImage2p5Quality) -> f64 { + match quality { + GptImage2p5Quality::Low => 1.0, + GptImage2p5Quality::Medium => 7.0 / 3.0, + // `auto` lets the model choose; `high` is the documented default so we price it there. + GptImage2p5Quality::High | GptImage2p5Quality::Auto => 9.0, + GptImage2p5Quality::XHigh => 16.0, + GptImage2p5Quality::Max => 36.0, + } +} + +/// Per-image base token count at `low` quality, which depends on the aspect ratio family. +/// Fitted from the canonical table: 1:1 => 158, 4:3 => 106, 3:2 => 101, 16:9 => 72. +fn low_quality_base_tokens(width: u32, height: u32) -> f64 { + let long_edge = width.max(height) as f64; + let short_edge = width.min(height).max(1) as f64; + let ratio = long_edge / short_edge; + if ratio < 1.25 { + 158.0 // ~1:1 + } else if ratio < 1.42 { + 106.0 // ~4:3 (1.333) + } else if ratio < 1.60 { + 101.0 // ~3:2 (1.5) + } else { + 72.0 // ~16:9 (1.778) and wider + } +} + +fn usd_to_hundredths_of_cent(usd: f64) -> UsdHundredthsOfCent { + // Small epsilon so values that are exactly on a boundary in decimal don't round up + // because of binary float noise. + (usd * 10_000.0 - 1e-6).ceil().max(0.0) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use GptImage2p5Quality::*; + + const ALL_QUALITIES: [GptImage2p5Quality; 5] = [Low, Medium, High, XHigh, Max]; + + /// Fal's published canonical table, in hundredths of a cent: (width, height, [low, medium, high, xhigh, max]). + const FAL_PUBLISHED_TABLE: &[(u32, u32, [u64; 5])] = &[ + (1024, 768, [ 40, 90, 361, 642, 1444]), + (1024, 1024, [ 59, 132, 527, 937, 2107]), + (1024, 1536, [ 47, 103, 412, 738, 1646]), + (1920, 1080, [ 44, 103, 396, 704, 1584]), + (2560, 1440, [ 62, 143, 553, 983, 2211]), + (3840, 2160, [111, 260, 1001, 1779, 4003]), + ]; + + /// What our model produces at the canonical sizes, in hundredths of a cent. Pinned so a + /// change to the fit is a deliberate, visible diff. + const MODEL_TABLE: &[(u32, u32, [u64; 5])] = &[ + (1024, 768, [ 41, 95, 363, 645, 1451]), + (1024, 1024, [ 59, 138, 529, 940, 2115]), + (1024, 1536, [ 48, 111, 426, 757, 1703]), + (1920, 1080, [ 44, 103, 396, 704, 1584]), + (2560, 1440, [ 62, 144, 553, 983, 2211]), + (3840, 2160, [112, 260, 1001, 1779, 4003]), + ]; + + mod canonical_table_tests { + use super::*; + + #[test] + fn model_matches_pinned_values() { + for &(w, h, expected) in MODEL_TABLE { + for (quality, expected) in ALL_QUALITIES.iter().zip(expected) { + let actual = estimate_output_image_cost(*quality, w, h); + assert_eq!(actual, expected, "{w}x{h} {quality:?}"); + } + } + } + + #[test] + fn model_never_undershoots_fal_published_prices() { + for &(w, h, published) in FAL_PUBLISHED_TABLE { + for (quality, published) in ALL_QUALITIES.iter().zip(published) { + let actual = estimate_output_image_cost(*quality, w, h); + assert!(actual >= published, "{w}x{h} {quality:?}: model {actual} < published {published}"); + } + } + } + + #[test] + fn model_is_within_eight_percent_of_fal_published_prices() { + for &(w, h, published) in FAL_PUBLISHED_TABLE { + for (quality, published) in ALL_QUALITIES.iter().zip(published) { + let actual = estimate_output_image_cost(*quality, w, h); + let ceiling = published * 108 / 100 + 1; + assert!(actual <= ceiling, "{w}x{h} {quality:?}: model {actual} > {ceiling} (published {published})"); + } + } + } + + #[test] + fn sixteen_by_nine_rows_are_exact() { + // The 16:9 rows pin the slope and the quality multipliers, so they should reproduce + // exactly (the 1080p low row is the only one with a fractional base). + assert_eq!(estimate_output_image_cost(High, 1920, 1080), 396); + assert_eq!(estimate_output_image_cost(High, 2560, 1440), 553); + assert_eq!(estimate_output_image_cost(High, 3840, 2160), 1001); + assert_eq!(estimate_output_image_cost(Max, 3840, 2160), 4003); + } + } + + mod quality_tests { + use super::*; + + #[test] + fn auto_is_priced_as_high() { + for &(w, h, _) in FAL_PUBLISHED_TABLE { + assert_eq!( + estimate_output_image_cost(Auto, w, h), + estimate_output_image_cost(High, w, h), + "{w}x{h}", + ); + } + } + + #[test] + fn higher_quality_costs_strictly_more() { + for &(w, h, _) in FAL_PUBLISHED_TABLE { + for pair in ALL_QUALITIES.windows(2) { + let lower = estimate_output_image_cost(pair[0], w, h); + let higher = estimate_output_image_cost(pair[1], w, h); + assert!(lower < higher, "{w}x{h}: {:?} ({lower}) should be < {:?} ({higher})", pair[0], pair[1]); + } + } + } + } + + mod dimension_tests { + use super::*; + + #[test] + fn more_pixels_at_same_aspect_costs_more() { + let sizes_16x9 = [(1088, 608), (1920, 1080), (2048, 1152), (2560, 1440), (3072, 1728), (3840, 2160)]; + for pair in sizes_16x9.windows(2) { + let smaller = estimate_output_image_cost(High, pair[0].0, pair[0].1); + let larger = estimate_output_image_cost(High, pair[1].0, pair[1].1); + assert!(smaller < larger, "{:?} ({smaller}) should be < {:?} ({larger})", pair[0], pair[1]); + } + } + + #[test] + fn orientation_does_not_change_cost() { + assert_eq!(estimate_output_image_cost(High, 1024, 768), estimate_output_image_cost(High, 768, 1024)); + assert_eq!(estimate_output_image_cost(High, 1920, 1080), estimate_output_image_cost(High, 1080, 1920)); + assert_eq!(estimate_output_image_cost(High, 1024, 1536), estimate_output_image_cost(High, 1536, 1024)); + } + + #[test] + fn aspect_family_bases() { + assert_eq!(low_quality_base_tokens(1024, 1024), 158.0); + assert_eq!(low_quality_base_tokens(2048, 2048), 158.0); + assert_eq!(low_quality_base_tokens(1024, 768), 106.0); + assert_eq!(low_quality_base_tokens(768, 1024), 106.0); + assert_eq!(low_quality_base_tokens(1024, 1536), 101.0); + assert_eq!(low_quality_base_tokens(1920, 1080), 72.0); + assert_eq!(low_quality_base_tokens(1088, 608), 72.0); + assert_eq!(low_quality_base_tokens(3000, 1000), 72.0); + } + + #[test] + fn two_k_square_high_quality() { + // 158 + 36 * 4.194304 = 308.99 tokens * 9 * $0.00003 = $0.08343 => 835 hundredths + assert_eq!(estimate_output_image_cost(High, 2048, 2048), 835); + } + } + + mod reference_image_tests { + use super::*; + + #[test] + fn reference_images_cost_eighty_hundredths_each() { + // 1,000 tokens * $8 / 1M = $0.008 per image + assert_eq!(estimate_reference_images_cost(0), 0); + assert_eq!(estimate_reference_images_cost(1), 80); + assert_eq!(estimate_reference_images_cost(3), 240); + assert_eq!(estimate_reference_images_cost(16), 1280); + } + } + + mod rounding_tests { + use super::*; + + #[test] + fn cents_round_up() { + assert_eq!(hundredths_of_cent_to_cents(0), 0); + assert_eq!(hundredths_of_cent_to_cents(1), 1); + assert_eq!(hundredths_of_cent_to_cents(99), 1); + assert_eq!(hundredths_of_cent_to_cents(100), 1); + assert_eq!(hundredths_of_cent_to_cents(101), 2); + assert_eq!(hundredths_of_cent_to_cents(1001), 11); + } + + #[test] + fn usd_conversion_rounds_up_without_float_noise() { + assert_eq!(usd_to_hundredths_of_cent(0.0), 0); + assert_eq!(usd_to_hundredths_of_cent(0.0001), 1); + assert_eq!(usd_to_hundredths_of_cent(0.00588), 59); + assert_eq!(usd_to_hundredths_of_cent(0.005881), 59); + assert_eq!(usd_to_hundredths_of_cent(0.00589), 59); + assert_eq!(usd_to_hundredths_of_cent(0.005901), 60); + } + } +} + diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_quality.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_quality.rs new file mode 100644 index 0000000000..dcb4d959a1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_quality.rs @@ -0,0 +1,40 @@ +/// Quality tier. Higher tiers spend more output image tokens (more detail, more latency). +/// Fal's default is `High`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GptImage2p5Quality { + /// Let the model choose. Priced as `High` since that is the documented default. + Auto, + Low, + Medium, + High, + XHigh, + Max, +} + +impl GptImage2p5Quality { + pub fn to_str(self) -> &'static str { + match self { + GptImage2p5Quality::Auto => "auto", + GptImage2p5Quality::Low => "low", + GptImage2p5Quality::Medium => "medium", + GptImage2p5Quality::High => "high", + GptImage2p5Quality::XHigh => "xhigh", + GptImage2p5Quality::Max => "max", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_strings_match_fal_schema() { + assert_eq!(GptImage2p5Quality::Auto.to_str(), "auto"); + assert_eq!(GptImage2p5Quality::Low.to_str(), "low"); + assert_eq!(GptImage2p5Quality::Medium.to_str(), "medium"); + assert_eq!(GptImage2p5Quality::High.to_str(), "high"); + assert_eq!(GptImage2p5Quality::XHigh.to_str(), "xhigh"); + assert_eq!(GptImage2p5Quality::Max.to_str(), "max"); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_text_to_image_params.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_text_to_image_params.rs new file mode 100644 index 0000000000..981df1802a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_text_to_image_params.rs @@ -0,0 +1,318 @@ +use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_background::GptImage2p5Background; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_pricing::{ + estimate_output_image_cost, hundredths_of_cent_to_cents, +}; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_text_to_image_input::GptImage2p5TextToImageInput; +use crate::requests::traits::fal_request_cost_calculator_trait::UsdCents; + +/// Parameters for a GPT Image 2.5 text-to-image request. Identical for Flare and Sunburst; +/// the per-endpoint request types wrap this. +#[derive(Clone, Debug)] +pub struct GptImage2p5TextToImageParams { + /// Text prompt describing the image to generate. 1 to 32,000 characters. + pub prompt: String, + + /// Number of images to generate. + pub num_images: GptImage2p5NumImages, + + /// Output image size preset. `None` lets Fal default to `landscape_4_3`. + pub image_size: Option, + + /// Optional resolution tier. When present (and the size is not `Auto`), a custom + /// width x height is computed from the preset's aspect ratio and sent instead of the + /// preset name. + pub resolution: Option, + + /// Quality tier. `None` lets Fal default to `high`. + pub quality: Option, + + /// Background treatment. `None` lets Fal default to `auto`. + pub background: Option, + + /// Output format. `None` sends `png`. + pub output_format: Option, + + /// Compression level 0 to 100. Only sent for `jpeg` and `webp`. + pub output_compression: Option, +} + +impl GptImage2p5TextToImageParams { + /// Fal's default when `image_size` is omitted. + pub const DEFAULT_IMAGE_SIZE: GptImage2p5ImageSize = GptImage2p5ImageSize::Landscape4x3; + + /// Fal's default when `quality` is omitted. + pub const DEFAULT_QUALITY: GptImage2p5Quality = GptImage2p5Quality::High; + + pub fn to_raw_input(&self) -> GptImage2p5TextToImageInput { + let output_format = self.output_format.unwrap_or(GptImage2p5OutputFormat::Png); + let output_compression = if output_format.supports_compression() { + self.output_compression.map(|c| c.min(100)) + } else { + None + }; + + GptImage2p5TextToImageInput { + prompt: self.prompt.clone(), + image_size: self.image_size.map(|size| size.to_raw_param(self.resolution)), + background: self.background.map(|b| b.to_str().to_string()), + quality: self.quality.map(|q| q.to_str().to_string()), + num_images: Some(self.num_images.to_u8()), + output_format: Some(output_format.to_str().to_string()), + output_compression, + } + } + + /// Estimated cost in whole cents, rounded up per image. + pub fn estimate_cost_in_cents(&self) -> UsdCents { + let quality = self.quality.unwrap_or(Self::DEFAULT_QUALITY); + let (width, height) = self.image_size + .unwrap_or(Self::DEFAULT_IMAGE_SIZE) + .estimated_output_dimensions(self.resolution); + let hundredths_per_image = estimate_output_image_cost(quality, width, height); + hundredths_of_cent_to_cents(hundredths_per_image) * self.num_images.to_u64() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + use GptImage2Resolution::*; + use GptImage2p5ImageSize::*; + use GptImage2p5NumImages::*; + use GptImage2p5Quality::{Low, Medium, High, XHigh, Max}; + + mod to_raw_input_tests { + use super::*; + + #[test] + fn minimal_request_only_sends_required_and_defaulted_fields() { + let raw = make_params(One, None, None, None).to_raw_input(); + assert_eq!(raw.prompt, "test"); + assert!(raw.image_size.is_none()); + assert!(raw.background.is_none()); + assert!(raw.quality.is_none()); + assert_eq!(raw.num_images, Some(1)); + assert_eq!(raw.output_format.as_deref(), Some("png")); + assert!(raw.output_compression.is_none()); + } + + #[test] + fn minimal_request_serializes_to_expected_json() { + let raw = make_params(One, None, None, None).to_raw_input(); + let json = serde_json::to_value(&raw).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "test", + "num_images": 1, + "output_format": "png", + })); + } + + #[test] + fn full_request_serializes_to_expected_json() { + let params = GptImage2p5TextToImageParams { + prompt: "a corgi".to_string(), + num_images: Three, + image_size: Some(Landscape16x9), + resolution: Some(TwoK), + quality: Some(XHigh), + background: Some(GptImage2p5Background::Transparent), + output_format: Some(GptImage2p5OutputFormat::Webp), + output_compression: Some(80), + }; + let json = serde_json::to_value(params.to_raw_input()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "a corgi", + "image_size": { "width": 2048, "height": 1152 }, + "background": "transparent", + "quality": "xhigh", + "num_images": 3, + "output_format": "webp", + "output_compression": 80, + })); + } + + #[test] + fn preset_without_resolution() { + let raw = make_params(One, None, Some(Square), None).to_raw_input(); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "square"), + GptImage2p5ImageSizeParam::Custom(_) => panic!("expected preset"), + } + } + + #[test] + fn auto_size_sends_auto_preset() { + let raw = make_params(One, None, Some(Auto), Some(TwoK)).to_raw_input(); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "auto"), + GptImage2p5ImageSizeParam::Custom(_) => panic!("expected preset"), + } + } + + #[test] + fn resolution_without_size_is_not_sent() { + // No preset means no aspect ratio to derive dimensions from; let Fal default. + let raw = make_params(One, None, None, Some(TwoK)).to_raw_input(); + assert!(raw.image_size.is_none()); + } + + #[test] + fn compression_dropped_for_png() { + let mut params = make_params(One, None, None, None); + params.output_format = Some(GptImage2p5OutputFormat::Png); + params.output_compression = Some(50); + assert!(params.to_raw_input().output_compression.is_none()); + + params.output_format = None; + assert!(params.to_raw_input().output_compression.is_none()); + } + + #[test] + fn compression_kept_for_jpeg_and_clamped() { + let mut params = make_params(One, None, None, None); + params.output_format = Some(GptImage2p5OutputFormat::Jpeg); + params.output_compression = Some(50); + assert_eq!(params.to_raw_input().output_compression, Some(50)); + + params.output_compression = Some(200); + assert_eq!(params.to_raw_input().output_compression, Some(100)); + } + + #[test] + fn all_qualities_serialize() { + for quality in [GptImage2p5Quality::Auto, Low, Medium, High, XHigh, Max] { + let raw = make_params(One, Some(quality), None, None).to_raw_input(); + assert_eq!(raw.quality.as_deref(), Some(quality.to_str())); + } + } + } + + mod cost_tests { + use super::*; + + // (size, resolution, [low, medium, high, xhigh, max]) — expected cents per image. + // Pinned from the model in `gpt_image_2p5_pricing`; a change here must be deliberate. + const GRID: &[(GptImage2p5ImageSize, Option, [u64; 5])] = &[ + // 1:1 + (Square, None, [1, 2, 6, 10, 22]), // 1024x1024 + (Square, Some(OneK), [1, 2, 6, 10, 22]), // 1024x1024 + (Square, Some(TwoK), [1, 3, 9, 15, 34]), // 2048x2048 + (Square, Some(ThreeK), [2, 4, 13, 22, 50]), // 2880x2880 + (Square, Some(FourK), [2, 4, 13, 22, 50]), // 2880x2880 (pixel cap) + (SquareHd, None, [1, 3, 9, 15, 34]), // 2048x2048 + (SquareHd, Some(OneK), [1, 2, 6, 10, 22]), // 1024x1024 + (SquareHd, Some(TwoK), [1, 3, 9, 15, 34]), // 2048x2048 + (SquareHd, Some(ThreeK), [2, 4, 13, 22, 50]), // 2880x2880 + (SquareHd, Some(FourK), [2, 4, 13, 22, 50]), // 2880x2880 (pixel cap) + // 4:3 + (Landscape4x3, None, [1, 1, 4, 7, 15]), // 1024x768 + (Landscape4x3, Some(OneK), [1, 1, 4, 7, 15]), // 1024x768 + (Landscape4x3, Some(TwoK), [1, 2, 6, 11, 24]), // 2048x1536 + (Landscape4x3, Some(ThreeK), [2, 3, 10, 18, 39]), // 3072x2304 + (Landscape4x3, Some(FourK), [2, 3, 11, 20, 44]), // 3312x2480 (pixel cap) + (Portrait4x3, None, [1, 1, 4, 7, 15]), // 768x1024 + (Portrait4x3, Some(OneK), [1, 1, 4, 7, 15]), // 768x1024 + (Portrait4x3, Some(TwoK), [1, 2, 6, 11, 24]), // 1536x2048 + (Portrait4x3, Some(ThreeK), [2, 3, 10, 18, 39]), // 2304x3072 + (Portrait4x3, Some(FourK), [2, 3, 11, 20, 44]), // 2480x3312 (pixel cap) + // 16:9 + (Landscape16x9, None, [1, 2, 4, 8, 16]), // 1920x1080 + (Landscape16x9, Some(OneK), [1, 1, 3, 5, 11]), // 1088x608 + (Landscape16x9, Some(TwoK), [1, 2, 5, 8, 17]), // 2048x1152 + (Landscape16x9, Some(ThreeK), [1, 2, 8, 13, 29]), // 3072x1728 + (Landscape16x9, Some(FourK), [2, 3, 11, 18, 41]), // 3840x2160 + (Portrait16x9, None, [1, 2, 4, 8, 16]), // 1080x1920 + (Portrait16x9, Some(OneK), [1, 1, 3, 5, 11]), // 608x1088 + (Portrait16x9, Some(TwoK), [1, 2, 5, 8, 17]), // 1152x2048 + (Portrait16x9, Some(ThreeK), [1, 2, 8, 13, 29]), // 1728x3072 + (Portrait16x9, Some(FourK), [2, 3, 11, 18, 41]), // 2160x3840 + // Auto always prices at the conservative 3840x2160 + (Auto, None, [2, 3, 11, 18, 41]), + (Auto, Some(OneK), [2, 3, 11, 18, 41]), + (Auto, Some(FourK), [2, 3, 11, 18, 41]), + ]; + + const ALL_QUALITIES: [GptImage2p5Quality; 5] = [Low, Medium, High, XHigh, Max]; + + #[test] + fn exhaustive_grid() { + for &(size, resolution, expected) in GRID { + for (quality, expected) in ALL_QUALITIES.iter().zip(expected) { + let actual = make_params(One, Some(*quality), Some(size), resolution).estimate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {resolution:?} {quality:?}"); + } + } + } + + #[test] + fn batch_scales_linearly_across_grid() { + for &(size, resolution, _) in GRID { + let per_image = make_params(One, Some(High), Some(size), resolution).estimate_cost_in_cents(); + for (num, n) in [(Two, 2), (Three, 3), (Four, 4)] { + let actual = make_params(num, Some(High), Some(size), resolution).estimate_cost_in_cents(); + assert_eq!(actual, per_image * n, "{size:?} {resolution:?} x{n}"); + } + } + } + + #[test] + fn higher_resolution_never_costs_less() { + for size in [Square, SquareHd, Landscape4x3, Portrait4x3, Landscape16x9, Portrait16x9] { + let tiers = [OneK, TwoK, ThreeK, FourK]; + for pair in tiers.windows(2) { + let lower = make_params(One, Some(High), Some(size), Some(pair[0])).estimate_cost_in_cents(); + let higher = make_params(One, Some(High), Some(size), Some(pair[1])).estimate_cost_in_cents(); + assert!(lower <= higher, "{size:?} {:?} ({lower}) should be <= {:?} ({higher})", pair[0], pair[1]); + } + } + } + + #[test] + fn defaults_to_high_quality_landscape_4x3() { + // 1024x768 high = 363 hundredths => 4 cents + assert_eq!(make_params(One, None, None, None).estimate_cost_in_cents(), 4); + assert_eq!(make_params(One, Some(High), Some(Landscape4x3), None).estimate_cost_in_cents(), 4); + } + + #[test] + fn resolution_without_size_prices_at_default_aspect() { + assert_eq!( + make_params(One, Some(High), None, Some(TwoK)).estimate_cost_in_cents(), + make_params(One, Some(High), Some(Landscape4x3), Some(TwoK)).estimate_cost_in_cents(), + ); + } + + #[test] + fn batch_multiplies_per_image_cents() { + let per_image = make_params(One, Some(High), Some(Square), None).estimate_cost_in_cents(); + assert_eq!(per_image, 6); + assert_eq!(make_params(Two, Some(High), Some(Square), None).estimate_cost_in_cents(), 12); + assert_eq!(make_params(Three, Some(High), Some(Square), None).estimate_cost_in_cents(), 18); + assert_eq!(make_params(Four, Some(High), Some(Square), None).estimate_cost_in_cents(), 24); + } + } + + fn make_params( + num_images: GptImage2p5NumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2p5TextToImageParams { + GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/mod.rs new file mode 100644 index 0000000000..ba1a1f7edd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/mod.rs @@ -0,0 +1,19 @@ +//! Types shared by the four GPT Image 2.5 endpoints (Flare / Sunburst, text-to-image / edit). +//! +//! All four endpoints accept the same parameters and are priced from the same token table; +//! only the Fal endpoint path differs. The per-endpoint request types under `text/` and +//! `edit/` are thin wrappers around the params structs defined here. +//! +//! GPT Image 2.5 shares GPT Image 2's dimension constraints (multiples of 16, max edge 3840, +//! aspect <= 3:1, 655,360 <= pixels <= 8,294,400), so the resolution tiers and custom size +//! calculator in `gpt_image_2_resolution` are reused as-is. + +pub mod gpt_image_2p5_text_to_image_params; +pub mod gpt_image_2p5_edit_image_params; +pub mod gpt_image_2p5_image_size; +pub mod gpt_image_2p5_quality; +pub mod gpt_image_2p5_background; +pub mod gpt_image_2p5_output_format; +pub mod gpt_image_2p5_num_images; +pub mod gpt_image_2p5_pricing; +pub mod raw; diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_edit_image_input.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_edit_image_input.rs new file mode 100644 index 0000000000..22b69685f1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_edit_image_input.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + +/// Wire shape for `openai/gpt-image-2.5/{flare,sunburst}/edit`. +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2p5EditImageInput { + /// 1 to 32,000 characters. + pub prompt: String, + + /// Reference images. At most 16. + pub image_urls: Vec, + + /// Mask indicating which part of the image to edit. + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_url: Option, + + /// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto + /// OR a custom { width, height } object. + /// Default: "auto" (inferred from the input images) + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "auto", "transparent", "opaque" + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "auto", "low", "medium", "high", "xhigh", "max" + /// Default: "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 10 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// 0 - 100. Only honoured for "jpeg" and "webp". + #[serde(skip_serializing_if = "Option::is_none")] + pub output_compression: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_output.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_output.rs new file mode 100644 index 0000000000..50723c40ce --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_output.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +/// Response shape shared by all four GPT Image 2.5 endpoints. +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2p5ImageOutput { + pub images: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2p5ImageFile { + pub url: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_name: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_size: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_fal_example_response() { + let json = r#"{"images":[{"file_name":"EnWrO3XWjPE0nxBDpaQrj.png","width":1024,"height":1024,"content_type":"image/png","url":"https://v3b.fal.media/files/b/0a869129/EnWrO3XWjPE0nxBDpaQrj.png"}]}"#; + let output: GptImage2p5ImageOutput = serde_json::from_str(json).unwrap(); + assert_eq!(output.images.len(), 1); + assert_eq!(output.images[0].url, "https://v3b.fal.media/files/b/0a869129/EnWrO3XWjPE0nxBDpaQrj.png"); + assert_eq!(output.images[0].width, Some(1024)); + assert_eq!(output.images[0].content_type.as_deref(), Some("image/png")); + assert_eq!(output.images[0].file_size, None); + } + + #[test] + fn deserializes_url_only_file() { + let json = r#"{"images":[{"url":"https://example.com/a.png"}]}"#; + let output: GptImage2p5ImageOutput = serde_json::from_str(json).unwrap(); + assert_eq!(output.images[0].url, "https://example.com/a.png"); + assert_eq!(output.images[0].width, None); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_size_param.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_size_param.rs new file mode 100644 index 0000000000..d4ac496c27 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_size_param.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use crate::requests::api::image::common::gpt_image_2_resolution::CustomImageSize; + +/// The `image_size` field is either a preset name (e.g. `"square"`, `"auto"`) or an explicit +/// `{ width, height }` object. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum GptImage2p5ImageSizeParam { + Preset(String), + Custom(CustomImageSize), +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_text_to_image_input.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_text_to_image_input.rs new file mode 100644 index 0000000000..8dc1375065 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_text_to_image_input.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + +/// Wire shape for `openai/gpt-image-2.5/{flare,sunburst}/text-to-image`. +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2p5TextToImageInput { + /// 1 to 32,000 characters. + pub prompt: String, + + /// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto + /// OR a custom { width, height } object. + /// Default: "landscape_4_3" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "auto", "transparent", "opaque" + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "auto", "low", "medium", "high", "xhigh", "max" + /// Default: "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 10 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// 0 - 100. Only honoured for "jpeg" and "webp". + #[serde(skip_serializing_if = "Option::is_none")] + pub output_compression: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/mod.rs new file mode 100644 index 0000000000..ad391b11e9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/mod.rs @@ -0,0 +1,6 @@ +//! Over-the-wire request and response shapes for the GPT Image 2.5 endpoints. + +pub mod gpt_image_2p5_text_to_image_input; +pub mod gpt_image_2p5_edit_image_input; +pub mod gpt_image_2p5_image_output; +pub mod gpt_image_2p5_image_size_param; diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs new file mode 100644 index 0000000000..aff347168d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs @@ -0,0 +1,2 @@ +pub mod gpt_image_2_resolution; +pub mod gpt_image_2p5; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/api.rs new file mode 100644 index 0000000000..212353f707 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/api.rs @@ -0,0 +1,100 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::edit::flux_1_dev_edit_image::raw_request::{ + Flux1DevEditImageInput, Flux1DevEditImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux1DevEditImageRequest { + /// Text prompt describing the desired edit. + pub prompt: String, + + /// URL of the source image to edit. + pub image_url: String, + + /// Number of images to generate. + pub num_images: Flux1DevEditImageNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevEditImageNumImages { + One, + Two, + Three, + Four, +} + +impl FalEndpoint for Flux1DevEditImageRequest { + const ENDPOINT: &str = "fal-ai/flux/dev/image-to-image"; + + type RawRequest = Flux1DevEditImageInput; + type RawResponse = Flux1DevEditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + Flux1DevEditImageNumImages::One => 1, + Flux1DevEditImageNumImages::Two => 2, + Flux1DevEditImageNumImages::Three => 3, + Flux1DevEditImageNumImages::Four => 4, + }; + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + num_images: Some(num_images), + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Defaults + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::GHOST_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_single_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux1DevEditImageRequest { + prompt: "make this image look like a watercolor painting".to_string(), + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::One, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {:?}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_batch_two() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux1DevEditImageRequest { + prompt: "turn this into a cyberpunk scene with neon lights".to_string(), + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::Two, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {:?}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/cost.rs new file mode 100644 index 0000000000..7e92774b25 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/cost.rs @@ -0,0 +1,52 @@ +use crate::requests::api::image::edit::flux_1_dev_edit_image::api::{ + Flux1DevEditImageNumImages, Flux1DevEditImageRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Flux1DevEditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.025 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~3 cents per image. + let unit_cost = 3; + let cost = match self.num_images { + Flux1DevEditImageNumImages::One => unit_cost, + Flux1DevEditImageNumImages::Two => unit_cost * 2, + Flux1DevEditImageNumImages::Three => unit_cost * 3, + Flux1DevEditImageNumImages::Four => unit_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(num_images: Flux1DevEditImageNumImages) -> Flux1DevEditImageRequest { + Flux1DevEditImageRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + num_images, + } + } + + #[test] + fn cost_one_image() { + assert_eq!(make_request(Flux1DevEditImageNumImages::One).calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_two_images() { + assert_eq!(make_request(Flux1DevEditImageNumImages::Two).calculate_cost_in_cents(), 6); + } + + #[test] + fn cost_three_images() { + assert_eq!(make_request(Flux1DevEditImageNumImages::Three).calculate_cost_in_cents(), 9); + } + + #[test] + fn cost_four_images() { + assert_eq!(make_request(Flux1DevEditImageNumImages::Four).calculate_cost_in_cents(), 12); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/raw_request.rs new file mode 100644 index 0000000000..ef3af630d9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_dev_edit_image/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1DevEditImageInput { + pub prompt: String, + + pub image_url: String, + + /// 0.0 - 1.0 + /// Default: 0.95 + #[serde(skip_serializing_if = "Option::is_none")] + pub strength: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: jpeg, png + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevEditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/api.rs new file mode 100644 index 0000000000..37eee91d2c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/api.rs @@ -0,0 +1,117 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::edit::flux_1_schnell_edit_image::raw_request::{ + Flux1SchnellEditImageInput, Flux1SchnellEditImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux1SchnellEditImageRequest { + /// URL of the source image to edit. + pub image_url: String, + + /// Number of images to generate. + pub num_images: Flux1SchnellEditImageNumImages, + + /// Output image size. + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellEditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellEditImageSize { + Square, + SquareHd, + LandscapeFourByThree, + LandscapeSixteenByNine, + PortraitThreeByFour, + PortraitNineBySixteen, +} + +impl FalEndpoint for Flux1SchnellEditImageRequest { + const ENDPOINT: &str = "fal-ai/flux/schnell/redux"; + + type RawRequest = Flux1SchnellEditImageInput; + type RawResponse = Flux1SchnellEditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + Flux1SchnellEditImageNumImages::One => 1, + Flux1SchnellEditImageNumImages::Two => 2, + Flux1SchnellEditImageNumImages::Three => 3, + Flux1SchnellEditImageNumImages::Four => 4, + }; + + let image_size = self.image_size.map(|s| match s { + Flux1SchnellEditImageSize::Square => "square", + Flux1SchnellEditImageSize::SquareHd => "square_hd", + Flux1SchnellEditImageSize::LandscapeFourByThree => "landscape_4_3", + Flux1SchnellEditImageSize::LandscapeSixteenByNine => "landscape_16_9", + Flux1SchnellEditImageSize::PortraitThreeByFour => "portrait_4_3", + Flux1SchnellEditImageSize::PortraitNineBySixteen => "portrait_16_9", + }.to_string()); + + Ok(Self::RawRequest { + image_url: self.image_url.clone(), + num_images: Some(num_images), + image_size, + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::GHOST_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_single_image_no_size() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux1SchnellEditImageRequest { + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::One, + image_size: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_with_landscape_size() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux1SchnellEditImageRequest { + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::Two, + image_size: Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/cost.rs new file mode 100644 index 0000000000..c2995ebe28 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/cost.rs @@ -0,0 +1,53 @@ +use crate::requests::api::image::edit::flux_1_schnell_edit_image::api::{ + Flux1SchnellEditImageNumImages, Flux1SchnellEditImageRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Flux1SchnellEditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.003 per megapixel. + // For a 1024x1024 image (~1 MP), that's less than 1 cent per image. + // We round up to 1 cent per image. + let unit_cost = 1; + let cost = match self.num_images { + Flux1SchnellEditImageNumImages::One => unit_cost, + Flux1SchnellEditImageNumImages::Two => unit_cost * 2, + Flux1SchnellEditImageNumImages::Three => unit_cost * 3, + Flux1SchnellEditImageNumImages::Four => unit_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(num_images: Flux1SchnellEditImageNumImages) -> Flux1SchnellEditImageRequest { + Flux1SchnellEditImageRequest { + image_url: "https://example.com/image.jpg".to_string(), + num_images, + image_size: None, + } + } + + #[test] + fn cost_one_image() { + assert_eq!(make_request(Flux1SchnellEditImageNumImages::One).calculate_cost_in_cents(), 1); + } + + #[test] + fn cost_two_images() { + assert_eq!(make_request(Flux1SchnellEditImageNumImages::Two).calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_three_images() { + assert_eq!(make_request(Flux1SchnellEditImageNumImages::Three).calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_four_images() { + assert_eq!(make_request(Flux1SchnellEditImageNumImages::Four).calculate_cost_in_cents(), 4); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/raw_request.rs new file mode 100644 index 0000000000..46b8826c35 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/flux_1_schnell_edit_image/raw_request.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1SchnellEditImageInput { + pub image_url: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: jpeg, png + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellEditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/api.rs new file mode 100644 index 0000000000..d587a479f4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/api.rs @@ -0,0 +1,207 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::edit::gpt_image_1_edit_image::raw_request::{ + GptImage1EditImageInput, GptImage1EditImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct GptImage1EditImageRequest { + /// Text prompt describing the edit to make. + pub prompt: String, + + /// One or more source image URLs to edit. + pub image_urls: Vec, + + /// Number of images to generate. + pub num_images: GptImage1EditImageNumImages, + + /// Optional mask URL indicating what part of the image to edit. + pub mask_image_url: Option, + + /// Output image size. + pub image_size: Option, + + /// Quality level. + pub quality: Option, + + /// Input fidelity level. + pub input_fidelity: Option, + + /// Background mode. + pub background: Option, + + /// Output format. + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1EditImageSize { + Auto, + /// 1024x1024 + Square, + /// 1536x1024 + Horizontal, + /// 1024x1536 + Vertical, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1EditImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1EditImageInputFidelity { + Low, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1EditImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1EditImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalEndpoint for GptImage1EditImageRequest { + const ENDPOINT: &str = "fal-ai/gpt-image-1/edit-image"; + + type RawRequest = GptImage1EditImageInput; + type RawResponse = GptImage1EditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + GptImage1EditImageNumImages::One => 1, + GptImage1EditImageNumImages::Two => 2, + GptImage1EditImageNumImages::Three => 3, + GptImage1EditImageNumImages::Four => 4, + }; + + let image_size = self.image_size.map(|s| match s { + GptImage1EditImageSize::Auto => "auto", + GptImage1EditImageSize::Square => "1024x1024", + GptImage1EditImageSize::Horizontal => "1536x1024", + GptImage1EditImageSize::Vertical => "1024x1536", + }.to_string()); + + let quality = self.quality.map(|q| match q { + GptImage1EditImageQuality::Low => "low", + GptImage1EditImageQuality::Medium => "medium", + GptImage1EditImageQuality::High => "high", + }.to_string()); + + let input_fidelity = self.input_fidelity.map(|f| match f { + GptImage1EditImageInputFidelity::Low => "low", + GptImage1EditImageInputFidelity::High => "high", + }.to_string()); + + let background = self.background.map(|b| match b { + GptImage1EditImageBackground::Auto => "auto", + GptImage1EditImageBackground::Transparent => "transparent", + GptImage1EditImageBackground::Opaque => "opaque", + }.to_string()); + + let output_format = Some(match self.output_format { + Some(GptImage1EditImageOutputFormat::Jpeg) => "jpeg", + Some(GptImage1EditImageOutputFormat::Png) => "png", + Some(GptImage1EditImageOutputFormat::Webp) => "webp", + None => "png", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + num_images: Some(num_images), + mask_image_url: self.mask_image_url.clone(), + image_size, + quality, + input_fidelity, + background, + output_format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1EditImageRequest { + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + num_images: GptImage1EditImageNumImages::One, + mask_image_url: None, + image_size: Some(GptImage1EditImageSize::Horizontal), + quality: Some(GptImage1EditImageQuality::Medium), + input_fidelity: None, + background: None, + output_format: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1EditImageRequest { + image_urls: vec![GHOST_IMAGE_URL.to_string()], + prompt: "make the ghost wear a top hat".to_string(), + num_images: GptImage1EditImageNumImages::One, + mask_image_url: None, + image_size: None, + quality: Some(GptImage1EditImageQuality::High), + input_fidelity: None, + background: None, + output_format: Some(GptImage1EditImageOutputFormat::Png), + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/cost.rs new file mode 100644 index 0000000000..e943ef801c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/cost.rs @@ -0,0 +1,182 @@ +use crate::requests::api::image::edit::gpt_image_1_edit_image::api::{ + GptImage1EditImageNumImages, GptImage1EditImageQuality, + GptImage1EditImageRequest, GptImage1EditImageSize, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage1EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Per fal docs (fal-ai/gpt-image-1/edit-image): + // Low: $0.011 (1024x1024) / $0.016 (other) per image + // Medium: $0.042 (1024x1024) / $0.063 (other) per image + // High: $0.167 (1024x1024) / $0.25 (other) per image + let use_quality = self.quality.unwrap_or(GptImage1EditImageQuality::Medium); + let is_square = matches!( + self.image_size, + None | Some(GptImage1EditImageSize::Square) | Some(GptImage1EditImageSize::Auto) + ); + + let base_cost: u64 = match (use_quality, is_square) { + (GptImage1EditImageQuality::Low, true) => 2, + (GptImage1EditImageQuality::Low, false) => 2, + (GptImage1EditImageQuality::Medium, true) => 5, + (GptImage1EditImageQuality::Medium, false) => 7, + (GptImage1EditImageQuality::High, true) => 17, + (GptImage1EditImageQuality::High, false) => 25, + }; + + let n: u64 = match self.num_images { + GptImage1EditImageNumImages::One => 1, + GptImage1EditImageNumImages::Two => 2, + GptImage1EditImageNumImages::Three => 3, + GptImage1EditImageNumImages::Four => 4, + }; + base_cost * n + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: GptImage1EditImageNumImages, + quality: Option, + image_size: Option, + ) -> GptImage1EditImageRequest { + GptImage1EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images, + mask_image_url: None, + image_size, + quality, + input_fidelity: None, + background: None, + output_format: None, + } + } + + mod default_tests { + use super::*; + + #[test] + fn cost_defaults_one_image() { + // Default quality=Medium, size=Square => 5 cents + assert_eq!( + make_request(GptImage1EditImageNumImages::One, None, None) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_defaults_four_images() { + assert_eq!( + make_request(GptImage1EditImageNumImages::Four, None, None) + .calculate_cost_in_cents(), 20); + } + } + + mod low_quality_tests { + use super::*; + + #[test] + fn cost_low_square_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Square)) + .calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_low_horizontal_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Horizontal)) + .calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_low_auto_four_images() { + // Auto is treated as square + assert_eq!( + make_request(GptImage1EditImageNumImages::Four, Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Auto)) + .calculate_cost_in_cents(), 8); + } + } + + mod medium_quality_tests { + use super::*; + + #[test] + fn cost_medium_square_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Square)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_auto_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Auto)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_horizontal_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal)) + .calculate_cost_in_cents(), 7); + } + + #[test] + fn cost_medium_vertical_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Vertical)) + .calculate_cost_in_cents(), 7); + } + + #[test] + fn cost_medium_horizontal_three_images() { + assert_eq!( + make_request(GptImage1EditImageNumImages::Three, Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal)) + .calculate_cost_in_cents(), 21); + } + } + + mod high_quality_tests { + use super::*; + + #[test] + fn cost_high_square_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square)) + .calculate_cost_in_cents(), 17); + } + + #[test] + fn cost_high_horizontal_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal)) + .calculate_cost_in_cents(), 25); + } + + #[test] + fn cost_high_vertical_one_image() { + assert_eq!( + make_request(GptImage1EditImageNumImages::One, Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Vertical)) + .calculate_cost_in_cents(), 25); + } + + #[test] + fn cost_high_square_two_images() { + assert_eq!( + make_request(GptImage1EditImageNumImages::Two, Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square)) + .calculate_cost_in_cents(), 34); + } + + #[test] + fn cost_high_horizontal_four_images() { + assert_eq!( + make_request(GptImage1EditImageNumImages::Four, Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal)) + .calculate_cost_in_cents(), 100); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/raw_request.rs new file mode 100644 index 0000000000..b7efaea2e2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1_edit_image/raw_request.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// The URL of the mask image indicating what part of the image to edit. + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_image_url: Option, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// "low", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub input_fidelity: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1EditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/api.rs new file mode 100644 index 0000000000..b0e232e14d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/api.rs @@ -0,0 +1,205 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::edit::gpt_image_1p5_edit_image::raw_request::{ + GptImage1p5EditImageInput, GptImage1p5EditImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct GptImage1p5EditImageRequest { + /// Text prompt describing the edit to make. + pub prompt: String, + + /// One or more source image URLs to edit. + pub image_urls: Vec, + + /// Number of images to generate. + pub num_images: GptImage1p5EditImageNumImages, + + /// Optional mask URL indicating what part of the image to edit. + pub mask_image_url: Option, + + /// Output image size. + pub image_size: Option, + + /// Background mode. + pub background: Option, + + /// Quality level. + pub quality: Option, + + /// Input fidelity level. + pub input_fidelity: Option, + + /// Output format. + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5EditImageSize { + /// 1024x1024 + Square, + /// 1536x1024 + Wide, + /// 1024x1536 + Tall, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5EditImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5EditImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5EditImageInputFidelity { + Low, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5EditImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalEndpoint for GptImage1p5EditImageRequest { + const ENDPOINT: &str = "fal-ai/gpt-image-1.5/edit"; + + type RawRequest = GptImage1p5EditImageInput; + type RawResponse = GptImage1p5EditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + GptImage1p5EditImageNumImages::One => 1, + GptImage1p5EditImageNumImages::Two => 2, + GptImage1p5EditImageNumImages::Three => 3, + GptImage1p5EditImageNumImages::Four => 4, + }; + + let image_size = self.image_size.map(|s| match s { + GptImage1p5EditImageSize::Square => "1024x1024", + GptImage1p5EditImageSize::Wide => "1536x1024", + GptImage1p5EditImageSize::Tall => "1024x1536", + }.to_string()); + + let background = self.background.map(|b| match b { + GptImage1p5EditImageBackground::Auto => "auto", + GptImage1p5EditImageBackground::Transparent => "transparent", + GptImage1p5EditImageBackground::Opaque => "opaque", + }.to_string()); + + let quality = self.quality.map(|q| match q { + GptImage1p5EditImageQuality::Low => "low", + GptImage1p5EditImageQuality::Medium => "medium", + GptImage1p5EditImageQuality::High => "high", + }.to_string()); + + let input_fidelity = self.input_fidelity.map(|f| match f { + GptImage1p5EditImageInputFidelity::Low => "low", + GptImage1p5EditImageInputFidelity::High => "high", + }.to_string()); + + let output_format = Some(match self.output_format { + Some(GptImage1p5EditImageOutputFormat::Jpeg) => "jpeg", + Some(GptImage1p5EditImageOutputFormat::Png) => "png", + Some(GptImage1p5EditImageOutputFormat::Webp) => "webp", + None => "png", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + num_images: Some(num_images), + mask_image_url: self.mask_image_url.clone(), + image_size, + background, + quality, + input_fidelity, + output_format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1p5EditImageRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + num_images: GptImage1p5EditImageNumImages::Two, + mask_image_url: None, + image_size: None, + background: None, + quality: None, + input_fidelity: None, + output_format: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1p5EditImageRequest { + image_urls: vec![GHOST_IMAGE_URL.to_string()], + prompt: "make the ghost wear a top hat".to_string(), + num_images: GptImage1p5EditImageNumImages::One, + mask_image_url: None, + image_size: Some(GptImage1p5EditImageSize::Square), + background: None, + quality: Some(GptImage1p5EditImageQuality::High), + input_fidelity: None, + output_format: Some(GptImage1p5EditImageOutputFormat::Png), + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/cost.rs new file mode 100644 index 0000000000..c7012f970e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/cost.rs @@ -0,0 +1,171 @@ +use crate::requests::api::image::edit::gpt_image_1p5_edit_image::api::{ + GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, + GptImage1p5EditImageRequest, GptImage1p5EditImageSize, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage1p5EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Per fal docs (fal-ai/gpt-image-1.5/edit): + // Low: $0.009 (1024x1024) / $0.013 (other) per image + // Medium: $0.034 (1024x1024) / $0.050-$0.051 (other) per image + // High: $0.133 (1024x1024) / $0.199-$0.200 (other) per image + let use_quality = self.quality.unwrap_or(GptImage1p5EditImageQuality::Medium); + let use_size = self.image_size.unwrap_or(GptImage1p5EditImageSize::Square); + + let base_cost = match (use_quality, use_size) { + (GptImage1p5EditImageQuality::Low, GptImage1p5EditImageSize::Square) => 1, + (GptImage1p5EditImageQuality::Low, _) => 1, + (GptImage1p5EditImageQuality::Medium, GptImage1p5EditImageSize::Square) => 3, + (GptImage1p5EditImageQuality::Medium, _) => 5, + (GptImage1p5EditImageQuality::High, GptImage1p5EditImageSize::Square) => 13, + (GptImage1p5EditImageQuality::High, _) => 20, + }; + + let cost = match self.num_images { + GptImage1p5EditImageNumImages::One => base_cost, + GptImage1p5EditImageNumImages::Two => base_cost * 2, + GptImage1p5EditImageNumImages::Three => base_cost * 3, + GptImage1p5EditImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: GptImage1p5EditImageNumImages, + quality: Option, + image_size: Option, + ) -> GptImage1p5EditImageRequest { + GptImage1p5EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images, + mask_image_url: None, + image_size, + background: None, + quality, + input_fidelity: None, + output_format: None, + } + } + + mod default_tests { + use super::*; + + #[test] + fn cost_defaults_one_image() { + // Default quality=Medium, size=Square => 3 cents + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, None, None) + .calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_defaults_four_images() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::Four, None, None) + .calculate_cost_in_cents(), 12); + } + } + + mod low_quality_tests { + use super::*; + + #[test] + fn cost_low_square_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Square)) + .calculate_cost_in_cents(), 1); + } + + #[test] + fn cost_low_wide_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Wide)) + .calculate_cost_in_cents(), 1); + } + + #[test] + fn cost_low_tall_four_images() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::Four, Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Tall)) + .calculate_cost_in_cents(), 4); + } + } + + mod medium_quality_tests { + use super::*; + + #[test] + fn cost_medium_square_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Square)) + .calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_medium_wide_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_tall_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Tall)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_wide_three_images() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::Three, Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide)) + .calculate_cost_in_cents(), 15); + } + } + + mod high_quality_tests { + use super::*; + + #[test] + fn cost_high_square_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square)) + .calculate_cost_in_cents(), 13); + } + + #[test] + fn cost_high_wide_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Wide)) + .calculate_cost_in_cents(), 20); + } + + #[test] + fn cost_high_tall_one_image() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::One, Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Tall)) + .calculate_cost_in_cents(), 20); + } + + #[test] + fn cost_high_square_two_images() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::Two, Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square)) + .calculate_cost_in_cents(), 26); + } + + #[test] + fn cost_high_wide_four_images() { + assert_eq!( + make_request(GptImage1p5EditImageNumImages::Four, Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Wide)) + .calculate_cost_in_cents(), 80); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/raw_request.rs new file mode 100644 index 0000000000..d17609e128 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_1p5_edit_image/raw_request.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1p5EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// The URL of the mask image indicating what part of the image to edit. + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_image_url: Option, + + /// "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// "low", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub input_fidelity: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5EditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/api.rs new file mode 100644 index 0000000000..007ffc87f9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/api.rs @@ -0,0 +1,371 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::common::gpt_image_2_resolution::{ + compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution, +}; +use crate::requests::api::image::edit::gpt_image_2_edit_image::raw_request::{ + GptImage2EditImageInput, GptImage2EditImageOutput, ImageSizeParam, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct GptImage2EditImageRequest { + /// Text prompt describing the edit to make. + pub prompt: String, + + /// One or more source image URLs to edit. + pub image_urls: Vec, + + /// Number of images to generate. + pub num_images: GptImage2EditImageNumImages, + + /// Optional mask URL indicating what part of the image to edit. + pub mask_url: Option, + + /// Output image size (aspect ratio preset). + pub image_size: Option, + + /// Optional resolution tier. When present, a custom width x height is + /// computed from the aspect ratio and this resolution, overriding the + /// standard preset dimensions. Not applicable when image_size is Auto. + pub resolution: Option, + + /// Quality level. + pub quality: Option, + + /// Output format. + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2EditImageSize { + SquareHd, + Square, + Portrait4x3, + Portrait16x9, + Landscape4x3, + Landscape16x9, + Auto, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2EditImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2EditImageOutputFormat { + Jpeg, + Png, + Webp, +} + +/// Map image_size to GptImage2AspectRatio. Returns None for Auto (no +/// meaningful aspect ratio to compute custom dimensions from). +fn size_to_aspect(size: GptImage2EditImageSize) -> Option { + match size { + GptImage2EditImageSize::Square => Some(GptImage2AspectRatio::Square), + GptImage2EditImageSize::SquareHd => Some(GptImage2AspectRatio::SquareHd), + GptImage2EditImageSize::Landscape4x3 => Some(GptImage2AspectRatio::Landscape4x3), + GptImage2EditImageSize::Landscape16x9 => Some(GptImage2AspectRatio::Landscape16x9), + GptImage2EditImageSize::Portrait4x3 => Some(GptImage2AspectRatio::Portrait4x3), + GptImage2EditImageSize::Portrait16x9 => Some(GptImage2AspectRatio::Portrait16x9), + GptImage2EditImageSize::Auto => None, + } +} + +fn size_to_preset_str(size: GptImage2EditImageSize) -> &'static str { + match size { + GptImage2EditImageSize::SquareHd => "square_hd", + GptImage2EditImageSize::Square => "square", + GptImage2EditImageSize::Portrait4x3 => "portrait_4_3", + GptImage2EditImageSize::Portrait16x9 => "portrait_16_9", + GptImage2EditImageSize::Landscape4x3 => "landscape_4_3", + GptImage2EditImageSize::Landscape16x9 => "landscape_16_9", + GptImage2EditImageSize::Auto => "auto", + } +} + +impl FalEndpoint for GptImage2EditImageRequest { + const ENDPOINT: &str = "openai/gpt-image-2/edit"; + + type RawRequest = GptImage2EditImageInput; + type RawResponse = GptImage2EditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + GptImage2EditImageNumImages::One => 1, + GptImage2EditImageNumImages::Two => 2, + GptImage2EditImageNumImages::Three => 3, + GptImage2EditImageNumImages::Four => 4, + }; + + let image_size = match (self.image_size, self.resolution) { + // Resolution present with a non-Auto size: compute custom dimensions + (Some(size), Some(resolution)) => { + match size_to_aspect(size) { + Some(aspect) => { + let custom = compute_custom_image_size(aspect, resolution); + Some(ImageSizeParam::Custom(custom)) + } + // Auto + resolution: can't compute dimensions, fall back to preset + None => Some(ImageSizeParam::Preset(size_to_preset_str(size).to_string())), + } + } + // Resolution present but no image_size: default to Square + (None, Some(resolution)) => { + let custom = compute_custom_image_size(GptImage2AspectRatio::Square, resolution); + Some(ImageSizeParam::Custom(custom)) + } + // No resolution: use the standard preset string + (Some(size), None) => { + Some(ImageSizeParam::Preset(size_to_preset_str(size).to_string())) + } + // Neither: let the API default + (None, None) => None, + }; + + let quality = self.quality.map(|q| match q { + GptImage2EditImageQuality::Low => "low", + GptImage2EditImageQuality::Medium => "medium", + GptImage2EditImageQuality::High => "high", + }.to_string()); + + let output_format = Some(match self.output_format { + Some(GptImage2EditImageOutputFormat::Jpeg) => "jpeg", + Some(GptImage2EditImageOutputFormat::Png) => "png", + Some(GptImage2EditImageOutputFormat::Webp) => "webp", + None => "png", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + num_images: Some(num_images), + mask_url: self.mask_url.clone(), + image_size, + quality, + output_format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2EditImageRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + num_images: GptImage2EditImageNumImages::Two, + mask_url: None, + image_size: None, + resolution: None, + quality: None, + output_format: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2EditImageRequest { + image_urls: vec![GHOST_IMAGE_URL.to_string()], + prompt: "make the ghost wear a top hat".to_string(), + num_images: GptImage2EditImageNumImages::One, + mask_url: None, + image_size: Some(GptImage2EditImageSize::Square), + resolution: None, + quality: Some(GptImage2EditImageQuality::High), + output_format: Some(GptImage2EditImageOutputFormat::Png), + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + /// Combinatorial test: every (image_size, resolution) pair for edit-image. + /// Uses a test_data source image and prompts to include the test parameters + /// as text in the output. + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_combinatorial_size_and_resolution() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let sizes = [ + ("square", GptImage2EditImageSize::Square), + ("square_hd", GptImage2EditImageSize::SquareHd), + ("landscape_4x3", GptImage2EditImageSize::Landscape4x3), + ("landscape_16x9", GptImage2EditImageSize::Landscape16x9), + ("portrait_4x3", GptImage2EditImageSize::Portrait4x3), + ("portrait_16x9", GptImage2EditImageSize::Portrait16x9), + ]; + + let resolutions = [ + ("1K", GptImage2Resolution::OneK), + ("2K", GptImage2Resolution::TwoK), + ("3K", GptImage2Resolution::ThreeK), + ("4K", GptImage2Resolution::FourK), + ]; + + let surfaces = ["t-shirt", "sign", "monitor", "billboard", "coffee mug", "poster"]; + let mut surface_idx = 0; + + for (size_name, size) in &sizes { + for (res_name, resolution) in &resolutions { + let surface = surfaces[surface_idx % surfaces.len()]; + surface_idx += 1; + + let prompt = format!( + "Edit this image: add a {} with the text \"{} {}\" prominently displayed, studio lighting", + surface, size_name, res_name, + ); + + let request = GptImage2EditImageRequest { + prompt, + image_urls: vec![GHOST_IMAGE_URL.to_string()], + num_images: GptImage2EditImageNumImages::One, + mask_url: None, + image_size: Some(*size), + resolution: Some(*resolution), + quality: Some(GptImage2EditImageQuality::Medium), + output_format: Some(GptImage2EditImageOutputFormat::Png), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("[{} + {}] Request ID: {}", size_name, res_name, result.request_id); + assert!(!result.request_id.is_empty()); + } + } + + Ok(()) + } + + mod to_raw_request_tests { + use super::*; + use crate::requests::api::image::edit::gpt_image_2_edit_image::raw_request::ImageSizeParam; + + fn make_request( + image_size: Option, + resolution: Option, + ) -> GptImage2EditImageRequest { + GptImage2EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images: GptImage2EditImageNumImages::One, + mask_url: None, + image_size, + resolution, + quality: None, + output_format: None, + } + } + + #[test] + fn preset_without_resolution() { + let raw = make_request(Some(GptImage2EditImageSize::Square), None) + .to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Preset(s) => assert_eq!(s, "square"), + ImageSizeParam::Custom(_) => panic!("expected preset"), + } + } + + #[test] + fn auto_without_resolution() { + let raw = make_request(Some(GptImage2EditImageSize::Auto), None) + .to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Preset(s) => assert_eq!(s, "auto"), + ImageSizeParam::Custom(_) => panic!("expected preset"), + } + } + + #[test] + fn custom_with_resolution() { + let raw = make_request( + Some(GptImage2EditImageSize::Landscape16x9), + Some(GptImage2Resolution::TwoK), + ).to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Custom(c) => { + assert_eq!(c.width, 2048); + assert_eq!(c.height, 1152); + } + ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[test] + fn auto_with_resolution_falls_back_to_preset() { + let raw = make_request( + Some(GptImage2EditImageSize::Auto), + Some(GptImage2Resolution::TwoK), + ).to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Preset(s) => assert_eq!(s, "auto"), + ImageSizeParam::Custom(_) => panic!("expected preset for Auto + resolution"), + } + } + + #[test] + fn resolution_without_image_size_defaults_to_square() { + let raw = make_request(None, Some(GptImage2Resolution::TwoK)) + .to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Custom(c) => { + assert_eq!(c.width, 2048); + assert_eq!(c.height, 2048); + } + ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[test] + fn neither_size_nor_resolution() { + let raw = make_request(None, None).to_raw_request().unwrap(); + assert!(raw.image_size.is_none()); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/cost.rs new file mode 100644 index 0000000000..205f278d43 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/cost.rs @@ -0,0 +1,443 @@ +use crate::requests::api::image::common::gpt_image_2_resolution::{ + compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution, +}; +use crate::requests::api::image::edit::gpt_image_2_edit_image::api::{ + GptImage2EditImageNumImages, GptImage2EditImageQuality, + GptImage2EditImageRequest, GptImage2EditImageSize, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage2EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let quality = self.quality.unwrap_or(GptImage2EditImageQuality::High); + let total_pixels = estimate_total_pixels(self.image_size, self.resolution); + let megapixels = total_pixels as f64 / 1_000_000.0; + + // Fal's GPT Image 2 edit-image pricing has two components: + // + // 1. A base request cost (prompt processing, API overhead) + // 2. A per-pixel generation cost that scales with output resolution + // + // Both components increase with quality level. All rates are in + // tenths of a US cent, derived from Fal's published pricing. + // + // Edit-image is slightly cheaper than text-to-image at the base level, + // but scales at a similar per-pixel rate at medium and high quality. + let (base_tenths, per_megapixel_tenths): (f64, f64) = match quality { + GptImage2EditImageQuality::Low => ( 3.0, 1.0), + GptImage2EditImageQuality::Medium => (25.0, 9.0), + GptImage2EditImageQuality::High => (110.0, 35.0), + }; + + let tenths_per_image = (base_tenths + per_megapixel_tenths * megapixels).ceil() as u64; + let cents_per_image = tenths_per_image.div_ceil(10); + let num = num_images_u64(self.num_images); + + cents_per_image * num + } +} + +fn estimate_total_pixels( + image_size: Option, + resolution: Option, +) -> u64 { + match (image_size, resolution) { + // Auto: no meaningful aspect ratio, use conservative max-size estimate + (Some(GptImage2EditImageSize::Auto), _) => 3840 * 2160, // 8,294,400 + (Some(size), Some(res)) => { + let aspect = size_to_aspect(size); + let dims = compute_custom_image_size(aspect, res); + dims.width as u64 * dims.height as u64 + } + (None, Some(res)) => { + let dims = compute_custom_image_size(GptImage2AspectRatio::Square, res); + dims.width as u64 * dims.height as u64 + } + (Some(size), None) => preset_pixel_count(size), + (None, None) => preset_pixel_count(GptImage2EditImageSize::Square), + } +} + +fn size_to_aspect(size: GptImage2EditImageSize) -> GptImage2AspectRatio { + match size { + GptImage2EditImageSize::Square => GptImage2AspectRatio::Square, + GptImage2EditImageSize::SquareHd => GptImage2AspectRatio::SquareHd, + GptImage2EditImageSize::Landscape4x3 => GptImage2AspectRatio::Landscape4x3, + GptImage2EditImageSize::Landscape16x9 => GptImage2AspectRatio::Landscape16x9, + GptImage2EditImageSize::Portrait4x3 => GptImage2AspectRatio::Portrait4x3, + GptImage2EditImageSize::Portrait16x9 => GptImage2AspectRatio::Portrait16x9, + GptImage2EditImageSize::Auto => GptImage2AspectRatio::Square, + } +} + +/// Standard preset pixel counts when no custom resolution is specified. +fn preset_pixel_count(size: GptImage2EditImageSize) -> u64 { + match size { + GptImage2EditImageSize::Square => 1024 * 1024, // 1,048,576 + GptImage2EditImageSize::SquareHd => 2048 * 2048, // 4,194,304 + GptImage2EditImageSize::Landscape4x3 => 1024 * 768, // 786,432 + GptImage2EditImageSize::Landscape16x9 => 1920 * 1080, // 2,073,600 + GptImage2EditImageSize::Portrait4x3 => 768 * 1024, // 786,432 + GptImage2EditImageSize::Portrait16x9 => 1080 * 1920, // 2,073,600 + GptImage2EditImageSize::Auto => 3840 * 2160, // 8,294,400 (conservative) + } +} + +fn num_images_u64(num: GptImage2EditImageNumImages) -> u64 { + match num { + GptImage2EditImageNumImages::One => 1, + GptImage2EditImageNumImages::Two => 2, + GptImage2EditImageNumImages::Three => 3, + GptImage2EditImageNumImages::Four => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: GptImage2EditImageNumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2EditImageRequest { + GptImage2EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images, + mask_url: None, + image_size, + resolution, + quality, + output_format: None, + } + } + + use GptImage2EditImageNumImages::*; + use GptImage2EditImageQuality::*; + use GptImage2EditImageSize::*; + use GptImage2Resolution::*; + + mod preset_pricing_tests { + use super::*; + + // Table: (size, low, medium, high) — expected cents per image + const PRESET_CASES: &[(GptImage2EditImageSize, u64, u64, u64)] = &[ + // Low Med High + (Landscape4x3, 1, 4, 14), + (Portrait4x3, 1, 4, 14), + (Square, 1, 4, 15), + (Landscape16x9, 1, 5, 19), + (Portrait16x9, 1, 5, 19), + (SquareHd, 1, 7, 26), + (Auto, 2, 10, 41), + ]; + + #[test] + fn preset_costs_at_each_quality() { + for &(size, expected_low, expected_med, expected_high) in PRESET_CASES { + let cases = [ + (Low, expected_low), + (Medium, expected_med), + (High, expected_high), + ]; + for (quality, expected) in cases { + let actual = make_request(One, Some(quality), Some(size), None) + .calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {quality:?}"); + } + } + } + + #[test] + fn defaults_to_high_quality_square() { + assert_eq!( + make_request(One, None, None, None).calculate_cost_in_cents(), + 15, + ); + } + } + + mod custom_resolution_pricing_tests { + use super::*; + + // Table: (size, resolution, low, medium, high) — expected cents per image + const RESOLUTION_CASES: &[(GptImage2EditImageSize, GptImage2Resolution, u64, u64, u64)] = &[ + // Square aspect + (Square, OneK, 1, 4, 15), + (Square, TwoK, 1, 7, 26), + (Square, ThreeK, 2, 10, 41), + (Square, FourK, 2, 10, 41), // capped at max pixels + + // SquareHd (same aspect as Square) + (SquareHd, OneK, 1, 4, 15), + (SquareHd, TwoK, 1, 7, 26), + (SquareHd, ThreeK, 2, 10, 41), + (SquareHd, FourK, 2, 10, 41), + + // Landscape 4:3 + (Landscape4x3, OneK, 1, 4, 14), + (Landscape4x3, TwoK, 1, 6, 23), + (Landscape4x3, ThreeK, 2, 9, 36), + (Landscape4x3, FourK, 2, 10, 40), + + // Landscape 16:9 + (Landscape16x9, OneK, 1, 4, 14), + (Landscape16x9, TwoK, 1, 5, 20), + (Landscape16x9, ThreeK, 1, 8, 30), + (Landscape16x9, FourK, 2, 10, 41), + + // Portrait 4:3 + (Portrait4x3, OneK, 1, 4, 14), + (Portrait4x3, TwoK, 1, 6, 23), + (Portrait4x3, ThreeK, 2, 9, 36), + (Portrait4x3, FourK, 2, 10, 40), + + // Portrait 16:9 + (Portrait16x9, OneK, 1, 4, 14), + (Portrait16x9, TwoK, 1, 5, 20), + (Portrait16x9, ThreeK, 1, 8, 30), + (Portrait16x9, FourK, 2, 10, 41), + ]; + + #[test] + fn resolution_costs_at_each_quality() { + for &(size, res, expected_low, expected_med, expected_high) in RESOLUTION_CASES { + let cases = [ + (Low, expected_low), + (Medium, expected_med), + (High, expected_high), + ]; + for (quality, expected) in cases { + let actual = make_request(One, Some(quality), Some(size), Some(res)) + .calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {res:?} {quality:?}"); + } + } + } + + #[test] + fn resolution_without_size_defaults_to_square() { + assert_eq!( + make_request(One, Some(High), None, Some(TwoK)).calculate_cost_in_cents(), + make_request(One, Some(High), Some(Square), Some(TwoK)).calculate_cost_in_cents(), + ); + } + + #[test] + fn auto_ignores_resolution_and_uses_max_estimate() { + // Auto always uses the conservative 3840x2160 estimate + let auto_cost = make_request(One, Some(High), Some(Auto), Some(OneK)) + .calculate_cost_in_cents(); + let auto_no_res = make_request(One, Some(High), Some(Auto), None) + .calculate_cost_in_cents(); + assert_eq!(auto_cost, auto_no_res); + } + } + + mod num_images_tests { + use super::*; + + const ALL_BATCH_SIZES: &[(GptImage2EditImageNumImages, u64)] = &[ + (One, 1), (Two, 2), (Three, 3), (Four, 4), + ]; + + const ALL_SIZES: &[GptImage2EditImageSize] = &[ + Square, SquareHd, Landscape4x3, Landscape16x9, Portrait4x3, Portrait16x9, Auto, + ]; + + const ALL_RESOLUTIONS: &[GptImage2Resolution] = &[OneK, TwoK, ThreeK, FourK]; + + #[test] + fn batch_scales_linearly_for_all_presets() { + for &size in ALL_SIZES { + let per_image = make_request(One, Some(High), Some(size), None) + .calculate_cost_in_cents(); + for &(num, n) in ALL_BATCH_SIZES { + let actual = make_request(num, Some(High), Some(size), None) + .calculate_cost_in_cents(); + assert_eq!(actual, per_image * n, "{size:?} x{n}"); + } + } + } + + #[test] + fn batch_scales_linearly_for_all_resolutions() { + for &size in &[Square, SquareHd, Landscape4x3, Landscape16x9, Portrait4x3, Portrait16x9] { + for &res in ALL_RESOLUTIONS { + let per_image = make_request(One, Some(High), Some(size), Some(res)) + .calculate_cost_in_cents(); + for &(num, n) in ALL_BATCH_SIZES { + let actual = make_request(num, Some(High), Some(size), Some(res)) + .calculate_cost_in_cents(); + assert_eq!(actual, per_image * n, "{size:?} {res:?} x{n}"); + } + } + } + } + } + + mod exhaustive_preset_batch_tests { + use super::*; + + // (size, quality, num_images, expected_cents) + const CASES: &[(GptImage2EditImageSize, GptImage2EditImageQuality, GptImage2EditImageNumImages, u64)] = &[ + (Square, Low, One, 1), (Square, Low, Two, 2), (Square, Low, Three, 3), (Square, Low, Four, 4), + (Square, Medium, One, 4), (Square, Medium, Two, 8), (Square, Medium, Three, 12), (Square, Medium, Four, 16), + (Square, High, One, 15), (Square, High, Two, 30), (Square, High, Three, 45), (Square, High, Four, 60), + (SquareHd, Low, One, 1), (SquareHd, Low, Two, 2), (SquareHd, Low, Three, 3), (SquareHd, Low, Four, 4), + (SquareHd, Medium, One, 7), (SquareHd, Medium, Two, 14), (SquareHd, Medium, Three, 21), (SquareHd, Medium, Four, 28), + (SquareHd, High, One, 26), (SquareHd, High, Two, 52), (SquareHd, High, Three, 78), (SquareHd, High, Four, 104), + (Landscape4x3, Low, One, 1), (Landscape4x3, Low, Two, 2), (Landscape4x3, Low, Three, 3), (Landscape4x3, Low, Four, 4), + (Landscape4x3, Medium, One, 4), (Landscape4x3, Medium, Two, 8), (Landscape4x3, Medium, Three, 12), (Landscape4x3, Medium, Four, 16), + (Landscape4x3, High, One, 14), (Landscape4x3, High, Two, 28), (Landscape4x3, High, Three, 42), (Landscape4x3, High, Four, 56), + (Landscape16x9, Low, One, 1), (Landscape16x9, Low, Two, 2), (Landscape16x9, Low, Three, 3), (Landscape16x9, Low, Four, 4), + (Landscape16x9, Medium, One, 5), (Landscape16x9, Medium, Two, 10), (Landscape16x9, Medium, Three, 15), (Landscape16x9, Medium, Four, 20), + (Landscape16x9, High, One, 19), (Landscape16x9, High, Two, 38), (Landscape16x9, High, Three, 57), (Landscape16x9, High, Four, 76), + (Portrait4x3, Low, One, 1), (Portrait4x3, Low, Two, 2), (Portrait4x3, Low, Three, 3), (Portrait4x3, Low, Four, 4), + (Portrait4x3, Medium, One, 4), (Portrait4x3, Medium, Two, 8), (Portrait4x3, Medium, Three, 12), (Portrait4x3, Medium, Four, 16), + (Portrait4x3, High, One, 14), (Portrait4x3, High, Two, 28), (Portrait4x3, High, Three, 42), (Portrait4x3, High, Four, 56), + (Portrait16x9, Low, One, 1), (Portrait16x9, Low, Two, 2), (Portrait16x9, Low, Three, 3), (Portrait16x9, Low, Four, 4), + (Portrait16x9, Medium, One, 5), (Portrait16x9, Medium, Two, 10), (Portrait16x9, Medium, Three, 15), (Portrait16x9, Medium, Four, 20), + (Portrait16x9, High, One, 19), (Portrait16x9, High, Two, 38), (Portrait16x9, High, Three, 57), (Portrait16x9, High, Four, 76), + (Auto, Low, One, 2), (Auto, Low, Two, 4), (Auto, Low, Three, 6), (Auto, Low, Four, 8), + (Auto, Medium, One, 10), (Auto, Medium, Two, 20), (Auto, Medium, Three, 30), (Auto, Medium, Four, 40), + (Auto, High, One, 41), (Auto, High, Two, 82), (Auto, High, Three, 123), (Auto, High, Four, 164), + ]; + + #[test] + fn all_preset_batch_costs() { + for &(size, quality, num, expected) in CASES { + let actual = make_request(num, Some(quality), Some(size), None).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {quality:?} {num:?}"); + } + } + } + + mod exhaustive_resolution_batch_tests { + use super::*; + + // (size, resolution, quality, num_images, expected_cents) + const CASES: &[(GptImage2EditImageSize, GptImage2Resolution, GptImage2EditImageQuality, GptImage2EditImageNumImages, u64)] = &[ + // Square + (Square, OneK, Low, One, 1), (Square, OneK, Low, Two, 2), (Square, OneK, Low, Three, 3), (Square, OneK, Low, Four, 4), + (Square, OneK, Medium, One, 4), (Square, OneK, Medium, Two, 8), (Square, OneK, Medium, Three, 12), (Square, OneK, Medium, Four, 16), + (Square, OneK, High, One, 15), (Square, OneK, High, Two, 30), (Square, OneK, High, Three, 45), (Square, OneK, High, Four, 60), + (Square, TwoK, Low, One, 1), (Square, TwoK, Low, Two, 2), (Square, TwoK, Low, Three, 3), (Square, TwoK, Low, Four, 4), + (Square, TwoK, Medium, One, 7), (Square, TwoK, Medium, Two, 14), (Square, TwoK, Medium, Three, 21), (Square, TwoK, Medium, Four, 28), + (Square, TwoK, High, One, 26), (Square, TwoK, High, Two, 52), (Square, TwoK, High, Three, 78), (Square, TwoK, High, Four, 104), + (Square, ThreeK, Low, One, 2), (Square, ThreeK, Low, Two, 4), (Square, ThreeK, Low, Three, 6), (Square, ThreeK, Low, Four, 8), + (Square, ThreeK, Medium, One, 10), (Square, ThreeK, Medium, Two, 20), (Square, ThreeK, Medium, Three, 30), (Square, ThreeK, Medium, Four, 40), + (Square, ThreeK, High, One, 41), (Square, ThreeK, High, Two, 82), (Square, ThreeK, High, Three, 123), (Square, ThreeK, High, Four, 164), + (Square, FourK, Low, One, 2), (Square, FourK, Low, Two, 4), (Square, FourK, Low, Three, 6), (Square, FourK, Low, Four, 8), + (Square, FourK, Medium, One, 10), (Square, FourK, Medium, Two, 20), (Square, FourK, Medium, Three, 30), (Square, FourK, Medium, Four, 40), + (Square, FourK, High, One, 41), (Square, FourK, High, Two, 82), (Square, FourK, High, Three, 123), (Square, FourK, High, Four, 164), + // SquareHd + (SquareHd, OneK, Low, One, 1), (SquareHd, OneK, Low, Two, 2), (SquareHd, OneK, Low, Three, 3), (SquareHd, OneK, Low, Four, 4), + (SquareHd, OneK, Medium, One, 4), (SquareHd, OneK, Medium, Two, 8), (SquareHd, OneK, Medium, Three, 12), (SquareHd, OneK, Medium, Four, 16), + (SquareHd, OneK, High, One, 15), (SquareHd, OneK, High, Two, 30), (SquareHd, OneK, High, Three, 45), (SquareHd, OneK, High, Four, 60), + (SquareHd, TwoK, Low, One, 1), (SquareHd, TwoK, Low, Two, 2), (SquareHd, TwoK, Low, Three, 3), (SquareHd, TwoK, Low, Four, 4), + (SquareHd, TwoK, Medium, One, 7), (SquareHd, TwoK, Medium, Two, 14), (SquareHd, TwoK, Medium, Three, 21), (SquareHd, TwoK, Medium, Four, 28), + (SquareHd, TwoK, High, One, 26), (SquareHd, TwoK, High, Two, 52), (SquareHd, TwoK, High, Three, 78), (SquareHd, TwoK, High, Four, 104), + (SquareHd, ThreeK, Low, One, 2), (SquareHd, ThreeK, Low, Two, 4), (SquareHd, ThreeK, Low, Three, 6), (SquareHd, ThreeK, Low, Four, 8), + (SquareHd, ThreeK, Medium, One, 10), (SquareHd, ThreeK, Medium, Two, 20), (SquareHd, ThreeK, Medium, Three, 30), (SquareHd, ThreeK, Medium, Four, 40), + (SquareHd, ThreeK, High, One, 41), (SquareHd, ThreeK, High, Two, 82), (SquareHd, ThreeK, High, Three, 123), (SquareHd, ThreeK, High, Four, 164), + (SquareHd, FourK, Low, One, 2), (SquareHd, FourK, Low, Two, 4), (SquareHd, FourK, Low, Three, 6), (SquareHd, FourK, Low, Four, 8), + (SquareHd, FourK, Medium, One, 10), (SquareHd, FourK, Medium, Two, 20), (SquareHd, FourK, Medium, Three, 30), (SquareHd, FourK, Medium, Four, 40), + (SquareHd, FourK, High, One, 41), (SquareHd, FourK, High, Two, 82), (SquareHd, FourK, High, Three, 123), (SquareHd, FourK, High, Four, 164), + // Landscape4x3 + (Landscape4x3, OneK, Low, One, 1), (Landscape4x3, OneK, Low, Two, 2), (Landscape4x3, OneK, Low, Three, 3), (Landscape4x3, OneK, Low, Four, 4), + (Landscape4x3, OneK, Medium, One, 4), (Landscape4x3, OneK, Medium, Two, 8), (Landscape4x3, OneK, Medium, Three, 12), (Landscape4x3, OneK, Medium, Four, 16), + (Landscape4x3, OneK, High, One, 14), (Landscape4x3, OneK, High, Two, 28), (Landscape4x3, OneK, High, Three, 42), (Landscape4x3, OneK, High, Four, 56), + (Landscape4x3, TwoK, Low, One, 1), (Landscape4x3, TwoK, Low, Two, 2), (Landscape4x3, TwoK, Low, Three, 3), (Landscape4x3, TwoK, Low, Four, 4), + (Landscape4x3, TwoK, Medium, One, 6), (Landscape4x3, TwoK, Medium, Two, 12), (Landscape4x3, TwoK, Medium, Three, 18), (Landscape4x3, TwoK, Medium, Four, 24), + (Landscape4x3, TwoK, High, One, 23), (Landscape4x3, TwoK, High, Two, 46), (Landscape4x3, TwoK, High, Three, 69), (Landscape4x3, TwoK, High, Four, 92), + (Landscape4x3, ThreeK, Low, One, 2), (Landscape4x3, ThreeK, Low, Two, 4), (Landscape4x3, ThreeK, Low, Three, 6), (Landscape4x3, ThreeK, Low, Four, 8), + (Landscape4x3, ThreeK, Medium, One, 9), (Landscape4x3, ThreeK, Medium, Two, 18), (Landscape4x3, ThreeK, Medium, Three, 27), (Landscape4x3, ThreeK, Medium, Four, 36), + (Landscape4x3, ThreeK, High, One, 36), (Landscape4x3, ThreeK, High, Two, 72), (Landscape4x3, ThreeK, High, Three, 108), (Landscape4x3, ThreeK, High, Four, 144), + (Landscape4x3, FourK, Low, One, 2), (Landscape4x3, FourK, Low, Two, 4), (Landscape4x3, FourK, Low, Three, 6), (Landscape4x3, FourK, Low, Four, 8), + (Landscape4x3, FourK, Medium, One, 10), (Landscape4x3, FourK, Medium, Two, 20), (Landscape4x3, FourK, Medium, Three, 30), (Landscape4x3, FourK, Medium, Four, 40), + (Landscape4x3, FourK, High, One, 40), (Landscape4x3, FourK, High, Two, 80), (Landscape4x3, FourK, High, Three, 120), (Landscape4x3, FourK, High, Four, 160), + // Landscape16x9 + (Landscape16x9, OneK, Low, One, 1), (Landscape16x9, OneK, Low, Two, 2), (Landscape16x9, OneK, Low, Three, 3), (Landscape16x9, OneK, Low, Four, 4), + (Landscape16x9, OneK, Medium, One, 4), (Landscape16x9, OneK, Medium, Two, 8), (Landscape16x9, OneK, Medium, Three, 12), (Landscape16x9, OneK, Medium, Four, 16), + (Landscape16x9, OneK, High, One, 14), (Landscape16x9, OneK, High, Two, 28), (Landscape16x9, OneK, High, Three, 42), (Landscape16x9, OneK, High, Four, 56), + (Landscape16x9, TwoK, Low, One, 1), (Landscape16x9, TwoK, Low, Two, 2), (Landscape16x9, TwoK, Low, Three, 3), (Landscape16x9, TwoK, Low, Four, 4), + (Landscape16x9, TwoK, Medium, One, 5), (Landscape16x9, TwoK, Medium, Two, 10), (Landscape16x9, TwoK, Medium, Three, 15), (Landscape16x9, TwoK, Medium, Four, 20), + (Landscape16x9, TwoK, High, One, 20), (Landscape16x9, TwoK, High, Two, 40), (Landscape16x9, TwoK, High, Three, 60), (Landscape16x9, TwoK, High, Four, 80), + (Landscape16x9, ThreeK, Low, One, 1), (Landscape16x9, ThreeK, Low, Two, 2), (Landscape16x9, ThreeK, Low, Three, 3), (Landscape16x9, ThreeK, Low, Four, 4), + (Landscape16x9, ThreeK, Medium, One, 8), (Landscape16x9, ThreeK, Medium, Two, 16), (Landscape16x9, ThreeK, Medium, Three, 24), (Landscape16x9, ThreeK, Medium, Four, 32), + (Landscape16x9, ThreeK, High, One, 30), (Landscape16x9, ThreeK, High, Two, 60), (Landscape16x9, ThreeK, High, Three, 90), (Landscape16x9, ThreeK, High, Four, 120), + (Landscape16x9, FourK, Low, One, 2), (Landscape16x9, FourK, Low, Two, 4), (Landscape16x9, FourK, Low, Three, 6), (Landscape16x9, FourK, Low, Four, 8), + (Landscape16x9, FourK, Medium, One, 10), (Landscape16x9, FourK, Medium, Two, 20), (Landscape16x9, FourK, Medium, Three, 30), (Landscape16x9, FourK, Medium, Four, 40), + (Landscape16x9, FourK, High, One, 41), (Landscape16x9, FourK, High, Two, 82), (Landscape16x9, FourK, High, Three, 123), (Landscape16x9, FourK, High, Four, 164), + // Portrait4x3 + (Portrait4x3, OneK, Low, One, 1), (Portrait4x3, OneK, Low, Two, 2), (Portrait4x3, OneK, Low, Three, 3), (Portrait4x3, OneK, Low, Four, 4), + (Portrait4x3, OneK, Medium, One, 4), (Portrait4x3, OneK, Medium, Two, 8), (Portrait4x3, OneK, Medium, Three, 12), (Portrait4x3, OneK, Medium, Four, 16), + (Portrait4x3, OneK, High, One, 14), (Portrait4x3, OneK, High, Two, 28), (Portrait4x3, OneK, High, Three, 42), (Portrait4x3, OneK, High, Four, 56), + (Portrait4x3, TwoK, Low, One, 1), (Portrait4x3, TwoK, Low, Two, 2), (Portrait4x3, TwoK, Low, Three, 3), (Portrait4x3, TwoK, Low, Four, 4), + (Portrait4x3, TwoK, Medium, One, 6), (Portrait4x3, TwoK, Medium, Two, 12), (Portrait4x3, TwoK, Medium, Three, 18), (Portrait4x3, TwoK, Medium, Four, 24), + (Portrait4x3, TwoK, High, One, 23), (Portrait4x3, TwoK, High, Two, 46), (Portrait4x3, TwoK, High, Three, 69), (Portrait4x3, TwoK, High, Four, 92), + (Portrait4x3, ThreeK, Low, One, 2), (Portrait4x3, ThreeK, Low, Two, 4), (Portrait4x3, ThreeK, Low, Three, 6), (Portrait4x3, ThreeK, Low, Four, 8), + (Portrait4x3, ThreeK, Medium, One, 9), (Portrait4x3, ThreeK, Medium, Two, 18), (Portrait4x3, ThreeK, Medium, Three, 27), (Portrait4x3, ThreeK, Medium, Four, 36), + (Portrait4x3, ThreeK, High, One, 36), (Portrait4x3, ThreeK, High, Two, 72), (Portrait4x3, ThreeK, High, Three, 108), (Portrait4x3, ThreeK, High, Four, 144), + (Portrait4x3, FourK, Low, One, 2), (Portrait4x3, FourK, Low, Two, 4), (Portrait4x3, FourK, Low, Three, 6), (Portrait4x3, FourK, Low, Four, 8), + (Portrait4x3, FourK, Medium, One, 10), (Portrait4x3, FourK, Medium, Two, 20), (Portrait4x3, FourK, Medium, Three, 30), (Portrait4x3, FourK, Medium, Four, 40), + (Portrait4x3, FourK, High, One, 40), (Portrait4x3, FourK, High, Two, 80), (Portrait4x3, FourK, High, Three, 120), (Portrait4x3, FourK, High, Four, 160), + // Portrait16x9 + (Portrait16x9, OneK, Low, One, 1), (Portrait16x9, OneK, Low, Two, 2), (Portrait16x9, OneK, Low, Three, 3), (Portrait16x9, OneK, Low, Four, 4), + (Portrait16x9, OneK, Medium, One, 4), (Portrait16x9, OneK, Medium, Two, 8), (Portrait16x9, OneK, Medium, Three, 12), (Portrait16x9, OneK, Medium, Four, 16), + (Portrait16x9, OneK, High, One, 14), (Portrait16x9, OneK, High, Two, 28), (Portrait16x9, OneK, High, Three, 42), (Portrait16x9, OneK, High, Four, 56), + (Portrait16x9, TwoK, Low, One, 1), (Portrait16x9, TwoK, Low, Two, 2), (Portrait16x9, TwoK, Low, Three, 3), (Portrait16x9, TwoK, Low, Four, 4), + (Portrait16x9, TwoK, Medium, One, 5), (Portrait16x9, TwoK, Medium, Two, 10), (Portrait16x9, TwoK, Medium, Three, 15), (Portrait16x9, TwoK, Medium, Four, 20), + (Portrait16x9, TwoK, High, One, 20), (Portrait16x9, TwoK, High, Two, 40), (Portrait16x9, TwoK, High, Three, 60), (Portrait16x9, TwoK, High, Four, 80), + (Portrait16x9, ThreeK, Low, One, 1), (Portrait16x9, ThreeK, Low, Two, 2), (Portrait16x9, ThreeK, Low, Three, 3), (Portrait16x9, ThreeK, Low, Four, 4), + (Portrait16x9, ThreeK, Medium, One, 8), (Portrait16x9, ThreeK, Medium, Two, 16), (Portrait16x9, ThreeK, Medium, Three, 24), (Portrait16x9, ThreeK, Medium, Four, 32), + (Portrait16x9, ThreeK, High, One, 30), (Portrait16x9, ThreeK, High, Two, 60), (Portrait16x9, ThreeK, High, Three, 90), (Portrait16x9, ThreeK, High, Four, 120), + (Portrait16x9, FourK, Low, One, 2), (Portrait16x9, FourK, Low, Two, 4), (Portrait16x9, FourK, Low, Three, 6), (Portrait16x9, FourK, Low, Four, 8), + (Portrait16x9, FourK, Medium, One, 10), (Portrait16x9, FourK, Medium, Two, 20), (Portrait16x9, FourK, Medium, Three, 30), (Portrait16x9, FourK, Medium, Four, 40), + (Portrait16x9, FourK, High, One, 41), (Portrait16x9, FourK, High, Two, 82), (Portrait16x9, FourK, High, Three, 123), (Portrait16x9, FourK, High, Four, 164), + ]; + + #[test] + fn all_resolution_batch_costs() { + for &(size, res, quality, num, expected) in CASES { + let actual = make_request(num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {res:?} {quality:?} {num:?}"); + } + } + } + + mod monotonicity_tests { + use super::*; + + #[test] + fn higher_quality_costs_more() { + for &size in &[Square, Landscape16x9, Portrait4x3, Auto] { + let low = make_request(One, Some(Low), Some(size), None).calculate_cost_in_cents(); + let med = make_request(One, Some(Medium), Some(size), None).calculate_cost_in_cents(); + let high = make_request(One, Some(High), Some(size), None).calculate_cost_in_cents(); + assert!(low <= med, "{size:?}: low ({low}) should be <= medium ({med})"); + assert!(med <= high, "{size:?}: medium ({med}) should be <= high ({high})"); + } + } + + #[test] + fn higher_resolution_costs_at_least_as_much() { + for &size in &[Square, Landscape16x9, Portrait4x3] { + let resolutions = [OneK, TwoK, ThreeK, FourK]; + for pair in resolutions.windows(2) { + let lower = make_request(One, Some(High), Some(size), Some(pair[0])) + .calculate_cost_in_cents(); + let higher = make_request(One, Some(High), Some(size), Some(pair[1])) + .calculate_cost_in_cents(); + assert!( + lower <= higher, + "{size:?}: {pair:?} — lower res ({lower}¢) should be <= higher res ({higher}¢)", + ); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/raw_request.rs new file mode 100644 index 0000000000..b2b0c54790 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2_edit_image/raw_request.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use crate::requests::api::image::common::gpt_image_2_resolution::CustomImageSize; + +/// The `image_size` field can be either a string enum value (e.g. "square") +/// or a custom object with explicit width/height. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum ImageSizeParam { + Preset(String), + Custom(CustomImageSize), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// The URL of the mask image to use for the generation. + /// This indicates what part of the image to edit. + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_url: Option, + + /// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto + /// OR a custom { width, height } object. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2EditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/api.rs new file mode 100644 index 0000000000..2ede5dd9c6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/api.rs @@ -0,0 +1,137 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_edit_image_input::GptImage2p5EditImageInput; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// GPT Image 2.5 Flare image edit. +/// +/// Flare and Sunburst take the same parameters (see `GptImage2p5EditImageParams`); +/// this type only selects the endpoint. +#[derive(Clone, Debug)] +pub struct GptImage2p5FlareEditImageRequest { + pub params: GptImage2p5EditImageParams, +} + +impl FalEndpoint for GptImage2p5FlareEditImageRequest { + const ENDPOINT: &str = "openai/gpt-image-2.5/flare/edit"; + + type RawRequest = GptImage2p5EditImageInput; + type RawResponse = GptImage2p5ImageOutput; + + fn to_raw_request(&self) -> Result { + Ok(self.params.to_raw_input()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[test] + fn endpoint() { + assert_eq!(GptImage2p5FlareEditImageRequest::ENDPOINT, "openai/gpt-image-2.5/flare/edit"); + assert_eq!(GptImage2p5FlareEditImageRequest::get_endpoint(), "openai/gpt-image-2.5/flare/edit"); + } + + #[test] + fn to_raw_request_delegates_to_params() { + let request = GptImage2p5FlareEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + mask_url: Some("https://example.com/mask.png".to_string()), + num_images: GptImage2p5NumImages::Two, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: Some(GptImage2Resolution::TwoK), + quality: Some(GptImage2p5Quality::Low), + background: None, + output_format: Some(GptImage2p5OutputFormat::Png), + output_compression: None, + }, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "test"); + assert_eq!(raw.image_urls, vec!["https://example.com/image.png".to_string()]); + assert_eq!(raw.mask_url.as_deref(), Some("https://example.com/mask.png")); + assert_eq!(raw.num_images, Some(2)); + assert_eq!(raw.quality.as_deref(), Some("low")); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 2048)), + GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5FlareEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + mask_url: None, + num_images: GptImage2p5NumImages::Two, + image_size: None, + resolution: None, + quality: None, + background: None, + output_format: None, + output_compression: None, + }, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5FlareEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "make the ghost wear a top hat".to_string(), + image_urls: vec![GHOST_IMAGE_URL.to_string()], + mask_url: None, + num_images: GptImage2p5NumImages::One, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: None, + quality: Some(GptImage2p5Quality::High), + background: None, + output_format: Some(GptImage2p5OutputFormat::Png), + output_compression: None, + }, + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/cost.rs new file mode 100644 index 0000000000..669e3a9c16 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/cost.rs @@ -0,0 +1,97 @@ +use crate::requests::api::image::edit::gpt_image_2p5_flare_edit_image::api::GptImage2p5FlareEditImageRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage2p5FlareEditImageRequest { + /// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model. + fn calculate_cost_in_cents(&self) -> UsdCents { + self.params.estimate_cost_in_cents() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use GptImage2Resolution::*; + use GptImage2p5ImageSize::*; + use GptImage2p5NumImages::*; + use GptImage2p5Quality::{Low, Medium, High, XHigh, Max}; + + // (num_reference_images, size, quality, num_images, expected_cents) + const PRESET_CASES: &[(usize, GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (1, Square, Low, One, 2), + (1, Square, High, One, 7), + (1, Square, High, Four, 28), + (1, Square, Max, One, 22), + (1, Landscape4x3, High, One, 5), + (1, Landscape16x9, High, One, 5), + (3, Landscape16x9, High, One, 7), + (1, SquareHd, High, One, 10), + (1, Auto, High, One, 11), + (4, Auto, XHigh, Two, 42), + ]; + + // (num_reference_images, size, resolution, quality, num_images, expected_cents) + const RESOLUTION_CASES: &[(usize, GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (1, Square, OneK, High, One, 7), + (1, Square, TwoK, High, One, 10), + (1, Square, FourK, High, One, 14), + (1, Landscape16x9, OneK, High, One, 4), + (1, Landscape16x9, FourK, High, One, 11), + (2, Portrait4x3, ThreeK, Medium, Three, 15), + ]; + + #[test] + fn defaults_to_high_quality_auto_size() { + assert_eq!(make_request(1, One, None, None, None).calculate_cost_in_cents(), 11); + } + + #[test] + fn preset_costs() { + for &(refs, size, quality, num, expected) in PRESET_CASES { + let actual = make_request(refs, num, Some(quality), Some(size), None).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{refs} refs {size:?} {quality:?} {num:?}"); + } + } + + #[test] + fn resolution_costs() { + for &(refs, size, res, quality, num, expected) in RESOLUTION_CASES { + let actual = make_request(refs, num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{refs} refs {size:?} {res:?} {quality:?} {num:?}"); + } + } + + #[test] + fn matches_shared_params_estimate() { + let request = make_request(2, Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK)); + assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents()); + } + + fn make_request( + num_reference_images: usize, + num_images: GptImage2p5NumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2p5FlareEditImageRequest { + GptImage2p5FlareEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string(); num_reference_images], + mask_url: None, + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/api.rs new file mode 100644 index 0000000000..b4610be636 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/api.rs @@ -0,0 +1,137 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_edit_image_input::GptImage2p5EditImageInput; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// GPT Image 2.5 Sunburst image edit. +/// +/// Flare and Sunburst take the same parameters (see `GptImage2p5EditImageParams`); +/// this type only selects the endpoint. +#[derive(Clone, Debug)] +pub struct GptImage2p5SunburstEditImageRequest { + pub params: GptImage2p5EditImageParams, +} + +impl FalEndpoint for GptImage2p5SunburstEditImageRequest { + const ENDPOINT: &str = "openai/gpt-image-2.5/sunburst/edit"; + + type RawRequest = GptImage2p5EditImageInput; + type RawResponse = GptImage2p5ImageOutput; + + fn to_raw_request(&self) -> Result { + Ok(self.params.to_raw_input()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[test] + fn endpoint() { + assert_eq!(GptImage2p5SunburstEditImageRequest::ENDPOINT, "openai/gpt-image-2.5/sunburst/edit"); + assert_eq!(GptImage2p5SunburstEditImageRequest::get_endpoint(), "openai/gpt-image-2.5/sunburst/edit"); + } + + #[test] + fn to_raw_request_delegates_to_params() { + let request = GptImage2p5SunburstEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + mask_url: Some("https://example.com/mask.png".to_string()), + num_images: GptImage2p5NumImages::Two, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: Some(GptImage2Resolution::TwoK), + quality: Some(GptImage2p5Quality::Low), + background: None, + output_format: Some(GptImage2p5OutputFormat::Png), + output_compression: None, + }, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "test"); + assert_eq!(raw.image_urls, vec!["https://example.com/image.png".to_string()]); + assert_eq!(raw.mask_url.as_deref(), Some("https://example.com/mask.png")); + assert_eq!(raw.num_images, Some(2)); + assert_eq!(raw.quality.as_deref(), Some("low")); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 2048)), + GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5SunburstEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + mask_url: None, + num_images: GptImage2p5NumImages::Two, + image_size: None, + resolution: None, + quality: None, + background: None, + output_format: None, + output_compression: None, + }, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5SunburstEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "make the ghost wear a top hat".to_string(), + image_urls: vec![GHOST_IMAGE_URL.to_string()], + mask_url: None, + num_images: GptImage2p5NumImages::One, + image_size: Some(GptImage2p5ImageSize::Square), + resolution: None, + quality: Some(GptImage2p5Quality::High), + background: None, + output_format: Some(GptImage2p5OutputFormat::Png), + output_compression: None, + }, + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/cost.rs new file mode 100644 index 0000000000..9429ab0983 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/cost.rs @@ -0,0 +1,97 @@ +use crate::requests::api::image::edit::gpt_image_2p5_sunburst_edit_image::api::GptImage2p5SunburstEditImageRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage2p5SunburstEditImageRequest { + /// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model. + fn calculate_cost_in_cents(&self) -> UsdCents { + self.params.estimate_cost_in_cents() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use GptImage2Resolution::*; + use GptImage2p5ImageSize::*; + use GptImage2p5NumImages::*; + use GptImage2p5Quality::{Low, Medium, High, XHigh, Max}; + + // (num_reference_images, size, quality, num_images, expected_cents) + const PRESET_CASES: &[(usize, GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (1, Square, Low, One, 2), + (1, Square, High, One, 7), + (1, Square, High, Four, 28), + (1, Square, Max, One, 22), + (1, Landscape4x3, High, One, 5), + (1, Landscape16x9, High, One, 5), + (3, Landscape16x9, High, One, 7), + (1, SquareHd, High, One, 10), + (1, Auto, High, One, 11), + (4, Auto, XHigh, Two, 42), + ]; + + // (num_reference_images, size, resolution, quality, num_images, expected_cents) + const RESOLUTION_CASES: &[(usize, GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (1, Square, OneK, High, One, 7), + (1, Square, TwoK, High, One, 10), + (1, Square, FourK, High, One, 14), + (1, Landscape16x9, OneK, High, One, 4), + (1, Landscape16x9, FourK, High, One, 11), + (2, Portrait4x3, ThreeK, Medium, Three, 15), + ]; + + #[test] + fn defaults_to_high_quality_auto_size() { + assert_eq!(make_request(1, One, None, None, None).calculate_cost_in_cents(), 11); + } + + #[test] + fn preset_costs() { + for &(refs, size, quality, num, expected) in PRESET_CASES { + let actual = make_request(refs, num, Some(quality), Some(size), None).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{refs} refs {size:?} {quality:?} {num:?}"); + } + } + + #[test] + fn resolution_costs() { + for &(refs, size, res, quality, num, expected) in RESOLUTION_CASES { + let actual = make_request(refs, num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{refs} refs {size:?} {res:?} {quality:?} {num:?}"); + } + } + + #[test] + fn matches_shared_params_estimate() { + let request = make_request(2, Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK)); + assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents()); + } + + fn make_request( + num_reference_images: usize, + num_images: GptImage2p5NumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2p5SunburstEditImageRequest { + GptImage2p5SunburstEditImageRequest { + params: GptImage2p5EditImageParams { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string(); num_reference_images], + mask_url: None, + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs new file mode 100644 index 0000000000..09b7d8f959 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs @@ -0,0 +1,9 @@ +pub mod flux_1_dev_edit_image; +pub mod flux_1_schnell_edit_image; +pub mod gpt_image_1_edit_image; +pub mod gpt_image_1p5_edit_image; +pub mod gpt_image_2_edit_image; +pub mod gpt_image_2p5_flare_edit_image; +pub mod gpt_image_2p5_sunburst_edit_image; +pub mod nano_banana_2_edit_image; +pub mod nano_banana_pro_edit_image; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/api.rs new file mode 100644 index 0000000000..85c5208ddc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/api.rs @@ -0,0 +1,188 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::edit::nano_banana_2_edit_image::raw_request::{ + NanoBanana2EditImageInput, NanoBanana2EditImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct NanoBanana2EditImageRequest { + /// Text prompt describing the desired edit. + pub prompt: String, + + /// URL(s) of the source image(s) to edit. + pub image_urls: Vec, + + /// Number of images to generate. + pub num_images: NanoBanana2EditImageNumImages, + + /// Output resolution. + pub resolution: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBanana2EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBanana2EditImageResolution { + HalfK, + OneK, + TwoK, + FourK, +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +#[derive(Copy, Clone, Debug)] +pub enum NanoBanana2EditImageAspectRatio { + Auto, + OneByOne, + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + +impl FalEndpoint for NanoBanana2EditImageRequest { + const ENDPOINT: &str = "fal-ai/nano-banana-2/edit"; + + type RawRequest = NanoBanana2EditImageInput; + type RawResponse = NanoBanana2EditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + NanoBanana2EditImageNumImages::One => 1, + NanoBanana2EditImageNumImages::Two => 2, + NanoBanana2EditImageNumImages::Three => 3, + NanoBanana2EditImageNumImages::Four => 4, + }; + + let resolution = self.resolution.map(|r| match r { + NanoBanana2EditImageResolution::HalfK => "0.5K", + NanoBanana2EditImageResolution::OneK => "1K", + NanoBanana2EditImageResolution::TwoK => "2K", + NanoBanana2EditImageResolution::FourK => "4K", + }.to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + NanoBanana2EditImageAspectRatio::Auto => "auto", + NanoBanana2EditImageAspectRatio::OneByOne => "1:1", + NanoBanana2EditImageAspectRatio::FiveByFour => "5:4", + NanoBanana2EditImageAspectRatio::FourByThree => "4:3", + NanoBanana2EditImageAspectRatio::ThreeByTwo => "3:2", + NanoBanana2EditImageAspectRatio::SixteenByNine => "16:9", + NanoBanana2EditImageAspectRatio::TwentyOneByNine => "21:9", + NanoBanana2EditImageAspectRatio::FourByFive => "4:5", + NanoBanana2EditImageAspectRatio::ThreeByFour => "3:4", + NanoBanana2EditImageAspectRatio::TwoByThree => "2:3", + NanoBanana2EditImageAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + num_images: Some(num_images), + aspect_ratio, + resolution, + output_format: Some("png".to_string()), + safety_tolerance: Some("6".to_string()), + limit_generations: None, + enable_web_search: None, + seed: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ + ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL, + WHITE_HOUSE_SUNSET_IMAGE_URL, + }; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = NanoBanana2EditImageRequest { + prompt: "make this image look like a watercolor painting".to_string(), + image_urls: vec![GHOST_IMAGE_URL.to_string()], + num_images: NanoBanana2EditImageNumImages::One, + resolution: Some(NanoBanana2EditImageResolution::OneK), + aspect_ratio: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_multi_image_spooky() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = NanoBanana2EditImageRequest { + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + num_images: NanoBanana2EditImageNumImages::Two, + resolution: Some(NanoBanana2EditImageResolution::TwoK), + aspect_ratio: Some(NanoBanana2EditImageAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_multi_image_white_house() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = NanoBanana2EditImageRequest { + prompt: "Put the scared man and the t-rex in front of the white house scene. Make the man afraid of the t-rex.".to_string(), + image_urls: vec![ + WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + num_images: NanoBanana2EditImageNumImages::Two, + resolution: Some(NanoBanana2EditImageResolution::TwoK), + aspect_ratio: Some(NanoBanana2EditImageAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/cost.rs new file mode 100644 index 0000000000..bbe03ba822 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::image::edit::nano_banana_2_edit_image::api::{ + NanoBanana2EditImageNumImages, NanoBanana2EditImageRequest, NanoBanana2EditImageResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for NanoBanana2EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // TODO(bt): Verify actual pricing for Nano Banana 2 edit on fal.ai. + // 4K outputs may be charged at double the standard rate. + let base_cost = match self.resolution { + None => 15, + Some(NanoBanana2EditImageResolution::HalfK) => 8, + Some(NanoBanana2EditImageResolution::OneK) => 15, + Some(NanoBanana2EditImageResolution::TwoK) => 15, + Some(NanoBanana2EditImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + NanoBanana2EditImageNumImages::One => base_cost, + NanoBanana2EditImageNumImages::Two => base_cost * 2, + NanoBanana2EditImageNumImages::Three => base_cost * 3, + NanoBanana2EditImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: NanoBanana2EditImageNumImages, + resolution: Option, + ) -> NanoBanana2EditImageRequest { + NanoBanana2EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.jpg".to_string()], + num_images, + resolution, + aspect_ratio: None, + } + } + + #[test] + fn cost_default_resolution_one_image() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::One, None).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_half_k_one_image() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::HalfK)).calculate_cost_in_cents(), 8); + } + + #[test] + fn cost_one_k_one_image() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::OneK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_two_k_one_image() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::TwoK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_four_k_one_image() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::FourK)).calculate_cost_in_cents(), 30); + } + + #[test] + fn cost_one_k_four_images() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::Four, Some(NanoBanana2EditImageResolution::OneK)).calculate_cost_in_cents(), 60); + } + + #[test] + fn cost_four_k_two_images() { + assert_eq!(make_request(NanoBanana2EditImageNumImages::Two, Some(NanoBanana2EditImageResolution::FourK)).calculate_cost_in_cents(), 60); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/raw_request.rs new file mode 100644 index 0000000000..234c97e76b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_2_edit_image/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBanana2EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Eg. "0.5K", "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// The safety tolerance level. 1 = most strict, 6 = least strict. + /// Default: "4" + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// Limit generations per round of prompting to 1. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_generations: Option, + + /// Enable web search for image generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_web_search: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2EditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/api.rs new file mode 100644 index 0000000000..4baf9edcf6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/api.rs @@ -0,0 +1,137 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::edit::nano_banana_pro_edit_image::raw_request::{ + NanoBananaProEditImageInput, NanoBananaProEditImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct NanoBananaProEditImageRequest { + /// Text prompt describing the desired edit. + pub prompt: String, + + /// URL(s) of the source image(s) to edit. + pub image_urls: Vec, + + /// Number of images to generate. + pub num_images: NanoBananaProEditImageNumImages, + + /// Output resolution. + pub resolution: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBananaProEditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBananaProEditImageResolution { + OneK, + TwoK, + FourK, +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +#[derive(Copy, Clone, Debug)] +pub enum NanoBananaProEditImageAspectRatio { + Auto, + OneByOne, + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + +impl FalEndpoint for NanoBananaProEditImageRequest { + const ENDPOINT: &str = "fal-ai/nano-banana-pro/edit"; + + type RawRequest = NanoBananaProEditImageInput; + type RawResponse = NanoBananaProEditImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + NanoBananaProEditImageNumImages::One => 1, + NanoBananaProEditImageNumImages::Two => 2, + NanoBananaProEditImageNumImages::Three => 3, + NanoBananaProEditImageNumImages::Four => 4, + }; + + let resolution = self.resolution.map(|r| match r { + NanoBananaProEditImageResolution::OneK => "1K", + NanoBananaProEditImageResolution::TwoK => "2K", + NanoBananaProEditImageResolution::FourK => "4K", + }.to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + NanoBananaProEditImageAspectRatio::Auto => "auto", + NanoBananaProEditImageAspectRatio::OneByOne => "1:1", + NanoBananaProEditImageAspectRatio::FiveByFour => "5:4", + NanoBananaProEditImageAspectRatio::FourByThree => "4:3", + NanoBananaProEditImageAspectRatio::ThreeByTwo => "3:2", + NanoBananaProEditImageAspectRatio::SixteenByNine => "16:9", + NanoBananaProEditImageAspectRatio::TwentyOneByNine => "21:9", + NanoBananaProEditImageAspectRatio::FourByFive => "4:5", + NanoBananaProEditImageAspectRatio::ThreeByFour => "3:4", + NanoBananaProEditImageAspectRatio::TwoByThree => "2:3", + NanoBananaProEditImageAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + num_images: Some(num_images), + aspect_ratio, + resolution, + output_format: Some("png".to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ + ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL, + }; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_edit_multi_image_spooky() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = NanoBananaProEditImageRequest { + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + num_images: NanoBananaProEditImageNumImages::Two, + resolution: Some(NanoBananaProEditImageResolution::TwoK), + aspect_ratio: Some(NanoBananaProEditImageAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/cost.rs new file mode 100644 index 0000000000..550cc663f5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/cost.rs @@ -0,0 +1,73 @@ +use crate::requests::api::image::edit::nano_banana_pro_edit_image::api::{ + NanoBananaProEditImageNumImages, NanoBananaProEditImageRequest, + NanoBananaProEditImageResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for NanoBananaProEditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.15 per image. + // 4K outputs will be charged at double the standard rate. + let base_cost = match self.resolution { + None => 15, + Some(NanoBananaProEditImageResolution::OneK) => 15, + Some(NanoBananaProEditImageResolution::TwoK) => 15, + Some(NanoBananaProEditImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + NanoBananaProEditImageNumImages::One => base_cost, + NanoBananaProEditImageNumImages::Two => base_cost * 2, + NanoBananaProEditImageNumImages::Three => base_cost * 3, + NanoBananaProEditImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: NanoBananaProEditImageNumImages, + resolution: Option, + ) -> NanoBananaProEditImageRequest { + NanoBananaProEditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.jpg".to_string()], + num_images, + resolution, + aspect_ratio: None, + } + } + + #[test] + fn cost_default_resolution_one_image() { + assert_eq!(make_request(NanoBananaProEditImageNumImages::One, None).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_one_k_one_image() { + assert_eq!(make_request(NanoBananaProEditImageNumImages::One, Some(NanoBananaProEditImageResolution::OneK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_two_k_one_image() { + assert_eq!(make_request(NanoBananaProEditImageNumImages::One, Some(NanoBananaProEditImageResolution::TwoK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_four_k_one_image() { + assert_eq!(make_request(NanoBananaProEditImageNumImages::One, Some(NanoBananaProEditImageResolution::FourK)).calculate_cost_in_cents(), 30); + } + + #[test] + fn cost_one_k_four_images() { + assert_eq!(make_request(NanoBananaProEditImageNumImages::Four, Some(NanoBananaProEditImageResolution::OneK)).calculate_cost_in_cents(), 60); + } + + #[test] + fn cost_four_k_two_images() { + assert_eq!(make_request(NanoBananaProEditImageNumImages::Two, Some(NanoBananaProEditImageResolution::FourK)).calculate_cost_in_cents(), 60); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/raw_request.rs new file mode 100644 index 0000000000..8f4dcb6347 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/nano_banana_pro_edit_image/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBananaProEditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Eg. "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProEditImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/mod.rs new file mode 100644 index 0000000000..21fe362b68 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/mod.rs @@ -0,0 +1,5 @@ +pub mod angle; +pub mod background; +pub mod common; +pub mod edit; +pub mod text; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/api.rs new file mode 100644 index 0000000000..5c6a488a8d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/api.rs @@ -0,0 +1,97 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::text::flux_1_dev_text_to_image::raw_request::{ + Flux1DevTextToImageInput, Flux1DevTextToImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux1DevTextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Aspect ratio / image size. + pub aspect_ratio: Flux1DevTextToImageAspectRatio, + + /// Number of images to generate. + pub num_images: Flux1DevTextToImageNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevTextToImageAspectRatio { + Square, + SquareHd, + LandscapeFourByThree, + LandscapeSixteenByNine, + PortraitThreeByFour, + PortraitNineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevTextToImageNumImages { + One, + Two, + Three, + Four, +} + +impl FalEndpoint for Flux1DevTextToImageRequest { + const ENDPOINT: &str = "fal-ai/flux/dev"; + + type RawRequest = Flux1DevTextToImageInput; + type RawResponse = Flux1DevTextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + Flux1DevTextToImageNumImages::One => 1, + Flux1DevTextToImageNumImages::Two => 2, + Flux1DevTextToImageNumImages::Three => 3, + Flux1DevTextToImageNumImages::Four => 4, + }; + + let image_size = match self.aspect_ratio { + Flux1DevTextToImageAspectRatio::Square => "square", + Flux1DevTextToImageAspectRatio::SquareHd => "square_hd", + Flux1DevTextToImageAspectRatio::LandscapeFourByThree => "landscape_4_3", + Flux1DevTextToImageAspectRatio::LandscapeSixteenByNine => "landscape_16_9", + Flux1DevTextToImageAspectRatio::PortraitThreeByFour => "portrait_4_3", + Flux1DevTextToImageAspectRatio::PortraitNineBySixteen => "portrait_16_9", + }; + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + image_size: Some(image_size.to_string()), + enable_safety_checker: Some(false), + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux1DevTextToImageRequest { + prompt: "a giant robot fighting a dragon in a futuristic city".to_string(), + num_images: Flux1DevTextToImageNumImages::One, + aspect_ratio: Flux1DevTextToImageAspectRatio::LandscapeFourByThree, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/cost.rs new file mode 100644 index 0000000000..3fd7ab51b1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/cost.rs @@ -0,0 +1,52 @@ +use crate::requests::api::image::text::flux_1_dev_text_to_image::api::{ + Flux1DevTextToImageAspectRatio, Flux1DevTextToImageNumImages, Flux1DevTextToImageRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Flux1DevTextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.025 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~3 cents per image. + let unit_cost = 3; + let cost = match self.num_images { + Flux1DevTextToImageNumImages::One => unit_cost, + Flux1DevTextToImageNumImages::Two => unit_cost * 2, + Flux1DevTextToImageNumImages::Three => unit_cost * 3, + Flux1DevTextToImageNumImages::Four => unit_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(num_images: Flux1DevTextToImageNumImages) -> Flux1DevTextToImageRequest { + Flux1DevTextToImageRequest { + prompt: "test".to_string(), + aspect_ratio: Flux1DevTextToImageAspectRatio::Square, + num_images, + } + } + + #[test] + fn cost_one_image() { + assert_eq!(make_request(Flux1DevTextToImageNumImages::One).calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_two_images() { + assert_eq!(make_request(Flux1DevTextToImageNumImages::Two).calculate_cost_in_cents(), 6); + } + + #[test] + fn cost_three_images() { + assert_eq!(make_request(Flux1DevTextToImageNumImages::Three).calculate_cost_in_cents(), 9); + } + + #[test] + fn cost_four_images() { + assert_eq!(make_request(Flux1DevTextToImageNumImages::Four).calculate_cost_in_cents(), 12); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/raw_request.rs new file mode 100644 index 0000000000..b1b09171dc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_dev_text_to_image/raw_request.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1DevTextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevTextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/api.rs new file mode 100644 index 0000000000..c6eef68566 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/api.rs @@ -0,0 +1,97 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::text::flux_1_schnell_text_to_image::raw_request::{ + Flux1SchnellTextToImageInput, Flux1SchnellTextToImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux1SchnellTextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Aspect ratio / image size. + pub aspect_ratio: Flux1SchnellTextToImageAspectRatio, + + /// Number of images to generate. + pub num_images: Flux1SchnellTextToImageNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellTextToImageAspectRatio { + Square, + SquareHd, + LandscapeFourByThree, + LandscapeSixteenByNine, + PortraitThreeByFour, + PortraitNineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellTextToImageNumImages { + One, + Two, + Three, + Four, +} + +impl FalEndpoint for Flux1SchnellTextToImageRequest { + const ENDPOINT: &str = "fal-ai/flux/schnell"; + + type RawRequest = Flux1SchnellTextToImageInput; + type RawResponse = Flux1SchnellTextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + Flux1SchnellTextToImageNumImages::One => 1, + Flux1SchnellTextToImageNumImages::Two => 2, + Flux1SchnellTextToImageNumImages::Three => 3, + Flux1SchnellTextToImageNumImages::Four => 4, + }; + + let image_size = match self.aspect_ratio { + Flux1SchnellTextToImageAspectRatio::Square => "square", + Flux1SchnellTextToImageAspectRatio::SquareHd => "square_hd", + Flux1SchnellTextToImageAspectRatio::LandscapeFourByThree => "landscape_4_3", + Flux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine => "landscape_16_9", + Flux1SchnellTextToImageAspectRatio::PortraitThreeByFour => "portrait_4_3", + Flux1SchnellTextToImageAspectRatio::PortraitNineBySixteen => "portrait_16_9", + }; + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + image_size: Some(image_size.to_string()), + enable_safety_checker: Some(false), + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux1SchnellTextToImageRequest { + prompt: "a giant robot fighting a dragon in a futuristic city".to_string(), + num_images: Flux1SchnellTextToImageNumImages::One, + aspect_ratio: Flux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/cost.rs new file mode 100644 index 0000000000..0b57d8ba6f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/cost.rs @@ -0,0 +1,54 @@ +use crate::requests::api::image::text::flux_1_schnell_text_to_image::api::{ + Flux1SchnellTextToImageAspectRatio, Flux1SchnellTextToImageNumImages, + Flux1SchnellTextToImageRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Flux1SchnellTextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.003 per megapixel. + // For a 1024x1024 image (~1 MP), that's less than 1 cent per image. + // We round up to 1 cent per image. + let unit_cost = 1; + let cost = match self.num_images { + Flux1SchnellTextToImageNumImages::One => unit_cost, + Flux1SchnellTextToImageNumImages::Two => unit_cost * 2, + Flux1SchnellTextToImageNumImages::Three => unit_cost * 3, + Flux1SchnellTextToImageNumImages::Four => unit_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(num_images: Flux1SchnellTextToImageNumImages) -> Flux1SchnellTextToImageRequest { + Flux1SchnellTextToImageRequest { + prompt: "test".to_string(), + aspect_ratio: Flux1SchnellTextToImageAspectRatio::Square, + num_images, + } + } + + #[test] + fn cost_one_image() { + assert_eq!(make_request(Flux1SchnellTextToImageNumImages::One).calculate_cost_in_cents(), 1); + } + + #[test] + fn cost_two_images() { + assert_eq!(make_request(Flux1SchnellTextToImageNumImages::Two).calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_three_images() { + assert_eq!(make_request(Flux1SchnellTextToImageNumImages::Three).calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_four_images() { + assert_eq!(make_request(Flux1SchnellTextToImageNumImages::Four).calculate_cost_in_cents(), 4); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/raw_request.rs new file mode 100644 index 0000000000..312faa2e20 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/flux_1_schnell_text_to_image/raw_request.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1SchnellTextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellTextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/api.rs new file mode 100644 index 0000000000..9532d4f5eb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/api.rs @@ -0,0 +1,173 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::text::gpt_image_1_text_to_image::raw_request::{ + GptImage1TextToImageInput, GptImage1TextToImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct GptImage1TextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Number of images to generate. + pub num_images: GptImage1TextToImageNumImages, + + /// Output image size. + pub image_size: Option, + + /// Quality level. + pub quality: Option, + + /// Background mode. + pub background: Option, + + /// Output format. + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1TextToImageSize { + Auto, + /// 1024x1024 + Square, + /// 1536x1024 + Horizontal, + /// 1024x1536 + Vertical, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1TextToImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1TextToImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1TextToImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalEndpoint for GptImage1TextToImageRequest { + const ENDPOINT: &str = "fal-ai/gpt-image-1/text-to-image"; + + type RawRequest = GptImage1TextToImageInput; + type RawResponse = GptImage1TextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + GptImage1TextToImageNumImages::One => 1, + GptImage1TextToImageNumImages::Two => 2, + GptImage1TextToImageNumImages::Three => 3, + GptImage1TextToImageNumImages::Four => 4, + }; + + let image_size = self.image_size.map(|s| match s { + GptImage1TextToImageSize::Auto => "auto", + GptImage1TextToImageSize::Square => "1024x1024", + GptImage1TextToImageSize::Horizontal => "1536x1024", + GptImage1TextToImageSize::Vertical => "1024x1536", + }.to_string()); + + let quality = self.quality.map(|q| match q { + GptImage1TextToImageQuality::Low => "low", + GptImage1TextToImageQuality::Medium => "medium", + GptImage1TextToImageQuality::High => "high", + }.to_string()); + + let background = self.background.map(|b| match b { + GptImage1TextToImageBackground::Auto => "auto", + GptImage1TextToImageBackground::Transparent => "transparent", + GptImage1TextToImageBackground::Opaque => "opaque", + }.to_string()); + + let output_format = Some(match self.output_format { + Some(GptImage1TextToImageOutputFormat::Jpeg) => "jpeg", + Some(GptImage1TextToImageOutputFormat::Png) => "png", + Some(GptImage1TextToImageOutputFormat::Webp) => "webp", + None => "png", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + image_size, + quality, + background, + output_format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: GptImage1TextToImageNumImages::One, + image_size: Some(GptImage1TextToImageSize::Horizontal), + quality: Some(GptImage1TextToImageQuality::Medium), + background: None, + output_format: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1TextToImageRequest { + prompt: "a corgi wearing sunglasses at the beach".to_string(), + num_images: GptImage1TextToImageNumImages::Two, + image_size: Some(GptImage1TextToImageSize::Square), + quality: Some(GptImage1TextToImageQuality::High), + background: Some(GptImage1TextToImageBackground::Opaque), + output_format: Some(GptImage1TextToImageOutputFormat::Png), + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/cost.rs new file mode 100644 index 0000000000..fce8ec7ee9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/cost.rs @@ -0,0 +1,179 @@ +use crate::requests::api::image::text::gpt_image_1_text_to_image::api::{ + GptImage1TextToImageNumImages, GptImage1TextToImageQuality, + GptImage1TextToImageRequest, GptImage1TextToImageSize, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage1TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Per fal docs (fal-ai/gpt-image-1/text-to-image): + // Low: $0.011 (1024x1024) / $0.016 (other) per image + // Medium: $0.042 (1024x1024) / $0.063 (other) per image + // High: $0.167 (1024x1024) / $0.25 (other) per image + // Default quality is Medium when unspecified, square when size unspecified. + let use_quality = self.quality.unwrap_or(GptImage1TextToImageQuality::Medium); + let is_square = matches!( + self.image_size, + None | Some(GptImage1TextToImageSize::Square) | Some(GptImage1TextToImageSize::Auto) + ); + + let base_cost: u64 = match (use_quality, is_square) { + (GptImage1TextToImageQuality::Low, true) => 2, + (GptImage1TextToImageQuality::Low, false) => 2, + (GptImage1TextToImageQuality::Medium, true) => 5, + (GptImage1TextToImageQuality::Medium, false) => 7, + (GptImage1TextToImageQuality::High, true) => 17, + (GptImage1TextToImageQuality::High, false) => 25, + }; + + let n: u64 = match self.num_images { + GptImage1TextToImageNumImages::One => 1, + GptImage1TextToImageNumImages::Two => 2, + GptImage1TextToImageNumImages::Three => 3, + GptImage1TextToImageNumImages::Four => 4, + }; + base_cost * n + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: GptImage1TextToImageNumImages, + quality: Option, + image_size: Option, + ) -> GptImage1TextToImageRequest { + GptImage1TextToImageRequest { + prompt: "test".to_string(), + num_images, + image_size, + quality, + background: None, + output_format: None, + } + } + + mod default_tests { + use super::*; + + #[test] + fn cost_defaults_one_image() { + // Default quality=Medium, size=Square => 5 cents + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, None, None) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_defaults_four_images() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::Four, None, None) + .calculate_cost_in_cents(), 20); + } + } + + mod low_quality_tests { + use super::*; + + #[test] + fn cost_low_square_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Square)) + .calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_low_horizontal_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Horizontal)) + .calculate_cost_in_cents(), 2); + } + + #[test] + fn cost_low_auto_four_images() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::Four, Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Auto)) + .calculate_cost_in_cents(), 8); + } + } + + mod medium_quality_tests { + use super::*; + + #[test] + fn cost_medium_square_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Square)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_auto_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Auto)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_horizontal_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Horizontal)) + .calculate_cost_in_cents(), 7); + } + + #[test] + fn cost_medium_vertical_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Vertical)) + .calculate_cost_in_cents(), 7); + } + + #[test] + fn cost_medium_horizontal_three_images() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::Three, Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Horizontal)) + .calculate_cost_in_cents(), 21); + } + } + + mod high_quality_tests { + use super::*; + + #[test] + fn cost_high_square_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Square)) + .calculate_cost_in_cents(), 17); + } + + #[test] + fn cost_high_horizontal_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Horizontal)) + .calculate_cost_in_cents(), 25); + } + + #[test] + fn cost_high_vertical_one_image() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::One, Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Vertical)) + .calculate_cost_in_cents(), 25); + } + + #[test] + fn cost_high_square_two_images() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::Two, Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Square)) + .calculate_cost_in_cents(), 34); + } + + #[test] + fn cost_high_vertical_four_images() { + assert_eq!( + make_request(GptImage1TextToImageNumImages::Four, Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Vertical)) + .calculate_cost_in_cents(), 100); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/raw_request.rs new file mode 100644 index 0000000000..58eb916026 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1_text_to_image/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1TextToImageInput { + pub prompt: String, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1TextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/api.rs new file mode 100644 index 0000000000..09d74343de --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/api.rs @@ -0,0 +1,171 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::text::gpt_image_1p5_text_to_image::raw_request::{ + GptImage1p5TextToImageInput, GptImage1p5TextToImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct GptImage1p5TextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Number of images to generate. + pub num_images: GptImage1p5TextToImageNumImages, + + /// Output image size. + pub image_size: Option, + + /// Background mode. + pub background: Option, + + /// Quality level. + pub quality: Option, + + /// Output format. + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5TextToImageSize { + /// 1024x1024 + Square, + /// 1536x1024 + Wide, + /// 1024x1536 + Tall, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5TextToImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5TextToImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage1p5TextToImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalEndpoint for GptImage1p5TextToImageRequest { + const ENDPOINT: &str = "fal-ai/gpt-image-1.5"; + + type RawRequest = GptImage1p5TextToImageInput; + type RawResponse = GptImage1p5TextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + GptImage1p5TextToImageNumImages::One => 1, + GptImage1p5TextToImageNumImages::Two => 2, + GptImage1p5TextToImageNumImages::Three => 3, + GptImage1p5TextToImageNumImages::Four => 4, + }; + + let image_size = self.image_size.map(|s| match s { + GptImage1p5TextToImageSize::Square => "1024x1024", + GptImage1p5TextToImageSize::Wide => "1536x1024", + GptImage1p5TextToImageSize::Tall => "1024x1536", + }.to_string()); + + let background = self.background.map(|b| match b { + GptImage1p5TextToImageBackground::Auto => "auto", + GptImage1p5TextToImageBackground::Transparent => "transparent", + GptImage1p5TextToImageBackground::Opaque => "opaque", + }.to_string()); + + let quality = self.quality.map(|q| match q { + GptImage1p5TextToImageQuality::Low => "low", + GptImage1p5TextToImageQuality::Medium => "medium", + GptImage1p5TextToImageQuality::High => "high", + }.to_string()); + + let output_format = Some(match self.output_format { + Some(GptImage1p5TextToImageOutputFormat::Jpeg) => "jpeg", + Some(GptImage1p5TextToImageOutputFormat::Png) => "png", + Some(GptImage1p5TextToImageOutputFormat::Webp) => "webp", + None => "png", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + image_size, + background, + quality, + output_format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1p5TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: GptImage1p5TextToImageNumImages::One, + image_size: None, + background: None, + quality: None, + output_format: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage1p5TextToImageRequest { + prompt: "a corgi wearing sunglasses at the beach".to_string(), + num_images: GptImage1p5TextToImageNumImages::Two, + image_size: Some(GptImage1p5TextToImageSize::Wide), + background: None, + quality: Some(GptImage1p5TextToImageQuality::High), + output_format: Some(GptImage1p5TextToImageOutputFormat::Png), + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/cost.rs new file mode 100644 index 0000000000..be31d8f3c0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/cost.rs @@ -0,0 +1,168 @@ +use crate::requests::api::image::text::gpt_image_1p5_text_to_image::api::{ + GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, + GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage1p5TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Per fal docs (fal-ai/gpt-image-1.5): + // Low: $0.009 (1024x1024) / $0.013 (other) per image + // Medium: $0.034 (1024x1024) / $0.050-$0.051 (other) per image + // High: $0.133 (1024x1024) / $0.199-$0.200 (other) per image + let use_quality = self.quality.unwrap_or(GptImage1p5TextToImageQuality::Medium); + let use_size = self.image_size.unwrap_or(GptImage1p5TextToImageSize::Square); + + let base_cost = match (use_quality, use_size) { + (GptImage1p5TextToImageQuality::Low, GptImage1p5TextToImageSize::Square) => 1, + (GptImage1p5TextToImageQuality::Low, _) => 1, + (GptImage1p5TextToImageQuality::Medium, GptImage1p5TextToImageSize::Square) => 3, + (GptImage1p5TextToImageQuality::Medium, _) => 5, + (GptImage1p5TextToImageQuality::High, GptImage1p5TextToImageSize::Square) => 13, + (GptImage1p5TextToImageQuality::High, _) => 20, + }; + + let cost = match self.num_images { + GptImage1p5TextToImageNumImages::One => base_cost, + GptImage1p5TextToImageNumImages::Two => base_cost * 2, + GptImage1p5TextToImageNumImages::Three => base_cost * 3, + GptImage1p5TextToImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: GptImage1p5TextToImageNumImages, + quality: Option, + image_size: Option, + ) -> GptImage1p5TextToImageRequest { + GptImage1p5TextToImageRequest { + prompt: "test".to_string(), + num_images, + image_size, + background: None, + quality, + output_format: None, + } + } + + mod default_tests { + use super::*; + + #[test] + fn cost_defaults_one_image() { + // Default quality=Medium, size=Square => 3 cents + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, None, None) + .calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_defaults_four_images() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::Four, None, None) + .calculate_cost_in_cents(), 12); + } + } + + mod low_quality_tests { + use super::*; + + #[test] + fn cost_low_square_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Square)) + .calculate_cost_in_cents(), 1); + } + + #[test] + fn cost_low_wide_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Wide)) + .calculate_cost_in_cents(), 1); + } + + #[test] + fn cost_low_tall_four_images() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::Four, Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Tall)) + .calculate_cost_in_cents(), 4); + } + } + + mod medium_quality_tests { + use super::*; + + #[test] + fn cost_medium_square_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square)) + .calculate_cost_in_cents(), 3); + } + + #[test] + fn cost_medium_wide_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Wide)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_tall_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Tall)) + .calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_medium_square_three_images() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::Three, Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square)) + .calculate_cost_in_cents(), 9); + } + } + + mod high_quality_tests { + use super::*; + + #[test] + fn cost_high_square_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square)) + .calculate_cost_in_cents(), 13); + } + + #[test] + fn cost_high_wide_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide)) + .calculate_cost_in_cents(), 20); + } + + #[test] + fn cost_high_tall_one_image() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::One, Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Tall)) + .calculate_cost_in_cents(), 20); + } + + #[test] + fn cost_high_square_two_images() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::Two, Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square)) + .calculate_cost_in_cents(), 26); + } + + #[test] + fn cost_high_wide_four_images() { + assert_eq!( + make_request(GptImage1p5TextToImageNumImages::Four, Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide)) + .calculate_cost_in_cents(), 80); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/raw_request.rs new file mode 100644 index 0000000000..b02a440766 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_1p5_text_to_image/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1p5TextToImageInput { + pub prompt: String, + + /// "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5TextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/api.rs new file mode 100644 index 0000000000..348f3616b9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/api.rs @@ -0,0 +1,330 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::common::gpt_image_2_resolution::{ + compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution, +}; +use crate::requests::api::image::text::gpt_image_2_text_to_image::raw_request::{ + GptImage2TextToImageInput, GptImage2TextToImageOutput, ImageSizeParam, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct GptImage2TextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Number of images to generate. + pub num_images: GptImage2TextToImageNumImages, + + /// Output image size (aspect ratio preset). + pub image_size: Option, + + /// Optional resolution tier. When present, a custom width x height is + /// computed from the aspect ratio and this resolution, overriding the + /// standard preset dimensions. + pub resolution: Option, + + /// Quality level. + pub quality: Option, + + /// Output format. + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2TextToImageSize { + SquareHd, + Square, + Portrait4x3, + Portrait16x9, + Landscape4x3, + Landscape16x9, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2TextToImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptImage2TextToImageOutputFormat { + Jpeg, + Png, + Webp, +} + +/// Map our image_size enum to the GptImage2AspectRatio used by the resolution +/// calculator. +fn size_to_aspect(size: GptImage2TextToImageSize) -> GptImage2AspectRatio { + match size { + GptImage2TextToImageSize::Square => GptImage2AspectRatio::Square, + GptImage2TextToImageSize::SquareHd => GptImage2AspectRatio::SquareHd, + GptImage2TextToImageSize::Landscape4x3 => GptImage2AspectRatio::Landscape4x3, + GptImage2TextToImageSize::Landscape16x9 => GptImage2AspectRatio::Landscape16x9, + GptImage2TextToImageSize::Portrait4x3 => GptImage2AspectRatio::Portrait4x3, + GptImage2TextToImageSize::Portrait16x9 => GptImage2AspectRatio::Portrait16x9, + } +} + +impl FalEndpoint for GptImage2TextToImageRequest { + const ENDPOINT: &str = "openai/gpt-image-2"; + + type RawRequest = GptImage2TextToImageInput; + type RawResponse = GptImage2TextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + GptImage2TextToImageNumImages::One => 1, + GptImage2TextToImageNumImages::Two => 2, + GptImage2TextToImageNumImages::Three => 3, + GptImage2TextToImageNumImages::Four => 4, + }; + + let image_size = match (self.image_size, self.resolution) { + // Resolution present: compute custom dimensions from aspect + resolution + (Some(size), Some(resolution)) => { + let aspect = size_to_aspect(size); + let custom = compute_custom_image_size(aspect, resolution); + Some(ImageSizeParam::Custom(custom)) + } + // Resolution present but no image_size: default to Square + (None, Some(resolution)) => { + let custom = compute_custom_image_size(GptImage2AspectRatio::Square, resolution); + Some(ImageSizeParam::Custom(custom)) + } + // No resolution: use the standard preset string + (Some(size), None) => { + let preset = match size { + GptImage2TextToImageSize::SquareHd => "square_hd", + GptImage2TextToImageSize::Square => "square", + GptImage2TextToImageSize::Portrait4x3 => "portrait_4_3", + GptImage2TextToImageSize::Portrait16x9 => "portrait_16_9", + GptImage2TextToImageSize::Landscape4x3 => "landscape_4_3", + GptImage2TextToImageSize::Landscape16x9 => "landscape_16_9", + }; + Some(ImageSizeParam::Preset(preset.to_string())) + } + // Neither: let the API default + (None, None) => None, + }; + + let quality = self.quality.map(|q| match q { + GptImage2TextToImageQuality::Low => "low", + GptImage2TextToImageQuality::Medium => "medium", + GptImage2TextToImageQuality::High => "high", + }.to_string()); + + let output_format = Some(match self.output_format { + Some(GptImage2TextToImageOutputFormat::Jpeg) => "jpeg", + Some(GptImage2TextToImageOutputFormat::Png) => "png", + Some(GptImage2TextToImageOutputFormat::Webp) => "webp", + None => "png", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + image_size, + quality, + output_format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: None, + resolution: None, + quality: None, + output_format: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2TextToImageRequest { + prompt: "a corgi wearing sunglasses at the beach".to_string(), + num_images: GptImage2TextToImageNumImages::Two, + image_size: Some(GptImage2TextToImageSize::Landscape16x9), + resolution: None, + quality: Some(GptImage2TextToImageQuality::High), + output_format: Some(GptImage2TextToImageOutputFormat::Png), + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + /// Combinatorial test: every (image_size, resolution) pair. + /// The prompt embeds the test parameters so the output image documents + /// which combination was used. + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_combinatorial_size_and_resolution() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let sizes = [ + ("square", GptImage2TextToImageSize::Square), + ("square_hd", GptImage2TextToImageSize::SquareHd), + ("landscape_4x3", GptImage2TextToImageSize::Landscape4x3), + ("landscape_16x9", GptImage2TextToImageSize::Landscape16x9), + ("portrait_4x3", GptImage2TextToImageSize::Portrait4x3), + ("portrait_16x9", GptImage2TextToImageSize::Portrait16x9), + ]; + + let resolutions = [ + ("1K", GptImage2Resolution::OneK), + ("2K", GptImage2Resolution::TwoK), + ("3K", GptImage2Resolution::ThreeK), + ("4K", GptImage2Resolution::FourK), + ]; + + let surfaces = ["t-shirt", "sign", "monitor", "billboard", "coffee mug", "poster"]; + let mut surface_idx = 0; + + for (size_name, size) in &sizes { + for (res_name, resolution) in &resolutions { + let surface = surfaces[surface_idx % surfaces.len()]; + surface_idx += 1; + + let prompt = format!( + "A photorealistic {} with the text \"{} {}\" printed on it, studio lighting, white background", + surface, size_name, res_name, + ); + + let request = GptImage2TextToImageRequest { + prompt, + num_images: GptImage2TextToImageNumImages::One, + image_size: Some(*size), + resolution: Some(*resolution), + quality: Some(GptImage2TextToImageQuality::Medium), + output_format: Some(GptImage2TextToImageOutputFormat::Png), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("[{} + {}] Request ID: {}", size_name, res_name, result.request_id); + assert!(!result.request_id.is_empty()); + } + } + + Ok(()) + } + + mod to_raw_request_tests { + use super::*; + use crate::requests::api::image::text::gpt_image_2_text_to_image::raw_request::ImageSizeParam; + + #[test] + fn preset_without_resolution() { + let request = GptImage2TextToImageRequest { + prompt: "test".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: Some(GptImage2TextToImageSize::Square), + resolution: None, + quality: None, + output_format: None, + }; + let raw = request.to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Preset(s) => assert_eq!(s, "square"), + ImageSizeParam::Custom(_) => panic!("expected preset"), + } + } + + #[test] + fn custom_with_resolution() { + let request = GptImage2TextToImageRequest { + prompt: "test".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: Some(GptImage2TextToImageSize::Landscape16x9), + resolution: Some(GptImage2Resolution::TwoK), + quality: None, + output_format: None, + }; + let raw = request.to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Custom(c) => { + assert_eq!(c.width, 2048); + assert_eq!(c.height, 1152); + } + ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[test] + fn resolution_without_image_size_defaults_to_square() { + let request = GptImage2TextToImageRequest { + prompt: "test".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: None, + resolution: Some(GptImage2Resolution::TwoK), + quality: None, + output_format: None, + }; + let raw = request.to_raw_request().unwrap(); + match raw.image_size.unwrap() { + ImageSizeParam::Custom(c) => { + assert_eq!(c.width, 2048); + assert_eq!(c.height, 2048); + } + ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[test] + fn neither_size_nor_resolution() { + let request = GptImage2TextToImageRequest { + prompt: "test".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: None, + resolution: None, + quality: None, + output_format: None, + }; + let raw = request.to_raw_request().unwrap(); + assert!(raw.image_size.is_none()); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/cost.rs new file mode 100644 index 0000000000..a673f446f4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/cost.rs @@ -0,0 +1,423 @@ +use crate::requests::api::image::common::gpt_image_2_resolution::{ + compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution, +}; +use crate::requests::api::image::text::gpt_image_2_text_to_image::api::{ + GptImage2TextToImageNumImages, GptImage2TextToImageQuality, + GptImage2TextToImageRequest, GptImage2TextToImageSize, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage2TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let quality = self.quality.unwrap_or(GptImage2TextToImageQuality::High); + let total_pixels = estimate_total_pixels(self.image_size, self.resolution); + let megapixels = total_pixels as f64 / 1_000_000.0; + + // Fal's GPT Image 2 text-to-image pricing has two components: + // + // 1. A base request cost (prompt processing, API overhead) + // 2. A per-pixel generation cost that scales with output resolution + // + // Both components increase with quality level. All rates are in + // tenths of a US cent, derived from Fal's published pricing. + // + // The base cost dominates at small resolutions (~1K); the per-pixel + // cost dominates at large resolutions (~4K). + let (base_tenths, per_megapixel_tenths): (f64, f64) = match quality { + GptImage2TextToImageQuality::Low => ( 8.0, 1.5), + GptImage2TextToImageQuality::Medium => (35.0, 9.5), + GptImage2TextToImageQuality::High => (120.0, 35.0), + }; + + let tenths_per_image = (base_tenths + per_megapixel_tenths * megapixels).ceil() as u64; + let cents_per_image = tenths_per_image.div_ceil(10); + let num = num_images_u64(self.num_images); + + cents_per_image * num + } +} + +fn estimate_total_pixels( + image_size: Option, + resolution: Option, +) -> u64 { + match (image_size, resolution) { + (Some(size), Some(res)) => { + let aspect = size_to_aspect(size); + let dims = compute_custom_image_size(aspect, res); + dims.width as u64 * dims.height as u64 + } + (None, Some(res)) => { + let dims = compute_custom_image_size(GptImage2AspectRatio::Square, res); + dims.width as u64 * dims.height as u64 + } + (Some(size), None) => preset_pixel_count(size), + (None, None) => preset_pixel_count(GptImage2TextToImageSize::Square), + } +} + +fn size_to_aspect(size: GptImage2TextToImageSize) -> GptImage2AspectRatio { + match size { + GptImage2TextToImageSize::Square => GptImage2AspectRatio::Square, + GptImage2TextToImageSize::SquareHd => GptImage2AspectRatio::SquareHd, + GptImage2TextToImageSize::Landscape4x3 => GptImage2AspectRatio::Landscape4x3, + GptImage2TextToImageSize::Landscape16x9 => GptImage2AspectRatio::Landscape16x9, + GptImage2TextToImageSize::Portrait4x3 => GptImage2AspectRatio::Portrait4x3, + GptImage2TextToImageSize::Portrait16x9 => GptImage2AspectRatio::Portrait16x9, + } +} + +/// Standard preset pixel counts when no custom resolution is specified. +fn preset_pixel_count(size: GptImage2TextToImageSize) -> u64 { + match size { + GptImage2TextToImageSize::Square => 1024 * 1024, // 1,048,576 + GptImage2TextToImageSize::SquareHd => 2048 * 2048, // 4,194,304 + GptImage2TextToImageSize::Landscape4x3 => 1024 * 768, // 786,432 + GptImage2TextToImageSize::Landscape16x9 => 1920 * 1080, // 2,073,600 + GptImage2TextToImageSize::Portrait4x3 => 768 * 1024, // 786,432 + GptImage2TextToImageSize::Portrait16x9 => 1080 * 1920, // 2,073,600 + } +} + +fn num_images_u64(num: GptImage2TextToImageNumImages) -> u64 { + match num { + GptImage2TextToImageNumImages::One => 1, + GptImage2TextToImageNumImages::Two => 2, + GptImage2TextToImageNumImages::Three => 3, + GptImage2TextToImageNumImages::Four => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: GptImage2TextToImageNumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2TextToImageRequest { + GptImage2TextToImageRequest { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + output_format: None, + } + } + + use GptImage2TextToImageNumImages::*; + use GptImage2TextToImageQuality::*; + use GptImage2TextToImageSize::*; + use GptImage2Resolution::*; + + mod preset_pricing_tests { + use super::*; + + // Table: (size, low, medium, high) — expected cents per image + const PRESET_CASES: &[(GptImage2TextToImageSize, u64, u64, u64)] = &[ + // Low Med High + (Landscape4x3, 1, 5, 15), + (Portrait4x3, 1, 5, 15), + (Square, 1, 5, 16), + (Landscape16x9, 2, 6, 20), + (Portrait16x9, 2, 6, 20), + (SquareHd, 2, 8, 27), + ]; + + #[test] + fn preset_costs_at_each_quality() { + for &(size, expected_low, expected_med, expected_high) in PRESET_CASES { + let cases = [ + (Low, expected_low), + (Medium, expected_med), + (High, expected_high), + ]; + for (quality, expected) in cases { + let actual = make_request(One, Some(quality), Some(size), None) + .calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {quality:?}"); + } + } + } + + #[test] + fn defaults_to_high_quality_square() { + assert_eq!( + make_request(One, None, None, None).calculate_cost_in_cents(), + 16, + ); + } + } + + mod custom_resolution_pricing_tests { + use super::*; + + // Table: (size, resolution, low, medium, high) — expected cents per image + const RESOLUTION_CASES: &[(GptImage2TextToImageSize, GptImage2Resolution, u64, u64, u64)] = &[ + // Square aspect + (Square, OneK, 1, 5, 16), + (Square, TwoK, 2, 8, 27), + (Square, ThreeK, 3, 12, 42), + (Square, FourK, 3, 12, 42), // capped at max pixels + + // SquareHd (same aspect as Square) + (SquareHd, OneK, 1, 5, 16), + (SquareHd, TwoK, 2, 8, 27), + (SquareHd, ThreeK, 3, 12, 42), + (SquareHd, FourK, 3, 12, 42), + + // Landscape 4:3 + (Landscape4x3, OneK, 1, 5, 15), + (Landscape4x3, TwoK, 2, 7, 24), + (Landscape4x3, ThreeK, 2, 11, 37), + (Landscape4x3, FourK, 3, 12, 41), + + // Landscape 16:9 + (Landscape16x9, OneK, 1, 5, 15), + (Landscape16x9, TwoK, 2, 6, 21), + (Landscape16x9, ThreeK, 2, 9, 31), + (Landscape16x9, FourK, 3, 12, 42), + + // Portrait 4:3 + (Portrait4x3, OneK, 1, 5, 15), + (Portrait4x3, TwoK, 2, 7, 24), + (Portrait4x3, ThreeK, 2, 11, 37), + (Portrait4x3, FourK, 3, 12, 41), + + // Portrait 16:9 + (Portrait16x9, OneK, 1, 5, 15), + (Portrait16x9, TwoK, 2, 6, 21), + (Portrait16x9, ThreeK, 2, 9, 31), + (Portrait16x9, FourK, 3, 12, 42), + ]; + + #[test] + fn resolution_costs_at_each_quality() { + for &(size, res, expected_low, expected_med, expected_high) in RESOLUTION_CASES { + let cases = [ + (Low, expected_low), + (Medium, expected_med), + (High, expected_high), + ]; + for (quality, expected) in cases { + let actual = make_request(One, Some(quality), Some(size), Some(res)) + .calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {res:?} {quality:?}"); + } + } + } + + #[test] + fn resolution_without_size_defaults_to_square() { + assert_eq!( + make_request(One, Some(High), None, Some(TwoK)).calculate_cost_in_cents(), + make_request(One, Some(High), Some(Square), Some(TwoK)).calculate_cost_in_cents(), + ); + } + } + + mod num_images_tests { + use super::*; + + const ALL_BATCH_SIZES: &[(GptImage2TextToImageNumImages, u64)] = &[ + (One, 1), (Two, 2), (Three, 3), (Four, 4), + ]; + + const ALL_SIZES: &[GptImage2TextToImageSize] = &[ + Square, SquareHd, Landscape4x3, Landscape16x9, Portrait4x3, Portrait16x9, + ]; + + const ALL_RESOLUTIONS: &[GptImage2Resolution] = &[OneK, TwoK, ThreeK, FourK]; + + #[test] + fn batch_scales_linearly_for_all_presets() { + for &size in ALL_SIZES { + let per_image = make_request(One, Some(High), Some(size), None) + .calculate_cost_in_cents(); + for &(num, n) in ALL_BATCH_SIZES { + let actual = make_request(num, Some(High), Some(size), None) + .calculate_cost_in_cents(); + assert_eq!(actual, per_image * n, "{size:?} x{n}"); + } + } + } + + #[test] + fn batch_scales_linearly_for_all_resolutions() { + for &size in ALL_SIZES { + for &res in ALL_RESOLUTIONS { + let per_image = make_request(One, Some(High), Some(size), Some(res)) + .calculate_cost_in_cents(); + for &(num, n) in ALL_BATCH_SIZES { + let actual = make_request(num, Some(High), Some(size), Some(res)) + .calculate_cost_in_cents(); + assert_eq!(actual, per_image * n, "{size:?} {res:?} x{n}"); + } + } + } + } + } + + mod exhaustive_preset_batch_tests { + use super::*; + + // (size, quality, num_images, expected_cents) + const CASES: &[(GptImage2TextToImageSize, GptImage2TextToImageQuality, GptImage2TextToImageNumImages, u64)] = &[ + (Square, Low, One, 1), (Square, Low, Two, 2), (Square, Low, Three, 3), (Square, Low, Four, 4), + (Square, Medium, One, 5), (Square, Medium, Two, 10), (Square, Medium, Three, 15), (Square, Medium, Four, 20), + (Square, High, One, 16), (Square, High, Two, 32), (Square, High, Three, 48), (Square, High, Four, 64), + (SquareHd, Low, One, 2), (SquareHd, Low, Two, 4), (SquareHd, Low, Three, 6), (SquareHd, Low, Four, 8), + (SquareHd, Medium, One, 8), (SquareHd, Medium, Two, 16), (SquareHd, Medium, Three, 24), (SquareHd, Medium, Four, 32), + (SquareHd, High, One, 27), (SquareHd, High, Two, 54), (SquareHd, High, Three, 81), (SquareHd, High, Four, 108), + (Landscape4x3, Low, One, 1), (Landscape4x3, Low, Two, 2), (Landscape4x3, Low, Three, 3), (Landscape4x3, Low, Four, 4), + (Landscape4x3, Medium, One, 5), (Landscape4x3, Medium, Two, 10), (Landscape4x3, Medium, Three, 15), (Landscape4x3, Medium, Four, 20), + (Landscape4x3, High, One, 15), (Landscape4x3, High, Two, 30), (Landscape4x3, High, Three, 45), (Landscape4x3, High, Four, 60), + (Landscape16x9, Low, One, 2), (Landscape16x9, Low, Two, 4), (Landscape16x9, Low, Three, 6), (Landscape16x9, Low, Four, 8), + (Landscape16x9, Medium, One, 6), (Landscape16x9, Medium, Two, 12), (Landscape16x9, Medium, Three, 18), (Landscape16x9, Medium, Four, 24), + (Landscape16x9, High, One, 20), (Landscape16x9, High, Two, 40), (Landscape16x9, High, Three, 60), (Landscape16x9, High, Four, 80), + (Portrait4x3, Low, One, 1), (Portrait4x3, Low, Two, 2), (Portrait4x3, Low, Three, 3), (Portrait4x3, Low, Four, 4), + (Portrait4x3, Medium, One, 5), (Portrait4x3, Medium, Two, 10), (Portrait4x3, Medium, Three, 15), (Portrait4x3, Medium, Four, 20), + (Portrait4x3, High, One, 15), (Portrait4x3, High, Two, 30), (Portrait4x3, High, Three, 45), (Portrait4x3, High, Four, 60), + (Portrait16x9, Low, One, 2), (Portrait16x9, Low, Two, 4), (Portrait16x9, Low, Three, 6), (Portrait16x9, Low, Four, 8), + (Portrait16x9, Medium, One, 6), (Portrait16x9, Medium, Two, 12), (Portrait16x9, Medium, Three, 18), (Portrait16x9, Medium, Four, 24), + (Portrait16x9, High, One, 20), (Portrait16x9, High, Two, 40), (Portrait16x9, High, Three, 60), (Portrait16x9, High, Four, 80), + ]; + + #[test] + fn all_preset_batch_costs() { + for &(size, quality, num, expected) in CASES { + let actual = make_request(num, Some(quality), Some(size), None).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {quality:?} {num:?}"); + } + } + } + + mod exhaustive_resolution_batch_tests { + use super::*; + + // (size, resolution, quality, num_images, expected_cents) + const CASES: &[(GptImage2TextToImageSize, GptImage2Resolution, GptImage2TextToImageQuality, GptImage2TextToImageNumImages, u64)] = &[ + // Square + (Square, OneK, Low, One, 1), (Square, OneK, Low, Two, 2), (Square, OneK, Low, Three, 3), (Square, OneK, Low, Four, 4), + (Square, OneK, Medium, One, 5), (Square, OneK, Medium, Two, 10), (Square, OneK, Medium, Three, 15), (Square, OneK, Medium, Four, 20), + (Square, OneK, High, One, 16), (Square, OneK, High, Two, 32), (Square, OneK, High, Three, 48), (Square, OneK, High, Four, 64), + (Square, TwoK, Low, One, 2), (Square, TwoK, Low, Two, 4), (Square, TwoK, Low, Three, 6), (Square, TwoK, Low, Four, 8), + (Square, TwoK, Medium, One, 8), (Square, TwoK, Medium, Two, 16), (Square, TwoK, Medium, Three, 24), (Square, TwoK, Medium, Four, 32), + (Square, TwoK, High, One, 27), (Square, TwoK, High, Two, 54), (Square, TwoK, High, Three, 81), (Square, TwoK, High, Four, 108), + (Square, ThreeK, Low, One, 3), (Square, ThreeK, Low, Two, 6), (Square, ThreeK, Low, Three, 9), (Square, ThreeK, Low, Four, 12), + (Square, ThreeK, Medium, One, 12), (Square, ThreeK, Medium, Two, 24), (Square, ThreeK, Medium, Three, 36), (Square, ThreeK, Medium, Four, 48), + (Square, ThreeK, High, One, 42), (Square, ThreeK, High, Two, 84), (Square, ThreeK, High, Three, 126), (Square, ThreeK, High, Four, 168), + (Square, FourK, Low, One, 3), (Square, FourK, Low, Two, 6), (Square, FourK, Low, Three, 9), (Square, FourK, Low, Four, 12), + (Square, FourK, Medium, One, 12), (Square, FourK, Medium, Two, 24), (Square, FourK, Medium, Three, 36), (Square, FourK, Medium, Four, 48), + (Square, FourK, High, One, 42), (Square, FourK, High, Two, 84), (Square, FourK, High, Three, 126), (Square, FourK, High, Four, 168), + // SquareHd + (SquareHd, OneK, Low, One, 1), (SquareHd, OneK, Low, Two, 2), (SquareHd, OneK, Low, Three, 3), (SquareHd, OneK, Low, Four, 4), + (SquareHd, OneK, Medium, One, 5), (SquareHd, OneK, Medium, Two, 10), (SquareHd, OneK, Medium, Three, 15), (SquareHd, OneK, Medium, Four, 20), + (SquareHd, OneK, High, One, 16), (SquareHd, OneK, High, Two, 32), (SquareHd, OneK, High, Three, 48), (SquareHd, OneK, High, Four, 64), + (SquareHd, TwoK, Low, One, 2), (SquareHd, TwoK, Low, Two, 4), (SquareHd, TwoK, Low, Three, 6), (SquareHd, TwoK, Low, Four, 8), + (SquareHd, TwoK, Medium, One, 8), (SquareHd, TwoK, Medium, Two, 16), (SquareHd, TwoK, Medium, Three, 24), (SquareHd, TwoK, Medium, Four, 32), + (SquareHd, TwoK, High, One, 27), (SquareHd, TwoK, High, Two, 54), (SquareHd, TwoK, High, Three, 81), (SquareHd, TwoK, High, Four, 108), + (SquareHd, ThreeK, Low, One, 3), (SquareHd, ThreeK, Low, Two, 6), (SquareHd, ThreeK, Low, Three, 9), (SquareHd, ThreeK, Low, Four, 12), + (SquareHd, ThreeK, Medium, One, 12), (SquareHd, ThreeK, Medium, Two, 24), (SquareHd, ThreeK, Medium, Three, 36), (SquareHd, ThreeK, Medium, Four, 48), + (SquareHd, ThreeK, High, One, 42), (SquareHd, ThreeK, High, Two, 84), (SquareHd, ThreeK, High, Three, 126), (SquareHd, ThreeK, High, Four, 168), + (SquareHd, FourK, Low, One, 3), (SquareHd, FourK, Low, Two, 6), (SquareHd, FourK, Low, Three, 9), (SquareHd, FourK, Low, Four, 12), + (SquareHd, FourK, Medium, One, 12), (SquareHd, FourK, Medium, Two, 24), (SquareHd, FourK, Medium, Three, 36), (SquareHd, FourK, Medium, Four, 48), + (SquareHd, FourK, High, One, 42), (SquareHd, FourK, High, Two, 84), (SquareHd, FourK, High, Three, 126), (SquareHd, FourK, High, Four, 168), + // Landscape4x3 + (Landscape4x3, OneK, Low, One, 1), (Landscape4x3, OneK, Low, Two, 2), (Landscape4x3, OneK, Low, Three, 3), (Landscape4x3, OneK, Low, Four, 4), + (Landscape4x3, OneK, Medium, One, 5), (Landscape4x3, OneK, Medium, Two, 10), (Landscape4x3, OneK, Medium, Three, 15), (Landscape4x3, OneK, Medium, Four, 20), + (Landscape4x3, OneK, High, One, 15), (Landscape4x3, OneK, High, Two, 30), (Landscape4x3, OneK, High, Three, 45), (Landscape4x3, OneK, High, Four, 60), + (Landscape4x3, TwoK, Low, One, 2), (Landscape4x3, TwoK, Low, Two, 4), (Landscape4x3, TwoK, Low, Three, 6), (Landscape4x3, TwoK, Low, Four, 8), + (Landscape4x3, TwoK, Medium, One, 7), (Landscape4x3, TwoK, Medium, Two, 14), (Landscape4x3, TwoK, Medium, Three, 21), (Landscape4x3, TwoK, Medium, Four, 28), + (Landscape4x3, TwoK, High, One, 24), (Landscape4x3, TwoK, High, Two, 48), (Landscape4x3, TwoK, High, Three, 72), (Landscape4x3, TwoK, High, Four, 96), + (Landscape4x3, ThreeK, Low, One, 2), (Landscape4x3, ThreeK, Low, Two, 4), (Landscape4x3, ThreeK, Low, Three, 6), (Landscape4x3, ThreeK, Low, Four, 8), + (Landscape4x3, ThreeK, Medium, One, 11), (Landscape4x3, ThreeK, Medium, Two, 22), (Landscape4x3, ThreeK, Medium, Three, 33), (Landscape4x3, ThreeK, Medium, Four, 44), + (Landscape4x3, ThreeK, High, One, 37), (Landscape4x3, ThreeK, High, Two, 74), (Landscape4x3, ThreeK, High, Three, 111), (Landscape4x3, ThreeK, High, Four, 148), + (Landscape4x3, FourK, Low, One, 3), (Landscape4x3, FourK, Low, Two, 6), (Landscape4x3, FourK, Low, Three, 9), (Landscape4x3, FourK, Low, Four, 12), + (Landscape4x3, FourK, Medium, One, 12), (Landscape4x3, FourK, Medium, Two, 24), (Landscape4x3, FourK, Medium, Three, 36), (Landscape4x3, FourK, Medium, Four, 48), + (Landscape4x3, FourK, High, One, 41), (Landscape4x3, FourK, High, Two, 82), (Landscape4x3, FourK, High, Three, 123), (Landscape4x3, FourK, High, Four, 164), + // Landscape16x9 + (Landscape16x9, OneK, Low, One, 1), (Landscape16x9, OneK, Low, Two, 2), (Landscape16x9, OneK, Low, Three, 3), (Landscape16x9, OneK, Low, Four, 4), + (Landscape16x9, OneK, Medium, One, 5), (Landscape16x9, OneK, Medium, Two, 10), (Landscape16x9, OneK, Medium, Three, 15), (Landscape16x9, OneK, Medium, Four, 20), + (Landscape16x9, OneK, High, One, 15), (Landscape16x9, OneK, High, Two, 30), (Landscape16x9, OneK, High, Three, 45), (Landscape16x9, OneK, High, Four, 60), + (Landscape16x9, TwoK, Low, One, 2), (Landscape16x9, TwoK, Low, Two, 4), (Landscape16x9, TwoK, Low, Three, 6), (Landscape16x9, TwoK, Low, Four, 8), + (Landscape16x9, TwoK, Medium, One, 6), (Landscape16x9, TwoK, Medium, Two, 12), (Landscape16x9, TwoK, Medium, Three, 18), (Landscape16x9, TwoK, Medium, Four, 24), + (Landscape16x9, TwoK, High, One, 21), (Landscape16x9, TwoK, High, Two, 42), (Landscape16x9, TwoK, High, Three, 63), (Landscape16x9, TwoK, High, Four, 84), + (Landscape16x9, ThreeK, Low, One, 2), (Landscape16x9, ThreeK, Low, Two, 4), (Landscape16x9, ThreeK, Low, Three, 6), (Landscape16x9, ThreeK, Low, Four, 8), + (Landscape16x9, ThreeK, Medium, One, 9), (Landscape16x9, ThreeK, Medium, Two, 18), (Landscape16x9, ThreeK, Medium, Three, 27), (Landscape16x9, ThreeK, Medium, Four, 36), + (Landscape16x9, ThreeK, High, One, 31), (Landscape16x9, ThreeK, High, Two, 62), (Landscape16x9, ThreeK, High, Three, 93), (Landscape16x9, ThreeK, High, Four, 124), + (Landscape16x9, FourK, Low, One, 3), (Landscape16x9, FourK, Low, Two, 6), (Landscape16x9, FourK, Low, Three, 9), (Landscape16x9, FourK, Low, Four, 12), + (Landscape16x9, FourK, Medium, One, 12), (Landscape16x9, FourK, Medium, Two, 24), (Landscape16x9, FourK, Medium, Three, 36), (Landscape16x9, FourK, Medium, Four, 48), + (Landscape16x9, FourK, High, One, 42), (Landscape16x9, FourK, High, Two, 84), (Landscape16x9, FourK, High, Three, 126), (Landscape16x9, FourK, High, Four, 168), + // Portrait4x3 + (Portrait4x3, OneK, Low, One, 1), (Portrait4x3, OneK, Low, Two, 2), (Portrait4x3, OneK, Low, Three, 3), (Portrait4x3, OneK, Low, Four, 4), + (Portrait4x3, OneK, Medium, One, 5), (Portrait4x3, OneK, Medium, Two, 10), (Portrait4x3, OneK, Medium, Three, 15), (Portrait4x3, OneK, Medium, Four, 20), + (Portrait4x3, OneK, High, One, 15), (Portrait4x3, OneK, High, Two, 30), (Portrait4x3, OneK, High, Three, 45), (Portrait4x3, OneK, High, Four, 60), + (Portrait4x3, TwoK, Low, One, 2), (Portrait4x3, TwoK, Low, Two, 4), (Portrait4x3, TwoK, Low, Three, 6), (Portrait4x3, TwoK, Low, Four, 8), + (Portrait4x3, TwoK, Medium, One, 7), (Portrait4x3, TwoK, Medium, Two, 14), (Portrait4x3, TwoK, Medium, Three, 21), (Portrait4x3, TwoK, Medium, Four, 28), + (Portrait4x3, TwoK, High, One, 24), (Portrait4x3, TwoK, High, Two, 48), (Portrait4x3, TwoK, High, Three, 72), (Portrait4x3, TwoK, High, Four, 96), + (Portrait4x3, ThreeK, Low, One, 2), (Portrait4x3, ThreeK, Low, Two, 4), (Portrait4x3, ThreeK, Low, Three, 6), (Portrait4x3, ThreeK, Low, Four, 8), + (Portrait4x3, ThreeK, Medium, One, 11), (Portrait4x3, ThreeK, Medium, Two, 22), (Portrait4x3, ThreeK, Medium, Three, 33), (Portrait4x3, ThreeK, Medium, Four, 44), + (Portrait4x3, ThreeK, High, One, 37), (Portrait4x3, ThreeK, High, Two, 74), (Portrait4x3, ThreeK, High, Three, 111), (Portrait4x3, ThreeK, High, Four, 148), + (Portrait4x3, FourK, Low, One, 3), (Portrait4x3, FourK, Low, Two, 6), (Portrait4x3, FourK, Low, Three, 9), (Portrait4x3, FourK, Low, Four, 12), + (Portrait4x3, FourK, Medium, One, 12), (Portrait4x3, FourK, Medium, Two, 24), (Portrait4x3, FourK, Medium, Three, 36), (Portrait4x3, FourK, Medium, Four, 48), + (Portrait4x3, FourK, High, One, 41), (Portrait4x3, FourK, High, Two, 82), (Portrait4x3, FourK, High, Three, 123), (Portrait4x3, FourK, High, Four, 164), + // Portrait16x9 + (Portrait16x9, OneK, Low, One, 1), (Portrait16x9, OneK, Low, Two, 2), (Portrait16x9, OneK, Low, Three, 3), (Portrait16x9, OneK, Low, Four, 4), + (Portrait16x9, OneK, Medium, One, 5), (Portrait16x9, OneK, Medium, Two, 10), (Portrait16x9, OneK, Medium, Three, 15), (Portrait16x9, OneK, Medium, Four, 20), + (Portrait16x9, OneK, High, One, 15), (Portrait16x9, OneK, High, Two, 30), (Portrait16x9, OneK, High, Three, 45), (Portrait16x9, OneK, High, Four, 60), + (Portrait16x9, TwoK, Low, One, 2), (Portrait16x9, TwoK, Low, Two, 4), (Portrait16x9, TwoK, Low, Three, 6), (Portrait16x9, TwoK, Low, Four, 8), + (Portrait16x9, TwoK, Medium, One, 6), (Portrait16x9, TwoK, Medium, Two, 12), (Portrait16x9, TwoK, Medium, Three, 18), (Portrait16x9, TwoK, Medium, Four, 24), + (Portrait16x9, TwoK, High, One, 21), (Portrait16x9, TwoK, High, Two, 42), (Portrait16x9, TwoK, High, Three, 63), (Portrait16x9, TwoK, High, Four, 84), + (Portrait16x9, ThreeK, Low, One, 2), (Portrait16x9, ThreeK, Low, Two, 4), (Portrait16x9, ThreeK, Low, Three, 6), (Portrait16x9, ThreeK, Low, Four, 8), + (Portrait16x9, ThreeK, Medium, One, 9), (Portrait16x9, ThreeK, Medium, Two, 18), (Portrait16x9, ThreeK, Medium, Three, 27), (Portrait16x9, ThreeK, Medium, Four, 36), + (Portrait16x9, ThreeK, High, One, 31), (Portrait16x9, ThreeK, High, Two, 62), (Portrait16x9, ThreeK, High, Three, 93), (Portrait16x9, ThreeK, High, Four, 124), + (Portrait16x9, FourK, Low, One, 3), (Portrait16x9, FourK, Low, Two, 6), (Portrait16x9, FourK, Low, Three, 9), (Portrait16x9, FourK, Low, Four, 12), + (Portrait16x9, FourK, Medium, One, 12), (Portrait16x9, FourK, Medium, Two, 24), (Portrait16x9, FourK, Medium, Three, 36), (Portrait16x9, FourK, Medium, Four, 48), + (Portrait16x9, FourK, High, One, 42), (Portrait16x9, FourK, High, Two, 84), (Portrait16x9, FourK, High, Three, 126), (Portrait16x9, FourK, High, Four, 168), + ]; + + #[test] + fn all_resolution_batch_costs() { + for &(size, res, quality, num, expected) in CASES { + let actual = make_request(num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {res:?} {quality:?} {num:?}"); + } + } + } + + mod monotonicity_tests { + use super::*; + + #[test] + fn higher_quality_costs_more() { + for &size in &[Square, Landscape16x9, Portrait4x3] { + let low = make_request(One, Some(Low), Some(size), None).calculate_cost_in_cents(); + let med = make_request(One, Some(Medium), Some(size), None).calculate_cost_in_cents(); + let high = make_request(One, Some(High), Some(size), None).calculate_cost_in_cents(); + assert!(low <= med, "{size:?}: low ({low}) should be <= medium ({med})"); + assert!(med <= high, "{size:?}: medium ({med}) should be <= high ({high})"); + } + } + + #[test] + fn higher_resolution_costs_at_least_as_much() { + for &size in &[Square, Landscape16x9, Portrait4x3] { + let resolutions = [OneK, TwoK, ThreeK, FourK]; + for pair in resolutions.windows(2) { + let lower = make_request(One, Some(High), Some(size), Some(pair[0])) + .calculate_cost_in_cents(); + let higher = make_request(One, Some(High), Some(size), Some(pair[1])) + .calculate_cost_in_cents(); + assert!( + lower <= higher, + "{size:?}: {pair:?} — lower res ({lower}¢) should be <= higher res ({higher}¢)", + ); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/raw_request.rs new file mode 100644 index 0000000000..56472bfae4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2_text_to_image/raw_request.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use crate::requests::api::image::common::gpt_image_2_resolution::CustomImageSize; + +/// The `image_size` field can be either a string enum value (e.g. "square") +/// or a custom object with explicit width/height. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum ImageSizeParam { + Preset(String), + Custom(CustomImageSize), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2TextToImageInput { + pub prompt: String, + + /// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + /// OR a custom { width, height } object. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2TextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/api.rs new file mode 100644 index 0000000000..9971daafea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/api.rs @@ -0,0 +1,126 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_text_to_image_input::GptImage2p5TextToImageInput; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// GPT Image 2.5 Flare text-to-image. +/// +/// Flare and Sunburst take the same parameters (see `GptImage2p5TextToImageParams`); +/// this type only selects the endpoint. +#[derive(Clone, Debug)] +pub struct GptImage2p5FlareTextToImageRequest { + pub params: GptImage2p5TextToImageParams, +} + +impl FalEndpoint for GptImage2p5FlareTextToImageRequest { + const ENDPOINT: &str = "openai/gpt-image-2.5/flare/text-to-image"; + + type RawRequest = GptImage2p5TextToImageInput; + type RawResponse = GptImage2p5ImageOutput; + + fn to_raw_request(&self) -> Result { + Ok(self.params.to_raw_input()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[test] + fn endpoint() { + assert_eq!(GptImage2p5FlareTextToImageRequest::ENDPOINT, "openai/gpt-image-2.5/flare/text-to-image"); + assert_eq!(GptImage2p5FlareTextToImageRequest::get_endpoint(), "openai/gpt-image-2.5/flare/text-to-image"); + } + + #[test] + fn to_raw_request_delegates_to_params() { + let request = GptImage2p5FlareTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images: GptImage2p5NumImages::Two, + image_size: Some(GptImage2p5ImageSize::Landscape16x9), + resolution: Some(GptImage2Resolution::TwoK), + quality: Some(GptImage2p5Quality::Medium), + background: None, + output_format: Some(GptImage2p5OutputFormat::Jpeg), + output_compression: Some(75), + }, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "test"); + assert_eq!(raw.num_images, Some(2)); + assert_eq!(raw.quality.as_deref(), Some("medium")); + assert_eq!(raw.output_format.as_deref(), Some("jpeg")); + assert_eq!(raw.output_compression, Some(75)); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 1152)), + GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5FlareTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: GptImage2p5NumImages::One, + image_size: None, + resolution: None, + quality: None, + background: None, + output_format: None, + output_compression: None, + }, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5FlareTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "a corgi wearing sunglasses at the beach".to_string(), + num_images: GptImage2p5NumImages::Two, + image_size: Some(GptImage2p5ImageSize::Landscape16x9), + resolution: None, + quality: Some(GptImage2p5Quality::High), + background: None, + output_format: Some(GptImage2p5OutputFormat::Png), + output_compression: None, + }, + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/cost.rs new file mode 100644 index 0000000000..53b40f8b2b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/cost.rs @@ -0,0 +1,93 @@ +use crate::requests::api::image::text::gpt_image_2p5_flare_text_to_image::api::GptImage2p5FlareTextToImageRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage2p5FlareTextToImageRequest { + /// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model. + fn calculate_cost_in_cents(&self) -> UsdCents { + self.params.estimate_cost_in_cents() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; + use GptImage2Resolution::*; + use GptImage2p5ImageSize::*; + use GptImage2p5NumImages::*; + use GptImage2p5Quality::{Low, Medium, High, XHigh, Max}; + + // (size, quality, num_images, expected_cents) + const PRESET_CASES: &[(GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (Square, Low, One, 1), + (Square, High, One, 6), + (Square, High, Four, 24), + (Square, Max, One, 22), + (Landscape4x3, High, One, 4), + (Landscape16x9, High, One, 4), + (Landscape16x9, XHigh, Two, 16), + (SquareHd, High, One, 9), + (Auto, High, One, 11), + ]; + + // (size, resolution, quality, num_images, expected_cents) + const RESOLUTION_CASES: &[(GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (Square, OneK, High, One, 6), + (Square, TwoK, High, One, 9), + (Square, FourK, High, One, 13), + (Landscape16x9, OneK, High, One, 3), + (Landscape16x9, FourK, High, One, 11), + (Portrait4x3, ThreeK, Medium, Three, 9), + ]; + + #[test] + fn defaults_to_high_quality_landscape_4x3() { + assert_eq!(make_request(One, None, None, None).calculate_cost_in_cents(), 4); + } + + #[test] + fn preset_costs() { + for &(size, quality, num, expected) in PRESET_CASES { + let actual = make_request(num, Some(quality), Some(size), None).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {quality:?} {num:?}"); + } + } + + #[test] + fn resolution_costs() { + for &(size, res, quality, num, expected) in RESOLUTION_CASES { + let actual = make_request(num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {res:?} {quality:?} {num:?}"); + } + } + + #[test] + fn matches_shared_params_estimate() { + let request = make_request(Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK)); + assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents()); + } + + fn make_request( + num_images: GptImage2p5NumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2p5FlareTextToImageRequest { + GptImage2p5FlareTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/api.rs new file mode 100644 index 0000000000..6b285850ae --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/api.rs @@ -0,0 +1,126 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput; +use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_text_to_image_input::GptImage2p5TextToImageInput; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// GPT Image 2.5 Sunburst text-to-image. +/// +/// Flare and Sunburst take the same parameters (see `GptImage2p5TextToImageParams`); +/// this type only selects the endpoint. +#[derive(Clone, Debug)] +pub struct GptImage2p5SunburstTextToImageRequest { + pub params: GptImage2p5TextToImageParams, +} + +impl FalEndpoint for GptImage2p5SunburstTextToImageRequest { + const ENDPOINT: &str = "openai/gpt-image-2.5/sunburst/text-to-image"; + + type RawRequest = GptImage2p5TextToImageInput; + type RawResponse = GptImage2p5ImageOutput; + + fn to_raw_request(&self) -> Result { + Ok(self.params.to_raw_input()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[test] + fn endpoint() { + assert_eq!(GptImage2p5SunburstTextToImageRequest::ENDPOINT, "openai/gpt-image-2.5/sunburst/text-to-image"); + assert_eq!(GptImage2p5SunburstTextToImageRequest::get_endpoint(), "openai/gpt-image-2.5/sunburst/text-to-image"); + } + + #[test] + fn to_raw_request_delegates_to_params() { + let request = GptImage2p5SunburstTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images: GptImage2p5NumImages::Two, + image_size: Some(GptImage2p5ImageSize::Landscape16x9), + resolution: Some(GptImage2Resolution::TwoK), + quality: Some(GptImage2p5Quality::Medium), + background: None, + output_format: Some(GptImage2p5OutputFormat::Jpeg), + output_compression: Some(75), + }, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "test"); + assert_eq!(raw.num_images, Some(2)); + assert_eq!(raw.quality.as_deref(), Some("medium")); + assert_eq!(raw.output_format.as_deref(), Some("jpeg")); + assert_eq!(raw.output_compression, Some(75)); + match raw.image_size.unwrap() { + GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 1152)), + GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"), + } + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5SunburstTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: GptImage2p5NumImages::One, + image_size: None, + resolution: None, + quality: None, + background: None, + output_format: None, + output_compression: None, + }, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = GptImage2p5SunburstTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "a corgi wearing sunglasses at the beach".to_string(), + num_images: GptImage2p5NumImages::Two, + image_size: Some(GptImage2p5ImageSize::Landscape16x9), + resolution: None, + quality: Some(GptImage2p5Quality::High), + background: None, + output_format: Some(GptImage2p5OutputFormat::Png), + output_compression: None, + }, + }; + + let result = request.send_webhook_request( + &api_key, + "https://example.com/webhook", + ).await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/cost.rs new file mode 100644 index 0000000000..eb04a98055 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/cost.rs @@ -0,0 +1,93 @@ +use crate::requests::api::image::text::gpt_image_2p5_sunburst_text_to_image::api::GptImage2p5SunburstTextToImageRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for GptImage2p5SunburstTextToImageRequest { + /// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model. + fn calculate_cost_in_cents(&self) -> UsdCents { + self.params.estimate_cost_in_cents() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality; + use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams; + use GptImage2Resolution::*; + use GptImage2p5ImageSize::*; + use GptImage2p5NumImages::*; + use GptImage2p5Quality::{Low, Medium, High, XHigh, Max}; + + // (size, quality, num_images, expected_cents) + const PRESET_CASES: &[(GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (Square, Low, One, 1), + (Square, High, One, 6), + (Square, High, Four, 24), + (Square, Max, One, 22), + (Landscape4x3, High, One, 4), + (Landscape16x9, High, One, 4), + (Landscape16x9, XHigh, Two, 16), + (SquareHd, High, One, 9), + (Auto, High, One, 11), + ]; + + // (size, resolution, quality, num_images, expected_cents) + const RESOLUTION_CASES: &[(GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[ + (Square, OneK, High, One, 6), + (Square, TwoK, High, One, 9), + (Square, FourK, High, One, 13), + (Landscape16x9, OneK, High, One, 3), + (Landscape16x9, FourK, High, One, 11), + (Portrait4x3, ThreeK, Medium, Three, 9), + ]; + + #[test] + fn defaults_to_high_quality_landscape_4x3() { + assert_eq!(make_request(One, None, None, None).calculate_cost_in_cents(), 4); + } + + #[test] + fn preset_costs() { + for &(size, quality, num, expected) in PRESET_CASES { + let actual = make_request(num, Some(quality), Some(size), None).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {quality:?} {num:?}"); + } + } + + #[test] + fn resolution_costs() { + for &(size, res, quality, num, expected) in RESOLUTION_CASES { + let actual = make_request(num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents(); + assert_eq!(actual, expected, "{size:?} {res:?} {quality:?} {num:?}"); + } + } + + #[test] + fn matches_shared_params_estimate() { + let request = make_request(Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK)); + assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents()); + } + + fn make_request( + num_images: GptImage2p5NumImages, + quality: Option, + image_size: Option, + resolution: Option, + ) -> GptImage2p5SunburstTextToImageRequest { + GptImage2p5SunburstTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs new file mode 100644 index 0000000000..4fb5e1821b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs @@ -0,0 +1,9 @@ +pub mod flux_1_dev_text_to_image; +pub mod flux_1_schnell_text_to_image; +pub mod gpt_image_1_text_to_image; +pub mod gpt_image_1p5_text_to_image; +pub mod gpt_image_2_text_to_image; +pub mod gpt_image_2p5_flare_text_to_image; +pub mod gpt_image_2p5_sunburst_text_to_image; +pub mod nano_banana_2_text_to_image; +pub mod nano_banana_pro_text_to_image; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/api.rs new file mode 100644 index 0000000000..9a6f613378 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/api.rs @@ -0,0 +1,131 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::text::nano_banana_2_text_to_image::raw_request::{ + NanoBanana2TextToImageInput, NanoBanana2TextToImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct NanoBanana2TextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Number of images to generate. + pub num_images: NanoBanana2TextToImageNumImages, + + /// Output resolution. + pub resolution: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBanana2TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBanana2TextToImageResolution { + HalfK, // "0.5K" + OneK, // "1K" (default) + TwoK, // "2K" + FourK, // "4K" +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +#[derive(Copy, Clone, Debug)] +pub enum NanoBanana2TextToImageAspectRatio { + Auto, + OneByOne, + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + +impl FalEndpoint for NanoBanana2TextToImageRequest { + const ENDPOINT: &str = "fal-ai/nano-banana-2"; + + type RawRequest = NanoBanana2TextToImageInput; + type RawResponse = NanoBanana2TextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + NanoBanana2TextToImageNumImages::One => 1, + NanoBanana2TextToImageNumImages::Two => 2, + NanoBanana2TextToImageNumImages::Three => 3, + NanoBanana2TextToImageNumImages::Four => 4, + }; + + let resolution = self.resolution.map(|r| match r { + NanoBanana2TextToImageResolution::HalfK => "0.5K", + NanoBanana2TextToImageResolution::OneK => "1K", + NanoBanana2TextToImageResolution::TwoK => "2K", + NanoBanana2TextToImageResolution::FourK => "4K", + }.to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + NanoBanana2TextToImageAspectRatio::Auto => "auto", + NanoBanana2TextToImageAspectRatio::OneByOne => "1:1", + NanoBanana2TextToImageAspectRatio::FiveByFour => "5:4", + NanoBanana2TextToImageAspectRatio::FourByThree => "4:3", + NanoBanana2TextToImageAspectRatio::ThreeByTwo => "3:2", + NanoBanana2TextToImageAspectRatio::SixteenByNine => "16:9", + NanoBanana2TextToImageAspectRatio::TwentyOneByNine => "21:9", + NanoBanana2TextToImageAspectRatio::FourByFive => "4:5", + NanoBanana2TextToImageAspectRatio::ThreeByFour => "3:4", + NanoBanana2TextToImageAspectRatio::TwoByThree => "2:3", + NanoBanana2TextToImageAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + aspect_ratio, + resolution, + output_format: Some("png".to_string()), + safety_tolerance: Some("6".to_string()), + limit_generations: None, + enable_web_search: None, + seed: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = NanoBanana2TextToImageRequest { + prompt: "a corgi wearing sunglasses at the beach".to_string(), + num_images: NanoBanana2TextToImageNumImages::One, + resolution: Some(NanoBanana2TextToImageResolution::OneK), + aspect_ratio: Some(NanoBanana2TextToImageAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/cost.rs new file mode 100644 index 0000000000..b14dae376d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::image::text::nano_banana_2_text_to_image::api::{ + NanoBanana2TextToImageNumImages, NanoBanana2TextToImageRequest, + NanoBanana2TextToImageResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for NanoBanana2TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // TODO(bt): Verify actual pricing for Nano Banana 2 on fal.ai. + // 4K outputs may be charged at double the standard rate. + let base_cost = match self.resolution { + None => 15, + Some(NanoBanana2TextToImageResolution::HalfK) => 8, + Some(NanoBanana2TextToImageResolution::OneK) => 15, + Some(NanoBanana2TextToImageResolution::TwoK) => 15, + Some(NanoBanana2TextToImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + NanoBanana2TextToImageNumImages::One => base_cost, + NanoBanana2TextToImageNumImages::Two => base_cost * 2, + NanoBanana2TextToImageNumImages::Three => base_cost * 3, + NanoBanana2TextToImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: NanoBanana2TextToImageNumImages, + resolution: Option, + ) -> NanoBanana2TextToImageRequest { + NanoBanana2TextToImageRequest { + prompt: "test".to_string(), + num_images, + resolution, + aspect_ratio: None, + } + } + + #[test] + fn cost_default_resolution_one_image() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::One, None).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_half_k_one_image() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::HalfK)).calculate_cost_in_cents(), 8); + } + + #[test] + fn cost_one_k_one_image() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::OneK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_two_k_one_image() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::TwoK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_four_k_one_image() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::FourK)).calculate_cost_in_cents(), 30); + } + + #[test] + fn cost_one_k_four_images() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::Four, Some(NanoBanana2TextToImageResolution::OneK)).calculate_cost_in_cents(), 60); + } + + #[test] + fn cost_four_k_two_images() { + assert_eq!(make_request(NanoBanana2TextToImageNumImages::Two, Some(NanoBanana2TextToImageResolution::FourK)).calculate_cost_in_cents(), 60); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/raw_request.rs new file mode 100644 index 0000000000..56d287ee91 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_2_text_to_image/raw_request.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBanana2TextToImageInput { + pub prompt: String, + + /// Eg. "auto", "16:9", "1:1", etc. + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Eg. "0.5K", "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// The safety tolerance level. 1 = most strict, 6 = least strict. + /// Default: "4" + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Limit generations per round of prompting to 1. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_generations: Option, + + /// Enable web search for image generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_web_search: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2ImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2TextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/api.rs new file mode 100644 index 0000000000..5f97d64d0c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/api.rs @@ -0,0 +1,123 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::image::text::nano_banana_pro_text_to_image::raw_request::{ + NanoBananaProTextToImageInput, NanoBananaProTextToImageOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct NanoBananaProTextToImageRequest { + /// Text prompt describing the image to generate. + pub prompt: String, + + /// Number of images to generate. + pub num_images: NanoBananaProTextToImageNumImages, + + /// Output resolution. + pub resolution: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBananaProTextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum NanoBananaProTextToImageResolution { + OneK, + TwoK, + FourK, +} + +/// 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +#[derive(Copy, Clone, Debug)] +pub enum NanoBananaProTextToImageAspectRatio { + OneByOne, + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + +impl FalEndpoint for NanoBananaProTextToImageRequest { + const ENDPOINT: &str = "fal-ai/nano-banana-pro"; + + type RawRequest = NanoBananaProTextToImageInput; + type RawResponse = NanoBananaProTextToImageOutput; + + fn to_raw_request(&self) -> Result { + let num_images = match self.num_images { + NanoBananaProTextToImageNumImages::One => 1, + NanoBananaProTextToImageNumImages::Two => 2, + NanoBananaProTextToImageNumImages::Three => 3, + NanoBananaProTextToImageNumImages::Four => 4, + }; + + let resolution = self.resolution.map(|r| match r { + NanoBananaProTextToImageResolution::OneK => "1K", + NanoBananaProTextToImageResolution::TwoK => "2K", + NanoBananaProTextToImageResolution::FourK => "4K", + }.to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + NanoBananaProTextToImageAspectRatio::OneByOne => "1:1", + NanoBananaProTextToImageAspectRatio::FiveByFour => "5:4", + NanoBananaProTextToImageAspectRatio::FourByThree => "4:3", + NanoBananaProTextToImageAspectRatio::ThreeByTwo => "3:2", + NanoBananaProTextToImageAspectRatio::SixteenByNine => "16:9", + NanoBananaProTextToImageAspectRatio::TwentyOneByNine => "21:9", + NanoBananaProTextToImageAspectRatio::FourByFive => "4:5", + NanoBananaProTextToImageAspectRatio::ThreeByFour => "3:4", + NanoBananaProTextToImageAspectRatio::TwoByThree => "2:3", + NanoBananaProTextToImageAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + num_images: Some(num_images), + aspect_ratio, + resolution, + output_format: Some("png".to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually test — requires real API key, incurs costs + async fn test_text_to_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = NanoBananaProTextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: NanoBananaProTextToImageNumImages::One, + resolution: Some(NanoBananaProTextToImageResolution::TwoK), + aspect_ratio: Some(NanoBananaProTextToImageAspectRatio::SixteenByNine), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/test").await?; + println!("Request ID: {:?}", result.request_id); + assert!(result.request_id.is_some()); + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/cost.rs new file mode 100644 index 0000000000..deb93c2aa7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/cost.rs @@ -0,0 +1,72 @@ +use crate::requests::api::image::text::nano_banana_pro_text_to_image::api::{ + NanoBananaProTextToImageNumImages, NanoBananaProTextToImageRequest, + NanoBananaProTextToImageResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for NanoBananaProTextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.15 per image. + // 4K outputs will be charged at double the standard rate. + let base_cost = match self.resolution { + None => 15, + Some(NanoBananaProTextToImageResolution::OneK) => 15, + Some(NanoBananaProTextToImageResolution::TwoK) => 15, + Some(NanoBananaProTextToImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + NanoBananaProTextToImageNumImages::One => base_cost, + NanoBananaProTextToImageNumImages::Two => base_cost * 2, + NanoBananaProTextToImageNumImages::Three => base_cost * 3, + NanoBananaProTextToImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + num_images: NanoBananaProTextToImageNumImages, + resolution: Option, + ) -> NanoBananaProTextToImageRequest { + NanoBananaProTextToImageRequest { + prompt: "test".to_string(), + num_images, + resolution, + aspect_ratio: None, + } + } + + #[test] + fn cost_default_resolution_one_image() { + assert_eq!(make_request(NanoBananaProTextToImageNumImages::One, None).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_one_k_one_image() { + assert_eq!(make_request(NanoBananaProTextToImageNumImages::One, Some(NanoBananaProTextToImageResolution::OneK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_two_k_one_image() { + assert_eq!(make_request(NanoBananaProTextToImageNumImages::One, Some(NanoBananaProTextToImageResolution::TwoK)).calculate_cost_in_cents(), 15); + } + + #[test] + fn cost_four_k_one_image() { + assert_eq!(make_request(NanoBananaProTextToImageNumImages::One, Some(NanoBananaProTextToImageResolution::FourK)).calculate_cost_in_cents(), 30); + } + + #[test] + fn cost_one_k_four_images() { + assert_eq!(make_request(NanoBananaProTextToImageNumImages::Four, Some(NanoBananaProTextToImageResolution::OneK)).calculate_cost_in_cents(), 60); + } + + #[test] + fn cost_four_k_two_images() { + assert_eq!(make_request(NanoBananaProTextToImageNumImages::Two, Some(NanoBananaProTextToImageResolution::FourK)).calculate_cost_in_cents(), 60); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/raw_request.rs new file mode 100644 index 0000000000..a85c715680 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/nano_banana_pro_text_to_image/raw_request.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBananaProTextToImageInput { + pub prompt: String, + + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProTextToImageOutput { + pub images: Vec, +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs new file mode 100644 index 0000000000..3e68bcf0bf --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs @@ -0,0 +1,228 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::raw_request::{ + Hunyuan3d3ImageToMeshInput, Hunyuan3d3ImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Hunyuan3d3ImageToMeshRequest { + /// URL of the input image. + pub image_url: String, + + /// Optional back-view image URL for multi-view generation. + pub back_image_url: Option, + + /// Optional left-view image URL for multi-view generation. + pub left_image_url: Option, + + /// Optional right-view image URL for multi-view generation. + pub right_image_url: Option, + + /// Target face count for the output mesh. + pub face_count: Option, + + /// Generation type. + pub generate_type: Option, + + /// Polygon type for the output mesh. + pub polygon_type: Option, + + /// Whether to enable PBR (physically-based rendering) materials. + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3ImageToMeshGenerateType { + Normal, + LowPoly, + Geometry, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3ImageToMeshPolygonType { + Triangle, + Quadrilateral, +} + +impl FalEndpoint for Hunyuan3d3ImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d-v3/image-to-3d"; + + type RawRequest = Hunyuan3d3ImageToMeshInput; + type RawResponse = Hunyuan3d3ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + let generate_type = self.generate_type.map(|t| match t { + Hunyuan3d3ImageToMeshGenerateType::Normal => "Normal", + Hunyuan3d3ImageToMeshGenerateType::LowPoly => "LowPoly", + Hunyuan3d3ImageToMeshGenerateType::Geometry => "Geometry", + }.to_string()); + + let polygon_type = self.polygon_type.map(|t| match t { + Hunyuan3d3ImageToMeshPolygonType::Triangle => "triangle", + Hunyuan3d3ImageToMeshPolygonType::Quadrilateral => "quadrilateral", + }.to_string()); + + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + back_image_url: self.back_image_url.clone(), + left_image_url: self.left_image_url.clone(), + right_image_url: self.right_image_url.clone(), + face_count: self.face_count, + generate_type, + polygon_type, + enable_pbr: self.enable_pbr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: Some("https://example.com/back.jpg".to_string()), + left_image_url: Some("https://example.com/left.jpg".to_string()), + right_image_url: Some("https://example.com/right.jpg".to_string()), + face_count: Some(100_000), + generate_type: Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly), + polygon_type: Some(Hunyuan3d3ImageToMeshPolygonType::Quadrilateral), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/front.jpg", + "back_image_url": "https://example.com/back.jpg", + "left_image_url": "https://example.com/left.jpg", + "right_image_url": "https://example.com/right.jpg", + "face_count": 100_000, + "generate_type": "LowPoly", + "polygon_type": "quadrilateral", + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/front.jpg" }), + ); + } + + #[test] + fn every_generate_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3ImageToMeshGenerateType::Normal, "Normal"), + (Hunyuan3d3ImageToMeshGenerateType::LowPoly, "LowPoly"), + (Hunyuan3d3ImageToMeshGenerateType::Geometry, "Geometry"), + ] { + let mut request = base_wire_request(); + request.generate_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.generate_type.as_deref(), Some(expected)); + } + } + + #[test] + fn every_polygon_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3ImageToMeshPolygonType::Triangle, "triangle"), + (Hunyuan3d3ImageToMeshPolygonType::Quadrilateral, "quadrilateral"), + ] { + let mut request = base_wire_request(); + request.polygon_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.polygon_type.as_deref(), Some(expected)); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3ImageToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v3/image-to-3d"); + } + + // NB: Pricing tests are in cost.rs + + fn base_wire_request() -> Hunyuan3d3ImageToMeshRequest { + Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/cost.rs new file mode 100644 index 0000000000..e34bcef75f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/cost.rs @@ -0,0 +1,162 @@ +use crate::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::{ + Hunyuan3d3ImageToMeshGenerateType, Hunyuan3d3ImageToMeshRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Base cost by generation type: + // Normal: $0.375 → 38¢ (rounded up) + // LowPoly: $0.45 → 45¢ + // Geometry: $0.225 → 23¢ (rounded up) + // + // Add-ons (each +$0.15 = 15¢): + // - PBR materials enabled + // - Custom face count specified + // - Multi-view images (any of back/left/right) + let mut cost: u64 = match self.generate_type { + None => 38, + Some(Hunyuan3d3ImageToMeshGenerateType::Normal) => 38, + Some(Hunyuan3d3ImageToMeshGenerateType::Geometry) => 23, + Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly) => 45, + }; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + let use_multi_view = self.left_image_url.is_some() + || self.right_image_url.is_some() + || self.back_image_url.is_some(); + if use_multi_view { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Hunyuan3d3ImageToMeshRequest { + Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + } + } + + mod base_costs { + use super::*; + + #[test] + fn default_generate_type() { + assert_eq!(base_request().calculate_cost_in_cents(), 38); + } + + #[test] + fn normal() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::Normal); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + + #[test] + fn low_poly() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly); + assert_eq!(req.calculate_cost_in_cents(), 45); + } + + #[test] + fn geometry() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::Geometry); + assert_eq!(req.calculate_cost_in_cents(), 23); + } + } + + mod add_ons { + use super::*; + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn pbr_false_no_extra() { + let mut req = base_request(); + req.enable_pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + + #[test] + fn face_count_adds_fifteen() { + let mut req = base_request(); + req.face_count = Some(50_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn back_image_adds_fifteen() { + let mut req = base_request(); + req.back_image_url = Some("https://example.com/back.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn left_image_adds_fifteen() { + let mut req = base_request(); + req.left_image_url = Some("https://example.com/left.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn right_image_adds_fifteen() { + let mut req = base_request(); + req.right_image_url = Some("https://example.com/right.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn multi_view_only_adds_fifteen_once() { + let mut req = base_request(); + req.back_image_url = Some("https://example.com/back.jpg".to_string()); + req.left_image_url = Some("https://example.com/left.jpg".to_string()); + req.right_image_url = Some("https://example.com/right.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly); + req.enable_pbr = Some(true); + req.face_count = Some(100_000); + req.back_image_url = Some("https://example.com/back.jpg".to_string()); + // LowPoly(45) + PBR(15) + face_count(15) + multi_view(15) = 90 + assert_eq!(req.calculate_cost_in_cents(), 90); + } + + #[test] + fn geometry_with_all_add_ons() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::Geometry); + req.enable_pbr = Some(true); + req.face_count = Some(10_000); + req.left_image_url = Some("https://example.com/left.jpg".to_string()); + // Geometry(23) + PBR(15) + face_count(15) + multi_view(15) = 68 + assert_eq!(req.calculate_cost_in_cents(), 68); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..1ddd910287 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/raw_request.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3ImageToMeshInput { + pub input_image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub back_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub left_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub right_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Options: "Normal", "LowPoly", "Geometry" + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Options: "triangle", "quadrilateral" + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/api.rs new file mode 100644 index 0000000000..9f29ef907b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/api.rs @@ -0,0 +1,151 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::raw_request::{ + Hunyuan3d2p0ImageToMeshInput, Hunyuan3d2p0ImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan3D 2.0 image-to-3D: turns a single image into a 3D mesh, +/// optionally textured. +#[derive(Clone, Debug)] +pub struct Hunyuan3d2p0ImageToMeshRequest { + /// URL of the input image. + pub image_url: String, + + /// Whether to generate a textured mesh instead of a white (untextured) + /// mesh. fal's default is false when `None`. Textured costs 3x. + pub textured_mesh: Option, + + /// Guidance scale for the model. fal's default is 7.5 when `None`. + pub guidance_scale: Option, + + /// Number of inference steps. fal's default is 50 when `None`. + pub num_inference_steps: Option, + + /// Octree resolution for the mesh. fal's default is 256 when `None`. + pub octree_resolution: Option, + + /// Seed for reproducible generation. Random when `None`. + pub seed: Option, +} + +impl FalEndpoint for Hunyuan3d2p0ImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d/v2"; + + type RawRequest = Hunyuan3d2p0ImageToMeshInput; + type RawResponse = Hunyuan3d2p0ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + textured_mesh: self.textured_mesh, + guidance_scale: self.guidance_scale, + num_inference_steps: self.num_inference_steps, + octree_resolution: self.octree_resolution, + seed: self.seed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: Some(true), + guidance_scale: Some(5.0), + num_inference_steps: Some(30), + octree_resolution: Some(512), + seed: Some(42), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/image.jpg", + "textured_mesh": true, + "guidance_scale": 5.0, + "num_inference_steps": 30, + "octree_resolution": 512, + "seed": 42, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/image.jpg" }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d2p0ImageToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d/v2"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/cost.rs new file mode 100644 index 0000000000..c751f61206 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/cost.rs @@ -0,0 +1,67 @@ +use crate::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d2p0ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan3d/v2): + // "Your request will cost $0.16 per generation." (white mesh) + // Textured mesh is charged at 3x the white mesh price: $0.48. + // + // White mesh: $0.16 → 16¢ + // Textured mesh: $0.48 → 48¢ + if self.textured_mesh.unwrap_or(false) { + 48 + } else { + 16 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod cost_table { + use super::*; + + #[test] + fn white_mesh_by_default() { + assert_eq!(base_request().calculate_cost_in_cents(), 16); + } + + #[test] + fn white_mesh_explicit() { + let mut req = base_request(); + req.textured_mesh = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 16); + } + + #[test] + fn textured_mesh_costs_three_times_white() { + let mut req = base_request(); + req.textured_mesh = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 48); + } + + #[test] + fn tuning_parameters_do_not_affect_cost() { + let mut req = base_request(); + req.guidance_scale = Some(10.0); + req.num_inference_steps = Some(25); + req.octree_resolution = Some(512); + req.seed = Some(7); + assert_eq!(req.calculate_cost_in_cents(), 16); + } + } + + fn base_request() -> Hunyuan3d2p0ImageToMeshRequest { + Hunyuan3d2p0ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..61a3cc069d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/raw_request.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan3d/v2`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d2p0ImageToMeshInput { + /// URL of the image to use while generating the 3D model. + pub input_image_url: String, + + /// If true, a textured mesh is generated and the price charged is 3x + /// that of a white (untextured) mesh. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub textured_mesh: Option, + + /// Guidance scale for the model. fal default: 7.5. + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// Number of inference steps to perform. fal default: 50. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Octree resolution for the model. fal default: 256. + #[serde(skip_serializing_if = "Option::is_none")] + pub octree_resolution: Option, + + /// The same seed and prompt given to the same model version produce the + /// same output. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d2p0ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/api.rs new file mode 100644 index 0000000000..009b140718 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/api.rs @@ -0,0 +1,153 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::raw_request::{ + Hunyuan3d2p1ImageToMeshInput, Hunyuan3d2p1ImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan3D 2.1 image-to-3D: turns a single image into a 3D mesh, +/// optionally with PBR textures. +/// +/// NB: fal lists this endpoint as deprecated, but it is still callable. +#[derive(Clone, Debug)] +pub struct Hunyuan3d2p1ImageToMeshRequest { + /// URL of the input image. + pub image_url: String, + + /// Whether to generate a textured (PBR) mesh instead of a white + /// (untextured) mesh. fal's default is false when `None`. Textured costs 3x. + pub textured_mesh: Option, + + /// Guidance scale for the model. fal's default is 7.5 when `None`. + pub guidance_scale: Option, + + /// Number of inference steps. fal's default is 50 when `None`. + pub num_inference_steps: Option, + + /// Octree resolution for the mesh. fal's default is 256 when `None`. + pub octree_resolution: Option, + + /// Seed for reproducible generation. Random when `None`. + pub seed: Option, +} + +impl FalEndpoint for Hunyuan3d2p1ImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d-v21"; + + type RawRequest = Hunyuan3d2p1ImageToMeshInput; + type RawResponse = Hunyuan3d2p1ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + textured_mesh: self.textured_mesh, + guidance_scale: self.guidance_scale, + num_inference_steps: self.num_inference_steps, + octree_resolution: self.octree_resolution, + seed: self.seed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: Some(true), + guidance_scale: Some(5.0), + num_inference_steps: Some(30), + octree_resolution: Some(512), + seed: Some(42), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/image.jpg", + "textured_mesh": true, + "guidance_scale": 5.0, + "num_inference_steps": 30, + "octree_resolution": 512, + "seed": 42, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/image.jpg" }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d2p1ImageToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v21"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/cost.rs new file mode 100644 index 0000000000..7435417b0e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/cost.rs @@ -0,0 +1,68 @@ +use crate::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d2p1ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan3d-v21): + // "Your request will cost $0.3 per generation." (white mesh) + // Per fal's input schema, textured mesh is charged at 3x the white + // mesh price: $0.90. + // + // White mesh: $0.30 → 30¢ + // Textured mesh: $0.90 → 90¢ + if self.textured_mesh.unwrap_or(false) { + 90 + } else { + 30 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod cost_table { + use super::*; + + #[test] + fn white_mesh_by_default() { + assert_eq!(base_request().calculate_cost_in_cents(), 30); + } + + #[test] + fn white_mesh_explicit() { + let mut req = base_request(); + req.textured_mesh = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + + #[test] + fn textured_mesh_costs_three_times_white() { + let mut req = base_request(); + req.textured_mesh = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 90); + } + + #[test] + fn tuning_parameters_do_not_affect_cost() { + let mut req = base_request(); + req.guidance_scale = Some(10.0); + req.num_inference_steps = Some(25); + req.octree_resolution = Some(512); + req.seed = Some(7); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + } + + fn base_request() -> Hunyuan3d2p1ImageToMeshRequest { + Hunyuan3d2p1ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..f6c2b9e18c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/raw_request.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan3d-v21`. +/// fal's schema: +/// +/// NB: fal lists this endpoint as deprecated, but it is still callable. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d2p1ImageToMeshInput { + /// URL of the image to use while generating the 3D model. + pub input_image_url: String, + + /// If true, a textured (PBR) mesh is generated and the price charged is + /// 3x that of a white (untextured) mesh. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub textured_mesh: Option, + + /// Guidance scale for the model. fal default: 7.5. + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// Number of inference steps to perform. fal default: 50. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Octree resolution for the model. fal default: 256. + #[serde(skip_serializing_if = "Option::is_none")] + pub octree_resolution: Option, + + /// The same seed and prompt given to the same model version produce the + /// same output. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d2p1ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/api.rs new file mode 100644 index 0000000000..0001d7a7a0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/api.rs @@ -0,0 +1,226 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::raw_request::{ + Hunyuan3d3p1ProImageToMeshInput, Hunyuan3d3p1ProImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan 3D v3.1 Pro image-to-3D. +/// +/// v3.1 expands multi-view input to seven optional extra views (back, left, +/// right, top, bottom, left-front 45°, right-front 45°) and drops the v3 +/// `LowPoly` generation type and `polygon_type` parameter. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3p1ProImageToMeshRequest { + /// URL of the front-view input image (required). + pub image_url: String, + + /// Optional back-view image URL for multi-view generation. + pub back_image_url: Option, + + /// Optional left-view image URL for multi-view generation. + pub left_image_url: Option, + + /// Optional right-view image URL for multi-view generation. + pub right_image_url: Option, + + /// Optional top-view image URL (v3.1 exclusive). + pub top_image_url: Option, + + /// Optional bottom-view image URL (v3.1 exclusive). + pub bottom_image_url: Option, + + /// Optional left-front 45° image URL (v3.1 exclusive). + pub left_front_image_url: Option, + + /// Optional right-front 45° image URL (v3.1 exclusive). + pub right_front_image_url: Option, + + /// Generation type. fal's default is `Normal` when `None`. + pub generate_type: Option, + + /// Target face count for the output mesh. Range 40000-1500000; + /// fal's default is 500000 when `None`. Setting a custom value adds cost. + pub face_count: Option, + + /// Whether to enable PBR (physically-based rendering) materials. + /// fal's default is false when `None`. Enabling adds cost. + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3p1ProImageToMeshGenerateType { + Normal, + Geometry, +} + +impl Hunyuan3d3p1ProImageToMeshRequest { + /// True when any of the optional extra views is provided (billed as the + /// multi-view add-on). + pub fn uses_multi_view(&self) -> bool { + self.back_image_url.is_some() + || self.left_image_url.is_some() + || self.right_image_url.is_some() + || self.top_image_url.is_some() + || self.bottom_image_url.is_some() + || self.left_front_image_url.is_some() + || self.right_front_image_url.is_some() + } +} + +impl FalEndpoint for Hunyuan3d3p1ProImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d"; + + type RawRequest = Hunyuan3d3p1ProImageToMeshInput; + type RawResponse = Hunyuan3d3p1ProImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + let generate_type = self.generate_type.map(|t| match t { + Hunyuan3d3p1ProImageToMeshGenerateType::Normal => "Normal", + Hunyuan3d3p1ProImageToMeshGenerateType::Geometry => "Geometry", + }.to_string()); + + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + back_image_url: self.back_image_url.clone(), + left_image_url: self.left_image_url.clone(), + right_image_url: self.right_image_url.clone(), + top_image_url: self.top_image_url.clone(), + bottom_image_url: self.bottom_image_url.clone(), + left_front_image_url: self.left_front_image_url.clone(), + right_front_image_url: self.right_front_image_url.clone(), + generate_type, + face_count: self.face_count, + enable_pbr: self.enable_pbr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + fn base_request() -> Hunyuan3d3p1ProImageToMeshRequest { + Hunyuan3d3p1ProImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + top_image_url: None, + bottom_image_url: None, + left_front_image_url: None, + right_front_image_url: None, + generate_type: None, + face_count: None, + enable_pbr: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1ProImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1ProImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3p1ProImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: Some("https://example.com/back.jpg".to_string()), + left_image_url: Some("https://example.com/left.jpg".to_string()), + right_image_url: Some("https://example.com/right.jpg".to_string()), + top_image_url: Some("https://example.com/top.jpg".to_string()), + bottom_image_url: Some("https://example.com/bottom.jpg".to_string()), + left_front_image_url: Some("https://example.com/lf.jpg".to_string()), + right_front_image_url: Some("https://example.com/rf.jpg".to_string()), + generate_type: Some(Hunyuan3d3p1ProImageToMeshGenerateType::Geometry), + face_count: Some(100_000), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/front.jpg", + "back_image_url": "https://example.com/back.jpg", + "left_image_url": "https://example.com/left.jpg", + "right_image_url": "https://example.com/right.jpg", + "top_image_url": "https://example.com/top.jpg", + "bottom_image_url": "https://example.com/bottom.jpg", + "left_front_image_url": "https://example.com/lf.jpg", + "right_front_image_url": "https://example.com/rf.jpg", + "generate_type": "Geometry", + "face_count": 100_000, + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/front.jpg" }), + ); + } + + #[test] + fn uses_multi_view_detects_every_extra_view() { + assert!(!base_request().uses_multi_view()); + let setters: Vec = vec![ + |r| r.back_image_url = Some("u".to_string()), + |r| r.left_image_url = Some("u".to_string()), + |r| r.right_image_url = Some("u".to_string()), + |r| r.top_image_url = Some("u".to_string()), + |r| r.bottom_image_url = Some("u".to_string()), + |r| r.left_front_image_url = Some("u".to_string()), + |r| r.right_front_image_url = Some("u".to_string()), + ]; + for set in setters { + let mut req = base_request(); + set(&mut req); + assert!(req.uses_multi_view()); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Hunyuan3d3p1ProImageToMeshRequest::ENDPOINT, + "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/cost.rs new file mode 100644 index 0000000000..c7106afbfd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/cost.rs @@ -0,0 +1,101 @@ +use crate::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::Hunyuan3d3p1ProImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3p1ProImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/pro/image-to-3d): + // "Your request will cost $0.375 per generation. Enabling PBR materials + // adds $0.15. Using multi-view images adds $0.15. Custom face count + // adds $0.15." + // + // Base: $0.375 → 38¢ (rounded up). Unlike v3, the v3.1 pro pricing has no + // per-generation-type price deltas — Geometry bills the same base. + let mut cost: u64 = 38; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + if self.uses_multi_view() { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::Hunyuan3d3p1ProImageToMeshGenerateType; + + fn base_request() -> Hunyuan3d3p1ProImageToMeshRequest { + Hunyuan3d3p1ProImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + top_image_url: None, + bottom_image_url: None, + left_front_image_url: None, + right_front_image_url: None, + generate_type: None, + face_count: None, + enable_pbr: None, + } + } + + #[test] + fn base_cost() { + assert_eq!(base_request().calculate_cost_in_cents(), 38); + } + + #[test] + fn generate_type_does_not_change_cost() { + for t in [ + Hunyuan3d3p1ProImageToMeshGenerateType::Normal, + Hunyuan3d3p1ProImageToMeshGenerateType::Geometry, + ] { + let mut req = base_request(); + req.generate_type = Some(t); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + } + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn face_count_adds_fifteen() { + let mut req = base_request(); + req.face_count = Some(50_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn any_extra_view_adds_fifteen_once() { + // v3.1-exclusive views must also trigger the multi-view add-on. + let mut req = base_request(); + req.top_image_url = Some("https://example.com/top.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + + // Stacking more views doesn't add more. + req.back_image_url = Some("https://example.com/back.jpg".to_string()); + req.left_front_image_url = Some("https://example.com/lf.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let mut req = base_request(); + req.enable_pbr = Some(true); + req.face_count = Some(100_000); + req.right_front_image_url = Some("https://example.com/rf.jpg".to_string()); + // 38 + 15 + 15 + 15 = 83 + assert_eq!(req.calculate_cost_in_cents(), 83); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..e3f431d638 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_pro_image_to_mesh/raw_request.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/pro/image-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3p1ProImageToMeshInput { + /// Front-view image (required). 128-5000px, max 8MB, JPG/PNG/WEBP. + pub input_image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub back_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub left_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub right_image_url: Option, + + /// Top view (v3.1 exclusive). + #[serde(skip_serializing_if = "Option::is_none")] + pub top_image_url: Option, + + /// Bottom view (v3.1 exclusive). + #[serde(skip_serializing_if = "Option::is_none")] + pub bottom_image_url: Option, + + /// Left-front 45° view (v3.1 exclusive). + #[serde(skip_serializing_if = "Option::is_none")] + pub left_front_image_url: Option, + + /// Right-front 45° view (v3.1 exclusive). + #[serde(skip_serializing_if = "Option::is_none")] + pub right_front_image_url: Option, + + /// Options: "Normal", "Geometry" (no LowPoly on v3.1). + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Range 40000-1500000. fal default: 500000. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3p1ProImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/api.rs new file mode 100644 index 0000000000..ae0bb55332 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/api.rs @@ -0,0 +1,125 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::raw_request::{ + Hunyuan3d3p1RapidImageToMeshInput, Hunyuan3d3p1RapidImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan 3D v3.1 Rapid image-to-3D — the fast, low-cost tier. +/// +/// NB: the rapid schema is minimal — single input image (no multi-view), no +/// `generate_type`, `face_count`, or `polygon_type`. Geometry-only output is +/// a boolean (`enable_geometry`). +#[derive(Clone, Debug)] +pub struct Hunyuan3d3p1RapidImageToMeshRequest { + /// URL of the input image (required). + pub image_url: String, + + /// Whether to enable PBR (physically-based rendering) materials. + /// fal's default is false when `None`. Enabling adds cost. + pub enable_pbr: Option, + + /// Generate a geometry-only white model without textures. + /// fal's default is false when `None`. Disables PBR output. + pub enable_geometry: Option, +} + +impl FalEndpoint for Hunyuan3d3p1RapidImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d"; + + type RawRequest = Hunyuan3d3p1RapidImageToMeshInput; + type RawResponse = Hunyuan3d3p1RapidImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + enable_pbr: self.enable_pbr, + enable_geometry: self.enable_geometry, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1RapidImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + enable_pbr: None, + enable_geometry: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1RapidImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + enable_pbr: None, + enable_geometry: None, + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3p1RapidImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + enable_pbr: Some(true), + enable_geometry: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/front.jpg", + "enable_pbr": true, + "enable_geometry": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3p1RapidImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + enable_pbr: None, + enable_geometry: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/front.jpg" }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Hunyuan3d3p1RapidImageToMeshRequest::ENDPOINT, + "fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/cost.rs new file mode 100644 index 0000000000..980e5b1703 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/cost.rs @@ -0,0 +1,49 @@ +use crate::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::api::Hunyuan3d3p1RapidImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3p1RapidImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d): + // "Base generation (text or image to 3D) costs $0.225. Enabling PBR + // materials adds $0.15." + // + // Base: $0.225 → 23¢ (rounded up). + let mut cost: u64 = 23; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Hunyuan3d3p1RapidImageToMeshRequest { + Hunyuan3d3p1RapidImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + enable_pbr: None, + enable_geometry: None, + } + } + + #[test] + fn base_cost() { + assert_eq!(base_request().calculate_cost_in_cents(), 23); + } + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 23 + 15); + } + + #[test] + fn geometry_does_not_change_cost() { + let mut req = base_request(); + req.enable_geometry = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 23); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..296902be75 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_3p1_rapid_image_to_mesh/raw_request.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3p1RapidImageToMeshInput { + /// Front-view image (required). 128-5000px, max 8MB, JPG/PNG/WEBP. + pub input_image_url: String, + + /// Generate PBR textures (metallic, roughness, normal). fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, + + /// Generate a geometry-only white model without textures. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_geometry: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3p1RapidImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/api.rs new file mode 100644 index 0000000000..70dd43c27b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/api.rs @@ -0,0 +1,184 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::meshy_v6_image_to_mesh::raw_request::{ + MeshyV6ImageToMeshInput, MeshyV6ImageToMeshOutput, +}; +use crate::requests::api::mesh::text::meshy_v6_text_to_mesh::api::{ + MeshyV6ModelType, MeshyV6PoseMode, MeshyV6SymmetryMode, MeshyV6Topology, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Meshy 6 image-to-3D. Shares its option enums with the text binding. +/// +/// NB: unlike text-to-3d, the image schema has no `mode`, `seed`, or +/// `enable_prompt_expansion`, and adds `should_texture`. +#[derive(Clone, Debug)] +pub struct MeshyV6ImageToMeshRequest { + /// URL of the input image (required). + pub image_url: String, + + /// Model type. fal's default is `Standard` when `None`. + pub model_type: Option, + + /// Mesh topology. fal's default is `Triangle` when `None`. + pub topology: Option, + + /// Target polygon count. Range 100-300000; fal's default is 30000. + pub target_polycount: Option, + + /// Symmetry mode. fal's default is `Auto` when `None`. + pub symmetry_mode: Option, + + /// Whether to remesh the output. fal's default is true when `None`. + pub should_remesh: Option, + + /// Whether to texture the output. fal's default is true when `None`. + pub should_texture: Option, + + /// Whether to generate PBR materials. fal's default is false when `None`. + pub enable_pbr: Option, + + /// Character pose mode. Unspecified when `None` (fal's default). + pub pose_mode: Option, + + /// Optional texture-specific prompt. + pub texture_prompt: Option, + + /// Optional texture reference image URL. + pub texture_image_url: Option, + + /// Rig the generated character. fal's default is false when `None`. + pub enable_rigging: Option, + + /// Character height for rigging, in meters. fal's default is 1.7. + pub rigging_height_meters: Option, + + /// Animate the rigged character. fal's default is false when `None`. + pub enable_animation: Option, + + /// Animation action ID. Range 0-696; fal's default is 92. + pub animation_action_id: Option, + + /// fal's default is true when `None`. + pub enable_safety_checker: Option, +} + +impl FalEndpoint for MeshyV6ImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/meshy/v6/image-to-3d"; + + type RawRequest = MeshyV6ImageToMeshInput; + type RawResponse = MeshyV6ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + image_url: self.image_url.clone(), + model_type: self.model_type.map(|m| m.to_str().to_string()), + topology: self.topology.map(|t| t.to_str().to_string()), + target_polycount: self.target_polycount, + symmetry_mode: self.symmetry_mode.map(|s| s.to_str().to_string()), + should_remesh: self.should_remesh, + should_texture: self.should_texture, + enable_pbr: self.enable_pbr, + pose_mode: self.pose_mode.map(|p| p.to_str().to_string()), + texture_prompt: self.texture_prompt.clone(), + texture_image_url: self.texture_image_url.clone(), + enable_rigging: self.enable_rigging, + rigging_height_meters: self.rigging_height_meters, + enable_animation: self.enable_animation, + animation_action_id: self.animation_action_id, + enable_safety_checker: self.enable_safety_checker, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + fn base_request() -> MeshyV6ImageToMeshRequest { + MeshyV6ImageToMeshRequest { + image_url: "https://example.com/input.jpg".to_string(), + model_type: None, + topology: None, + target_polycount: None, + symmetry_mode: None, + should_remesh: None, + should_texture: None, + enable_pbr: None, + pose_mode: None, + texture_prompt: None, + texture_image_url: None, + enable_rigging: None, + rigging_height_meters: None, + enable_animation: None, + animation_action_id: None, + enable_safety_checker: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = MeshyV6ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = MeshyV6ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_core_fields() { + let request = MeshyV6ImageToMeshRequest { + model_type: Some(MeshyV6ModelType::LowPoly), + topology: Some(MeshyV6Topology::Quad), + should_texture: Some(false), + enable_pbr: Some(true), + ..base_request() + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json["image_url"], "https://example.com/input.jpg"); + assert_eq!(json["model_type"], "lowpoly"); + assert_eq!(json["topology"], "quad"); + assert_eq!(json["should_texture"], false); + assert_eq!(json["enable_pbr"], true); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "image_url": "https://example.com/input.jpg" })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(MeshyV6ImageToMeshRequest::ENDPOINT, "fal-ai/meshy/v6/image-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/cost.rs new file mode 100644 index 0000000000..b3dde0c19b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/cost.rs @@ -0,0 +1,42 @@ +use crate::requests::api::mesh::image::meshy_v6_image_to_mesh::api::MeshyV6ImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for MeshyV6ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/meshy/v6/image-to-3d): + // "Your request will cost $0.8 per generation." + 80 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_cost_regardless_of_options() { + let mut request = MeshyV6ImageToMeshRequest { + image_url: "https://example.com/input.jpg".to_string(), + model_type: None, + topology: None, + target_polycount: None, + symmetry_mode: None, + should_remesh: None, + should_texture: None, + enable_pbr: None, + pose_mode: None, + texture_prompt: None, + texture_image_url: None, + enable_rigging: None, + rigging_height_meters: None, + enable_animation: None, + animation_action_id: None, + enable_safety_checker: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 80); + + request.should_texture = Some(false); + request.enable_pbr = Some(true); + assert_eq!(request.calculate_cost_in_cents(), 80); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..2d90ddf483 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/meshy_v6_image_to_mesh/raw_request.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/meshy/v6/image-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct MeshyV6ImageToMeshInput { + /// URL of the input image (.jpg/.jpeg/.png/AVIF/HEIF). + pub image_url: String, + + /// Options: "standard", "lowpoly". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub model_type: Option, + + /// Options: "quad", "triangle". fal default: "triangle". + #[serde(skip_serializing_if = "Option::is_none")] + pub topology: Option, + + /// Range 100-300000. fal default: 30000. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_polycount: Option, + + /// Options: "off", "auto", "on". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub symmetry_mode: Option, + + /// fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub should_remesh: Option, + + /// fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub should_texture: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, + + /// Options: "a-pose", "t-pose". fal default: "" (unspecified). + #[serde(skip_serializing_if = "Option::is_none")] + pub pose_mode: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_image_url: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_rigging: Option, + + /// fal default: 1.7. + #[serde(skip_serializing_if = "Option::is_none")] + pub rigging_height_meters: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_animation: Option, + + /// Range 0-696. fal default: 92. + #[serde(skip_serializing_if = "Option::is_none")] + pub animation_action_id: Option, + + /// fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MeshyV6ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs new file mode 100644 index 0000000000..b0766f2b4f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs @@ -0,0 +1,8 @@ +pub mod hunyuan_3d_2p0_image_to_mesh; +pub mod hunyuan_3d_2p1_image_to_mesh; +pub mod hunyuan3d_3_image_to_mesh; +pub mod hunyuan_3d_3p1_pro_image_to_mesh; +pub mod hunyuan_3d_3p1_rapid_image_to_mesh; +pub mod meshy_v6_image_to_mesh; +pub mod rodin_2p5_fast_image_to_mesh; +pub mod tripo3d_h3p1_image_to_mesh; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/api.rs new file mode 100644 index 0000000000..f9a5fc8aaa --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/api.rs @@ -0,0 +1,191 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::raw_request::{ + Rodin2p5FastImageToMeshInput, Rodin2p5FastImageToMeshOutput, +}; +use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::{ + Rodin2p5FastGeometryFileFormat, Rodin2p5FastMaterial, Rodin2p5FastQualityMeshOption, + Rodin2p5FastTextureMode, Rodin2p5FastTier, +}; +use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::raw_request::Rodin2p5FastBboxCondition; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hyper3D Rodin v2.5 Fast image-to-3D. Shares its option enums with the +/// text binding. +#[derive(Clone, Debug)] +pub struct Rodin2p5FastImageToMeshRequest { + /// Up to 5 input images of the object. + pub image_urls: Vec, + + /// Optional guidance prompt. Auto-generated from the images when `None`. + pub prompt: Option, + + /// Preserve the transparency channel of the input. fal's default is false. + pub use_original_alpha: Option, + + /// Generation tier. fal's default is `ExtremeLow` when `None`. + pub tier: Option, + + /// Seed for reproducibility. Range 0-65535. + pub seed: Option, + + /// Output geometry format. fal's default is `Glb` when `None`. + pub geometry_file_format: Option, + + /// Material type. fal's default is `Shaded` when `None`. + pub material: Option, + + /// Mesh quality/topology preset. fal's default is `Auto` when `None`. + pub quality_mesh_option: Option, + + /// Texture resolution mode. fal's default is tier-dependent when `None`. + pub texture_mode: Option, + + /// Enhanced generative robustness. fal's default is false when `None`. + pub enable_creative_mode: Option, + + /// Enhanced texture post-processing. fal's default is false when `None`. + pub hd_texture: Option, + + /// Removes baked lighting from textures. fal's default is false when `None`. + pub texture_delight: Option, + + /// Finer geometric detail. fal's default is false when `None`. + pub is_micro: Option, + + /// Generate in T/A-pose for rigging/animation. fal's default is false. + pub ta_pose: Option, + + /// Bounding-box controlnet limiting the maximum model size. + pub bbox_condition: Option, + + /// Generate a preview render image. fal's default is false when `None`. + pub preview_render: Option, +} + +impl FalEndpoint for Rodin2p5FastImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hyper3d/rodin/v2.5/fast"; + + type RawRequest = Rodin2p5FastImageToMeshInput; + type RawResponse = Rodin2p5FastImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + image_urls: self.image_urls.clone(), + prompt: self.prompt.clone(), + use_original_alpha: self.use_original_alpha, + tier: self.tier.map(|t| t.to_str().to_string()), + seed: self.seed, + geometry_file_format: self.geometry_file_format.map(|f| f.to_str().to_string()), + material: self.material.map(|m| m.to_str().to_string()), + quality_mesh_option: self.quality_mesh_option.map(|q| q.to_str().to_string()), + texture_mode: self.texture_mode.map(|t| t.to_str().to_string()), + enable_creative_mode: self.enable_creative_mode, + hd_texture: self.hd_texture, + texture_delight: self.texture_delight, + is_micro: self.is_micro, + ta_pose: self.ta_pose, + bbox_condition: self.bbox_condition.clone(), + preview_render: self.preview_render, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + fn base_request() -> Rodin2p5FastImageToMeshRequest { + Rodin2p5FastImageToMeshRequest { + image_urls: vec!["https://example.com/input.jpg".to_string()], + prompt: None, + use_original_alpha: None, + tier: None, + seed: None, + geometry_file_format: None, + material: None, + quality_mesh_option: None, + texture_mode: None, + enable_creative_mode: None, + hd_texture: None, + texture_delight: None, + is_micro: None, + ta_pose: None, + bbox_condition: None, + preview_render: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Rodin2p5FastImageToMeshRequest { + image_urls: vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()], + ..base_request() + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Rodin2p5FastImageToMeshRequest { + image_urls: vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()], + ..base_request() + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_core_fields() { + let request = Rodin2p5FastImageToMeshRequest { + prompt: Some("a robot".to_string()), + use_original_alpha: Some(true), + tier: Some(Rodin2p5FastTier::Minimum), + quality_mesh_option: Some(Rodin2p5FastQualityMeshOption::Triangle20k), + ta_pose: Some(true), + preview_render: Some(true), + ..base_request() + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json["image_urls"], serde_json::json!(["https://example.com/input.jpg"])); + assert_eq!(json["prompt"], "a robot"); + assert_eq!(json["use_original_alpha"], true); + assert_eq!(json["tier"], "Gen-2.5-Minimum"); + assert_eq!(json["quality_mesh_option"], "20K Triangle"); + assert_eq!(json["TAPose"], true); + assert_eq!(json["preview_render"], true); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "image_urls": ["https://example.com/input.jpg"] }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Rodin2p5FastImageToMeshRequest::ENDPOINT, "fal-ai/hyper3d/rodin/v2.5/fast"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/cost.rs new file mode 100644 index 0000000000..74c27426b2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/cost.rs @@ -0,0 +1,44 @@ +use crate::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::api::Rodin2p5FastImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Rodin2p5FastImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hyper3d/rodin/v2.5/fast): + // "Your request will cost $0.1 per generation." + 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::Rodin2p5FastMaterial; + + #[test] + fn flat_cost_regardless_of_options() { + let mut request = Rodin2p5FastImageToMeshRequest { + image_urls: vec!["https://example.com/input.jpg".to_string()], + prompt: None, + use_original_alpha: None, + tier: None, + seed: None, + geometry_file_format: None, + material: None, + quality_mesh_option: None, + texture_mode: None, + enable_creative_mode: None, + hd_texture: None, + texture_delight: None, + is_micro: None, + ta_pose: None, + bbox_condition: None, + preview_render: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 10); + + request.material = Some(Rodin2p5FastMaterial::Pbr); + request.hd_texture = Some(true); + request.image_urls.push("https://example.com/second.jpg".to_string()); + assert_eq!(request.calculate_cost_in_cents(), 10); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..53e10de6c1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/rodin_2p5_fast_image_to_mesh/raw_request.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::raw_request::Rodin2p5FastBboxCondition; + +/// Over-the-wire input shape for `fal-ai/hyper3d/rodin/v2.5/fast` +/// (image-to-3D mode). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Rodin2p5FastImageToMeshInput { + /// Up to 5 input images for image-to-3D. + pub image_urls: Vec, + + /// Optional guidance prompt. Auto-generated from images when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// Preserve the transparency channel of the input. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub use_original_alpha: Option, + + /// Options: "Gen-2.5-Minimum", "Gen-2.5-Extreme-Low", "Gen-2.5-Low". + /// fal default: "Gen-2.5-Extreme-Low". + #[serde(skip_serializing_if = "Option::is_none")] + pub tier: Option, + + /// Range 0-65535. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Options: "glb", "usdz", "fbx", "obj", "stl". fal default: "glb". + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_file_format: Option, + + /// Options: "PBR", "Shaded", "All", "None". fal default: "Shaded". + #[serde(skip_serializing_if = "Option::is_none")] + pub material: Option, + + /// See the text binding for the full option list. fal default: "Auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_mesh_option: Option, + + /// Options: "legacy", "extreme-low", "low", "medium", "high". + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_mode: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_creative_mode: Option, + + /// Enhanced texture post-processing. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub hd_texture: Option, + + /// Removes baked lighting from textures. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_delight: Option, + + /// Finer geometric detail. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_micro: Option, + + /// Generate in T/A-pose for rigging/animation. fal default: false. + /// NB: fal's field name is literally "TAPose". + #[serde(rename = "TAPose", skip_serializing_if = "Option::is_none")] + pub ta_pose: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox_condition: Option, + + /// Generate a preview render image. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_render: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Rodin2p5FastImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/api.rs new file mode 100644 index 0000000000..9ecade17bc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/api.rs @@ -0,0 +1,240 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::raw_request::{ + Tripo3dH3p1ImageToMeshInput, Tripo3dH3p1ImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Tripo3D H3.1 image-to-3D. +#[derive(Clone, Debug)] +pub struct Tripo3dH3p1ImageToMeshRequest { + /// URL of the input image (required). + pub image_url: String, + + /// Maximum face count. Range 1000-2000000; adaptive when `None`. + pub face_limit: Option, + + /// Enable texture generation. fal's default is true when `None`. + /// Disabling drops to the cheaper no-texture price tier. + pub texture: Option, + + /// Enable PBR materials (implies texture). fal's default is true when `None`. + pub pbr: Option, + + /// Seed for geometry reproducibility. + pub model_seed: Option, + + /// Seed for texture reproducibility. + pub texture_seed: Option, + + /// Texture quality. `Detailed` bills at the HD-texture price tier. + /// fal's default is `Standard` when `None`. + pub texture_quality: Option, + + /// Geometry quality. `Detailed` adds cost. fal's default is `Standard`. + pub geometry_quality: Option, + + /// How textures align to the source. fal's default is `OriginalImage`. + pub texture_alignment: Option, + + /// Auto-scale the model to real-world dimensions (meters). fal default: false. + pub auto_size: Option, + + /// Model orientation. fal's default is `Default` when `None`. + pub orientation: Option, + + /// Generate quad mesh topology instead of triangles (returns FBX). + /// Adds cost. fal's default is false when `None`. + pub quad: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tripo3dH3p1ImageTextureQuality { + Standard, + Detailed, +} + +impl Tripo3dH3p1ImageTextureQuality { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Detailed => "detailed", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tripo3dH3p1ImageGeometryQuality { + Standard, + Detailed, +} + +impl Tripo3dH3p1ImageGeometryQuality { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Detailed => "detailed", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tripo3dH3p1TextureAlignment { + OriginalImage, + Geometry, +} + +impl Tripo3dH3p1TextureAlignment { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::OriginalImage => "original_image", + Self::Geometry => "geometry", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tripo3dH3p1Orientation { + Default, + AlignImage, +} + +impl Tripo3dH3p1Orientation { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Default => "default", + Self::AlignImage => "align_image", + } + } +} + +impl FalEndpoint for Tripo3dH3p1ImageToMeshRequest { + const ENDPOINT: &str = "tripo3d/h3.1/image-to-3d"; + + type RawRequest = Tripo3dH3p1ImageToMeshInput; + type RawResponse = Tripo3dH3p1ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + image_url: self.image_url.clone(), + face_limit: self.face_limit, + texture: self.texture, + pbr: self.pbr, + model_seed: self.model_seed, + texture_seed: self.texture_seed, + texture_quality: self.texture_quality.map(|q| q.to_str().to_string()), + geometry_quality: self.geometry_quality.map(|q| q.to_str().to_string()), + texture_alignment: self.texture_alignment.map(|a| a.to_str().to_string()), + auto_size: self.auto_size, + orientation: self.orientation.map(|o| o.to_str().to_string()), + quad: self.quad, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + fn base_request() -> Tripo3dH3p1ImageToMeshRequest { + Tripo3dH3p1ImageToMeshRequest { + image_url: "https://example.com/input.jpg".to_string(), + face_limit: None, + texture: None, + pbr: None, + model_seed: None, + texture_seed: None, + texture_quality: None, + geometry_quality: None, + texture_alignment: None, + auto_size: None, + orientation: None, + quad: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Tripo3dH3p1ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Tripo3dH3p1ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Tripo3dH3p1ImageToMeshRequest { + image_url: "https://example.com/input.jpg".to_string(), + face_limit: Some(50_000), + texture: Some(true), + pbr: Some(false), + model_seed: Some(1), + texture_seed: Some(3), + texture_quality: Some(Tripo3dH3p1ImageTextureQuality::Detailed), + geometry_quality: Some(Tripo3dH3p1ImageGeometryQuality::Detailed), + texture_alignment: Some(Tripo3dH3p1TextureAlignment::Geometry), + auto_size: Some(true), + orientation: Some(Tripo3dH3p1Orientation::AlignImage), + quad: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "image_url": "https://example.com/input.jpg", + "face_limit": 50_000, + "texture": true, + "pbr": false, + "model_seed": 1, + "texture_seed": 3, + "texture_quality": "detailed", + "geometry_quality": "detailed", + "texture_alignment": "geometry", + "auto_size": true, + "orientation": "align_image", + "quad": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "image_url": "https://example.com/input.jpg" })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Tripo3dH3p1ImageToMeshRequest::ENDPOINT, "tripo3d/h3.1/image-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/cost.rs new file mode 100644 index 0000000000..1516cfb77b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/cost.rs @@ -0,0 +1,110 @@ +use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{ + Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, Tripo3dH3p1ImageToMeshRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Tripo3dH3p1ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/tripo3d/h3.1/image-to-3d): + // "Your request will cost $0.20 (without textures), $0.30 (with standard + // textures), or $0.40 (with HD textures), plus an additional $0.20 for + // detailed geometry and $0.05 for quad mesh if selected." + // + // NB: the image endpoint's texture tiers are 10¢ above the text/multiview + // endpoints'. `texture` defaults to true → standard tier is the default. + // `pbr` implies texturing and ALSO defaults to true, so the cheap + // untextured tier only applies when both `texture` and `pbr` are + // explicitly disabled. Anything else may texture server-side; bill the + // textured tier so we never undercharge. + let texture_enabled = self.texture.unwrap_or(true) || self.pbr.unwrap_or(true); + let mut cost: u64 = if !texture_enabled { + 20 + } else { + match self.texture_quality { + Some(Tripo3dH3p1ImageTextureQuality::Detailed) => 40, + None | Some(Tripo3dH3p1ImageTextureQuality::Standard) => 30, + } + }; + if matches!(self.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Detailed)) { + cost += 20; + } + if self.quad.unwrap_or(false) { + cost += 5; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Tripo3dH3p1ImageToMeshRequest { + Tripo3dH3p1ImageToMeshRequest { + image_url: "https://example.com/input.jpg".to_string(), + face_limit: None, + texture: None, + pbr: None, + model_seed: None, + texture_seed: None, + texture_quality: None, + geometry_quality: None, + texture_alignment: None, + auto_size: None, + orientation: None, + quad: None, + } + } + + #[test] + fn default_is_standard_textures() { + assert_eq!(base_request().calculate_cost_in_cents(), 30); + } + + #[test] + fn no_texture_requires_pbr_off_too() { + let mut req = base_request(); + req.texture = Some(false); + req.pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 20); + } + + #[test] + fn texture_off_but_pbr_default_bills_textured_tier() { + // `pbr` defaults to true and implies texturing. + let mut req = base_request(); + req.texture = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + + #[test] + fn detailed_texture() { + let mut req = base_request(); + req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed); + assert_eq!(req.calculate_cost_in_cents(), 40); + } + + #[test] + fn detailed_geometry_adds_twenty() { + let mut req = base_request(); + req.geometry_quality = Some(Tripo3dH3p1ImageGeometryQuality::Detailed); + assert_eq!(req.calculate_cost_in_cents(), 30 + 20); + } + + #[test] + fn quad_adds_five() { + let mut req = base_request(); + req.quad = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 30 + 5); + } + + #[test] + fn everything_stacks() { + let mut req = base_request(); + req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed); + req.geometry_quality = Some(Tripo3dH3p1ImageGeometryQuality::Detailed); + req.quad = Some(true); + // 40 + 20 + 5 = 65 + assert_eq!(req.calculate_cost_in_cents(), 65); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..17a773127b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/tripo3d_h3p1_image_to_mesh/raw_request.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `tripo3d/h3.1/image-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Tripo3dH3p1ImageToMeshInput { + /// URL of the input image. + pub image_url: String, + + /// Range 1000-2000000. Adaptive when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_limit: Option, + + /// Enable texture generation. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture: Option, + + /// Enable PBR materials (implies texture). fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub pbr: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_seed: Option, + + /// Options: "standard", "detailed". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_quality: Option, + + /// Options: "standard", "detailed". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_quality: Option, + + /// Options: "original_image", "geometry". fal default: "original_image". + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_alignment: Option, + + /// Auto-scale the model to real-world dimensions (meters). fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_size: Option, + + /// Options: "default", "align_image". fal default: "default". + #[serde(skip_serializing_if = "Option::is_none")] + pub orientation: Option, + + /// Generate quad mesh topology instead of triangles. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub quad: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tripo3dH3p1ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs new file mode 100644 index 0000000000..41b074836a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs @@ -0,0 +1,6 @@ +pub mod image; +pub mod multiview; +pub mod part; +pub mod sketch; +pub mod text; +pub mod topology; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/multiview/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/mod.rs new file mode 100644 index 0000000000..5548eb0244 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/mod.rs @@ -0,0 +1 @@ +pub mod tripo3d_h3p1_multiview_to_mesh; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/api.rs new file mode 100644 index 0000000000..cc5b44afa7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/api.rs @@ -0,0 +1,181 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{ + Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, Tripo3dH3p1Orientation, + Tripo3dH3p1TextureAlignment, +}; +use crate::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::raw_request::{ + Tripo3dH3p1MultiviewToMeshInput, Tripo3dH3p1MultiviewToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Tripo3D H3.1 multiview-to-3D: reconstructs a mesh from 2-4 views of the +/// same object. Shares its option enums with the single-image binding. +#[derive(Clone, Debug)] +pub struct Tripo3dH3p1MultiviewToMeshRequest { + /// 2 to 4 image URLs of the same object from different angles. + /// Order: [front, left, back, right]. + pub image_urls: Vec, + + /// Maximum face count. Range 1000-2000000; adaptive when `None`. + pub face_limit: Option, + + /// Enable texture generation. fal's default is true when `None`. + /// Disabling drops to the cheaper no-texture price tier. + pub texture: Option, + + /// Enable PBR materials (implies texture). fal's default is true when `None`. + pub pbr: Option, + + /// Seed for geometry reproducibility. + pub model_seed: Option, + + /// Seed for texture reproducibility. + pub texture_seed: Option, + + /// Texture quality. `Detailed` bills at the HD-texture price tier. + pub texture_quality: Option, + + /// Geometry quality. `Detailed` adds cost. + pub geometry_quality: Option, + + /// How textures align to the source. fal's default is `OriginalImage`. + pub texture_alignment: Option, + + /// Auto-scale the model to real-world dimensions (meters). fal default: false. + pub auto_size: Option, + + /// Model orientation. fal's default is `Default` when `None`. + pub orientation: Option, + + /// Generate quad mesh topology instead of triangles (returns FBX). + /// Adds cost. fal's default is false when `None`. + pub quad: Option, +} + +impl FalEndpoint for Tripo3dH3p1MultiviewToMeshRequest { + const ENDPOINT: &str = "tripo3d/h3.1/multiview-to-3d"; + + type RawRequest = Tripo3dH3p1MultiviewToMeshInput; + type RawResponse = Tripo3dH3p1MultiviewToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + image_urls: self.image_urls.clone(), + face_limit: self.face_limit, + texture: self.texture, + pbr: self.pbr, + model_seed: self.model_seed, + texture_seed: self.texture_seed, + texture_quality: self.texture_quality.map(|q| q.to_str().to_string()), + geometry_quality: self.geometry_quality.map(|q| q.to_str().to_string()), + texture_alignment: self.texture_alignment.map(|a| a.to_str().to_string()), + auto_size: self.auto_size, + orientation: self.orientation.map(|o| o.to_str().to_string()), + quad: self.quad, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL}; + + fn base_request() -> Tripo3dH3p1MultiviewToMeshRequest { + Tripo3dH3p1MultiviewToMeshRequest { + image_urls: vec![ + "https://example.com/front.jpg".to_string(), + "https://example.com/left.jpg".to_string(), + ], + face_limit: None, + texture: None, + pbr: None, + model_seed: None, + texture_seed: None, + texture_quality: None, + geometry_quality: None, + texture_alignment: None, + auto_size: None, + orientation: None, + quad: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_multiview_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Tripo3dH3p1MultiviewToMeshRequest { + image_urls: vec![ + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + ..base_request() + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_multiview_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Tripo3dH3p1MultiviewToMeshRequest { + image_urls: vec![ + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + ..base_request() + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_core_fields() { + let request = Tripo3dH3p1MultiviewToMeshRequest { + texture_quality: Some(Tripo3dH3p1ImageTextureQuality::Detailed), + quad: Some(true), + ..base_request() + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "image_urls": ["https://example.com/front.jpg", "https://example.com/left.jpg"], + "texture_quality": "detailed", + "quad": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "image_urls": ["https://example.com/front.jpg", "https://example.com/left.jpg"], + }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Tripo3dH3p1MultiviewToMeshRequest::ENDPOINT, "tripo3d/h3.1/multiview-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/cost.rs new file mode 100644 index 0000000000..a3eacb91ab --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/cost.rs @@ -0,0 +1,107 @@ +use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{ + Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, +}; +use crate::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::api::Tripo3dH3p1MultiviewToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Tripo3dH3p1MultiviewToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/tripo3d/h3.1/multiview-to-3d): + // "Your request will cost $0.10 (without textures), $0.20 (with standard + // textures), or $0.30 (with HD textures), plus an additional $0.20 for + // detailed geometry and $0.05 for quad mesh if selected." + // + // Same tiers as text-to-3d (NOT the +10¢ image-to-3d tiers). + // `pbr` implies texturing and ALSO defaults to true, so the cheap + // untextured tier only applies when both `texture` and `pbr` are + // explicitly disabled. Anything else may texture server-side; bill the + // textured tier so we never undercharge. + let texture_enabled = self.texture.unwrap_or(true) || self.pbr.unwrap_or(true); + let mut cost: u64 = if !texture_enabled { + 10 + } else { + match self.texture_quality { + Some(Tripo3dH3p1ImageTextureQuality::Detailed) => 30, + None | Some(Tripo3dH3p1ImageTextureQuality::Standard) => 20, + } + }; + if matches!(self.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Detailed)) { + cost += 20; + } + if self.quad.unwrap_or(false) { + cost += 5; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Tripo3dH3p1MultiviewToMeshRequest { + Tripo3dH3p1MultiviewToMeshRequest { + image_urls: vec![ + "https://example.com/front.jpg".to_string(), + "https://example.com/left.jpg".to_string(), + ], + face_limit: None, + texture: None, + pbr: None, + model_seed: None, + texture_seed: None, + texture_quality: None, + geometry_quality: None, + texture_alignment: None, + auto_size: None, + orientation: None, + quad: None, + } + } + + #[test] + fn default_is_standard_textures() { + assert_eq!(base_request().calculate_cost_in_cents(), 20); + } + + #[test] + fn no_texture_requires_pbr_off_too() { + let mut req = base_request(); + req.texture = Some(false); + req.pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 10); + } + + #[test] + fn texture_off_but_pbr_default_bills_textured_tier() { + // `pbr` defaults to true and implies texturing. + let mut req = base_request(); + req.texture = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 20); + } + + #[test] + fn detailed_texture() { + let mut req = base_request(); + req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + + #[test] + fn image_count_does_not_change_cost() { + let mut req = base_request(); + req.image_urls.push("https://example.com/back.jpg".to_string()); + req.image_urls.push("https://example.com/right.jpg".to_string()); + assert_eq!(req.calculate_cost_in_cents(), 20); + } + + #[test] + fn everything_stacks() { + let mut req = base_request(); + req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed); + req.geometry_quality = Some(Tripo3dH3p1ImageGeometryQuality::Detailed); + req.quad = Some(true); + // 30 + 20 + 5 = 55 + assert_eq!(req.calculate_cost_in_cents(), 55); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/raw_request.rs new file mode 100644 index 0000000000..a774b33c2b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/multiview/tripo3d_h3p1_multiview_to_mesh/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `tripo3d/h3.1/multiview-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Tripo3dH3p1MultiviewToMeshInput { + /// 2 to 4 image URLs of the same object from different angles. + /// Order: [front, left, back, right]. + pub image_urls: Vec, + + /// Range 1000-2000000. Adaptive when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_limit: Option, + + /// Enable texture generation. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture: Option, + + /// Enable PBR materials (implies texture). fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub pbr: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_seed: Option, + + /// Options: "standard", "detailed". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_quality: Option, + + /// Options: "standard", "detailed". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_quality: Option, + + /// Options: "original_image", "geometry". fal default: "original_image". + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_alignment: Option, + + /// Auto-scale the model to real-world dimensions (meters). fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_size: Option, + + /// Options: "default", "align_image". fal default: "default". + #[serde(skip_serializing_if = "Option::is_none")] + pub orientation: Option, + + /// Generate quad mesh topology instead of triangles. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub quad: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tripo3dH3p1MultiviewToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/api.rs new file mode 100644 index 0000000000..c69b1faea6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/api.rs @@ -0,0 +1,87 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::part::hunyuan_3d_3p1_part::raw_request::{ + Hunyuan3d3p1PartInput, Hunyuan3d3p1PartOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan 3D v3.1 Part: splits an existing 3D mesh into semantically +/// meaningful parts (returned as a list of FBX files). +/// +/// Input constraints: FBX only, max 100MB, face count ≤ 30,000. Designed for +/// AIGC-generated models. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3p1PartRequest { + /// URL of the input mesh file (FBX). + pub input_file_url: String, +} + +impl FalEndpoint for Hunyuan3d3p1PartRequest { + const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/part"; + + type RawRequest = Hunyuan3d3p1PartInput; + type RawResponse = Hunyuan3d3p1PartOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_file_url: self.input_file_url.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost; needs a real FBX URL + async fn test_part_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1PartRequest { + input_file_url: "https://example.com/model.fbx".to_string(), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost; needs a real FBX URL + async fn test_part_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1PartRequest { + input_file_url: "https://example.com/model.fbx".to_string(), + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_is_just_input_file_url() { + let request = Hunyuan3d3p1PartRequest { + input_file_url: "https://example.com/model.fbx".to_string(), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_file_url": "https://example.com/model.fbx" }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3p1PartRequest::ENDPOINT, "fal-ai/hunyuan-3d/v3.1/part"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/cost.rs new file mode 100644 index 0000000000..f965b41a1b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/cost.rs @@ -0,0 +1,23 @@ +use crate::requests::api::mesh::part::hunyuan_3d_3p1_part::api::Hunyuan3d3p1PartRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3p1PartRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/part): + // "Your request will cost $0.45 per generation." + 45 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_cost() { + let request = Hunyuan3d3p1PartRequest { + input_file_url: "https://example.com/model.fbx".to_string(), + }; + assert_eq!(request.calculate_cost_in_cents(), 45); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/raw_request.rs new file mode 100644 index 0000000000..ec0d0cea0f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/part/hunyuan_3d_3p1_part/raw_request.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/part`. +/// Splits a 3D mesh into semantically meaningful parts. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3p1PartInput { + /// URL of the input mesh. FBX only, max 100MB, face count ≤ 30,000. + pub input_file_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3p1PartOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/part/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/part/mod.rs new file mode 100644 index 0000000000..59ba9c6b79 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/part/mod.rs @@ -0,0 +1 @@ +pub mod hunyuan_3d_3p1_part; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/api.rs new file mode 100644 index 0000000000..3cd24e27d4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/api.rs @@ -0,0 +1,142 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::raw_request::{ + Hunyuan3d3SketchToMeshInput, Hunyuan3d3SketchToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan3D V3 sketch-to-3D: turns a sketch or line-art image plus a text +/// prompt into a textured 3D mesh. +/// +/// NB: unlike the v3 image-to-3d and text-to-3d endpoints, fal's published +/// sketch-to-3d schema has no `generate_type` or `polygon_type` parameters. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3SketchToMeshRequest { + /// URL of the sketch or line-art image. Resolution must be between + /// 128x128 and 5000x5000 pixels. + pub image_url: String, + + /// Text prompt describing the 3D content attributes such as color, + /// category, and material. + pub prompt: String, + + /// Target face count for the output mesh. Range 40000-1500000; + /// fal's default is 500000 when `None`. Setting a custom value adds cost. + pub face_count: Option, + + /// Whether to enable PBR (physically-based rendering) materials. + /// fal's default is false when `None`. Enabling adds cost. + pub enable_pbr: Option, +} + +impl FalEndpoint for Hunyuan3d3SketchToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d-v3/sketch-to-3d"; + + type RawRequest = Hunyuan3d3SketchToMeshInput; + type RawResponse = Hunyuan3d3SketchToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + prompt: self.prompt.clone(), + face_count: self.face_count, + enable_pbr: self.enable_pbr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_sketch_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3SketchToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + prompt: "A cute robot".to_string(), + face_count: None, + enable_pbr: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_sketch_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3SketchToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + prompt: "A cute robot".to_string(), + face_count: None, + enable_pbr: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3SketchToMeshRequest { + image_url: "https://example.com/sketch.png".to_string(), + prompt: "A red ceramic teapot".to_string(), + face_count: Some(100_000), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/sketch.png", + "prompt": "A red ceramic teapot", + "face_count": 100_000, + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3SketchToMeshRequest { + image_url: "https://example.com/sketch.png".to_string(), + prompt: "minimal".to_string(), + face_count: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/sketch.png", + "prompt": "minimal", + }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3SketchToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v3/sketch-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/cost.rs new file mode 100644 index 0000000000..7ab955a2a5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3SketchToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan3d-v3/sketch-to-3d): + // "Your request will cost $0.375 per generation." + // + // Base: $0.375 → 38¢ (rounded up). Sketch-to-3d has no generate_type + // parameter, so the LowPoly/Geometry prices of the other v3 endpoints + // do not apply here. + // + // Add-ons (each +$0.15 = 15¢): + // - PBR materials enabled + // - Custom face count specified + let mut cost: u64 = 38; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod cost_table { + use super::*; + + #[test] + fn base_cost() { + assert_eq!(base_request().calculate_cost_in_cents(), 38); + } + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn pbr_false_no_extra() { + let mut req = base_request(); + req.enable_pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + + #[test] + fn face_count_adds_fifteen() { + let mut req = base_request(); + req.face_count = Some(50_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let mut req = base_request(); + req.enable_pbr = Some(true); + req.face_count = Some(100_000); + // Base(38) + PBR(15) + face_count(15) = 68 + assert_eq!(req.calculate_cost_in_cents(), 68); + } + } + + fn base_request() -> Hunyuan3d3SketchToMeshRequest { + Hunyuan3d3SketchToMeshRequest { + image_url: "https://example.com/sketch.png".to_string(), + prompt: "test".to_string(), + face_count: None, + enable_pbr: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/raw_request.rs new file mode 100644 index 0000000000..cb3d700ac7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/raw_request.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan3d-v3/sketch-to-3d`. +/// fal's schema: +/// +/// NB: unlike the v3 image-to-3d and text-to-3d endpoints, fal's published +/// sketch-to-3d schema has no `generate_type` or `polygon_type` parameters. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3SketchToMeshInput { + /// URL of the sketch or line-art image to transform into a 3D model. + /// Image resolution must be between 128x128 and 5000x5000 pixels. + pub input_image_url: String, + + /// Text prompt describing the 3D content attributes such as color, + /// category, and material. + pub prompt: String, + + /// Target face count. Range: 40000-1500000. fal default: 500000. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Whether to enable PBR material generation. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3SketchToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/mod.rs new file mode 100644 index 0000000000..b3d854f8a1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/mod.rs @@ -0,0 +1 @@ +pub mod hunyuan3d_3_sketch_to_mesh; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs new file mode 100644 index 0000000000..b9f49c05b9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs @@ -0,0 +1,194 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::raw_request::{ + Hunyuan3d3TextToMeshInput, Hunyuan3d3TextToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Hunyuan3d3TextToMeshRequest { + /// Text prompt describing the 3D object to generate. + pub prompt: String, + + /// Target face count for the output mesh. + pub face_count: Option, + + /// Generation type. + pub generate_type: Option, + + /// Polygon type for the output mesh. + pub polygon_type: Option, + + /// Whether to enable PBR (physically-based rendering) materials. + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3TextToMeshGenerateType { + Normal, + LowPoly, + Geometry, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3TextToMeshPolygonType { + Triangle, + Quadrilateral, +} + +impl FalEndpoint for Hunyuan3d3TextToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d-v3/text-to-3d"; + + type RawRequest = Hunyuan3d3TextToMeshInput; + type RawResponse = Hunyuan3d3TextToMeshOutput; + + fn to_raw_request(&self) -> Result { + let generate_type = self.generate_type.map(|t| match t { + Hunyuan3d3TextToMeshGenerateType::Normal => "Normal", + Hunyuan3d3TextToMeshGenerateType::LowPoly => "LowPoly", + Hunyuan3d3TextToMeshGenerateType::Geometry => "Geometry", + }.to_string()); + + let polygon_type = self.polygon_type.map(|t| match t { + Hunyuan3d3TextToMeshPolygonType::Triangle => "triangle", + Hunyuan3d3TextToMeshPolygonType::Quadrilateral => "quadrilateral", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + face_count: self.face_count, + generate_type, + polygon_type, + enable_pbr: self.enable_pbr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3TextToMeshRequest { + prompt: "A velociraptor with an open mouth full of sharp teeth. Large claws, ready to strike.".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3TextToMeshRequest { + prompt: "A velociraptor with an open mouth full of sharp teeth. Large claws, ready to strike.".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3TextToMeshRequest { + prompt: "A red ceramic teapot".to_string(), + face_count: Some(100_000), + generate_type: Some(Hunyuan3d3TextToMeshGenerateType::LowPoly), + polygon_type: Some(Hunyuan3d3TextToMeshPolygonType::Quadrilateral), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "A red ceramic teapot", + "face_count": 100_000, + "generate_type": "LowPoly", + "polygon_type": "quadrilateral", + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3TextToMeshRequest { + prompt: "minimal".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_generate_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3TextToMeshGenerateType::Normal, "Normal"), + (Hunyuan3d3TextToMeshGenerateType::LowPoly, "LowPoly"), + (Hunyuan3d3TextToMeshGenerateType::Geometry, "Geometry"), + ] { + let mut request = base_wire_request(); + request.generate_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.generate_type.as_deref(), Some(expected)); + } + } + + #[test] + fn every_polygon_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3TextToMeshPolygonType::Triangle, "triangle"), + (Hunyuan3d3TextToMeshPolygonType::Quadrilateral, "quadrilateral"), + ] { + let mut request = base_wire_request(); + request.polygon_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.polygon_type.as_deref(), Some(expected)); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3TextToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v3/text-to-3d"); + } + + // NB: Pricing tests are in cost.rs + + fn base_wire_request() -> Hunyuan3d3TextToMeshRequest { + Hunyuan3d3TextToMeshRequest { + prompt: "test".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/cost.rs new file mode 100644 index 0000000000..e85b8ca4b0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/cost.rs @@ -0,0 +1,122 @@ +use crate::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::{ + Hunyuan3d3TextToMeshGenerateType, Hunyuan3d3TextToMeshRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3TextToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Base cost by generation type: + // Normal: $0.375 → 38¢ (rounded up) + // LowPoly: $0.45 → 45¢ + // Geometry: $0.225 → 23¢ (rounded up) + // + // Add-ons (each +$0.15 = 15¢): + // - PBR materials enabled + // - Custom face count specified + // + // Note: text-to-mesh has no multi-view add-on (no image inputs). + let mut cost: u64 = match self.generate_type { + None => 38, + Some(Hunyuan3d3TextToMeshGenerateType::Normal) => 38, + Some(Hunyuan3d3TextToMeshGenerateType::Geometry) => 23, + Some(Hunyuan3d3TextToMeshGenerateType::LowPoly) => 45, + }; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Hunyuan3d3TextToMeshRequest { + Hunyuan3d3TextToMeshRequest { + prompt: "test".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + } + } + + mod base_costs { + use super::*; + + #[test] + fn default_generate_type() { + assert_eq!(base_request().calculate_cost_in_cents(), 38); + } + + #[test] + fn normal() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::Normal); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + + #[test] + fn low_poly() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::LowPoly); + assert_eq!(req.calculate_cost_in_cents(), 45); + } + + #[test] + fn geometry() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::Geometry); + assert_eq!(req.calculate_cost_in_cents(), 23); + } + } + + mod add_ons { + use super::*; + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn pbr_false_no_extra() { + let mut req = base_request(); + req.enable_pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + + #[test] + fn face_count_adds_fifteen() { + let mut req = base_request(); + req.face_count = Some(50_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::LowPoly); + req.enable_pbr = Some(true); + req.face_count = Some(100_000); + // LowPoly(45) + PBR(15) + face_count(15) = 75 + assert_eq!(req.calculate_cost_in_cents(), 75); + } + + #[test] + fn geometry_with_all_add_ons() { + let mut req = base_request(); + req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::Geometry); + req.enable_pbr = Some(true); + req.face_count = Some(10_000); + // Geometry(23) + PBR(15) + face_count(15) = 53 + assert_eq!(req.calculate_cost_in_cents(), 53); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/raw_request.rs new file mode 100644 index 0000000000..ac199a44b8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/raw_request.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3TextToMeshInput { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Options: "Normal", "LowPoly", "Geometry" + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Options: "triangle", "quadrilateral" + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3TextToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/api.rs new file mode 100644 index 0000000000..f235bebd7b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/api.rs @@ -0,0 +1,140 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::raw_request::{ + Hunyuan3d3p1ProTextToMeshInput, Hunyuan3d3p1ProTextToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan 3D v3.1 Pro text-to-3D. +/// +/// NB: v3.1 drops the v3 `LowPoly` generation type (only Normal/Geometry) and +/// has no `polygon_type` parameter. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3p1ProTextToMeshRequest { + /// Text prompt describing the 3D object (max 1024 UTF-8 characters). + pub prompt: String, + + /// Generation type. fal's default is `Normal` when `None`. + pub generate_type: Option, + + /// Target face count for the output mesh. Range 40000-1500000; + /// fal's default is 500000 when `None`. Setting a custom value adds cost. + pub face_count: Option, + + /// Whether to enable PBR (physically-based rendering) materials. + /// fal's default is false when `None`. Enabling adds cost. + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3p1ProTextToMeshGenerateType { + Normal, + Geometry, +} + +impl FalEndpoint for Hunyuan3d3p1ProTextToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/pro/text-to-3d"; + + type RawRequest = Hunyuan3d3p1ProTextToMeshInput; + type RawResponse = Hunyuan3d3p1ProTextToMeshOutput; + + fn to_raw_request(&self) -> Result { + let generate_type = self.generate_type.map(|t| match t { + Hunyuan3d3p1ProTextToMeshGenerateType::Normal => "Normal", + Hunyuan3d3p1ProTextToMeshGenerateType::Geometry => "Geometry", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + generate_type, + face_count: self.face_count, + enable_pbr: self.enable_pbr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1ProTextToMeshRequest { + prompt: "a small ceramic teapot with a bamboo handle".to_string(), + generate_type: None, + face_count: None, + enable_pbr: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1ProTextToMeshRequest { + prompt: "a wooden rocking chair".to_string(), + generate_type: None, + face_count: None, + enable_pbr: None, + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3p1ProTextToMeshRequest { + prompt: "p".to_string(), + generate_type: Some(Hunyuan3d3p1ProTextToMeshGenerateType::Geometry), + face_count: Some(100_000), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "p", + "generate_type": "Geometry", + "face_count": 100_000, + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3p1ProTextToMeshRequest { + prompt: "p".to_string(), + generate_type: None, + face_count: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "p" })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Hunyuan3d3p1ProTextToMeshRequest::ENDPOINT, + "fal-ai/hunyuan-3d/v3.1/pro/text-to-3d", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/cost.rs new file mode 100644 index 0000000000..28e4da05f2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/cost.rs @@ -0,0 +1,77 @@ +use crate::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::api::Hunyuan3d3p1ProTextToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3p1ProTextToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/pro/text-to-3d): + // "Your request will cost $0.375 per generation. Enabling PBR materials + // adds $0.15. Using multi-view images adds $0.15. Custom face count + // adds $0.15." + // + // Base: $0.375 → 38¢ (rounded up). Unlike v3, the v3.1 pro pricing has no + // per-generation-type price deltas — Geometry bills the same base. + // Text-to-3d has no image inputs, so no multi-view add-on here. + let mut cost: u64 = 38; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::api::Hunyuan3d3p1ProTextToMeshGenerateType; + + fn base_request() -> Hunyuan3d3p1ProTextToMeshRequest { + Hunyuan3d3p1ProTextToMeshRequest { + prompt: "p".to_string(), + generate_type: None, + face_count: None, + enable_pbr: None, + } + } + + #[test] + fn base_cost() { + assert_eq!(base_request().calculate_cost_in_cents(), 38); + } + + #[test] + fn generate_type_does_not_change_cost() { + for t in [ + Hunyuan3d3p1ProTextToMeshGenerateType::Normal, + Hunyuan3d3p1ProTextToMeshGenerateType::Geometry, + ] { + let mut req = base_request(); + req.generate_type = Some(t); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + } + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn face_count_adds_fifteen() { + let mut req = base_request(); + req.face_count = Some(50_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn add_ons_stack() { + let mut req = base_request(); + req.enable_pbr = Some(true); + req.face_count = Some(100_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15 + 15); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/raw_request.rs new file mode 100644 index 0000000000..359f1d3569 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_pro_text_to_mesh/raw_request.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/pro/text-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3p1ProTextToMeshInput { + /// Text prompt (max 1024 UTF-8 characters). + pub prompt: String, + + /// Options: "Normal", "Geometry" (no LowPoly on v3.1). + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Range 40000-1500000. fal default: 500000. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3p1ProTextToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/api.rs new file mode 100644 index 0000000000..6da03bb4fc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/api.rs @@ -0,0 +1,117 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::text::hunyuan_3d_3p1_rapid_text_to_mesh::raw_request::{ + Hunyuan3d3p1RapidTextToMeshInput, Hunyuan3d3p1RapidTextToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan 3D v3.1 Rapid text-to-3D — the fast, low-cost tier. +/// +/// NB: the rapid schema is minimal — no `generate_type`, `face_count`, or +/// `polygon_type`. Geometry-only output is a boolean (`enable_geometry`) +/// rather than a generation-type enum. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3p1RapidTextToMeshRequest { + /// Text prompt describing the 3D object (max 200 UTF-8 characters). + pub prompt: String, + + /// Whether to enable PBR (physically-based rendering) materials. + /// fal's default is false when `None`. Enabling adds cost. + pub enable_pbr: Option, + + /// Generate a geometry-only white model without textures. + /// fal's default is false when `None`. Disables PBR output. + pub enable_geometry: Option, +} + +impl FalEndpoint for Hunyuan3d3p1RapidTextToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/rapid/text-to-3d"; + + type RawRequest = Hunyuan3d3p1RapidTextToMeshInput; + type RawResponse = Hunyuan3d3p1RapidTextToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + enable_pbr: self.enable_pbr, + enable_geometry: self.enable_geometry, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1RapidTextToMeshRequest { + prompt: "a small ceramic teapot".to_string(), + enable_pbr: None, + enable_geometry: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1RapidTextToMeshRequest { + prompt: "a wooden rocking chair".to_string(), + enable_pbr: None, + enable_geometry: None, + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3p1RapidTextToMeshRequest { + prompt: "p".to_string(), + enable_pbr: Some(true), + enable_geometry: Some(false), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "enable_pbr": true, "enable_geometry": false }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3p1RapidTextToMeshRequest { + prompt: "p".to_string(), + enable_pbr: None, + enable_geometry: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "p" })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Hunyuan3d3p1RapidTextToMeshRequest::ENDPOINT, + "fal-ai/hunyuan-3d/v3.1/rapid/text-to-3d", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/cost.rs new file mode 100644 index 0000000000..1cc3fc776d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/cost.rs @@ -0,0 +1,49 @@ +use crate::requests::api::mesh::text::hunyuan_3d_3p1_rapid_text_to_mesh::api::Hunyuan3d3p1RapidTextToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3p1RapidTextToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/rapid/text-to-3d): + // "Base generation (text or image to 3D) costs $0.225. Enabling PBR + // materials adds $0.15." + // + // Base: $0.225 → 23¢ (rounded up). + let mut cost: u64 = 23; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Hunyuan3d3p1RapidTextToMeshRequest { + Hunyuan3d3p1RapidTextToMeshRequest { + prompt: "p".to_string(), + enable_pbr: None, + enable_geometry: None, + } + } + + #[test] + fn base_cost() { + assert_eq!(base_request().calculate_cost_in_cents(), 23); + } + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 23 + 15); + } + + #[test] + fn geometry_does_not_change_cost() { + let mut req = base_request(); + req.enable_geometry = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 23); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/raw_request.rs new file mode 100644 index 0000000000..ab389f1cd8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan_3d_3p1_rapid_text_to_mesh/raw_request.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/rapid/text-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3p1RapidTextToMeshInput { + /// Text prompt (max 200 UTF-8 characters). + pub prompt: String, + + /// Generate PBR textures (metallic, roughness, normal). fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, + + /// Generate a geometry-only white model without textures. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_geometry: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3p1RapidTextToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/api.rs new file mode 100644 index 0000000000..d1f9e72864 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/api.rs @@ -0,0 +1,279 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::text::meshy_v6_text_to_mesh::raw_request::{ + MeshyV6TextToMeshInput, MeshyV6TextToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Meshy 6 text-to-3D. +#[derive(Clone, Debug)] +pub struct MeshyV6TextToMeshRequest { + /// Text prompt describing the 3D object (max 600 characters). + pub prompt: String, + + /// Generation mode. fal's default is `Full` when `None`. + pub mode: Option, + + /// Seed for reproducibility. + pub seed: Option, + + /// Model type. fal's default is `Standard` when `None`. + pub model_type: Option, + + /// Mesh topology. fal's default is `Triangle` when `None`. + pub topology: Option, + + /// Target polygon count. Range 100-300000; fal's default is 30000. + pub target_polycount: Option, + + /// Whether to remesh the output. fal's default is true when `None`. + pub should_remesh: Option, + + /// Symmetry mode. fal's default is `Auto` when `None`. + pub symmetry_mode: Option, + + /// Whether to generate PBR materials. fal's default is false when `None`. + pub enable_pbr: Option, + + /// Character pose mode. Unspecified when `None` (fal's default). + pub pose_mode: Option, + + /// Let Meshy expand/rewrite the prompt. fal's default is false when `None`. + pub enable_prompt_expansion: Option, + + /// Optional texture-specific prompt. + pub texture_prompt: Option, + + /// Optional texture reference image URL. + pub texture_image_url: Option, + + /// Rig the generated character. fal's default is false when `None`. + pub enable_rigging: Option, + + /// Character height for rigging, in meters. fal's default is 1.7. + pub rigging_height_meters: Option, + + /// Animate the rigged character. fal's default is false when `None`. + pub enable_animation: Option, + + /// Animation action ID. Range 0-696; fal's default is 92. + pub animation_action_id: Option, + + /// fal's default is true when `None`. + pub enable_safety_checker: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MeshyV6Mode { + Preview, + Full, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MeshyV6ModelType { + Standard, + LowPoly, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MeshyV6Topology { + Quad, + Triangle, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MeshyV6SymmetryMode { + Off, + Auto, + On, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MeshyV6PoseMode { + APose, + TPose, +} + +impl MeshyV6Mode { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Full => "full", + } + } +} + +impl MeshyV6ModelType { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::LowPoly => "lowpoly", + } + } +} + +impl MeshyV6Topology { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Quad => "quad", + Self::Triangle => "triangle", + } + } +} + +impl MeshyV6SymmetryMode { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Off => "off", + Self::Auto => "auto", + Self::On => "on", + } + } +} + +impl MeshyV6PoseMode { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::APose => "a-pose", + Self::TPose => "t-pose", + } + } +} + +impl FalEndpoint for MeshyV6TextToMeshRequest { + const ENDPOINT: &str = "fal-ai/meshy/v6/text-to-3d"; + + type RawRequest = MeshyV6TextToMeshInput; + type RawResponse = MeshyV6TextToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + mode: self.mode.map(|m| m.to_str().to_string()), + seed: self.seed, + model_type: self.model_type.map(|m| m.to_str().to_string()), + topology: self.topology.map(|t| t.to_str().to_string()), + target_polycount: self.target_polycount, + should_remesh: self.should_remesh, + symmetry_mode: self.symmetry_mode.map(|s| s.to_str().to_string()), + enable_pbr: self.enable_pbr, + pose_mode: self.pose_mode.map(|p| p.to_str().to_string()), + enable_prompt_expansion: self.enable_prompt_expansion, + texture_prompt: self.texture_prompt.clone(), + texture_image_url: self.texture_image_url.clone(), + enable_rigging: self.enable_rigging, + rigging_height_meters: self.rigging_height_meters, + enable_animation: self.enable_animation, + animation_action_id: self.animation_action_id, + enable_safety_checker: self.enable_safety_checker, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + fn base_request() -> MeshyV6TextToMeshRequest { + MeshyV6TextToMeshRequest { + prompt: "a small ceramic teapot".to_string(), + mode: None, + seed: None, + model_type: None, + topology: None, + target_polycount: None, + should_remesh: None, + symmetry_mode: None, + enable_pbr: None, + pose_mode: None, + enable_prompt_expansion: None, + texture_prompt: None, + texture_image_url: None, + enable_rigging: None, + rigging_height_meters: None, + enable_animation: None, + animation_action_id: None, + enable_safety_checker: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let result = base_request().send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let result = base_request().send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_core_fields() { + let request = MeshyV6TextToMeshRequest { + mode: Some(MeshyV6Mode::Preview), + model_type: Some(MeshyV6ModelType::LowPoly), + topology: Some(MeshyV6Topology::Quad), + target_polycount: Some(5_000), + symmetry_mode: Some(MeshyV6SymmetryMode::On), + enable_pbr: Some(true), + pose_mode: Some(MeshyV6PoseMode::TPose), + ..base_request() + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json["mode"], "preview"); + assert_eq!(json["model_type"], "lowpoly"); + assert_eq!(json["topology"], "quad"); + assert_eq!(json["target_polycount"], 5_000); + assert_eq!(json["symmetry_mode"], "on"); + assert_eq!(json["enable_pbr"], true); + assert_eq!(json["pose_mode"], "t-pose"); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = MeshyV6TextToMeshRequest { prompt: "p".to_string(), ..base_request() }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "p" })); + } + + #[test] + fn every_symmetry_mode_maps_to_wire_string() { + for (variant, expected) in [ + (MeshyV6SymmetryMode::Off, "off"), + (MeshyV6SymmetryMode::Auto, "auto"), + (MeshyV6SymmetryMode::On, "on"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn pose_modes_use_hyphenated_wire_strings() { + assert_eq!(MeshyV6PoseMode::APose.to_str(), "a-pose"); + assert_eq!(MeshyV6PoseMode::TPose.to_str(), "t-pose"); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(MeshyV6TextToMeshRequest::ENDPOINT, "fal-ai/meshy/v6/text-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/cost.rs new file mode 100644 index 0000000000..5c5777e450 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/cost.rs @@ -0,0 +1,46 @@ +use crate::requests::api::mesh::text::meshy_v6_text_to_mesh::api::MeshyV6TextToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for MeshyV6TextToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/meshy/v6/text-to-3d): + // "Your request will cost $0.8 per generation." + 80 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::mesh::text::meshy_v6_text_to_mesh::api::MeshyV6ModelType; + + #[test] + fn flat_cost_regardless_of_options() { + let mut request = MeshyV6TextToMeshRequest { + prompt: "p".to_string(), + mode: None, + seed: None, + model_type: None, + topology: None, + target_polycount: None, + should_remesh: None, + symmetry_mode: None, + enable_pbr: None, + pose_mode: None, + enable_prompt_expansion: None, + texture_prompt: None, + texture_image_url: None, + enable_rigging: None, + rigging_height_meters: None, + enable_animation: None, + animation_action_id: None, + enable_safety_checker: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 80); + + request.enable_pbr = Some(true); + request.model_type = Some(MeshyV6ModelType::LowPoly); + request.enable_rigging = Some(true); + assert_eq!(request.calculate_cost_in_cents(), 80); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/raw_request.rs new file mode 100644 index 0000000000..00052d7e07 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/meshy_v6_text_to_mesh/raw_request.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/meshy/v6/text-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct MeshyV6TextToMeshInput { + /// Text prompt (max 600 characters). + pub prompt: String, + + /// Options: "preview", "full". fal default: "full". + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Options: "standard", "lowpoly". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub model_type: Option, + + /// Options: "quad", "triangle". fal default: "triangle". + #[serde(skip_serializing_if = "Option::is_none")] + pub topology: Option, + + /// Range 100-300000. fal default: 30000. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_polycount: Option, + + /// fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub should_remesh: Option, + + /// Options: "off", "auto", "on". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub symmetry_mode: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, + + /// Options: "a-pose", "t-pose". fal default: "" (unspecified). + #[serde(skip_serializing_if = "Option::is_none")] + pub pose_mode: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_prompt_expansion: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_image_url: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_rigging: Option, + + /// fal default: 1.7. + #[serde(skip_serializing_if = "Option::is_none")] + pub rigging_height_meters: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_animation: Option, + + /// Range 0-696. fal default: 92. + #[serde(skip_serializing_if = "Option::is_none")] + pub animation_action_id: Option, + + /// fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MeshyV6TextToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/mod.rs new file mode 100644 index 0000000000..2448a1779e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/mod.rs @@ -0,0 +1,6 @@ +pub mod hunyuan3d_3_text_to_mesh; +pub mod hunyuan_3d_3p1_pro_text_to_mesh; +pub mod hunyuan_3d_3p1_rapid_text_to_mesh; +pub mod meshy_v6_text_to_mesh; +pub mod rodin_2p5_fast_text_to_mesh; +pub mod tripo3d_h3p1_text_to_mesh; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/api.rs new file mode 100644 index 0000000000..4d0eaa7c67 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/api.rs @@ -0,0 +1,305 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::raw_request::{ + Rodin2p5FastBboxCondition, Rodin2p5FastTextToMeshInput, Rodin2p5FastTextToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hyper3D Rodin v2.5 Fast text-to-3D. +#[derive(Clone, Debug)] +pub struct Rodin2p5FastTextToMeshRequest { + /// Text prompt describing the 3D object. + pub prompt: String, + + /// Generation tier. fal's default is `ExtremeLow` when `None`. + pub tier: Option, + + /// Seed for reproducibility. Range 0-65535. + pub seed: Option, + + /// Output geometry format. fal's default is `Glb` when `None`. + pub geometry_file_format: Option, + + /// Material type. fal's default is `Shaded` when `None`. + pub material: Option, + + /// Mesh quality/topology preset. fal's default is `Auto` when `None`. + pub quality_mesh_option: Option, + + /// Texture resolution mode. fal's default is tier-dependent when `None`. + pub texture_mode: Option, + + /// Enhanced generative robustness. fal's default is false when `None`. + pub enable_creative_mode: Option, + + /// Enhanced texture post-processing. fal's default is false when `None`. + pub hd_texture: Option, + + /// Removes baked lighting from textures. fal's default is false when `None`. + pub texture_delight: Option, + + /// Finer geometric detail. fal's default is false when `None`. + pub is_micro: Option, + + /// Generate in T/A-pose for rigging/animation. fal's default is false. + pub ta_pose: Option, + + /// Bounding-box controlnet limiting the maximum model size. + pub bbox_condition: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Rodin2p5FastTier { + Minimum, + ExtremeLow, + Low, +} + +impl Rodin2p5FastTier { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Minimum => "Gen-2.5-Minimum", + Self::ExtremeLow => "Gen-2.5-Extreme-Low", + Self::Low => "Gen-2.5-Low", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Rodin2p5FastGeometryFileFormat { + Glb, + Usdz, + Fbx, + Obj, + Stl, +} + +impl Rodin2p5FastGeometryFileFormat { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Glb => "glb", + Self::Usdz => "usdz", + Self::Fbx => "fbx", + Self::Obj => "obj", + Self::Stl => "stl", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Rodin2p5FastMaterial { + Pbr, + Shaded, + All, + /// No material — fal's literal "None" option. + NoMaterial, +} + +impl Rodin2p5FastMaterial { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Pbr => "PBR", + Self::Shaded => "Shaded", + Self::All => "All", + Self::NoMaterial => "None", + } + } +} + +/// Mesh quality/topology presets. Wire values contain spaces (e.g. "1K Quad"). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Rodin2p5FastQualityMeshOption { + Auto, + Quad1k, + Quad4k, + Quad8k, + Quad18k, + Quad20k, + Triangle2k, + Triangle4k, + Triangle8k, + Triangle10k, + Triangle20k, +} + +impl Rodin2p5FastQualityMeshOption { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::Quad1k => "1K Quad", + Self::Quad4k => "4K Quad", + Self::Quad8k => "8K Quad", + Self::Quad18k => "18K Quad", + Self::Quad20k => "20K Quad", + Self::Triangle2k => "2K Triangle", + Self::Triangle4k => "4K Triangle", + Self::Triangle8k => "8K Triangle", + Self::Triangle10k => "10K Triangle", + Self::Triangle20k => "20K Triangle", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Rodin2p5FastTextureMode { + Legacy, + ExtremeLow, + Low, + Medium, + High, +} + +impl Rodin2p5FastTextureMode { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Legacy => "legacy", + Self::ExtremeLow => "extreme-low", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } +} + +impl FalEndpoint for Rodin2p5FastTextToMeshRequest { + const ENDPOINT: &str = "fal-ai/hyper3d/rodin/v2.5/text-to-3d/fast"; + + type RawRequest = Rodin2p5FastTextToMeshInput; + type RawResponse = Rodin2p5FastTextToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + tier: self.tier.map(|t| t.to_str().to_string()), + seed: self.seed, + geometry_file_format: self.geometry_file_format.map(|f| f.to_str().to_string()), + material: self.material.map(|m| m.to_str().to_string()), + quality_mesh_option: self.quality_mesh_option.map(|q| q.to_str().to_string()), + texture_mode: self.texture_mode.map(|t| t.to_str().to_string()), + enable_creative_mode: self.enable_creative_mode, + hd_texture: self.hd_texture, + texture_delight: self.texture_delight, + is_micro: self.is_micro, + ta_pose: self.ta_pose, + bbox_condition: self.bbox_condition.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + fn base_request() -> Rodin2p5FastTextToMeshRequest { + Rodin2p5FastTextToMeshRequest { + prompt: "a futuristic robot with sleek metallic design".to_string(), + tier: None, + seed: None, + geometry_file_format: None, + material: None, + quality_mesh_option: None, + texture_mode: None, + enable_creative_mode: None, + hd_texture: None, + texture_delight: None, + is_micro: None, + ta_pose: None, + bbox_condition: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let result = base_request().send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let result = base_request().send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_core_fields() { + let request = Rodin2p5FastTextToMeshRequest { + tier: Some(Rodin2p5FastTier::Low), + geometry_file_format: Some(Rodin2p5FastGeometryFileFormat::Fbx), + material: Some(Rodin2p5FastMaterial::Pbr), + quality_mesh_option: Some(Rodin2p5FastQualityMeshOption::Quad4k), + texture_mode: Some(Rodin2p5FastTextureMode::High), + ta_pose: Some(true), + bbox_condition: Some(Rodin2p5FastBboxCondition { width: 10, height: 20, length: 30 }), + ..base_request() + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json["tier"], "Gen-2.5-Low"); + assert_eq!(json["geometry_file_format"], "fbx"); + assert_eq!(json["material"], "PBR"); + assert_eq!(json["quality_mesh_option"], "4K Quad"); + assert_eq!(json["texture_mode"], "high"); + // fal's field name is literally "TAPose". + assert_eq!(json["TAPose"], true); + assert!(json.get("ta_pose").is_none()); + assert_eq!(json["bbox_condition"], serde_json::json!({ "width": 10, "height": 20, "length": 30 })); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Rodin2p5FastTextToMeshRequest { + prompt: "p".to_string(), + ..base_request() + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "p" })); + } + + #[test] + fn every_quality_mesh_option_maps_to_wire_string() { + for (variant, expected) in [ + (Rodin2p5FastQualityMeshOption::Auto, "Auto"), + (Rodin2p5FastQualityMeshOption::Quad1k, "1K Quad"), + (Rodin2p5FastQualityMeshOption::Quad4k, "4K Quad"), + (Rodin2p5FastQualityMeshOption::Quad8k, "8K Quad"), + (Rodin2p5FastQualityMeshOption::Quad18k, "18K Quad"), + (Rodin2p5FastQualityMeshOption::Quad20k, "20K Quad"), + (Rodin2p5FastQualityMeshOption::Triangle2k, "2K Triangle"), + (Rodin2p5FastQualityMeshOption::Triangle4k, "4K Triangle"), + (Rodin2p5FastQualityMeshOption::Triangle8k, "8K Triangle"), + (Rodin2p5FastQualityMeshOption::Triangle10k, "10K Triangle"), + (Rodin2p5FastQualityMeshOption::Triangle20k, "20K Triangle"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn material_none_maps_to_capitalized_none() { + assert_eq!(Rodin2p5FastMaterial::NoMaterial.to_str(), "None"); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Rodin2p5FastTextToMeshRequest::ENDPOINT, + "fal-ai/hyper3d/rodin/v2.5/text-to-3d/fast", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/cost.rs new file mode 100644 index 0000000000..baa9f6d0bb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/cost.rs @@ -0,0 +1,43 @@ +use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::Rodin2p5FastTextToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Rodin2p5FastTextToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hyper3d/rodin/v2.5/text-to-3d/fast): + // "Your request will cost $0.1 per generation." + 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::{ + Rodin2p5FastMaterial, Rodin2p5FastTier, + }; + + #[test] + fn flat_cost_regardless_of_options() { + let mut request = Rodin2p5FastTextToMeshRequest { + prompt: "p".to_string(), + tier: None, + seed: None, + geometry_file_format: None, + material: None, + quality_mesh_option: None, + texture_mode: None, + enable_creative_mode: None, + hd_texture: None, + texture_delight: None, + is_micro: None, + ta_pose: None, + bbox_condition: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 10); + + request.tier = Some(Rodin2p5FastTier::Low); + request.material = Some(Rodin2p5FastMaterial::Pbr); + request.hd_texture = Some(true); + assert_eq!(request.calculate_cost_in_cents(), 10); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/raw_request.rs new file mode 100644 index 0000000000..56bc9aaf8b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/rodin_2p5_fast_text_to_mesh/raw_request.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +/// Bounding-box controlnet limiting the maximum size of the generated model. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Rodin2p5FastBboxCondition { + /// Width (x axis) dimension for the bounding box constraint. + pub width: u32, + /// Height (y axis) dimension for the bounding box constraint. + pub height: u32, + /// Length (z axis) dimension for the bounding box constraint. + pub length: u32, +} + +/// Over-the-wire input shape for `fal-ai/hyper3d/rodin/v2.5/text-to-3d/fast`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Rodin2p5FastTextToMeshInput { + /// Text prompt describing the 3D object. + pub prompt: String, + + /// Options: "Gen-2.5-Minimum", "Gen-2.5-Extreme-Low", "Gen-2.5-Low". + /// fal default: "Gen-2.5-Extreme-Low". + #[serde(skip_serializing_if = "Option::is_none")] + pub tier: Option, + + /// Range 0-65535. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Options: "glb", "usdz", "fbx", "obj", "stl". fal default: "glb". + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_file_format: Option, + + /// Options: "PBR", "Shaded", "All", "None". fal default: "Shaded". + #[serde(skip_serializing_if = "Option::is_none")] + pub material: Option, + + /// Options: "Auto", "1K Quad", "4K Quad", "8K Quad", "18K Quad", "20K Quad", + /// "2K Triangle", "4K Triangle", "8K Triangle", "10K Triangle", + /// "20K Triangle". fal default: "Auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_mesh_option: Option, + + /// Options: "legacy", "extreme-low", "low", "medium", "high". + /// fal default is tier-dependent. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_mode: Option, + + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_creative_mode: Option, + + /// Enhanced texture post-processing. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub hd_texture: Option, + + /// Removes baked lighting from textures. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_delight: Option, + + /// Finer geometric detail. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_micro: Option, + + /// Generate in T/A-pose for rigging/animation. fal default: false. + /// NB: fal's field name is literally "TAPose". + #[serde(rename = "TAPose", skip_serializing_if = "Option::is_none")] + pub ta_pose: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox_condition: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Rodin2p5FastTextToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/api.rs new file mode 100644 index 0000000000..915ccac593 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/api.rs @@ -0,0 +1,202 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::text::tripo3d_h3p1_text_to_mesh::raw_request::{ + Tripo3dH3p1TextToMeshInput, Tripo3dH3p1TextToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Tripo3D H3.1 text-to-3D. +#[derive(Clone, Debug)] +pub struct Tripo3dH3p1TextToMeshRequest { + /// Text prompt describing the 3D object (max 1024 characters). + pub prompt: String, + + /// Features to avoid in the generated model. + pub negative_prompt: Option, + + /// Maximum face count. Range 1000-2000000; adaptive when `None`. + pub face_limit: Option, + + /// Enable texture generation. fal's default is true when `None`. + /// Disabling drops to the cheaper no-texture price tier. + pub texture: Option, + + /// Enable PBR materials (implies texture). fal's default is true when `None`. + pub pbr: Option, + + /// Seed for geometry reproducibility. + pub model_seed: Option, + + /// Seed for the internal text-to-image step. + pub image_seed: Option, + + /// Seed for texture reproducibility. + pub texture_seed: Option, + + /// Texture quality. `Detailed` bills at the HD-texture price tier. + /// fal's default is `Standard` when `None`. + pub texture_quality: Option, + + /// Geometry quality. `Detailed` adds cost. fal's default is `Standard`. + pub geometry_quality: Option, + + /// Auto-scale the model to real-world dimensions (meters). fal default: false. + pub auto_size: Option, + + /// Generate quad mesh topology instead of triangles (returns FBX). + /// Adds cost. fal's default is false when `None`. + pub quad: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tripo3dH3p1TextureQuality { + Standard, + Detailed, +} + +impl Tripo3dH3p1TextureQuality { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Detailed => "detailed", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tripo3dH3p1GeometryQuality { + Standard, + Detailed, +} + +impl Tripo3dH3p1GeometryQuality { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Detailed => "detailed", + } + } +} + +impl FalEndpoint for Tripo3dH3p1TextToMeshRequest { + const ENDPOINT: &str = "tripo3d/h3.1/text-to-3d"; + + type RawRequest = Tripo3dH3p1TextToMeshInput; + type RawResponse = Tripo3dH3p1TextToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + negative_prompt: self.negative_prompt.clone(), + face_limit: self.face_limit, + texture: self.texture, + pbr: self.pbr, + model_seed: self.model_seed, + image_seed: self.image_seed, + texture_seed: self.texture_seed, + texture_quality: self.texture_quality.map(|q| q.to_str().to_string()), + geometry_quality: self.geometry_quality.map(|q| q.to_str().to_string()), + auto_size: self.auto_size, + quad: self.quad, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + fn base_request() -> Tripo3dH3p1TextToMeshRequest { + Tripo3dH3p1TextToMeshRequest { + prompt: "a small ceramic teapot".to_string(), + negative_prompt: None, + face_limit: None, + texture: None, + pbr: None, + model_seed: None, + image_seed: None, + texture_seed: None, + texture_quality: None, + geometry_quality: None, + auto_size: None, + quad: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let result = base_request().send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let result = base_request().send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Tripo3dH3p1TextToMeshRequest { + prompt: "p".to_string(), + negative_prompt: Some("blurry".to_string()), + face_limit: Some(50_000), + texture: Some(true), + pbr: Some(false), + model_seed: Some(1), + image_seed: Some(2), + texture_seed: Some(3), + texture_quality: Some(Tripo3dH3p1TextureQuality::Detailed), + geometry_quality: Some(Tripo3dH3p1GeometryQuality::Detailed), + auto_size: Some(true), + quad: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "p", + "negative_prompt": "blurry", + "face_limit": 50_000, + "texture": true, + "pbr": false, + "model_seed": 1, + "image_seed": 2, + "texture_seed": 3, + "texture_quality": "detailed", + "geometry_quality": "detailed", + "auto_size": true, + "quad": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Tripo3dH3p1TextToMeshRequest { prompt: "p".to_string(), ..base_request() }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "p" })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Tripo3dH3p1TextToMeshRequest::ENDPOINT, "tripo3d/h3.1/text-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/cost.rs new file mode 100644 index 0000000000..5ed107731f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/cost.rs @@ -0,0 +1,137 @@ +use crate::requests::api::mesh::text::tripo3d_h3p1_text_to_mesh::api::{ + Tripo3dH3p1GeometryQuality, Tripo3dH3p1TextToMeshRequest, Tripo3dH3p1TextureQuality, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Tripo3dH3p1TextToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/tripo3d/h3.1/text-to-3d): + // "Your request will cost $0.10 (without textures), $0.20 (with standard + // textures), or $0.30 (with HD textures), plus an additional $0.20 for + // detailed geometry and $0.05 for quad mesh if selected." + // + // `texture` defaults to true, so the standard-texture tier is the default. + // "HD textures" = texture_quality "detailed". + // `pbr` implies texturing and ALSO defaults to true, so the cheap + // untextured tier only applies when both `texture` and `pbr` are + // explicitly disabled. Anything else may texture server-side; bill the + // textured tier so we never undercharge. + let texture_enabled = self.texture.unwrap_or(true) || self.pbr.unwrap_or(true); + let mut cost: u64 = if !texture_enabled { + 10 + } else { + match self.texture_quality { + Some(Tripo3dH3p1TextureQuality::Detailed) => 30, + None | Some(Tripo3dH3p1TextureQuality::Standard) => 20, + } + }; + if matches!(self.geometry_quality, Some(Tripo3dH3p1GeometryQuality::Detailed)) { + cost += 20; + } + if self.quad.unwrap_or(false) { + cost += 5; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> Tripo3dH3p1TextToMeshRequest { + Tripo3dH3p1TextToMeshRequest { + prompt: "p".to_string(), + negative_prompt: None, + face_limit: None, + texture: None, + pbr: None, + model_seed: None, + image_seed: None, + texture_seed: None, + texture_quality: None, + geometry_quality: None, + auto_size: None, + quad: None, + } + } + + mod texture_tiers { + use super::*; + + #[test] + fn default_is_standard_textures() { + // texture defaults to true → $0.20 + assert_eq!(base_request().calculate_cost_in_cents(), 20); + } + + #[test] + fn no_texture_requires_pbr_off_too() { + let mut req = base_request(); + req.texture = Some(false); + req.pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 10); + } + + #[test] + fn texture_off_but_pbr_default_bills_textured_tier() { + // `pbr` defaults to true and implies texturing. + let mut req = base_request(); + req.texture = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 20); + } + + #[test] + fn detailed_texture() { + let mut req = base_request(); + req.texture_quality = Some(Tripo3dH3p1TextureQuality::Detailed); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + } + + mod add_ons { + use super::*; + + #[test] + fn detailed_geometry_adds_twenty() { + let mut req = base_request(); + req.geometry_quality = Some(Tripo3dH3p1GeometryQuality::Detailed); + assert_eq!(req.calculate_cost_in_cents(), 20 + 20); + } + + #[test] + fn quad_adds_five() { + let mut req = base_request(); + req.quad = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 20 + 5); + } + + #[test] + fn everything_stacks() { + let mut req = base_request(); + req.texture_quality = Some(Tripo3dH3p1TextureQuality::Detailed); + req.geometry_quality = Some(Tripo3dH3p1GeometryQuality::Detailed); + req.quad = Some(true); + // 30 + 20 + 5 = 55 + assert_eq!(req.calculate_cost_in_cents(), 55); + } + + #[test] + fn no_texture_with_add_ons() { + let mut req = base_request(); + req.texture = Some(false); + req.pbr = Some(false); + req.geometry_quality = Some(Tripo3dH3p1GeometryQuality::Detailed); + req.quad = Some(true); + // 10 + 20 + 5 = 35 + assert_eq!(req.calculate_cost_in_cents(), 35); + } + + #[test] + fn pbr_does_not_change_cost() { + let mut req = base_request(); + req.pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 20); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/raw_request.rs new file mode 100644 index 0000000000..17f2d24e6c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/tripo3d_h3p1_text_to_mesh/raw_request.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `tripo3d/h3.1/text-to-3d`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Tripo3dH3p1TextToMeshInput { + /// Text prompt (max 1024 characters). + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Range 1000-2000000. Adaptive when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_limit: Option, + + /// Enable texture generation. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub texture: Option, + + /// Enable PBR materials (implies texture). fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub pbr: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_seed: Option, + + /// Seed for the internal text-to-image step. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_seed: Option, + + /// Options: "standard", "detailed". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub texture_quality: Option, + + /// Options: "standard", "detailed". fal default: "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_quality: Option, + + /// Auto-scale the model to real-world dimensions (meters). fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_size: Option, + + /// Generate quad mesh topology instead of triangles. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub quad: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tripo3dH3p1TextToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/api.rs new file mode 100644 index 0000000000..c35b6c7eeb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/api.rs @@ -0,0 +1,183 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::raw_request::{ + Hunyuan3d3p1SmartTopologyInput, Hunyuan3d3p1SmartTopologyOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan 3D v3.1 Smart Topology: retopologizes an existing 3D mesh into a +/// cleaner, more efficient topology. +/// +/// Input constraints: GLB or OBJ, max 200MB. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3p1SmartTopologyRequest { + /// URL of the input mesh file (GLB or OBJ). + pub input_file_url: String, + + /// Format of the input file. fal's default is `Glb` when `None`. + pub input_file_type: Option, + + /// Output polygon type. fal's default is `Triangle` when `None`. + pub polygon_type: Option, + + /// Output detail level. fal's default is `Medium` when `None`. + pub face_level: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3p1SmartTopologyInputFileType { + Glb, + Obj, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3p1SmartTopologyPolygonType { + Triangle, + Quadrilateral, +} + +#[derive(Copy, Clone, Debug)] +pub enum Hunyuan3d3p1SmartTopologyFaceLevel { + High, + Medium, + Low, +} + +impl FalEndpoint for Hunyuan3d3p1SmartTopologyRequest { + const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/smart-topology"; + + type RawRequest = Hunyuan3d3p1SmartTopologyInput; + type RawResponse = Hunyuan3d3p1SmartTopologyOutput; + + fn to_raw_request(&self) -> Result { + let input_file_type = self.input_file_type.map(|t| match t { + Hunyuan3d3p1SmartTopologyInputFileType::Glb => "glb", + Hunyuan3d3p1SmartTopologyInputFileType::Obj => "obj", + }.to_string()); + + let polygon_type = self.polygon_type.map(|t| match t { + Hunyuan3d3p1SmartTopologyPolygonType::Triangle => "triangle", + Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral => "quadrilateral", + }.to_string()); + + let face_level = self.face_level.map(|l| match l { + Hunyuan3d3p1SmartTopologyFaceLevel::High => "high", + Hunyuan3d3p1SmartTopologyFaceLevel::Medium => "medium", + Hunyuan3d3p1SmartTopologyFaceLevel::Low => "low", + }.to_string()); + + Ok(Self::RawRequest { + input_file_url: self.input_file_url.clone(), + input_file_type, + polygon_type, + face_level, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost; needs a real GLB URL + async fn test_smart_topology_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url: "https://example.com/model.glb".to_string(), + input_file_type: None, + polygon_type: None, + face_level: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost; needs a real GLB URL + async fn test_smart_topology_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url: "https://example.com/model.glb".to_string(), + input_file_type: None, + polygon_type: None, + face_level: None, + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url: "https://example.com/model.obj".to_string(), + input_file_type: Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj), + polygon_type: Some(Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral), + face_level: Some(Hunyuan3d3p1SmartTopologyFaceLevel::Low), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_file_url": "https://example.com/model.obj", + "input_file_type": "obj", + "polygon_type": "quadrilateral", + "face_level": "low", + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url: "https://example.com/model.glb".to_string(), + input_file_type: None, + polygon_type: None, + face_level: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_file_url": "https://example.com/model.glb" }), + ); + } + + #[test] + fn every_face_level_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3p1SmartTopologyFaceLevel::High, "high"), + (Hunyuan3d3p1SmartTopologyFaceLevel::Medium, "medium"), + (Hunyuan3d3p1SmartTopologyFaceLevel::Low, "low"), + ] { + let request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url: "https://example.com/model.glb".to_string(), + input_file_type: None, + polygon_type: None, + face_level: Some(variant), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.face_level.as_deref(), Some(expected)); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Hunyuan3d3p1SmartTopologyRequest::ENDPOINT, + "fal-ai/hunyuan-3d/v3.1/smart-topology", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/cost.rs new file mode 100644 index 0000000000..6be5a58ddc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/cost.rs @@ -0,0 +1,33 @@ +use crate::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::api::Hunyuan3d3p1SmartTopologyRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3p1SmartTopologyRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/smart-topology): + // "Your request will cost $0.75 per generation." + 75 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::api::{ + Hunyuan3d3p1SmartTopologyFaceLevel, Hunyuan3d3p1SmartTopologyPolygonType, + }; + + #[test] + fn flat_cost_regardless_of_options() { + let mut request = Hunyuan3d3p1SmartTopologyRequest { + input_file_url: "https://example.com/model.glb".to_string(), + input_file_type: None, + polygon_type: None, + face_level: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 75); + + request.polygon_type = Some(Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral); + request.face_level = Some(Hunyuan3d3p1SmartTopologyFaceLevel::High); + assert_eq!(request.calculate_cost_in_cents(), 75); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/raw_request.rs new file mode 100644 index 0000000000..e63512abad --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/topology/hunyuan_3d_3p1_smart_topology/raw_request.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/smart-topology`. +/// Retopologizes a 3D mesh into a more efficient topology. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3p1SmartTopologyInput { + /// URL of the input mesh. GLB or OBJ, max 200MB. + pub input_file_url: String, + + /// Options: "glb", "obj". fal default: "glb". + #[serde(skip_serializing_if = "Option::is_none")] + pub input_file_type: Option, + + /// Options: "triangle" (triangles only), "quadrilateral" (mixed quads and + /// triangles). fal default: "triangle". + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_type: Option, + + /// Options: "high", "medium", "low". fal default: "medium". + #[serde(skip_serializing_if = "Option::is_none")] + pub face_level: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3p1SmartTopologyOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/topology/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/topology/mod.rs new file mode 100644 index 0000000000..dda35c910f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/topology/mod.rs @@ -0,0 +1 @@ +pub mod hunyuan_3d_3p1_smart_topology; diff --git a/crates/api_clients/fal_client/src/requests/api/mod.rs b/crates/api_clients/fal_client/src/requests/api/mod.rs new file mode 100644 index 0000000000..8272f0437d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mod.rs @@ -0,0 +1,5 @@ +pub mod audio; +pub mod image; +pub mod mesh; +pub mod splat; +pub mod video; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests/api/splat/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/splat/image/mod.rs new file mode 100644 index 0000000000..82f151561a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/splat/image/mod.rs @@ -0,0 +1 @@ +pub mod triposplat_image_to_splat; diff --git a/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/api.rs b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/api.rs new file mode 100644 index 0000000000..7e7b9ac982 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/api.rs @@ -0,0 +1,160 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::splat::image::triposplat_image_to_splat::raw_request::{ + TripoSplatImageToSplatInput, TripoSplatImageToSplatOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// TripoSplat image-to-Gaussian-splat: reconstructs a 3D Gaussian splat from +/// a single input image. +#[derive(Clone, Debug)] +pub struct TripoSplatImageToSplatRequest { + /// URL of the input image (required). + pub image_url: String, + + /// Number of Gaussians to generate. Range 32768-262144; + /// fal's default is 262144 when `None`. + pub num_gaussians: Option, + + /// Number of inference steps. Range 1-50; fal's default is 20 when `None`. + pub num_inference_steps: Option, + + /// Guidance scale. Range 0-10; fal's default is 3 when `None`. + pub guidance_scale: Option, + + /// Output file format. fal's default is `Ply` when `None`. + pub output_format: Option, + + /// Seed for reproducibility. fal picks a random seed when `None`. + pub seed: Option, + + /// fal's default is true when `None`. + pub enable_safety_checker: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum TripoSplatOutputFormat { + Ply, + Splat, +} + +impl TripoSplatOutputFormat { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Ply => "ply", + Self::Splat => "splat", + } + } +} + +impl FalEndpoint for TripoSplatImageToSplatRequest { + const ENDPOINT: &str = "tripo3d/triposplat"; + + type RawRequest = TripoSplatImageToSplatInput; + type RawResponse = TripoSplatImageToSplatOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + image_url: self.image_url.clone(), + num_gaussians: self.num_gaussians, + num_inference_steps: self.num_inference_steps, + guidance_scale: self.guidance_scale, + output_format: self.output_format.map(|f| f.to_str().to_string()), + seed: self.seed, + enable_safety_checker: self.enable_safety_checker, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + mod serialization_tests { + use super::*; + + #[test] + fn minimal_request_serializes_image_url_only() { + let raw = base_request().to_raw_request().expect("to_raw_request"); + let json = serde_json::to_value(&raw).unwrap(); + let obj = json.as_object().unwrap(); + assert_eq!(obj.len(), 1, "only image_url should serialize: {json}"); + assert_eq!(json["image_url"], "https://example.com/input.png"); + } + + #[test] + fn output_format_wire_values() { + assert_eq!(TripoSplatOutputFormat::Ply.to_str(), "ply"); + assert_eq!(TripoSplatOutputFormat::Splat.to_str(), "splat"); + } + + #[test] + fn full_request_serializes_all_fields() { + let request = TripoSplatImageToSplatRequest { + num_gaussians: Some(131_072), + num_inference_steps: Some(30), + guidance_scale: Some(5.0), + output_format: Some(TripoSplatOutputFormat::Ply), + seed: Some(42), + enable_safety_checker: Some(false), + ..base_request() + }; + let raw = request.to_raw_request().expect("to_raw_request"); + let json = serde_json::to_value(&raw).unwrap(); + assert_eq!(json["num_gaussians"], 131_072); + assert_eq!(json["num_inference_steps"], 30); + assert_eq!(json["guidance_scale"], 5.0); + assert_eq!(json["output_format"], "ply"); + assert_eq!(json["seed"], 42); + assert_eq!(json["enable_safety_checker"], false); + } + } + + // ── Helpers ── + + fn base_request() -> TripoSplatImageToSplatRequest { + TripoSplatImageToSplatRequest { + image_url: "https://example.com/input.png".to_string(), + num_gaussians: None, + num_inference_steps: None, + guidance_scale: None, + output_format: None, + seed: None, + enable_safety_checker: None, + } + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_splat_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = TripoSplatImageToSplatRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_splat_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + let request = TripoSplatImageToSplatRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ..base_request() + }; + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/cost.rs b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/cost.rs new file mode 100644 index 0000000000..8baaed8774 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/cost.rs @@ -0,0 +1,47 @@ +use crate::requests::api::splat::image::triposplat_image_to_splat::api::TripoSplatImageToSplatRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for TripoSplatImageToSplatRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/tripo3d/triposplat): + // "Price: $0.05 per generations" + 5 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::splat::image::triposplat_image_to_splat::api::TripoSplatOutputFormat; + + fn base_request() -> TripoSplatImageToSplatRequest { + TripoSplatImageToSplatRequest { + image_url: "https://example.com/input.png".to_string(), + num_gaussians: None, + num_inference_steps: None, + guidance_scale: None, + output_format: None, + seed: None, + enable_safety_checker: None, + } + } + + #[test] + fn flat_five_cents() { + assert_eq!(base_request().calculate_cost_in_cents(), 5); + } + + #[test] + fn options_do_not_change_cost() { + let request = TripoSplatImageToSplatRequest { + num_gaussians: Some(32_768), + num_inference_steps: Some(50), + guidance_scale: Some(10.0), + output_format: Some(TripoSplatOutputFormat::Splat), + seed: Some(7), + enable_safety_checker: Some(false), + ..base_request() + }; + assert_eq!(request.calculate_cost_in_cents(), 5); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/mod.rs b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/raw_request.rs new file mode 100644 index 0000000000..906bb24e71 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/splat/image/triposplat_image_to_splat/raw_request.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `tripo3d/triposplat` (image-to-Gaussian-splat). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct TripoSplatImageToSplatInput { + /// URL of the input image. + pub image_url: String, + + /// Number of Gaussians to generate. Range 32768-262144. + /// fal default: 262144. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_gaussians: Option, + + /// Number of inference steps. Range 1-50. fal default: 20. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Guidance scale. Range 0-10. fal default: 3. + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// Options: "ply", "splat". fal default: "ply". + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// Seed for reproducibility. fal default: random. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TripoSplatImageToSplatOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/splat/mod.rs b/crates/api_clients/fal_client/src/requests/api/splat/mod.rs new file mode 100644 index 0000000000..14995d4228 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/splat/mod.rs @@ -0,0 +1 @@ +pub mod image; diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs new file mode 100644 index 0000000000..c27c95e6cb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/api.rs @@ -0,0 +1,257 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::raw_request::{ + Kling1p6ProElementsToVideoInput, Kling1p6ProElementsToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling1p6ProElementsToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Image URLs that drive the generation. fal documents support for up + /// to 4 images; passing more is rejected upstream rather than here. + pub input_image_urls: Vec, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 1.6 Pro Elements supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProElementsToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling1p6ProElementsToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProElementsToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling1p6ProElementsToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/elements"; + + type RawRequest = Kling1p6ProElementsToVideoInput; + type RawResponse = Kling1p6ProElementsToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling1p6ProElementsToVideoAspectRatio::Square => "1:1", + Kling1p6ProElementsToVideoAspectRatio::SixteenByNine => "16:9", + Kling1p6ProElementsToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + input_image_urls: self.input_image_urls.clone(), + aspect_ratio, + duration, + negative_prompt: self.negative_prompt.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_elements_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the two dogs run side by side across a sunlit meadow".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_elements_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling1p6ProElementsToVideoAspectRatio::Square, + Kling1p6ProElementsToVideoAspectRatio::SixteenByNine, + Kling1p6ProElementsToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling1p6ProElementsToVideoDuration::FiveSeconds, + Kling1p6ProElementsToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling1p6ProElementsToVideoRequest { + prompt: "the dogs leap through tall grass in golden hour light".to_string(), + input_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::Square), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_passes_through_input_image_urls() { + let urls = vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + "https://example.com/d.png".to_string(), + ]; + let request = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: urls.clone(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.input_image_urls, urls); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration: None, + aspect_ratio: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling1p6ProElementsToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v1.6/pro/elements", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs new file mode 100644 index 0000000000..dbc20023c1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/cost.rs @@ -0,0 +1,261 @@ +use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoDuration, Kling1p6ProElementsToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling1p6ProElementsToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 1.6 Pro elements-to-video: $0.098/second + // (see https://fal.ai/models/fal-ai/kling-video/v1.6/pro/elements). + // + // Same per-second rate as the v1.6 Pro text-to-video variant. Rate held + // in tenths-of-cents and rounded up to whole cents at the end so the + // user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling1p6ProElementsToVideoDuration::FiveSeconds) + .to_seconds(); + (98u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::elements::kling_1p6_pro_elements_to_video::api::{ + Kling1p6ProElementsToVideoAspectRatio, Kling1p6ProElementsToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling1p6ProElementsToVideoRequest { + Kling1p6ProElementsToVideoRequest { + prompt: "test".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration, + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + } + } + + // Pricing: $0.098/sec = 98 tenths-of-cents/sec. + // Cents per duration = ceil(98 × secs / 10). + + #[test] + fn five_seconds() { + // (98 * 5 + 9) / 10 = 49 + assert_eq!( + make_request(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 49, + ); + } + + #[test] + fn ten_seconds() { + // (98 * 10 + 9) / 10 = 98 + assert_eq!( + make_request(Some(Kling1p6ProElementsToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 98, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 49¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 49); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling1p6ProElementsToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling1p6ProElementsToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + #[test] + fn input_image_count_does_not_affect_cost() { + // fal bills per second of output, not per input image. Build matched + // pairs differing only in input_image_urls length and confirm cost + // matches exactly. + let base = Kling1p6ProElementsToVideoRequest { + prompt: "p".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration: Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + }; + let baseline_cost = base.calculate_cost_in_cents(); + for n in 1..=4usize { + let urls: Vec = (0..n).map(|i| format!("https://example.com/{i}.png")).collect(); + let req = Kling1p6ProElementsToVideoRequest { input_image_urls: urls, ..base.clone() }; + assert_eq!( + req.calculate_cost_in_cents(), + baseline_cost, + "image count {n} should not change cost", + ); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration for elements-to-video. There is no direct legacy + /// equivalent (the legacy `enqueue_kling_v1p6_pro_image_to_video_webhook` + /// is image-to-video at a slightly lower rate), so the parity here is + /// against the canonical $0.098/sec formula. A separate sub-test + /// cross-references the legacy image-to-video function to confirm the + /// expected per-duration premium ($0.003/sec). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ + Kling1p6ProAspectRatio as LegacyAspectRatio, + Kling1p6ProDuration as LegacyDuration, + Kling1p6ProRequest as LegacyRequest, + }; + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling1p6ProElementsToVideoRequest { + Kling1p6ProElementsToVideoRequest { + prompt: "test".to_string(), + input_image_urls: vec!["https://example.com/a.png".to_string()], + negative_prompt: None, + duration, + aspect_ratio, + } + } + + // (duration, aspect_ratio, expected_cents) + // + // Math: ceil(98 × secs / 10) where 5s → 49¢ and 10s → 98¢. + // duration=None defaults to 5s. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 49¢ + (None, None, 49), + (None, Some(Kling1p6ProElementsToVideoAspectRatio::Square), 49), + (None, Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 49), + (None, Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 49), + // duration=5s → 49¢ + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), None, 49), + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::Square), 49), + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 49), + (Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 49), + // duration=10s → 98¢ + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), None, 98), + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::Square), 98), + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), 98), + (Some(Kling1p6ProElementsToVideoDuration::TenSeconds), Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), 98), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every row in the + /// cost table must produce the same value for any aspect-ratio choice. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling1p6ProElementsToVideoAspectRatio::Square), + Some(Kling1p6ProElementsToVideoAspectRatio::SixteenByNine), + Some(Kling1p6ProElementsToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling1p6ProElementsToVideoDuration::FiveSeconds), + Some(Kling1p6ProElementsToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// Cross-check against the legacy image-to-video function. Elements-to-video + /// is `$0.098/sec` and the legacy image-to-video is `$0.095/sec`, so at + /// each duration the cost difference equals `ceil(0.003 × secs)`: + /// 5s: 49¢ − 48¢ = 1¢ + /// 10s: 98¢ − 95¢ = 3¢ + /// If a future change to the legacy image-to-video rate breaks this + /// invariant, this test calls it out. + #[test] + fn premium_over_legacy_image_to_video_is_as_expected() { + fn legacy_image(secs_variant: LegacyDuration) -> u64 { + LegacyRequest { + image_url: "https://example.com/i.png".to_string(), + end_frame_image_url: None, + prompt: "test".to_string(), + duration: secs_variant, + aspect_ratio: LegacyAspectRatio::WideSixteenNine, + }.calculate_cost_in_cents() + } + + // (elements duration, equivalent legacy image duration, expected premium in cents) + let cases = [ + (Kling1p6ProElementsToVideoDuration::FiveSeconds, LegacyDuration::FiveSeconds, 1u64), + (Kling1p6ProElementsToVideoDuration::TenSeconds, LegacyDuration::TenSeconds, 3u64), + ]; + + for (elements_d, legacy_d, expected_premium) in cases { + let elements_cost = new_request(Some(elements_d), None).calculate_cost_in_cents(); + let image_cost = legacy_image(legacy_d); + let premium = elements_cost.saturating_sub(image_cost); + assert_eq!( + premium, expected_premium, + "{elements_d:?}: elements={elements_cost}¢ legacy_image={image_cost}¢ premium={premium}¢ (want {expected_premium}¢)", + ); + } + } + + /// Elements and text-to-video share the same $0.098/sec rate — cells + /// should match exactly at every duration. Documents the rate-equality + /// expectation, so if either rate drifts in the future this test fires. + #[test] + fn matches_text_to_video_cost_at_each_duration() { + use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + Kling1p6ProTextToVideoRequest, + }; + fn t2v(duration: Kling1p6ProTextToVideoDuration) -> u64 { + Kling1p6ProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling1p6ProElementsToVideoDuration::FiveSeconds, Kling1p6ProTextToVideoDuration::FiveSeconds), + (Kling1p6ProElementsToVideoDuration::TenSeconds, Kling1p6ProTextToVideoDuration::TenSeconds), + ]; + for (e_d, t_d) in pairs { + let elements_cost = new_request(Some(e_d), None).calculate_cost_in_cents(); + let text_cost = t2v(t_d); + assert_eq!( + elements_cost, text_cost, + "{e_d:?} vs {t_d:?}: elements={elements_cost}¢ text={text_cost}¢ — rates should match at $0.098/sec", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs new file mode 100644 index 0000000000..4e4d7a2e5d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/kling_1p6_pro_elements_to_video/raw_request.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/elements`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling1p6ProElementsToVideoInput { + pub prompt: String, + + /// List of image URLs that drive the generation. fal documents support + /// for up to 4 images. + pub input_image_urls: Vec, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProElementsToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProElementsToVideoOutput { + pub video: Kling1p6ProElementsToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs new file mode 100644 index 0000000000..1dbfd19473 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/elements/mod.rs @@ -0,0 +1 @@ +pub mod kling_1p6_pro_elements_to_video; diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/api.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/api.rs new file mode 100644 index 0000000000..851eee3680 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/api.rs @@ -0,0 +1,105 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::enhance::flux_3_draft_enhance::raw_request::{ + Flux3DraftEnhanceInput, Flux3DraftEnhanceOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Re-render a Flux 3 Draft generation at full quality (1080p, with +/// synchronized audio) from its `draft_cache` bundle. +#[derive(Clone, Debug)] +pub struct Flux3DraftEnhanceRequest { + /// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation. + pub draft_cache_url: String, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, + + /// Duration of the draft being enhanced, in seconds. NOT sent to fal — the + /// cache bundle already fixes the duration — but used by the cost + /// calculator, which otherwise cannot know the billable length. Estimated + /// at 5 seconds (the shortest draft) when `None`. + pub expected_duration_seconds: Option, +} + +impl FalEndpoint for Flux3DraftEnhanceRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/draft-enhance"; + + type RawRequest = Flux3DraftEnhanceInput; + type RawResponse = Flux3DraftEnhanceOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + draft_cache_url: self.draft_cache_url.clone(), + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — needs a fresh draft_cache URL from a prior draft run, and incurs cost + async fn test_draft_enhance_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + // Paste a `draft_cache` URL from a recent Flux 3 Draft generation here. + let draft_cache_url = read_to_string("/tmp/flux3_draft_cache_url.txt")?; + + let request = Flux3DraftEnhanceRequest { + draft_cache_url: draft_cache_url.trim().to_string(), + safety_tolerance: None, + expected_duration_seconds: Some(5), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3DraftEnhanceRequest { + draft_cache_url: "https://example.com/cache.bin".to_string(), + safety_tolerance: Some(3), + expected_duration_seconds: Some(10), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.draft_cache_url, "https://example.com/cache.bin"); + assert_eq!(raw.safety_tolerance, Some(3)); + } + + /// `expected_duration_seconds` is cost-calculator metadata and must never + /// reach the wire. + #[test] + fn expected_duration_is_not_serialized() { + let request = Flux3DraftEnhanceRequest { + draft_cache_url: "https://example.com/cache.bin".to_string(), + safety_tolerance: None, + expected_duration_seconds: Some(20), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "draft_cache_url": "https://example.com/cache.bin", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3DraftEnhanceRequest::ENDPOINT, "blackforestlabs/flux-3/draft-enhance"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/cost.rs new file mode 100644 index 0000000000..fc34106249 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/cost.rs @@ -0,0 +1,62 @@ +use crate::requests::api::video::enhance::flux_3_draft_enhance::api::Flux3DraftEnhanceRequest; +use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Flux 3 draft-enhance pricing (see +// https://fal.ai/models/blackforestlabs/flux-3/draft-enhance): +// $0.29 per second of the enhanced video (full-quality 1080p render); +// synchronized audio is included at no extra cost. +// +// The request carries only a `draft_cache_url`, so the billable duration is +// taken from the request's `expected_duration_seconds` hint (5s — the +// shortest draft — when unset). +const ENHANCE_RATE_CENTS_PER_SEC: u64 = 29; // $0.29/sec + +impl FalRequestCostCalculator for Flux3DraftEnhanceRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = u64::from(self.expected_duration_seconds.unwrap_or(5)); + flux_3_video_cost_cents(ENHANCE_RATE_CENTS_PER_SEC, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(expected_duration_seconds: Option) -> Flux3DraftEnhanceRequest { + Flux3DraftEnhanceRequest { + draft_cache_url: "https://example.com/cache.bin".to_string(), + safety_tolerance: None, + expected_duration_seconds, + } + } + + mod cost_table { + use super::*; + + // (expected_duration, expected_cents) — 29¢ × secs; None estimates at 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(5), 145), + (Some(10), 290), + (Some(20), 580), + (None, 145), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + let got = make_request(duration).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?}"); + } + } + + /// Enhance matches the full-quality 1080p generation rate — drafting then + /// enhancing ($0.06 + $0.29) intentionally costs more than generating at + /// full quality directly ($0.29 at 1080p). + #[test] + fn enhance_rate_matches_full_quality_1080p_rate() { + use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec; + assert_eq!(ENHANCE_RATE_CENTS_PER_SEC, flux_3_standard_rate_cents_per_sec(true)); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/raw_request.rs new file mode 100644 index 0000000000..cc913dd493 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/flux_3_draft_enhance/raw_request.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/draft-enhance`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3DraftEnhanceInput { + /// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation. + pub draft_cache_url: String, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftEnhanceVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftEnhanceOutput { + pub video: Flux3DraftEnhanceVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/enhance/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/enhance/mod.rs new file mode 100644 index 0000000000..52ac0070b1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/enhance/mod.rs @@ -0,0 +1 @@ +pub mod flux_3_draft_enhance; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/api.rs new file mode 100644 index 0000000000..4c84f3ff0e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/api.rs @@ -0,0 +1,133 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::extend::flux_3::raw_request::{ + Flux3ExtendVideoInput, Flux3ExtendVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::{ + Flux3AspectRatio, Flux3Duration, Flux3Resolution, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux3ExtendVideoRequest { + /// Text prompt describing how the video should continue. + pub prompt: String, + + /// URL of the video to extend. + pub video_url: String, + + /// Duration of the extension in seconds. fal's default is `auto` when + /// `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3ExtendVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video"; + + type RawRequest = Flux3ExtendVideoInput; + type RawResponse = Flux3ExtendVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + video_url: self.video_url.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_extend_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3ExtendVideoRequest { + prompt: "the camera keeps drifting forward as the scene continues".to_string(), + video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3ExtendVideoRequest { + prompt: "p".to_string(), + video_url: "https://example.com/source.mp4".to_string(), + duration: Some(Flux3Duration::Seconds(10)), + resolution: Some(Flux3Resolution::TenEightyP), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(true), + safety_tolerance: Some(2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.video_url, "https://example.com/source.mp4"); + assert_eq!(raw.duration, Some(Flux3Duration::Seconds(10))); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3ExtendVideoRequest { + prompt: "minimal".to_string(), + video_url: "https://example.com/source.mp4".to_string(), + duration: None, + resolution: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "video_url": "https://example.com/source.mp4", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3ExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/cost.rs new file mode 100644 index 0000000000..3d188bfba7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/cost.rs @@ -0,0 +1,80 @@ +use crate::requests::api::video::extend::flux_3::api::Flux3ExtendVideoRequest; +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Flux 3 extend-video pricing (see https://fal.ai/models/blackforestlabs/flux-3/extend-video): +// 720p: $0.41 per second of generated video +// 1080p: $0.53 per second of generated video +// +// Extend bills higher than the other Flux 3 modalities (which are $0.17 and +// $0.29; see the text module). The source video is free; pricing depends on +// resolution and the generated extension's duration only. +const EXTEND_RATE_720P_CENTS_PER_SEC: u64 = 41; // $0.41/sec +const EXTEND_RATE_1080P_CENTS_PER_SEC: u64 = 53; // $0.53/sec + +impl FalRequestCostCalculator for Flux3ExtendVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = flux_3_estimated_duration_secs(self.duration); + let rate = if flux_3_is_1080p(self.resolution) { + EXTEND_RATE_1080P_CENTS_PER_SEC + } else { + EXTEND_RATE_720P_CENTS_PER_SEC + }; + flux_3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution}; + + fn make_request( + duration: Option, + resolution: Option, + ) -> Flux3ExtendVideoRequest { + Flux3ExtendVideoRequest { + prompt: "test".to_string(), + video_url: "https://example.com/source.mp4".to_string(), + duration, + resolution, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + // Math: rate × secs where rate (cents) = 41 (720p) or 53 (1080p); + // None/auto durations are estimated at the 5s floor. + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 205), + (Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 820), + (Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 265), + (Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 1060), + // Defaults: duration=None/auto→5s estimate, resolution=None→720p + (None, None, 205), + (Some(Flux3Duration::Auto), None, 205), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + #[test] + fn extend_is_more_expensive_than_standard_generation() { + use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec; + assert!(EXTEND_RATE_720P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(false)); + assert!(EXTEND_RATE_1080P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(true)); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/raw_request.rs new file mode 100644 index 0000000000..90808ff8fe --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3/raw_request.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::text::flux_3::api::Flux3Duration; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3ExtendVideoInput { + /// Text prompt describing how the video should continue. + pub prompt: String, + + /// URL of the video to extend. + pub video_url: String, + + /// Duration of the extension in seconds ("auto" or 5–20). fal default: + /// "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ExtendVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ExtendVideoOutput { + pub video: Flux3ExtendVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/api.rs new file mode 100644 index 0000000000..55ae9789dd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/api.rs @@ -0,0 +1,128 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::extend::flux_3_draft::raw_request::{ + Flux3DraftExtendVideoInput, Flux3DraftExtendVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Flux 3 Draft extend-video: lower cost, lower fidelity (always 720p). The +/// response includes a `draft_cache` that `draft-enhance` can upgrade to a +/// full-quality 1080p render. +#[derive(Clone, Debug)] +pub struct Flux3DraftExtendVideoRequest { + /// Text prompt describing how the video should continue. + pub prompt: String, + + /// URL of the video to extend. + pub video_url: String, + + /// Duration of the extension in seconds. fal's default is `auto` when + /// `None`. + pub duration: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3DraftExtendVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video/draft"; + + type RawRequest = Flux3DraftExtendVideoInput; + type RawResponse = Flux3DraftExtendVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + video_url: self.video_url.clone(), + duration: self.duration, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_extend_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftExtendVideoRequest { + prompt: "the camera keeps drifting forward as the scene continues".to_string(), + video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3DraftExtendVideoRequest { + prompt: "p".to_string(), + video_url: "https://example.com/source.mp4".to_string(), + duration: Some(Flux3Duration::Auto), + aspect_ratio: Some(Flux3AspectRatio::Auto), + generate_audio: Some(true), + safety_tolerance: Some(4), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.video_url, "https://example.com/source.mp4"); + assert_eq!(raw.duration, Some(Flux3Duration::Auto)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.safety_tolerance, Some(4)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3DraftExtendVideoRequest { + prompt: "minimal".to_string(), + video_url: "https://example.com/source.mp4".to_string(), + duration: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "video_url": "https://example.com/source.mp4", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3DraftExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video/draft"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/cost.rs new file mode 100644 index 0000000000..8918172ffb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/cost.rs @@ -0,0 +1,64 @@ +use crate::requests::api::video::extend::flux_3_draft::api::Flux3DraftExtendVideoRequest; +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_estimated_duration_secs, flux_3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Flux 3 Draft extend-video pricing (see +// https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft): +// $0.12 per second of generated draft video (always 720p) +// +// Draft extend bills higher than the other draft modalities ($0.06/sec; see +// the draft text module). The source video is free; pricing depends on the +// generated extension's duration only. +const DRAFT_EXTEND_RATE_CENTS_PER_SEC: u64 = 12; // $0.12/sec + +impl FalRequestCostCalculator for Flux3DraftExtendVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = flux_3_estimated_duration_secs(self.duration); + flux_3_video_cost_cents(DRAFT_EXTEND_RATE_CENTS_PER_SEC, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::Flux3Duration; + + fn make_request(duration: Option) -> Flux3DraftExtendVideoRequest { + Flux3DraftExtendVideoRequest { + prompt: "test".to_string(), + video_url: "https://example.com/source.mp4".to_string(), + duration, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, expected_cents) — 12¢ × secs; None/auto estimate at 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(Flux3Duration::Seconds(5)), 60), + (Some(Flux3Duration::Seconds(20)), 240), + (None, 60), + (Some(Flux3Duration::Auto), 60), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + let got = make_request(duration).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?}"); + } + } + + #[test] + fn draft_extend_is_double_the_draft_generation_rate() { + use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec; + assert_eq!(DRAFT_EXTEND_RATE_CENTS_PER_SEC, flux_3_draft_generation_rate_cents_per_sec() * 2); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..ebe4a871ed --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/flux_3_draft/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::text::flux_3::api::Flux3Duration; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video/draft`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3DraftExtendVideoInput { + /// Text prompt describing how the video should continue. + pub prompt: String, + + /// URL of the video to extend. + pub video_url: String, + + /// Duration of the extension in seconds ("auto" or 5–20). fal default: + /// "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftExtendVideoVideoFile { + pub url: String, +} + +/// Draft outputs carry the 720p draft video plus a durable encrypted cache +/// bundle whose URL can be passed to `draft-enhance` for a full-quality +/// 1080p render. +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftExtendVideoOutput { + pub video: Flux3DraftExtendVideoVideoFile, + pub draft_cache: Flux3DraftExtendVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs new file mode 100644 index 0000000000..2ca4a6ab67 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/mod.rs @@ -0,0 +1,4 @@ +pub mod flux_3; +pub mod flux_3_draft; +pub mod veo_3p1; +pub mod veo_3p1_fast; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/api.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/api.rs new file mode 100644 index 0000000000..c3f7ff63ed --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/api.rs @@ -0,0 +1,288 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::extend::veo_3p1::raw_request::{ + Veo3p1ExtendVideoInput, Veo3p1ExtendVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1ExtendVideoRequest { + /// Text prompt describing how the video should be extended. + pub prompt: String, + + /// URL of the video to extend (720p/1080p, 16:9 or 9:16). + pub video_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Extension duration. fal's default is `7s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ExtendVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1ExtendVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +/// Extension duration. fal documents only the `7s` default for extend-video +/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 family +/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects +/// a value, trim this enum. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ExtendVideoDuration { + FourSeconds, + SixSeconds, + /// fal's documented default for extend-video. + SevenSeconds, + EightSeconds, +} + +impl Veo3p1ExtendVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::SevenSeconds => "7s", + Self::EightSeconds => "8s", + } + } +} + +/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ExtendVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3p1ExtendVideoResolution { + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ExtendVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1ExtendVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1ExtendVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/extend-video"; + + type RawRequest = Veo3p1ExtendVideoInput; + type RawResponse = Veo3p1ExtendVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + video_url: self.video_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_extend_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1ExtendVideoRequest { + prompt: "the scene continues naturally, keeping the same motion and style".to_string(), + video_url: ANGRY_SHIBA_VIDEO_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds), + resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_extend_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1ExtendVideoRequest { + prompt: "the camera pulls back to reveal more of the landscape".to_string(), + video_url: ANGRY_SHIBA_VIDEO_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds), + resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1ExtendVideoRequest { + prompt: "p".to_string(), + video_url: "https://example.com/in.mp4".to_string(), + aspect_ratio: Some(Veo3p1ExtendVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds), + resolution: Some(Veo3p1ExtendVideoResolution::TenEightyP), + negative_prompt: Some("nope".to_string()), + generate_audio: Some(true), + seed: Some(3), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1ExtendVideoSafetyTolerance::Level4), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.video_url, "https://example.com/in.mp4"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("7s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(3)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("4")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1ExtendVideoRequest { + prompt: "p".to_string(), + video_url: "https://example.com/in.mp4".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }), + ); + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo3p1ExtendVideoDuration::FourSeconds, "4s", 4), + (Veo3p1ExtendVideoDuration::SixSeconds, "6s", 6), + (Veo3p1ExtendVideoDuration::SevenSeconds, "7s", 7), + (Veo3p1ExtendVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1ExtendVideoRequest::ENDPOINT, + "fal-ai/veo3.1/extend-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/cost.rs new file mode 100644 index 0000000000..b6980ebccd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/cost.rs @@ -0,0 +1,109 @@ +use crate::requests::api::video::extend::veo_3p1::api::{ + Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest, +}; +use crate::requests::api::video::text::veo_3p1::cost::{ + veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/extend-video): +// $0.20/sec (audio off), $0.40/sec (audio on). +// +// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the +// family's SD rate — `four_k` is fixed to `false`. + +impl FalRequestCostCalculator for Veo3p1ExtendVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 7s, audio = on. Resolution does not + // affect price here (720p and 1080p bill identically). + let duration_secs = self.duration + .unwrap_or(Veo3p1ExtendVideoDuration::SevenSeconds) + .to_seconds(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_rate_tenth_cents_per_sec(false, audio_on); + veo_3p1_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoResolution; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Veo3p1ExtendVideoRequest { + Veo3p1ExtendVideoRequest { + prompt: "test".to_string(), + video_url: "https://example.com/in.mp4".to_string(), + aspect_ratio: None, + duration, + resolution: None, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(false), 80), + (Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(true), 160), + (Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(false), 140), + (Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(true), 280), + (Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(false), 160), + (Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(true), 320), + // Defaults: 7s, audio on + (None, None, 280), + (None, Some(false), 140), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, generate_audio).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}"); + } + } + + /// fal: "a 5s video with audio on will cost $2" → 5s × $0.40 = 200¢. + #[test] + fn matches_documented_5s_audio_on_example() { + let rate = veo_3p1_rate_tenth_cents_per_sec(false, true); + assert_eq!(veo_3p1_cost_cents(rate, 5), 200); + } + + #[test] + fn default_duration_is_seven_seconds() { + // duration=None, audio=on → 7s @ $0.40 = 280¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 280); + } + + /// Resolution (720p vs 1080p) must not change the bill for extend. + #[test] + fn cost_is_independent_of_resolution() { + for audio in [Some(false), Some(true), None] { + let base = make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio) + .calculate_cost_in_cents(); + for res in [Veo3p1ExtendVideoResolution::SevenTwentyP, Veo3p1ExtendVideoResolution::TenEightyP] { + let cost = Veo3p1ExtendVideoRequest { + resolution: Some(res), + ..make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio) + }.calculate_cost_in_cents(); + assert_eq!(cost, base, "audio={audio:?} res={res:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/raw_request.rs new file mode 100644 index 0000000000..b7b7afc428 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1/raw_request.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/extend-video`. +/// Extends an existing video by generating a continuation. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1ExtendVideoInput { + pub prompt: String, + + /// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16. + pub video_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). fal default: "7s". fal documents no + /// explicit enum for this endpoint (only the default), so it is sent as a + /// free-form string. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. fal default: "720p" (the docs describe 720p/1080p + /// source requirements and list no 4k tier for extend). + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ExtendVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ExtendVideoOutput { + pub video: Veo3p1ExtendVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/api.rs new file mode 100644 index 0000000000..2331a19599 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/api.rs @@ -0,0 +1,288 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::extend::veo_3p1_fast::raw_request::{ + Veo3p1FastExtendVideoInput, Veo3p1FastExtendVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1FastExtendVideoRequest { + /// Text prompt describing how the video should be extended. + pub prompt: String, + + /// URL of the video to extend (720p/1080p, 16:9 or 9:16). + pub video_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Extension duration. fal's default is `7s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastExtendVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1FastExtendVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +/// Extension duration. fal documents only the `7s` default for extend-video +/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 Fast family +/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects +/// a value, trim this enum. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastExtendVideoDuration { + FourSeconds, + SixSeconds, + /// fal's documented default for extend-video. + SevenSeconds, + EightSeconds, +} + +impl Veo3p1FastExtendVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::SevenSeconds => "7s", + Self::EightSeconds => "8s", + } + } +} + +/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastExtendVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3p1FastExtendVideoResolution { + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastExtendVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1FastExtendVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1FastExtendVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/fast/extend-video"; + + type RawRequest = Veo3p1FastExtendVideoInput; + type RawResponse = Veo3p1FastExtendVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + video_url: self.video_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_extend_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastExtendVideoRequest { + prompt: "the scene continues naturally, keeping the same motion and style".to_string(), + video_url: ANGRY_SHIBA_VIDEO_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds), + resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_extend_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastExtendVideoRequest { + prompt: "the camera pulls back to reveal more of the landscape".to_string(), + video_url: ANGRY_SHIBA_VIDEO_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds), + resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1FastExtendVideoRequest { + prompt: "p".to_string(), + video_url: "https://example.com/in.mp4".to_string(), + aspect_ratio: Some(Veo3p1FastExtendVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds), + resolution: Some(Veo3p1FastExtendVideoResolution::TenEightyP), + negative_prompt: Some("nope".to_string()), + generate_audio: Some(true), + seed: Some(3), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1FastExtendVideoSafetyTolerance::Level4), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.video_url, "https://example.com/in.mp4"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("7s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(3)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("4")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1FastExtendVideoRequest { + prompt: "p".to_string(), + video_url: "https://example.com/in.mp4".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }), + ); + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo3p1FastExtendVideoDuration::FourSeconds, "4s", 4), + (Veo3p1FastExtendVideoDuration::SixSeconds, "6s", 6), + (Veo3p1FastExtendVideoDuration::SevenSeconds, "7s", 7), + (Veo3p1FastExtendVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1FastExtendVideoRequest::ENDPOINT, + "fal-ai/veo3.1/fast/extend-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..6f76b84727 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/cost.rs @@ -0,0 +1,102 @@ +use crate::requests::api::video::extend::veo_3p1_fast::api::{ + Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest, +}; +use crate::requests::api::video::text::veo_3p1_fast::cost::{ + veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/fast/extend-video): +// $0.10/sec (audio off), $0.15/sec (audio on). +// +// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the +// family's SD rate — `four_k` is fixed to `false`. + +impl FalRequestCostCalculator for Veo3p1FastExtendVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 7s, audio = on. Resolution does not + // affect price here (720p and 1080p bill identically). + let duration_secs = self.duration + .unwrap_or(Veo3p1FastExtendVideoDuration::SevenSeconds) + .to_seconds(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_fast_rate_tenth_cents_per_sec(false, audio_on); + veo_3p1_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoResolution; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Veo3p1FastExtendVideoRequest { + Veo3p1FastExtendVideoRequest { + prompt: "test".to_string(), + video_url: "https://example.com/in.mp4".to_string(), + aspect_ratio: None, + duration, + resolution: None, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(false), 40), + (Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(true), 60), + (Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(false), 70), + (Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(true), 105), + (Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(false), 80), + (Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(true), 120), + // Defaults: 7s, audio on + (None, None, 105), + (None, Some(false), 70), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, generate_audio).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}"); + } + } + + #[test] + fn default_duration_is_seven_seconds() { + // duration=None, audio=on → 7s @ $0.15 = 105¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 105); + } + + /// Resolution (720p vs 1080p) must not change the bill for extend. + #[test] + fn cost_is_independent_of_resolution() { + for audio in [Some(false), Some(true), None] { + let base = make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio) + .calculate_cost_in_cents(); + for res in [Veo3p1FastExtendVideoResolution::SevenTwentyP, Veo3p1FastExtendVideoResolution::TenEightyP] { + let cost = Veo3p1FastExtendVideoRequest { + resolution: Some(res), + ..make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio) + }.calculate_cost_in_cents(); + assert_eq!(cost, base, "audio={audio:?} res={res:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/raw_request.rs new file mode 100644 index 0000000000..b6da8ff7bf --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/extend/veo_3p1_fast/raw_request.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/fast/extend-video`. +/// Extends an existing video by generating a continuation. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastExtendVideoInput { + pub prompt: String, + + /// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16. + pub video_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). fal default: "7s". fal documents no + /// explicit enum for this endpoint (only the default), so it is sent as a + /// free-form string. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. fal default: "720p" (the docs describe 720p/1080p + /// source requirements and list no 4k tier for extend). + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastExtendVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastExtendVideoOutput { + pub video: Veo3p1FastExtendVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/api.rs new file mode 100644 index 0000000000..3bd9d8e5d0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/api.rs @@ -0,0 +1,159 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::flux_3::raw_request::{ + Flux3ImageToVideoInput, Flux3ImageToVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::{ + Flux3AspectRatio, Flux3Duration, Flux3Resolution, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux3ImageToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the image to animate. + pub image_url: String, + + /// Duration in seconds. fal's default is `auto` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3ImageToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video"; + + type RawRequest = Flux3ImageToVideoInput; + type RawResponse = Flux3ImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const EXAMPLE_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3ImageToVideoRequest { + prompt: "the camera slowly pulls back, revealing the full landscape".to_string(), + image_url: EXAMPLE_IMAGE_URL.to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3ImageToVideoRequest { + prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(), + image_url: EXAMPLE_IMAGE_URL.to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/frame.png".to_string(), + duration: Some(Flux3Duration::Auto), + resolution: Some(Flux3Resolution::TenEightyP), + aspect_ratio: Some(Flux3AspectRatio::Auto), + generate_audio: Some(true), + safety_tolerance: Some(0), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/frame.png"); + assert_eq!(raw.duration, Some(Flux3Duration::Auto)); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.safety_tolerance, Some(0)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3ImageToVideoRequest { + prompt: "minimal".to_string(), + image_url: "https://example.com/frame.png".to_string(), + duration: None, + resolution: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "image_url": "https://example.com/frame.png", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3ImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/cost.rs new file mode 100644 index 0000000000..41d97632ee --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/cost.rs @@ -0,0 +1,64 @@ +use crate::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest; +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, + flux_3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the standard Flux 3 per-second pricing (see the text +// module for the canonical rate table): +// 720p: $0.17/sec +// 1080p: $0.29/sec +// The input image is free; pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for Flux3ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = flux_3_estimated_duration_secs(self.duration); + let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution)); + flux_3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution}; + + fn make_request( + duration: Option, + resolution: Option, + ) -> Flux3ImageToVideoRequest { + Flux3ImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/frame.png".to_string(), + duration, + resolution, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85), + (Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340), + (Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145), + (Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580), + // Defaults: duration=None/auto→5s estimate, resolution=None→720p + (None, None, 85), + (Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/raw_request.rs new file mode 100644 index 0000000000..226e6b723c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::text::flux_3::api::Flux3Duration; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3ImageToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the image to animate. + pub image_url: String, + + /// Duration in seconds ("auto" or 5–20). fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3ImageToVideoOutput { + pub video: Flux3ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/api.rs new file mode 100644 index 0000000000..ae7f686cc0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/api.rs @@ -0,0 +1,151 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::flux_3_draft::raw_request::{ + Flux3DraftImageToVideoInput, Flux3DraftImageToVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Flux 3 Draft image-to-video: lower cost, lower fidelity (always 720p). The +/// response includes a `draft_cache` that `draft-enhance` can upgrade to a +/// full-quality 1080p render. +#[derive(Clone, Debug)] +pub struct Flux3DraftImageToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the image to animate. + pub image_url: String, + + /// Duration in seconds. fal's default is `auto` when `None`. + pub duration: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3DraftImageToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video/draft"; + + type RawRequest = Flux3DraftImageToVideoInput; + type RawResponse = Flux3DraftImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + duration: self.duration, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const EXAMPLE_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftImageToVideoRequest { + prompt: "the camera slowly pulls back, revealing the full landscape".to_string(), + image_url: EXAMPLE_IMAGE_URL.to_string(), + duration: Some(Flux3Duration::Seconds(5)), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftImageToVideoRequest { + prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(), + image_url: EXAMPLE_IMAGE_URL.to_string(), + duration: Some(Flux3Duration::Seconds(5)), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3DraftImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/frame.png".to_string(), + duration: Some(Flux3Duration::Seconds(8)), + aspect_ratio: Some(Flux3AspectRatio::NineBySixteen), + generate_audio: Some(false), + safety_tolerance: Some(1), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/frame.png"); + assert_eq!(raw.duration, Some(Flux3Duration::Seconds(8))); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.safety_tolerance, Some(1)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3DraftImageToVideoRequest { + prompt: "minimal".to_string(), + image_url: "https://example.com/frame.png".to_string(), + duration: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "image_url": "https://example.com/frame.png", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3DraftImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video/draft"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/cost.rs new file mode 100644 index 0000000000..68ec1c9464 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/cost.rs @@ -0,0 +1,54 @@ +use crate::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest; +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_estimated_duration_secs, flux_3_video_cost_cents, +}; +use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Draft image-to-video shares the Flux 3 Draft generation pricing (see the +// draft text module for the canonical rate): +// $0.06 per second of generated draft video (always 720p) + +impl FalRequestCostCalculator for Flux3DraftImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = flux_3_estimated_duration_secs(self.duration); + flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::Flux3Duration; + + fn make_request(duration: Option) -> Flux3DraftImageToVideoRequest { + Flux3DraftImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/frame.png".to_string(), + duration, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, expected_cents) — 6¢ × secs; None/auto estimate at 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(Flux3Duration::Seconds(5)), 30), + (Some(Flux3Duration::Seconds(20)), 120), + (None, 30), + (Some(Flux3Duration::Auto), 30), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + let got = make_request(duration).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..7610f3f789 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/flux_3_draft/raw_request.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::text::flux_3::api::Flux3Duration; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video/draft`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3DraftImageToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the image to animate. + pub image_url: String, + + /// Duration in seconds ("auto" or 5–20). fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftImageToVideoVideoFile { + pub url: String, +} + +/// Draft outputs carry the 720p draft video plus a durable encrypted cache +/// bundle whose URL can be passed to `draft-enhance` for a full-quality +/// 1080p render. +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftImageToVideoOutput { + pub video: Flux3DraftImageToVideoVideoFile, + pub draft_cache: Flux3DraftImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs new file mode 100644 index 0000000000..ba17fa6baa --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/api.rs @@ -0,0 +1,236 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_1p6_pro_image_to_video::raw_request::{ + Kling1p6ProImageToVideoInput, Kling1p6ProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling1p6ProImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end-frame image URL. + pub end_image_url: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 1.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling1p6ProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProImageToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling1p6ProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/image-to-video"; + + type RawRequest = Kling1p6ProImageToVideoInput; + type RawResponse = Kling1p6ProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling1p6ProImageToVideoAspectRatio::Square => "1:1", + Kling1p6ProImageToVideoAspectRatio::SixteenByNine => "16:9", + Kling1p6ProImageToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + // fal's schema names this `tail_image_url`; our public API uses + // `end_image_url` for consistency with the other Kling modules. + tail_image_url: self.end_image_url.clone(), + aspect_ratio, + duration, + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba in glasses runs to the lake and stands by the shore".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling1p6ProImageToVideoAspectRatio::Square, + Kling1p6ProImageToVideoAspectRatio::SixteenByNine, + Kling1p6ProImageToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling1p6ProImageToVideoDuration::FiveSeconds, + Kling1p6ProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling1p6ProImageToVideoRequest { + prompt: "shiba bounds across the meadow chasing a falling leaf".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling1p6ProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/i.png".to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("5")); + } + + #[test] + fn raw_request_translates_end_image_url_to_tail_image_url() { + let request = Kling1p6ProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/i.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.tail_image_url.as_deref(), Some("https://example.com/end.png")); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..1af3f5eefc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/cost.rs @@ -0,0 +1,236 @@ +use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoDuration, Kling1p6ProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling1p6ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 1.6 Pro image-to-video: $0.095/second. + // + // Rate is 95 tenths-of-cents per second; round up to whole cents at the + // end so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling1p6ProImageToVideoDuration::FiveSeconds) + .to_seconds(); + (95u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling1p6ProImageToVideoRequest { + Kling1p6ProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + end_image_url: None, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.095/sec = 95 tenths-of-cents/sec. + // Cents per duration = ceil(95 × secs / 10). + + #[test] + fn five_seconds() { + // (95 * 5 + 9) / 10 = 48 (round up from 47.5¢) + assert_eq!( + make_request(Some(Kling1p6ProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 48, + ); + } + + #[test] + fn ten_seconds() { + // (95 * 10 + 9) / 10 = 95 + assert_eq!( + make_request(Some(Kling1p6ProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 95, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 48¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 48); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling1p6ProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling1p6ProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration, asserted both against the legacy + /// `enqueue_kling_v1p6_pro_image_to_video_webhook` cost function and + /// against the canonical $0.095/sec rate (`(95 * secs + 9) / 10`). + /// + /// Aspect ratio is included in the cross product as a sanity check — + /// neither the new nor legacy implementation reads it for billing, so + /// every cell in a given row must agree. + mod legacy_parity { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ + Kling1p6ProAspectRatio as LegacyAspectRatio, + Kling1p6ProDuration as LegacyDuration, + Kling1p6ProRequest as LegacyRequest, + }; + + // ── Mapping helpers from the new strongly-typed API to the legacy one ── + + fn legacy_duration_for(d: Option) -> LegacyDuration { + match d { + // The legacy `Default` variant means "5 seconds" semantically — same + // as the new module's `None` default. + None => LegacyDuration::Default, + Some(Kling1p6ProImageToVideoDuration::FiveSeconds) => LegacyDuration::FiveSeconds, + Some(Kling1p6ProImageToVideoDuration::TenSeconds) => LegacyDuration::TenSeconds, + } + } + + fn legacy_aspect_ratio_for(ar: Option) -> LegacyAspectRatio { + // The legacy API has no "None" — pick 16:9 as the default when the + // new request leaves it unset, mirroring fal's documented server + // default. + match ar { + None | Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine) => LegacyAspectRatio::WideSixteenNine, + Some(Kling1p6ProImageToVideoAspectRatio::Square) => LegacyAspectRatio::Square, + Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen) => LegacyAspectRatio::TallNineSixteen, + } + } + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling1p6ProImageToVideoRequest { + Kling1p6ProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + end_image_url: None, + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + fn legacy_request( + duration: Option, + aspect_ratio: Option, + ) -> LegacyRequest { + LegacyRequest { + image_url: "https://example.com/image.jpg".to_string(), + end_frame_image_url: None, + prompt: "test".to_string(), + duration: legacy_duration_for(duration), + aspect_ratio: legacy_aspect_ratio_for(aspect_ratio), + } + } + + // (new_duration, new_aspect_ratio, expected_cents) + // + // Math: ceil(95 × secs / 10) where 5s → 48¢ and 10s → 95¢. + // duration=None defaults to 5s in both the new and legacy modules. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 48¢ + (None, None, 48), + (None, Some(Kling1p6ProImageToVideoAspectRatio::Square), 48), + (None, Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 48), + (None, Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 48), + // duration=5s → 48¢ + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), None, 48), + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::Square), 48), + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 48), + (Some(Kling1p6ProImageToVideoDuration::FiveSeconds), Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 48), + // duration=10s → 95¢ + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), None, 95), + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::Square), 95), + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), 95), + (Some(Kling1p6ProImageToVideoDuration::TenSeconds), Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), 95), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + #[test] + fn legacy_function_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "legacy function: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// The headline parity check: at every permutation of the legacy's + /// cost-relevant configuration, the new and legacy + /// `calculate_cost_in_cents` return the same value. If this fails, the + /// new module's pricing has drifted from what shipped previously. + #[test] + fn new_module_matches_legacy_at_every_combo() { + for &(duration, aspect_ratio, _) in COST_TABLE { + let new_cost = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + let legacy_cost = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + new_cost, legacy_cost, + "parity mismatch at duration={duration:?} aspect_ratio={aspect_ratio:?}: \ + new={new_cost}¢ vs legacy={legacy_cost}¢", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every row in the + /// cost table must produce the same value for any aspect-ratio choice. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling1p6ProImageToVideoAspectRatio::Square), + Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + Some(Kling1p6ProImageToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling1p6ProImageToVideoDuration::FiveSeconds), + Some(Kling1p6ProImageToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..9b415b8de0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_1p6_pro_image_to_video/raw_request.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/image-to-video`. +/// xAI's schema: +/// +/// NB: `tail_image_url` is xAI's name for the end-frame keyframe. Our public +/// API surface (`Kling1p6ProImageToVideoRequest::end_image_url`) uses the +/// `end_image_url` name to match the convention in other Kling modules; we +/// translate at the [`crate::requests::traits::fal_endpoint_trait::FalEndpoint::to_raw_request`] +/// boundary. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling1p6ProImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end-frame image URL (the fal schema calls this `tail_image_url`). + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProImageToVideoOutput { + pub video: Kling1p6ProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs new file mode 100644 index 0000000000..2b2c39c1a8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/api.rs @@ -0,0 +1,324 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::raw_request::{ + Kling2p5TurboProImageToVideoInput, Kling2p5TurboProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p5TurboProImageToVideoRequest { + /// Text prompt describing the desired video content. + pub prompt: String, + + /// URL of the image used as the first frame. + pub image_url: String, + + /// Optional URL of the image used as the last frame. + pub tail_image_url: Option, + + /// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p5TurboProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +impl FalEndpoint for Kling2p5TurboProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.5-turbo/pro/image-to-video"; + + type RawRequest = Kling2p5TurboProImageToVideoInput; + type RawResponse = Kling2p5TurboProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + duration, + tail_image_url: self.tail_image_url.clone(), + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p5TurboProImageToVideoDuration::FiveSeconds, + Kling2p5TurboProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "the dog wags its tail".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + tail_image_url: None, + duration: Some(dur), + negative_prompt: None, + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_uses_canonical_field_names() { + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + negative_prompt: Some("nope".to_string()), + cfg_scale: Some(0.5), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.tail_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration.as_deref(), Some("10")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.cfg_scale, Some(0.5)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: None, + duration: None, + negative_prompt: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert!(raw.tail_image_url.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p5TurboProImageToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.5-turbo/pro/image-to-video", + ); + } + + /// Wire-shape and endpoint parity vs the legacy + /// `requests_old/http/video/image/http_kling_v2p5_turbo_pro_image_to_video.rs`. + /// + /// No legacy *pro* webhook exists for image-to-video (only the legacy + /// `enqueue_kling_v2p5_turbo_standard_image_to_video_webhook` ships, and + /// that targets the `standard` model with a different pricing tier — cost + /// parity for the pro model is asserted in `cost.rs` against the + /// documented pricing instead. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::image::http_kling_v2p5_turbo_pro_image_to_video::{ + kling_v2p5_turbo_pro_image_to_video, KlingV2p5TurboProImageToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.5-turbo/pro/image-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p5_turbo_pro_image_to_video(KlingV2p5TurboProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }); + assert_eq!( + Kling2p5TurboProImageToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative fully-populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON. + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/start.png".to_string(), + tail_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + negative_prompt: Some("blurry".to_string()), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/start.png".to_string(), + duration: Some("10".to_string()), + tail_image_url: Some("https://example.com/end.png".to_string()), + negative_prompt: Some("blurry".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// Minimal case (prompt + image_url only): unset optionals must be + /// omitted on the wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "minimal".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration: None, + negative_prompt: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "minimal".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + assert_eq!( + new_json, + serde_json::json!({ + "prompt": "minimal", + "image_url": "https://example.com/x.png", + }), + ); + } + + /// Cross product over duration × tail_image_url presence: every cell's + /// wire JSON must match the legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), Some("10")), + ]; + let tail_urls: [Option<&str>; 2] = [None, Some("https://example.com/tail.png")]; + + for (d_new, d_legacy) in durations { + for tail in tail_urls { + let new = Kling2p5TurboProImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: tail.map(String::from), + duration: d_new, + negative_prompt: None, + cfg_scale: None, + }; + let legacy = KlingV2p5TurboProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + duration: d_legacy.map(String::from), + tail_image_url: tail.map(String::from), + negative_prompt: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} tail_image_url={tail:?}", + ); + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..86d7554fa9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/cost.rs @@ -0,0 +1,215 @@ +use crate::requests::api::video::image::kling_2p5_turbo_pro_image_to_video::api::{ + Kling2p5TurboProImageToVideoDuration, Kling2p5TurboProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p5TurboProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.5 Turbo Pro image-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video): + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + // + // That works out to a flat $0.07/second rate, identical to the + // text-to-video pro variant. Rate is held in tenths-of-cents and + // rounded up to whole cents so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p5TurboProImageToVideoDuration::FiveSeconds) + .to_seconds(); + (70u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + ) -> Kling2p5TurboProImageToVideoRequest { + Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration, + negative_prompt: None, + cfg_scale: None, + } + } + + // Pricing: flat $0.07/sec = 70 tenths-of-cents/sec. + // Cents per duration = ceil(70 × secs / 10). + + #[test] + fn five_seconds() { + // (70 * 5 + 9) / 10 = 35 — matches fal's "$0.35 for 5s" base rate + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn ten_seconds() { + // (70 * 10 + 9) / 10 = 70 — matches "$0.35 + 5×$0.07 = $0.70" + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 35¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests. No legacy *pro* webhook exists for + /// image-to-video — only the legacy `standard` webhook ships — so this is + /// parity against the *documented* fal pricing and against the + /// text-to-video pro rate, which fal sets at the same $0.07/sec. + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + fn new_request( + duration: Option, + tail_image_url: Option<&str>, + ) -> Kling2p5TurboProImageToVideoRequest { + Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: tail_image_url.map(String::from), + duration, + negative_prompt: None, + cfg_scale: None, + } + } + + // (duration, tail_image_url_present, expected_cents) + // + // Math: ceil(70 × secs / 10). 5s → 35¢; 10s → 70¢. + // duration=None defaults to 5s; tail_image_url is irrelevant to cost. + const COST_TABLE: &[( + Option, + bool, + u64, + )] = &[ + // duration=None → 5s default → 35¢ + (None, false, 35), + (None, true, 35), + // duration=5s → 35¢ + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), false, 35), + (Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), true, 35), + // duration=10s → 70¢ + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), false, 70), + (Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), true, 70), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, tail_present, expected) in COST_TABLE { + let tail = if tail_present { Some("https://example.com/tail.png") } else { None }; + let got = new_request(duration, tail).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} tail_present={tail_present}", + ); + } + } + + /// tail_image_url is not part of the billing formula — every choice + /// must produce the same value for fixed duration. + #[test] + fn cost_is_independent_of_tail_image_url() { + let durations = [ + None, + Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + Some(Kling2p5TurboProImageToVideoDuration::TenSeconds), + ]; + for duration in durations { + let without = new_request(duration, None).calculate_cost_in_cents(); + let with = new_request(duration, Some("https://example.com/tail.png")) + .calculate_cost_in_cents(); + assert_eq!(with, without, "duration={duration:?}"); + } + } + + /// cfg_scale is not part of the billing formula. + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p5TurboProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + tail_image_url: None, + duration: Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 35, "cfg_scale={cfg:?}"); + } + } + + /// Image-to-video and text-to-video pro share the same $0.07/sec rate + /// per fal's docs — costs must match exactly at every duration. If + /// either rate drifts in the future this test fires. + #[test] + fn matches_text_to_video_pro_cost_at_each_duration() { + use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + Kling2p5TurboProTextToVideoRequest, + }; + fn t2v(duration: Kling2p5TurboProTextToVideoDuration) -> u64 { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling2p5TurboProImageToVideoDuration::FiveSeconds, Kling2p5TurboProTextToVideoDuration::FiveSeconds), + (Kling2p5TurboProImageToVideoDuration::TenSeconds, Kling2p5TurboProTextToVideoDuration::TenSeconds), + ]; + for (i2v_d, t2v_d) in pairs { + let image_cost = new_request(Some(i2v_d), None).calculate_cost_in_cents(); + let text_cost = t2v(t2v_d); + assert_eq!( + image_cost, text_cost, + "{i2v_d:?} vs {t2v_d:?}: image={image_cost}¢ text={text_cost}¢ — rates should match at $0.07/sec", + ); + } + } + + /// Sanity-check fal's documented examples: + /// "For 5s video your request will cost $0.35" + /// "For every additional second you will be charged $0.07" + /// → 10s costs $0.35 + 5 × $0.07 = $0.70 + #[test] + fn matches_documented_examples() { + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + assert_eq!( + make_request(Some(Kling2p5TurboProImageToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..40486c3ec2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p5_turbo_pro_image_to_video/raw_request.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.5-turbo/pro/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p5TurboProImageToVideoInput { + pub prompt: String, + + /// URL of the image to be used for the video. + pub image_url: String, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" (fal default: "5") + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// URL of the image to be used for the end of the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProImageToVideoOutput { + pub video: Kling2p5TurboProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs new file mode 100644 index 0000000000..6233462583 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/api.rs @@ -0,0 +1,311 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_2p6_pro_image_to_video::raw_request::{ + Kling2p6ProImageToVideoInput, Kling2p6ProImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p6ProImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the first frame. + pub start_image_url: String, + + /// URL of the image used as the last frame (optional). + pub end_image_url: Option, + + /// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Voice IDs to use for the generated audio. Maximum of 2 voices per task; + /// reference them in the prompt as `<<>>` and `<<>>`. + /// + /// Note: supplying voice IDs along with `generate_audio = Some(true)` + /// activates fal's "audio + voice control" pricing tier ($0.168/sec). + pub voice_ids: Option>, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProImageToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p6ProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +impl FalEndpoint for Kling2p6ProImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.6/pro/image-to-video"; + + type RawRequest = Kling2p6ProImageToVideoInput; + type RawResponse = Kling2p6ProImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + start_image_url: self.start_image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration, + negative_prompt: self.negative_prompt.clone(), + generate_audio: self.generate_audio, + voice_ids: self.voice_ids.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p6ProImageToVideoDuration::FiveSeconds, + Kling2p6ProImageToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p6ProImageToVideoRequest { + prompt: "the dog wags its tail".to_string(), + start_image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(dur), + negative_prompt: None, + generate_audio: Some(false), + voice_ids: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_uses_canonical_field_names() { + let request = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(Kling2p6ProImageToVideoDuration::TenSeconds), + negative_prompt: Some("nope".to_string()), + generate_audio: Some(true), + voice_ids: Some(vec!["voice_a".to_string(), "voice_b".to_string()]), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.start_image_url, "https://example.com/start.png"); + assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration.as_deref(), Some("10")); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.voice_ids.as_deref(), Some(&["voice_a".to_string(), "voice_b".to_string()][..])); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + negative_prompt: None, + generate_audio: None, + voice_ids: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.start_image_url, "https://example.com/start.png"); + assert!(raw.end_image_url.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.generate_audio.is_none()); + assert!(raw.voice_ids.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p6ProImageToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.6/pro/image-to-video", + ); + } + + /// Parity vs the legacy + /// `requests_old/http/video/image/http_kling_v2p6_pro_image_to_video.rs`. + /// + /// The legacy has no cost calculator, so cost parity is N/A. The wire + /// shape *intentionally diverges*: fal renamed the image field from + /// `image_url` (legacy) to `start_image_url` (current docs), and the + /// legacy gained no concept of `voice_ids` or `end_image_url`. These + /// tests document the differences and pin the endpoint URL, so we'll + /// notice if fal ever flips back or further changes the schema. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::image::http_kling_v2p6_pro_image_to_video::{ + kling_v2p6_pro_image_to_video, KlingV2p6ProImageToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.6/pro/image-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p6_pro_image_to_video(KlingV2p6ProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }); + assert_eq!( + Kling2p6ProImageToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// The image-URL field was renamed by fal: legacy `image_url` → + /// current `start_image_url`. This test pins the new module to the + /// current name and confirms the legacy field name is *not* emitted. + #[test] + fn new_module_emits_start_image_url_not_image_url() { + let new = Kling2p6ProImageToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/x.png".to_string(), + end_image_url: None, + duration: None, + negative_prompt: None, + generate_audio: None, + voice_ids: None, + }; + let json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + assert!(json.get("start_image_url").is_some(), "missing start_image_url: {json}"); + assert!(json.get("image_url").is_none(), "unexpected legacy image_url: {json}"); + } + + /// Legacy struct still emits the *old* `image_url` field name — + /// asserting this here flags any future schema drift in the legacy + /// archive (it shouldn't change; if it does we want to know). + #[test] + fn legacy_module_still_emits_image_url() { + let legacy = KlingV2p6ProImageToVideoInput { + prompt: "p".to_string(), + image_url: "https://example.com/x.png".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&legacy).unwrap(); + assert!(json.get("image_url").is_some(), "missing image_url: {json}"); + assert!(json.get("start_image_url").is_none(), "unexpected start_image_url: {json}"); + } + + /// On fields that *did* survive the rename (prompt, duration, + /// negative_prompt, generate_audio), wire JSON must match between + /// new and legacy when populated with the same values. + #[test] + fn surviving_fields_match_legacy_wire_shape() { + let new = Kling2p6ProImageToVideoRequest { + prompt: "shared prompt".to_string(), + start_image_url: "https://example.com/x.png".to_string(), + end_image_url: None, + duration: Some(Kling2p6ProImageToVideoDuration::TenSeconds), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(false), + voice_ids: None, + }; + let mut new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p6ProImageToVideoInput { + prompt: "shared prompt".to_string(), + image_url: "https://example.com/x.png".to_string(), + duration: Some("10".to_string()), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(false), + }; + let mut legacy_json = serde_json::to_value(&legacy).unwrap(); + + // Strip the renamed image-URL field from each side, then compare. + // (`voice_ids` and `end_image_url` are new in 2.6 — neither is set + // in this test, so they don't appear in JSON.) + new_json.as_object_mut().unwrap().remove("start_image_url"); + legacy_json.as_object_mut().unwrap().remove("image_url"); + assert_eq!(new_json, legacy_json); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs new file mode 100644 index 0000000000..3822077e08 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/cost.rs @@ -0,0 +1,314 @@ +use crate::requests::api::video::image::kling_2p6_pro_image_to_video::api::{ + Kling2p6ProImageToVideoDuration, Kling2p6ProImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p6ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.6 Pro image-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video): + // $0.07/sec — audio off + // $0.14/sec — audio on, no voice control + // $0.168/sec — audio on with voice IDs (voice control) + // + // `generate_audio` defaults to `true` on fal's server, so `None` bills as + // audio-on. Voice control is active when audio is on and `voice_ids` + // contains at least one entry. + // + // Rate is held in tenths-of-cents and rounded up to whole cents so the + // user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p6ProImageToVideoDuration::FiveSeconds) + .to_seconds(); + + let audio_on = self.generate_audio.unwrap_or(true); + let has_voice = self.voice_ids.as_ref().is_some_and(|v| !v.is_empty()); + + let rate_tenth_cents_per_sec: u64 = match (audio_on, has_voice) { + (false, _) => 70, // $0.07/sec — audio off + (true, false) => 140, // $0.14/sec — audio on, no voice control + (true, true) => 168, // $0.168/sec — audio + voice control + }; + + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + generate_audio: Option, + voice_ids: Option>, + ) -> Kling2p6ProImageToVideoRequest { + Kling2p6ProImageToVideoRequest { + prompt: "test".to_string(), + start_image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration, + negative_prompt: None, + generate_audio, + voice_ids, + } + } + + // Pricing tiers in tenths-of-cents per second: + // audio off → 70 + // audio on (no voice) → 140 + // audio on + voice control → 168 + // Cents per duration = ceil(rate × secs / 10). + + #[test] + fn five_seconds_audio_off() { + // (70 * 5 + 9) / 10 = 35 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), None) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn five_seconds_audio_on_no_voice() { + // (140 * 5 + 9) / 10 = 70 — matches fal's "$0.70 for 5s with audio" example + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn five_seconds_audio_on_with_voice() { + // (168 * 5 + 9) / 10 = 84 + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ) + .calculate_cost_in_cents(), + 84, + ); + } + + #[test] + fn ten_seconds_audio_off() { + // (70 * 10 + 9) / 10 = 70 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), None) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_on_no_voice() { + // (140 * 10 + 9) / 10 = 140 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), None) + .calculate_cost_in_cents(), + 140, + ); + } + + #[test] + fn ten_seconds_audio_on_with_voice() { + // (168 * 10 + 9) / 10 = 168 — matches fal's "$1.68 for 10s with audio + voice" example + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::TenSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ) + .calculate_cost_in_cents(), + 168, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None, audio=on (default), no voice → 5s @ $0.14 = 70¢ + assert_eq!(make_request(None, None, None).calculate_cost_in_cents(), 70); + } + + #[test] + fn default_audio_is_on() { + let none_default = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + None, + None, + ).calculate_cost_in_cents(); + let explicit_on = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ).calculate_cost_in_cents(); + assert_eq!(none_default, explicit_on); + } + + #[test] + fn empty_voice_ids_does_not_trigger_voice_tier() { + // `Some(vec![])` is semantically "no voices supplied" — it should bill at + // the $0.14/sec audio-on rate, not the $0.168/sec voice-control rate. + let with_empty = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec![]), + ).calculate_cost_in_cents(); + let with_none = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ).calculate_cost_in_cents(); + assert_eq!(with_empty, with_none, "empty voice_ids should match None"); + assert_eq!(with_empty, 70); + } + + #[test] + fn voice_ids_with_audio_off_does_not_change_cost() { + // Audio is off → voice control is meaningless. Pricing must stay at the + // $0.07/sec floor. (fal silently ignores voice_ids if audio is off.) + let with_voice = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(false), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(); + let without_voice = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(false), + None, + ).calculate_cost_in_cents(); + assert_eq!(with_voice, without_voice); + assert_eq!(with_voice, 35); + } + + #[test] + fn pricing_tiers_are_strictly_ascending() { + // At each duration: audio_off < audio_on < audio_on+voice + for d in [ + Kling2p6ProImageToVideoDuration::FiveSeconds, + Kling2p6ProImageToVideoDuration::TenSeconds, + ] { + let off = make_request(Some(d), Some(false), None).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true), None).calculate_cost_in_cents(); + let vc = make_request(Some(d), Some(true), Some(vec!["v".to_string()])) + .calculate_cost_in_cents(); + assert!(off < on, "duration={d:?}: audio_off={off}¢ < audio_on={on}¢"); + assert!(on < vc, "duration={d:?}: audio_on={on}¢ < voice_ctrl={vc}¢"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration (duration × generate_audio × voice_ids presence). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + // (duration, generate_audio, voice_ids_present, expected_cents) + // + // Tenths-of-cents/sec by tier: off=70, on=140, voice=168. + // Cents per duration = ceil(rate × secs / 10). + // duration=None defaults to 5s; generate_audio=None defaults to true. + const COST_TABLE: &[( + Option, + Option, + bool, // voice_ids non-empty + u64, + )] = &[ + // duration=None → 5s default + (None, Some(false), false, 35), + (None, Some(false), true, 35), // voice ignored when audio off + (None, Some(true), false, 70), + (None, Some(true), true, 84), + (None, None, false, 70), // audio default = on + (None, None, true, 84), + // duration=5s + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), false, 35), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(false), true, 35), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), false, 70), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), true, 84), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), None, false, 70), + (Some(Kling2p6ProImageToVideoDuration::FiveSeconds), None, true, 84), + // duration=10s + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), false, 70), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(false), true, 70), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), false, 140), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), Some(true), true, 168), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), None, false, 140), + (Some(Kling2p6ProImageToVideoDuration::TenSeconds), None, true, 168), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, voice_present, expected) in COST_TABLE { + let voice_ids = if voice_present { Some(vec!["voice_a".to_string()]) } else { None }; + let got = make_request(duration, generate_audio, voice_ids).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} audio={generate_audio:?} voice={voice_present}", + ); + } + } + + /// Number of voice IDs (1 vs 2) doesn't affect cost — only the presence + /// of at least one. Tests 1 and 2 voices (fal's max). + #[test] + fn voice_count_does_not_affect_cost() { + let one = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(); + let two = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + Some(vec!["voice_a".to_string(), "voice_b".to_string()]), + ).calculate_cost_in_cents(); + assert_eq!(one, two); + assert_eq!(one, 84); + } + + /// end_image_url is not part of the billing formula. + #[test] + fn cost_is_independent_of_end_image_url() { + let with_end = Kling2p6ProImageToVideoRequest { + end_image_url: Some("https://example.com/end.png".to_string()), + ..make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + }; + let without_end = make_request( + Some(Kling2p6ProImageToVideoDuration::FiveSeconds), + Some(true), + None, + ); + assert_eq!( + with_end.calculate_cost_in_cents(), + without_end.calculate_cost_in_cents(), + ); + } + + /// Sanity-check fal's documented examples: + /// "a 5-second video with audio costs $0.70" + /// "a 10-second video with audio and voice control costs $1.68" + #[test] + fn matches_documented_examples() { + // 5s + audio = $0.70 + assert_eq!( + make_request(Some(Kling2p6ProImageToVideoDuration::FiveSeconds), Some(true), None) + .calculate_cost_in_cents(), + 70, + ); + // 10s + audio + voice = $1.68 + assert_eq!( + make_request( + Some(Kling2p6ProImageToVideoDuration::TenSeconds), + Some(true), + Some(vec!["voice_a".to_string()]), + ).calculate_cost_in_cents(), + 168, + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs new file mode 100644 index 0000000000..125101c748 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_2p6_pro_image_to_video/raw_request.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.6/pro/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p6ProImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the first frame. + pub start_image_url: String, + + /// URL of the image used as the last frame (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Whether to generate native audio for the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Voice IDs for video generation. Reference them in the prompt with + /// `<<>>` and `<<>>` — maximum of 2 voices per task. + /// Sourced from fal's `create-voice` endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_ids: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProImageToVideoOutput { + pub video: Kling2p6ProImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs new file mode 100644 index 0000000000..967a78d7b1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/api.rs @@ -0,0 +1,294 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::kling_3p0_standard_image_to_video::raw_request::{ + Kling3p0StandardImageToVideoInput, Kling3p0StandardImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling3p0StandardImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end frame image URL. + pub end_image_url: Option, + + /// Whether to generate audio. Defaults to true if not specified. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Video duration. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// Shot type for multi-shot generation. + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardImageToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl Kling3p0StandardImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardImageToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardImageToVideoShotType { + Customize, + Intelligent, +} + +impl FalEndpoint for Kling3p0StandardImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v3/standard/image-to-video"; + + type RawRequest = Kling3p0StandardImageToVideoInput; + type RawResponse = Kling3p0StandardImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling3p0StandardImageToVideoAspectRatio::Square => "1:1", + Kling3p0StandardImageToVideoAspectRatio::SixteenByNine => "16:9", + Kling3p0StandardImageToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + let shot_type = self.shot_type.map(|st| match st { + Kling3p0StandardImageToVideoShotType::Customize => "customize", + Kling3p0StandardImageToVideoShotType::Intelligent => "intelligent", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + end_image_url: self.end_image_url.clone(), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + duration, + aspect_ratio, + shot_type, + cfg_scale: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking toward the camera".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::ThreeSeconds), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling3p0StandardImageToVideoAspectRatio::Square, + Kling3p0StandardImageToVideoAspectRatio::SixteenByNine, + Kling3p0StandardImageToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling3p0StandardImageToVideoDuration::ThreeSeconds, + Kling3p0StandardImageToVideoDuration::FourSeconds, + Kling3p0StandardImageToVideoDuration::FiveSeconds, + Kling3p0StandardImageToVideoDuration::SixSeconds, + Kling3p0StandardImageToVideoDuration::SevenSeconds, + Kling3p0StandardImageToVideoDuration::EightSeconds, + Kling3p0StandardImageToVideoDuration::NineSeconds, + Kling3p0StandardImageToVideoDuration::TenSeconds, + Kling3p0StandardImageToVideoDuration::ElevenSeconds, + Kling3p0StandardImageToVideoDuration::TwelveSeconds, + Kling3p0StandardImageToVideoDuration::ThirteenSeconds, + Kling3p0StandardImageToVideoDuration::FourteenSeconds, + Kling3p0StandardImageToVideoDuration::FifteenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head and opens its jaw".to_string(), + duration: Some(dur), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let shot_types = [ + Kling3p0StandardImageToVideoShotType::Customize, + Kling3p0StandardImageToVideoShotType::Intelligent, + ]; + + for st in shot_types { + println!("--- shot type: {:?} ---", st); + let request = Kling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton lurches forward dramatically".to_string(), + duration: Some(Kling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: Some(st), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs new file mode 100644 index 0000000000..9adb31b871 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/cost.rs @@ -0,0 +1,110 @@ +use crate::requests::api::video::image::kling_3p0_standard_image_to_video::api::{ + Kling3p0StandardImageToVideoDuration, Kling3p0StandardImageToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling3p0StandardImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Same pricing as text-to-video for Kling 3.0 Standard: + // Audio off: $0.168/second + // Audio on: $0.252/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(Kling3p0StandardImageToVideoDuration::FiveSeconds) + .to_seconds(); + + // Rate in tenths-of-cents per second, rounded up + let rate = if generate_audio { 252u64 } else { 168u64 }; + (rate * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::kling_3p0_standard_image_to_video::api::{ + Kling3p0StandardImageToVideoAspectRatio, Kling3p0StandardImageToVideoDuration, + }; + + fn make_request( + generate_audio: Option, + duration: Option, + ) -> Kling3p0StandardImageToVideoRequest { + Kling3p0StandardImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + end_image_url: None, + generate_audio, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine), + shot_type: None, + } + } + + mod audio_off { + use super::*; + + // Audio off: $0.168/sec → rate = 168 tenths-of-cents/sec + + #[test] + fn five_seconds() { + // (168 * 5 + 9) / 10 = 84 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 84); + } + + #[test] + fn ten_seconds() { + // (168 * 10 + 9) / 10 = 168 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 168); + } + + #[test] + fn fifteen_seconds() { + // (168 * 15 + 9) / 10 = 252 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 252); + } + } + + mod audio_on { + use super::*; + + // Audio on: $0.252/sec → rate = 252 tenths-of-cents/sec + + #[test] + fn five_seconds() { + // (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126); + } + + #[test] + fn ten_seconds() { + // (252 * 10 + 9) / 10 = 252 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardImageToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 252); + } + } + + mod defaults { + use super::*; + + #[test] + fn default_audio_is_on() { + // generate_audio=None defaults to true (audio on) + // duration=5s: (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(None, Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None defaults to 5s + // audio off: (168 * 5 + 9) / 10 = 84 + assert_eq!(make_request(Some(false), None).calculate_cost_in_cents(), 84); + } + + #[test] + fn both_defaults() { + // audio on + 5s: (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 126); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/raw_request.rs new file mode 100644 index 0000000000..cab12ca32f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/kling_3p0_standard_image_to_video/raw_request.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0StandardImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Optional end frame image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate audio alongside the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardImageToVideoOutput { + pub video: Kling3p0StandardImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/api.rs new file mode 100644 index 0000000000..0834146607 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/api.rs @@ -0,0 +1,139 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::minimax_h3::raw_request::{ + MinimaxH3ImageToVideoInput, MinimaxH3ImageToVideoOutput, +}; +use crate::requests::api::video::text::minimax_h3::api::MinimaxH3Resolution; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct MinimaxH3ImageToVideoRequest { + /// Text prompt (1 to 7000 characters). + pub prompt: String, + + /// URL of the image to use as the first frame. The output aspect ratio + /// follows this image. + pub image_url: String, + + /// Optional URL of the image to use as the last frame, for first-to-last + /// keyframe generation. + pub end_image_url: Option, + + /// Duration in seconds. Valid range 5–15; fal's default is `5` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `2K` when `None`. + pub resolution: Option, +} + +impl FalEndpoint for MinimaxH3ImageToVideoRequest { + const ENDPOINT: &str = "minimax/h3/image-to-video"; + + type RawRequest = MinimaxH3ImageToVideoInput; + type RawResponse = MinimaxH3ImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const EXAMPLE_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/hailuo23/pro_i2v_in.jpg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = MinimaxH3ImageToVideoRequest { + prompt: "the camera slowly pulls back, revealing the full landscape".to_string(), + image_url: EXAMPLE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = MinimaxH3ImageToVideoRequest { + prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(), + image_url: EXAMPLE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = MinimaxH3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/first.png".to_string(), + end_image_url: Some("https://example.com/last.png".to_string()), + duration: Some(12), + resolution: Some(MinimaxH3Resolution::TwoK), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/first.png"); + assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/last.png")); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.resolution.as_deref(), Some("2K")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = MinimaxH3ImageToVideoRequest { + prompt: "minimal".to_string(), + image_url: "https://example.com/first.png".to_string(), + end_image_url: None, + duration: None, + resolution: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "image_url": "https://example.com/first.png", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(MinimaxH3ImageToVideoRequest::ENDPOINT, "minimax/h3/image-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/cost.rs new file mode 100644 index 0000000000..dff1dca483 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest; +use crate::requests::api::video::text::minimax_h3::api::MinimaxH3Resolution; +use crate::requests::api::video::text::minimax_h3::cost::{ + minimax_h3_rate_cents_per_sec, minimax_h3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the MiniMax H3 per-second pricing (see the text module +// for the canonical rate table): +// 768P: $0.16/sec +// 2K: $0.26/sec +// Pricing depends on resolution and duration only (start/end frames are free). + +impl FalRequestCostCalculator for MinimaxH3ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 2K. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_2k = self.resolution + .unwrap_or(MinimaxH3Resolution::TwoK) + .is_2k(); + + let rate = minimax_h3_rate_cents_per_sec(is_2k); + minimax_h3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + ) -> MinimaxH3ImageToVideoRequest { + MinimaxH3ImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/first.png".to_string(), + end_image_url: None, + duration, + resolution, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + // 768P → $0.16/s + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 80), + (Some(15), Some(MinimaxH3Resolution::SevenSixtyEightP), 240), + // 2K → $0.26/s + (Some(5), Some(MinimaxH3Resolution::TwoK), 130), + (Some(15), Some(MinimaxH3Resolution::TwoK), 390), + // Defaults: duration=None→5s, resolution=None→2K + (None, None, 130), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// The end frame does not affect the bill (only resolution + duration do). + #[test] + fn cost_ignores_end_image() { + let without = make_request(Some(8), Some(MinimaxH3Resolution::TwoK)).calculate_cost_in_cents(); + let with = MinimaxH3ImageToVideoRequest { + end_image_url: Some("https://example.com/last.png".to_string()), + ..make_request(Some(8), Some(MinimaxH3Resolution::TwoK)) + }.calculate_cost_in_cents(); + assert_eq!(with, without); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/raw_request.rs new file mode 100644 index 0000000000..7772259df9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/minimax_h3/raw_request.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `minimax/h3/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct MinimaxH3ImageToVideoInput { + /// Text prompt (1 to 7000 characters). + pub prompt: String, + + /// URL of the image to use as the first frame. The output aspect ratio + /// follows this image. + pub image_url: String, + + /// Optional URL of the image to use as the last frame, for first-to-last + /// keyframe generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Duration in seconds. Range 5–15. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "768P", "2K". fal default: "2K". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinimaxH3ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinimaxH3ImageToVideoOutput { + pub video: MinimaxH3ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs new file mode 100644 index 0000000000..b79cf33aa1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/mod.rs @@ -0,0 +1,15 @@ +pub mod flux_3; +pub mod flux_3_draft; +pub mod kling_1p6_pro_image_to_video; +pub mod kling_2p5_turbo_pro_image_to_video; +pub mod kling_2p6_pro_image_to_video; +pub mod kling_3p0_standard_image_to_video; +pub mod minimax_h3; +pub mod veo_2; +pub mod veo_3; +pub mod veo_3_fast; +pub mod veo_3p1; +pub mod veo_3p1_fast; +pub mod veo_3p1_lite; +pub mod vidu_q3; +pub mod vidu_q3_turbo; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/api.rs new file mode 100644 index 0000000000..1b7ad60246 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/api.rs @@ -0,0 +1,158 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::veo_2::raw_request::{ + Veo2ImageToVideoInput, Veo2ImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo2ImageToVideoRequest { + /// Text prompt describing how the image should be animated. + pub prompt: String, + + /// URL of the input image to animate (720p+, 16:9 or 9:16). + pub image_url: String, + + /// Video duration. fal's default is `5s` when `None`. + pub duration: Option, +} + +/// Veo 2 supports 5–8 second durations (default 5s). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo2ImageToVideoDuration { + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, +} + +impl Veo2ImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5s", + Self::SixSeconds => "6s", + Self::SevenSeconds => "7s", + Self::EightSeconds => "8s", + } + } +} + +impl FalEndpoint for Veo2ImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo2/image-to-video"; + + type RawRequest = Veo2ImageToVideoInput; + type RawResponse = Veo2ImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + duration: self.duration.map(|d| d.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo2ImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + duration: Some(Veo2ImageToVideoDuration::FiveSeconds), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo2ImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + duration: Some(Veo2ImageToVideoDuration::FiveSeconds), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo2ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + duration: Some(Veo2ImageToVideoDuration::EightSeconds), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.duration.as_deref(), Some("8s")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo2ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + duration: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo2ImageToVideoDuration::FiveSeconds, "5s", 5), + (Veo2ImageToVideoDuration::SixSeconds, "6s", 6), + (Veo2ImageToVideoDuration::SevenSeconds, "7s", 7), + (Veo2ImageToVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo2ImageToVideoRequest::ENDPOINT, "fal-ai/veo2/image-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/cost.rs new file mode 100644 index 0000000000..91680db4e8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/cost.rs @@ -0,0 +1,62 @@ +use crate::requests::api::video::image::veo_2::api::{Veo2ImageToVideoDuration, Veo2ImageToVideoRequest}; +use crate::requests::api::video::text::veo_2::cost::veo_2_cost_cents; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Veo 2 pricing (see the text module for the +// canonical formula): $2.50 base for 5s, +$0.50 per additional second. + +impl FalRequestCostCalculator for Veo2ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal default when unset: duration = 5s. + let duration_secs = self.duration + .unwrap_or(Veo2ImageToVideoDuration::FiveSeconds) + .to_seconds(); + veo_2_cost_cents(duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(duration: Option) -> Veo2ImageToVideoRequest { + Veo2ImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + duration, + } + } + + mod cost_table { + use super::*; + + // (duration, expected_cents): 250 base + 50 per second over 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(Veo2ImageToVideoDuration::FiveSeconds), 250), + (Some(Veo2ImageToVideoDuration::SixSeconds), 300), + (Some(Veo2ImageToVideoDuration::SevenSeconds), 350), + (Some(Veo2ImageToVideoDuration::EightSeconds), 400), + // Default: duration=None → 5s + (None, 250), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + assert_eq!( + make_request(duration).calculate_cost_in_cents(), + expected, + "duration={duration:?}", + ); + } + } + + /// Image-to-video must bill identically to text-to-video at every duration. + #[test] + fn matches_text_to_video_pricing() { + for (secs, expected) in [(5, 250), (6, 300), (7, 350), (8, 400)] { + assert_eq!(veo_2_cost_cents(secs), expected, "secs={secs}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/raw_request.rs new file mode 100644 index 0000000000..eb6afd3a66 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_2/raw_request.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo2/image-to-video`. +/// fal's schema: +/// +/// NB: Veo 2 image-to-video has a minimal schema — only `prompt`, `image_url`, +/// and `duration`. There is no aspect_ratio, negative_prompt, seed, auto_fix, +/// enhance_prompt, resolution, or generate_audio (aspect ratio is taken from +/// the input image). +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo2ImageToVideoInput { + pub prompt: String, + + /// URL of the input image to animate (720p+, 16:9 or 9:16). + pub image_url: String, + + /// Duration in seconds (as a string). + /// Possible values: "5s", "6s", "7s", "8s". fal default: "5s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2ImageToVideoOutput { + pub video: Veo2ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/api.rs new file mode 100644 index 0000000000..22cd69d1ea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/api.rs @@ -0,0 +1,276 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::veo_3::raw_request::{ + Veo3ImageToVideoInput, Veo3ImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3ImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` for image-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3ImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3ImageToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3ImageToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3ImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3 exposes only 720p/1080p (no 4k tier). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3ImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3ImageToVideoResolution { + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3ImageToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3ImageToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3ImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3/image-to-video"; + + type RawRequest = Veo3ImageToVideoInput; + type RawResponse = Veo3ImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3ImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3ImageToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3ImageToVideoDuration::FourSeconds), + resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3ImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3ImageToVideoDuration::FourSeconds), + resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: Some(Veo3ImageToVideoAspectRatio::Auto), + duration: Some(Veo3ImageToVideoDuration::SixSeconds), + resolution: Some(Veo3ImageToVideoResolution::TenEightyP), + generate_audio: Some(false), + negative_prompt: Some("nope".to_string()), + seed: Some(7), + auto_fix: Some(true), + safety_tolerance: Some(Veo3ImageToVideoSafetyTolerance::Level2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(7)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("2")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3ImageToVideoAspectRatio::Auto, "auto"), + (Veo3ImageToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3ImageToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo3ImageToVideoRequest::ENDPOINT, "fal-ai/veo3/image-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/cost.rs new file mode 100644 index 0000000000..d9bbe0be63 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/cost.rs @@ -0,0 +1,90 @@ +use crate::requests::api::video::image::veo_3::api::{ + Veo3ImageToVideoDuration, Veo3ImageToVideoRequest, +}; +use crate::requests::api::video::text::veo_3::cost::{veo_3_cost_cents, veo_3_rate_tenth_cents_per_sec}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Veo 3 (non-fast) per-second pricing (see the text +// module for the canonical rate table): $0.20/sec (audio off), $0.40/sec +// (audio on). Flat — resolution does not affect the price. + +impl FalRequestCostCalculator for Veo3ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3ImageToVideoDuration::EightSeconds) + .to_seconds(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3_rate_tenth_cents_per_sec(audio_on); + veo_3_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::veo_3::api::Veo3ImageToVideoAspectRatio; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Veo3ImageToVideoRequest { + Veo3ImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + aspect_ratio: None, + duration, + resolution: None, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + (Some(Veo3ImageToVideoDuration::FourSeconds), Some(false), 80), + (Some(Veo3ImageToVideoDuration::FourSeconds), Some(true), 160), + (Some(Veo3ImageToVideoDuration::EightSeconds), Some(false), 160), + (Some(Veo3ImageToVideoDuration::EightSeconds), Some(true), 320), + // Defaults: 8s, audio on + (None, None, 320), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, generate_audio).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}"); + } + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let baseline = { + let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true)); + r.aspect_ratio = Some(Veo3ImageToVideoAspectRatio::Auto); + r.calculate_cost_in_cents() + }; + for ar in [ + Veo3ImageToVideoAspectRatio::Auto, + Veo3ImageToVideoAspectRatio::SixteenByNine, + Veo3ImageToVideoAspectRatio::NineBySixteen, + ] { + let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true)); + r.aspect_ratio = Some(ar); + assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/raw_request.rs new file mode 100644 index 0000000000..7432fff6f5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3/raw_request.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3ImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false (image-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3ImageToVideoOutput { + pub video: Veo3ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/api.rs new file mode 100644 index 0000000000..91e4161a08 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/api.rs @@ -0,0 +1,279 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::veo_3_fast::raw_request::{ + Veo3FastImageToVideoInput, Veo3FastImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3FastImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` for image-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3FastImageToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastImageToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3FastImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3 Fast exposes only 720p/1080p (no 4k tier). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3FastImageToVideoResolution { + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastImageToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3FastImageToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3FastImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3/fast/image-to-video"; + + type RawRequest = Veo3FastImageToVideoInput; + type RawResponse = Veo3FastImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3FastImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3FastImageToVideoDuration::FourSeconds), + resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3FastImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3FastImageToVideoDuration::FourSeconds), + resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3FastImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::Auto), + duration: Some(Veo3FastImageToVideoDuration::SixSeconds), + resolution: Some(Veo3FastImageToVideoResolution::TenEightyP), + generate_audio: Some(false), + negative_prompt: Some("nope".to_string()), + seed: Some(7), + auto_fix: Some(true), + safety_tolerance: Some(Veo3FastImageToVideoSafetyTolerance::Level2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(7)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("2")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3FastImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3FastImageToVideoAspectRatio::Auto, "auto"), + (Veo3FastImageToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3FastImageToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3FastImageToVideoRequest::ENDPOINT, + "fal-ai/veo3/fast/image-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/cost.rs new file mode 100644 index 0000000000..fa1e72fdd7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/cost.rs @@ -0,0 +1,92 @@ +use crate::requests::api::video::image::veo_3_fast::api::{ + Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest, +}; +use crate::requests::api::video::text::veo_3_fast::cost::{ + veo_3_fast_cost_cents, veo_3_fast_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Veo 3 Fast per-second pricing (see the text module +// for the canonical rate table): $0.10/sec (audio off), $0.15/sec (audio on). +// Flat — resolution does not affect the price. + +impl FalRequestCostCalculator for Veo3FastImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3FastImageToVideoDuration::EightSeconds) + .to_seconds(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3_fast_rate_tenth_cents_per_sec(audio_on); + veo_3_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoAspectRatio; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Veo3FastImageToVideoRequest { + Veo3FastImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + aspect_ratio: None, + duration, + resolution: None, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + (Some(Veo3FastImageToVideoDuration::FourSeconds), Some(false), 40), + (Some(Veo3FastImageToVideoDuration::FourSeconds), Some(true), 60), + (Some(Veo3FastImageToVideoDuration::EightSeconds), Some(false), 80), + (Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true), 120), + // Defaults: 8s, audio on + (None, None, 120), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, generate_audio).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}"); + } + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let baseline = { + let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true)); + r.aspect_ratio = Some(Veo3FastImageToVideoAspectRatio::Auto); + r.calculate_cost_in_cents() + }; + for ar in [ + Veo3FastImageToVideoAspectRatio::Auto, + Veo3FastImageToVideoAspectRatio::SixteenByNine, + Veo3FastImageToVideoAspectRatio::NineBySixteen, + ] { + let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true)); + r.aspect_ratio = Some(ar); + assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/raw_request.rs new file mode 100644 index 0000000000..ceabad967b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3_fast/raw_request.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3/fast/image-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3FastImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false (image-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3FastImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3FastImageToVideoOutput { + pub video: Veo3FastImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/api.rs new file mode 100644 index 0000000000..c0041da24a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/api.rs @@ -0,0 +1,285 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::veo_3p1::raw_request::{ + Veo3p1ImageToVideoInput, Veo3p1ImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1ImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` for image-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1ImageToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ImageToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1ImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ImageToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1ImageToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ImageToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1ImageToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1ImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/image-to-video"; + + type RawRequest = Veo3p1ImageToVideoInput; + type RawResponse = Veo3p1ImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1ImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1ImageToVideoDuration::FourSeconds), + resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1ImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1ImageToVideoDuration::FourSeconds), + resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::Auto), + duration: Some(Veo3p1ImageToVideoDuration::SixSeconds), + resolution: Some(Veo3p1ImageToVideoResolution::TenEightyP), + generate_audio: Some(false), + negative_prompt: Some("nope".to_string()), + seed: Some(7), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1ImageToVideoSafetyTolerance::Level2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(7)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("2")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3p1ImageToVideoAspectRatio::Auto, "auto"), + (Veo3p1ImageToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3p1ImageToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1ImageToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/image-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/cost.rs new file mode 100644 index 0000000000..e12875958a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/cost.rs @@ -0,0 +1,112 @@ +use crate::requests::api::video::image::veo_3p1::api::{ + Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest, Veo3p1ImageToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1::cost::{ + veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the +// text module for the canonical rate table): +// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on) +// 4k: $0.40/sec (audio off), $0.60/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1ImageToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1ImageToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoAspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1ImageToVideoRequest { + Veo3p1ImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine), + duration, + resolution, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(false), 80), + (Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(true), 160), + (Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(false), 160), + (Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(true), 320), + (Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(false), 320), + (Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(true), 480), + // Defaults: 8s, 720p, audio on + (None, None, None, 320), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + Veo3p1ImageToVideoAspectRatio::Auto, + Veo3p1ImageToVideoAspectRatio::SixteenByNine, + Veo3p1ImageToVideoAspectRatio::NineBySixteen, + ]; + let baseline = { + let mut r = make_request( + Some(Veo3p1ImageToVideoDuration::EightSeconds), + Some(Veo3p1ImageToVideoResolution::TenEightyP), + Some(true), + ); + r.aspect_ratio = Some(aspect_ratios[0]); + r.calculate_cost_in_cents() + }; + for ar in aspect_ratios { + let mut r = make_request( + Some(Veo3p1ImageToVideoDuration::EightSeconds), + Some(Veo3p1ImageToVideoResolution::TenEightyP), + Some(true), + ); + r.aspect_ratio = Some(ar); + assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/raw_request.rs new file mode 100644 index 0000000000..4fb1f8a9e1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1/raw_request.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/image-to-video` +/// (single start-frame keyframe). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1ImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false (image-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ImageToVideoOutput { + pub video: Veo3p1ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/api.rs new file mode 100644 index 0000000000..08b3f88cd4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/api.rs @@ -0,0 +1,286 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::veo_3p1_fast::raw_request::{ + Veo3p1FastImageToVideoInput, Veo3p1FastImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1FastImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` for image-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1FastImageToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastImageToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1FastImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastImageToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1FastImageToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastImageToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1FastImageToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1FastImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/fast/image-to-video"; + + type RawRequest = Veo3p1FastImageToVideoInput; + type RawResponse = Veo3p1FastImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1FastImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::Auto), + duration: Some(Veo3p1FastImageToVideoDuration::SixSeconds), + resolution: Some(Veo3p1FastImageToVideoResolution::TenEightyP), + generate_audio: Some(false), + negative_prompt: Some("nope".to_string()), + seed: Some(7), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1FastImageToVideoSafetyTolerance::Level2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(7)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("2")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1FastImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + // Only the two required fields remain on the wire. + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3p1FastImageToVideoAspectRatio::Auto, "auto"), + (Veo3p1FastImageToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3p1FastImageToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1FastImageToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/fast/image-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..856b4d0bde --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/cost.rs @@ -0,0 +1,112 @@ +use crate::requests::api::video::image::veo_3p1_fast::api::{ + Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest, Veo3p1FastImageToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1_fast::cost::{ + veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Veo 3.1 Fast per-second pricing (see the text +// module for the canonical rate table): +// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on) +// 4k: $0.30/sec (audio off), $0.35/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1FastImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1FastImageToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1FastImageToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoAspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1FastImageToVideoRequest { + Veo3p1FastImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine), + duration, + resolution, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(false), 40), + (Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(true), 60), + (Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(false), 80), + (Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(true), 120), + (Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(false), 240), + (Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(true), 280), + // Defaults: 8s, 720p, audio on + (None, None, None, 120), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + Veo3p1FastImageToVideoAspectRatio::Auto, + Veo3p1FastImageToVideoAspectRatio::SixteenByNine, + Veo3p1FastImageToVideoAspectRatio::NineBySixteen, + ]; + let baseline = { + let mut r = make_request( + Some(Veo3p1FastImageToVideoDuration::EightSeconds), + Some(Veo3p1FastImageToVideoResolution::TenEightyP), + Some(true), + ); + r.aspect_ratio = Some(aspect_ratios[0]); + r.calculate_cost_in_cents() + }; + for ar in aspect_ratios { + let mut r = make_request( + Some(Veo3p1FastImageToVideoDuration::EightSeconds), + Some(Veo3p1FastImageToVideoResolution::TenEightyP), + Some(true), + ); + r.aspect_ratio = Some(ar); + assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/raw_request.rs new file mode 100644 index 0000000000..5eb7bde58d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_fast/raw_request.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/fast/image-to-video` +/// (single start-frame keyframe). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false (image-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastImageToVideoOutput { + pub video: Veo3p1FastImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/api.rs new file mode 100644 index 0000000000..434dc2be7a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/api.rs @@ -0,0 +1,285 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::veo_3p1_lite::raw_request::{ + Veo3p1LiteImageToVideoInput, Veo3p1LiteImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1LiteImageToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` for image-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1LiteImageToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteImageToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1LiteImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND +/// audio affect the per-second price. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3p1LiteImageToVideoResolution { + /// 1080p is billed at a higher per-second rate than 720p. + pub fn is_ten_eighty_p(&self) -> bool { + matches!(self, Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteImageToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1LiteImageToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1LiteImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/lite/image-to-video"; + + type RawRequest = Veo3p1LiteImageToVideoInput; + type RawResponse = Veo3p1LiteImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1LiteImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1LiteImageToVideoRequest { + prompt: "wind moves through the trees".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1LiteImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::Auto), + duration: Some(Veo3p1LiteImageToVideoDuration::SixSeconds), + resolution: Some(Veo3p1LiteImageToVideoResolution::TenEightyP), + generate_audio: Some(false), + negative_prompt: Some("nope".to_string()), + seed: Some(7), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1LiteImageToVideoSafetyTolerance::Level2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("auto")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(7)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("2")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1LiteImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3p1LiteImageToVideoAspectRatio::Auto, "auto"), + (Veo3p1LiteImageToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3p1LiteImageToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1LiteImageToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/lite/image-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/cost.rs new file mode 100644 index 0000000000..0116c2ce0e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/cost.rs @@ -0,0 +1,102 @@ +use crate::requests::api::video::image::veo_3p1_lite::api::{ + Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest, Veo3p1LiteImageToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1_lite::cost::{ + veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Veo 3.1 Lite per-second pricing (see the text +// module for the canonical rate table). Both resolution and audio matter: +// 720p: $0.03/sec (audio off), $0.05/sec (audio on) +// 1080p: $0.05/sec (audio off), $0.08/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1LiteImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1LiteImageToVideoDuration::EightSeconds) + .to_seconds(); + let is_1080p = self.resolution + .unwrap_or(Veo3p1LiteImageToVideoResolution::SevenTwentyP) + .is_ten_eighty_p(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on); + veo_3p1_lite_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoAspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1LiteImageToVideoRequest { + Veo3p1LiteImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + aspect_ratio: None, + duration, + resolution, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(false), 12), + (Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 20), + (Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 40), + (Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(false), 40), + (Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true), 64), + // Defaults: 8s, 720p, audio on + (None, None, None, 40), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let baseline = { + let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true)); + r.aspect_ratio = Some(Veo3p1LiteImageToVideoAspectRatio::Auto); + r.calculate_cost_in_cents() + }; + for ar in [ + Veo3p1LiteImageToVideoAspectRatio::Auto, + Veo3p1LiteImageToVideoAspectRatio::SixteenByNine, + Veo3p1LiteImageToVideoAspectRatio::NineBySixteen, + ] { + let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true)); + r.aspect_ratio = Some(ar); + assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/raw_request.rs new file mode 100644 index 0000000000..4c0ccd78b9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/veo_3p1_lite/raw_request.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/lite/image-to-video` +/// (single start-frame keyframe). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1LiteImageToVideoInput { + pub prompt: String, + + /// URL of the image used as the starting frame. + pub image_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false (image-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1LiteImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1LiteImageToVideoOutput { + pub video: Veo3p1LiteImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/api.rs new file mode 100644 index 0000000000..5ed499fc33 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/api.rs @@ -0,0 +1,197 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::vidu_q3::raw_request::{ + ViduQ3ImageToVideoInput, ViduQ3ImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct ViduQ3ImageToVideoRequest { + /// Text prompt (max 2000 characters). Optional server-side; pass an empty + /// string for a pure image-to-video generation. + pub prompt: String, + + /// URL of the image used as the starting frame (URL or base64). + pub image_url: String, + + /// Optional URL of an ending frame, for start→end transition videos. + pub end_image_url: Option, + + /// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`. + pub duration: Option, + + /// Seed for reproducibility. Random when `None`. + pub seed: Option, + + /// Output resolution. fal's default is `720p` when `None`. Note fal rejects + /// `360p` when `end_image_url` is set. + pub resolution: Option, + + /// Whether to generate audio. fal's server default is `true` when `None`. + pub audio: Option, +} + +/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3ImageToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +impl ViduQ3ImageToVideoResolution { + /// 720p and 1080p are billed at the higher (2.2×) per-second rate. + pub fn is_high_res(&self) -> bool { + matches!(self, Self::SevenTwentyP | Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSixtyP => "360p", + Self::FiveFortyP => "540p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl FalEndpoint for ViduQ3ImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video"; + + type RawRequest = ViduQ3ImageToVideoInput; + type RawResponse = ViduQ3ImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration: self.duration, + seed: self.seed, + resolution: self.resolution.map(|r| r.to_str().to_string()), + audio: self.audio, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3ImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + seed: None, + resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP), + audio: Some(false), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3ImageToVideoRequest { + prompt: "a smooth transition between the two frames".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + duration: Some(5), + seed: Some(1234), + resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP), + audio: Some(false), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = ViduQ3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(7), + seed: Some(42), + resolution: Some(ViduQ3ImageToVideoResolution::TenEightyP), + audio: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration, Some(7)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.audio, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = ViduQ3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + seed: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn seed_serializes_when_set() { + let request = ViduQ3ImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + seed: Some(777), + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777)); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + ViduQ3ImageToVideoRequest::ENDPOINT, + "fal-ai/vidu/q3/image-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/cost.rs new file mode 100644 index 0000000000..6f1db4bda6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::video::image::vidu_q3::api::{ + ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution, +}; +use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Vidu Q3 per-second pricing (see the text module for +// the canonical rate table): +// 360p / 540p: $0.07/sec +// 720p / 1080p: $0.154/sec (2.2×) +// Pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for ViduQ3ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 720p. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_high_res = self.resolution + .unwrap_or(ViduQ3ImageToVideoResolution::SevenTwentyP) + .is_high_res(); + + let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res); + vidu_q3_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + ) -> ViduQ3ImageToVideoRequest { + ViduQ3ImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + end_image_url: None, + duration, + seed: None, + resolution, + audio: Some(true), + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(5), Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), 35), + (Some(5), Some(ViduQ3ImageToVideoResolution::FiveFortyP), 35), + (Some(5), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 77), + (Some(5), Some(ViduQ3ImageToVideoResolution::TenEightyP), 77), + (Some(10), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 154), + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 77), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// An end frame does not change the bill (only resolution + duration do). + #[test] + fn cost_is_independent_of_end_frame() { + let without = make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents(); + let with = ViduQ3ImageToVideoRequest { + end_image_url: Some("https://example.com/end.png".to_string()), + ..make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP)) + }.calculate_cost_in_cents(); + assert_eq!(without, with); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/raw_request.rs new file mode 100644 index 0000000000..7bab26cbe0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video`. +/// fal's schema: +/// +/// NB: this endpoint has no `aspect_ratio` (it is derived from the input +/// image). A `prompt` is optional server-side but always sent here. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ViduQ3ImageToVideoInput { + /// Text prompt (max 2000 characters). Optional server-side. + pub prompt: String, + + /// URL of the image used as the starting frame (URL or base64). + pub image_url: String, + + /// Optional URL of an ending frame, for start→end transition videos. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Duration in seconds. Range 1–16. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Seed for reproducibility. Random when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Output resolution. + /// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p". + /// (360p is unavailable when `end_image_url` is set.) + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3ImageToVideoOutput { + pub video: ViduQ3ImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/api.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/api.rs new file mode 100644 index 0000000000..0b2bfbe654 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/api.rs @@ -0,0 +1,197 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::image::vidu_q3_turbo::raw_request::{ + ViduQ3TurboImageToVideoInput, ViduQ3TurboImageToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct ViduQ3TurboImageToVideoRequest { + /// Text prompt (max 2000 characters). Optional server-side; pass an empty + /// string for a pure image-to-video generation. + pub prompt: String, + + /// URL of the image used as the starting frame (URL or base64). + pub image_url: String, + + /// Optional URL of an ending frame, for start→end transition videos. + pub end_image_url: Option, + + /// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`. + pub duration: Option, + + /// Seed for reproducibility. Random when `None`. + pub seed: Option, + + /// Output resolution. fal's default is `720p` when `None`. Note fal rejects + /// `360p` when `end_image_url` is set. + pub resolution: Option, + + /// Whether to generate audio. fal's server default is `true` when `None`. + pub audio: Option, +} + +/// Vidu Q3 Turbo resolutions. 720p/1080p bill at 2.2× the 360p/540p rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3TurboImageToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +impl ViduQ3TurboImageToVideoResolution { + /// 720p and 1080p are billed at the higher (2.2×) per-second rate. + pub fn is_high_res(&self) -> bool { + matches!(self, Self::SevenTwentyP | Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSixtyP => "360p", + Self::FiveFortyP => "540p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl FalEndpoint for ViduQ3TurboImageToVideoRequest { + const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video/turbo"; + + type RawRequest = ViduQ3TurboImageToVideoInput; + type RawResponse = ViduQ3TurboImageToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_url: self.image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration: self.duration, + seed: self.seed, + resolution: self.resolution.map(|r| r.to_str().to_string()), + audio: self.audio, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3TurboImageToVideoRequest { + prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + seed: None, + resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), + audio: Some(false), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3TurboImageToVideoRequest { + prompt: "a smooth transition between the two frames".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + duration: Some(5), + seed: Some(1234), + resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), + audio: Some(false), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = ViduQ3TurboImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + end_image_url: Some("https://example.com/end.png".to_string()), + duration: Some(7), + seed: Some(42), + resolution: Some(ViduQ3TurboImageToVideoResolution::TenEightyP), + audio: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.image_url, "https://example.com/start.png"); + assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png")); + assert_eq!(raw.duration, Some(7)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.audio, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = ViduQ3TurboImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + seed: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }), + ); + } + + #[test] + fn seed_serializes_when_set() { + let request = ViduQ3TurboImageToVideoRequest { + prompt: "p".to_string(), + image_url: "https://example.com/start.png".to_string(), + end_image_url: None, + duration: None, + seed: Some(777), + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777)); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + ViduQ3TurboImageToVideoRequest::ENDPOINT, + "fal-ai/vidu/q3/image-to-video/turbo", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/cost.rs new file mode 100644 index 0000000000..f674cd75b2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/cost.rs @@ -0,0 +1,80 @@ +use crate::requests::api::video::image::vidu_q3_turbo::api::{ + ViduQ3TurboImageToVideoRequest, ViduQ3TurboImageToVideoResolution, +}; +use crate::requests::api::video::text::vidu_q3_turbo::cost::{ + vidu_q3_turbo_cost_cents, vidu_q3_turbo_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Image-to-video shares the Vidu Q3 Turbo per-second pricing (see the text +// module for the canonical rate table): +// 360p / 540p: $0.035/sec +// 720p / 1080p: $0.077/sec (2.2×) +// Pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for ViduQ3TurboImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 720p. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_high_res = self.resolution + .unwrap_or(ViduQ3TurboImageToVideoResolution::SevenTwentyP) + .is_high_res(); + + let rate = vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res); + vidu_q3_turbo_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + ) -> ViduQ3TurboImageToVideoRequest { + ViduQ3TurboImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/x.png".to_string(), + end_image_url: None, + duration, + seed: None, + resolution, + audio: Some(true), + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(5), Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), 18), // 17.5 → 18 + (Some(5), Some(ViduQ3TurboImageToVideoResolution::FiveFortyP), 18), + (Some(5), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 39), // 38.5 → 39 + (Some(5), Some(ViduQ3TurboImageToVideoResolution::TenEightyP), 39), + (Some(10), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 77), + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 39), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// An end frame does not change the bill (only resolution + duration do). + #[test] + fn cost_is_independent_of_end_frame() { + let without = make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents(); + let with = ViduQ3TurboImageToVideoRequest { + end_image_url: Some("https://example.com/end.png".to_string()), + ..make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP)) + }.calculate_cost_in_cents(); + assert_eq!(without, with); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/raw_request.rs new file mode 100644 index 0000000000..a1d59532f2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/image/vidu_q3_turbo/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video/turbo`. +/// fal's schema: +/// +/// NB: this endpoint has no `aspect_ratio` (derived from the input image). +/// A `prompt` is optional server-side but always sent here. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ViduQ3TurboImageToVideoInput { + /// Text prompt (max 2000 characters). Optional server-side. + pub prompt: String, + + /// URL of the image used as the starting frame (URL or base64). + pub image_url: String, + + /// Optional URL of an ending frame, for start→end transition videos. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Duration in seconds. Range 1–16. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Seed for reproducibility. Random when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Output resolution. + /// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p". + /// (360p is unavailable when `end_image_url` is set.) + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3TurboImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3TurboImageToVideoOutput { + pub video: ViduQ3TurboImageToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/api.rs new file mode 100644 index 0000000000..acbd62e6f2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/api.rs @@ -0,0 +1,148 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::images::flux_3::raw_request::{ + Flux3FirstLastFrameToVideoInput, Flux3FirstLastFrameToVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux3FirstLastFrameToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the first frame. + pub start_image_url: String, + + /// URL of the last frame. + pub end_image_url: String, + + /// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3FirstLastFrameToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video"; + + type RawRequest = Flux3FirstLastFrameToVideoInput; + type RawResponse = Flux3FirstLastFrameToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + start_image_url: self.start_image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const START_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg"; + const END_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3FirstLastFrameToVideoRequest { + prompt: "the scene smoothly transitions from the first frame to the last".to_string(), + start_image_url: START_IMAGE_URL.to_string(), + end_image_url: END_IMAGE_URL.to_string(), + duration: Some(5), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3FirstLastFrameToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/first.png".to_string(), + end_image_url: "https://example.com/last.png".to_string(), + duration: Some(12), + resolution: Some(Flux3Resolution::TenEightyP), + aspect_ratio: Some(Flux3AspectRatio::FourByThree), + generate_audio: Some(true), + safety_tolerance: Some(3), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.start_image_url, "https://example.com/first.png"); + assert_eq!(raw.end_image_url, "https://example.com/last.png"); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.safety_tolerance, Some(3)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3FirstLastFrameToVideoRequest { + prompt: "minimal".to_string(), + start_image_url: "https://example.com/first.png".to_string(), + end_image_url: "https://example.com/last.png".to_string(), + duration: None, + resolution: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "start_image_url": "https://example.com/first.png", + "end_image_url": "https://example.com/last.png", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Flux3FirstLastFrameToVideoRequest::ENDPOINT, + "blackforestlabs/flux-3/first-last-frame-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/cost.rs new file mode 100644 index 0000000000..3e31f92249 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/cost.rs @@ -0,0 +1,64 @@ +use crate::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest; +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// First-last-frame-to-video shares the standard Flux 3 per-second pricing +// (see the text module for the canonical rate table): +// 720p: $0.17/sec +// 1080p: $0.29/sec +// The input frames are free; pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for Flux3FirstLastFrameToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal default when unset: duration = 5s. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution)); + flux_3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::Flux3Resolution; + + fn make_request( + duration: Option, + resolution: Option, + ) -> Flux3FirstLastFrameToVideoRequest { + Flux3FirstLastFrameToVideoRequest { + prompt: "test".to_string(), + start_image_url: "https://example.com/first.png".to_string(), + end_image_url: "https://example.com/last.png".to_string(), + duration, + resolution, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(5), Some(Flux3Resolution::SevenTwentyP), 85), + (Some(20), Some(Flux3Resolution::SevenTwentyP), 340), + (Some(5), Some(Flux3Resolution::TenEightyP), 145), + (Some(20), Some(Flux3Resolution::TenEightyP), 580), + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 85), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/raw_request.rs new file mode 100644 index 0000000000..8e1a4c10ea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3/raw_request.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3FirstLastFrameToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the first frame. + pub start_image_url: String, + + /// URL of the last frame. + pub end_image_url: String, + + /// Duration in seconds (5–20). fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3FirstLastFrameToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3FirstLastFrameToVideoOutput { + pub video: Flux3FirstLastFrameToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/api.rs new file mode 100644 index 0000000000..061899d377 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/api.rs @@ -0,0 +1,143 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::images::flux_3_draft::raw_request::{ + Flux3DraftFirstLastFrameToVideoInput, Flux3DraftFirstLastFrameToVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Flux 3 Draft first-last-frame-to-video: lower cost, lower fidelity (always +/// 720p). The response includes a `draft_cache` that `draft-enhance` can +/// upgrade to a full-quality 1080p render. +#[derive(Clone, Debug)] +pub struct Flux3DraftFirstLastFrameToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the first frame. + pub start_image_url: String, + + /// URL of the last frame. + pub end_image_url: String, + + /// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`. + pub duration: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3DraftFirstLastFrameToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video/draft"; + + type RawRequest = Flux3DraftFirstLastFrameToVideoInput; + type RawResponse = Flux3DraftFirstLastFrameToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + start_image_url: self.start_image_url.clone(), + end_image_url: self.end_image_url.clone(), + duration: self.duration, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const START_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg"; + const END_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftFirstLastFrameToVideoRequest { + prompt: "the scene smoothly transitions from the first frame to the last".to_string(), + start_image_url: START_IMAGE_URL.to_string(), + end_image_url: END_IMAGE_URL.to_string(), + duration: Some(5), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3DraftFirstLastFrameToVideoRequest { + prompt: "p".to_string(), + start_image_url: "https://example.com/first.png".to_string(), + end_image_url: "https://example.com/last.png".to_string(), + duration: Some(7), + aspect_ratio: Some(Flux3AspectRatio::Square), + generate_audio: Some(false), + safety_tolerance: Some(2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.start_image_url, "https://example.com/first.png"); + assert_eq!(raw.end_image_url, "https://example.com/last.png"); + assert_eq!(raw.duration, Some(7)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.safety_tolerance, Some(2)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3DraftFirstLastFrameToVideoRequest { + prompt: "minimal".to_string(), + start_image_url: "https://example.com/first.png".to_string(), + end_image_url: "https://example.com/last.png".to_string(), + duration: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "start_image_url": "https://example.com/first.png", + "end_image_url": "https://example.com/last.png", + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Flux3DraftFirstLastFrameToVideoRequest::ENDPOINT, + "blackforestlabs/flux-3/first-last-frame-to-video/draft"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/cost.rs new file mode 100644 index 0000000000..8c002cdaba --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/cost.rs @@ -0,0 +1,52 @@ +use crate::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest; +use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents; +use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Draft first-last-frame-to-video shares the Flux 3 Draft generation pricing +// (see the draft text module for the canonical rate): +// $0.06 per second of generated draft video (always 720p) + +impl FalRequestCostCalculator for Flux3DraftFirstLastFrameToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal default when unset: duration = 5s. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request(duration: Option) -> Flux3DraftFirstLastFrameToVideoRequest { + Flux3DraftFirstLastFrameToVideoRequest { + prompt: "test".to_string(), + start_image_url: "https://example.com/first.png".to_string(), + end_image_url: "https://example.com/last.png".to_string(), + duration, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, expected_cents) — 6¢ × secs; None defaults to 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(5), 30), + (Some(20), 120), + (None, 30), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + let got = make_request(duration).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..0dbc91873a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/flux_3_draft/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video/draft`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3DraftFirstLastFrameToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// URL of the first frame. + pub start_image_url: String, + + /// URL of the last frame. + pub end_image_url: String, + + /// Duration in seconds (5–20). fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftFirstLastFrameToVideoVideoFile { + pub url: String, +} + +/// Draft outputs carry the 720p draft video plus a durable encrypted cache +/// bundle whose URL can be passed to `draft-enhance` for a full-quality +/// 1080p render. +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftFirstLastFrameToVideoOutput { + pub video: Flux3DraftFirstLastFrameToVideoVideoFile, + pub draft_cache: Flux3DraftFirstLastFrameToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs new file mode 100644 index 0000000000..c0dd954dfb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/mod.rs @@ -0,0 +1,5 @@ +pub mod flux_3; +pub mod flux_3_draft; +pub mod veo_3p1; +pub mod veo_3p1_fast; +pub mod veo_3p1_lite; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/api.rs new file mode 100644 index 0000000000..9f4a870383 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/api.rs @@ -0,0 +1,286 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::images::veo_3p1::raw_request::{ + Veo3p1FirstLastFrameToVideoInput, Veo3p1FirstLastFrameToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1FirstLastFrameToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the first frame. + pub first_frame_url: String, + + /// URL of the image used as the last frame. + pub last_frame_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FirstLastFrameToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1FirstLastFrameToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FirstLastFrameToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1FirstLastFrameToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FirstLastFrameToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1FirstLastFrameToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FirstLastFrameToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1FirstLastFrameToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1FirstLastFrameToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/first-last-frame-to-video"; + + type RawRequest = Veo3p1FirstLastFrameToVideoInput; + type RawResponse = Veo3p1FirstLastFrameToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + first_frame_url: self.first_frame_url.clone(), + last_frame_url: self.last_frame_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FirstLastFrameToVideoRequest { + prompt: "a smooth transition from the first scene to the last".to_string(), + first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FirstLastFrameToVideoRequest { + prompt: "morph between the two frames".to_string(), + first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1FirstLastFrameToVideoRequest { + prompt: "p".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(true), + negative_prompt: Some("nope".to_string()), + seed: Some(1), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1FirstLastFrameToVideoSafetyTolerance::Level3), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.first_frame_url, "https://example.com/first.png"); + assert_eq!(raw.last_frame_url, "https://example.com/last.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("4s")); + assert_eq!(raw.resolution.as_deref(), Some("720p")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(1)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("3")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1FirstLastFrameToVideoRequest { + prompt: "p".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "p", + "first_frame_url": "https://example.com/first.png", + "last_frame_url": "https://example.com/last.png", + }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1FirstLastFrameToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/first-last-frame-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/cost.rs new file mode 100644 index 0000000000..b5b5839276 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/cost.rs @@ -0,0 +1,85 @@ +use crate::requests::api::video::images::veo_3p1::api::{ + Veo3p1FirstLastFrameToVideoDuration, Veo3p1FirstLastFrameToVideoRequest, + Veo3p1FirstLastFrameToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1::cost::{ + veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// First-last-frame-to-video shares the Veo 3.1 (non-fast) per-second pricing +// (see the text module for the canonical rate table): +// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on) +// 4k: $0.40/sec (audio off), $0.60/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1FirstLastFrameToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1FirstLastFrameToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1FirstLastFrameToVideoRequest { + Veo3p1FirstLastFrameToVideoRequest { + prompt: "test".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: None, + duration, + resolution, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 80), + (Some(Veo3p1FirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 240), + (Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(false), 160), + (Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(true), 320), + (Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(false), 320), + (Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(true), 480), + (None, None, None, 320), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/raw_request.rs new file mode 100644 index 0000000000..3ae7e4944f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1/raw_request.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/first-last-frame-to-video` +/// (start + end keyframes). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FirstLastFrameToVideoInput { + pub prompt: String, + + /// URL of the image used as the first frame. + pub first_frame_url: String, + + /// URL of the image used as the last frame. + pub last_frame_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FirstLastFrameToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FirstLastFrameToVideoOutput { + pub video: Veo3p1FirstLastFrameToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/api.rs new file mode 100644 index 0000000000..f0b40cc6dd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/api.rs @@ -0,0 +1,286 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::images::veo_3p1_fast::raw_request::{ + Veo3p1FastFirstLastFrameToVideoInput, Veo3p1FastFirstLastFrameToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1FastFirstLastFrameToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the first frame. + pub first_frame_url: String, + + /// URL of the image used as the last frame. + pub last_frame_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastFirstLastFrameToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1FastFirstLastFrameToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastFirstLastFrameToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1FastFirstLastFrameToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastFirstLastFrameToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1FastFirstLastFrameToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastFirstLastFrameToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1FastFirstLastFrameToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1FastFirstLastFrameToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/fast/first-last-frame-to-video"; + + type RawRequest = Veo3p1FastFirstLastFrameToVideoInput; + type RawResponse = Veo3p1FastFirstLastFrameToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + first_frame_url: self.first_frame_url.clone(), + last_frame_url: self.last_frame_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastFirstLastFrameToVideoRequest { + prompt: "a smooth transition from the first scene to the last".to_string(), + first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastFirstLastFrameToVideoRequest { + prompt: "morph between the two frames".to_string(), + first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1FastFirstLastFrameToVideoRequest { + prompt: "p".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(true), + negative_prompt: Some("nope".to_string()), + seed: Some(1), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1FastFirstLastFrameToVideoSafetyTolerance::Level3), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.first_frame_url, "https://example.com/first.png"); + assert_eq!(raw.last_frame_url, "https://example.com/last.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("4s")); + assert_eq!(raw.resolution.as_deref(), Some("720p")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(1)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("3")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1FastFirstLastFrameToVideoRequest { + prompt: "p".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "p", + "first_frame_url": "https://example.com/first.png", + "last_frame_url": "https://example.com/last.png", + }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1FastFirstLastFrameToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/fast/first-last-frame-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..90b920389b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/cost.rs @@ -0,0 +1,85 @@ +use crate::requests::api::video::images::veo_3p1_fast::api::{ + Veo3p1FastFirstLastFrameToVideoDuration, Veo3p1FastFirstLastFrameToVideoRequest, + Veo3p1FastFirstLastFrameToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1_fast::cost::{ + veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// First-last-frame-to-video shares the Veo 3.1 Fast per-second pricing (see the +// text module for the canonical rate table): +// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on) +// 4k: $0.30/sec (audio off), $0.35/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1FastFirstLastFrameToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1FastFirstLastFrameToVideoRequest { + Veo3p1FastFirstLastFrameToVideoRequest { + prompt: "test".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: None, + duration, + resolution, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 40), + (Some(Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 90), + (Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(false), 80), + (Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(true), 120), + (Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(false), 240), + (Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(true), 280), + (None, None, None, 120), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/raw_request.rs new file mode 100644 index 0000000000..6dc26af174 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_fast/raw_request.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/fast/first-last-frame-to-video` +/// (start + end keyframes). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastFirstLastFrameToVideoInput { + pub prompt: String, + + /// URL of the image used as the first frame. + pub first_frame_url: String, + + /// URL of the image used as the last frame. + pub last_frame_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastFirstLastFrameToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastFirstLastFrameToVideoOutput { + pub video: Veo3p1FastFirstLastFrameToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/api.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/api.rs new file mode 100644 index 0000000000..6a49bfb8e3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/api.rs @@ -0,0 +1,286 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::images::veo_3p1_lite::raw_request::{ + Veo3p1LiteFirstLastFrameToVideoInput, Veo3p1LiteFirstLastFrameToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1LiteFirstLastFrameToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// URL of the image used as the first frame. + pub first_frame_url: String, + + /// URL of the image used as the last frame. + pub last_frame_url: String, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteFirstLastFrameToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1LiteFirstLastFrameToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteFirstLastFrameToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1LiteFirstLastFrameToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND +/// audio affect the per-second price. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteFirstLastFrameToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3p1LiteFirstLastFrameToVideoResolution { + /// 1080p is billed at a higher per-second rate than 720p. + pub fn is_ten_eighty_p(&self) -> bool { + matches!(self, Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteFirstLastFrameToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1LiteFirstLastFrameToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1LiteFirstLastFrameToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/lite/first-last-frame-to-video"; + + type RawRequest = Veo3p1LiteFirstLastFrameToVideoInput; + type RawResponse = Veo3p1LiteFirstLastFrameToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + first_frame_url: self.first_frame_url.clone(), + last_frame_url: self.last_frame_url.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1LiteFirstLastFrameToVideoRequest { + prompt: "a smooth transition from the first scene to the last".to_string(), + first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_first_last_frame_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1LiteFirstLastFrameToVideoRequest { + prompt: "morph between the two frames".to_string(), + first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + aspect_ratio: None, + duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1LiteFirstLastFrameToVideoRequest { + prompt: "p".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), + generate_audio: Some(true), + negative_prompt: Some("nope".to_string()), + seed: Some(1), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1LiteFirstLastFrameToVideoSafetyTolerance::Level3), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.first_frame_url, "https://example.com/first.png"); + assert_eq!(raw.last_frame_url, "https://example.com/last.png"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(raw.duration.as_deref(), Some("4s")); + assert_eq!(raw.resolution.as_deref(), Some("720p")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.negative_prompt.as_deref(), Some("nope")); + assert_eq!(raw.seed, Some(1)); + assert_eq!(raw.auto_fix, Some(true)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("3")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1LiteFirstLastFrameToVideoRequest { + prompt: "p".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "p", + "first_frame_url": "https://example.com/first.png", + "last_frame_url": "https://example.com/last.png", + }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1LiteFirstLastFrameToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/lite/first-last-frame-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/cost.rs new file mode 100644 index 0000000000..d449c3f638 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/cost.rs @@ -0,0 +1,85 @@ +use crate::requests::api::video::images::veo_3p1_lite::api::{ + Veo3p1LiteFirstLastFrameToVideoDuration, Veo3p1LiteFirstLastFrameToVideoRequest, + Veo3p1LiteFirstLastFrameToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1_lite::cost::{ + veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// First-last-frame-to-video shares the Veo 3.1 Lite per-second pricing (see the +// text module for the canonical rate table). Both resolution and audio matter: +// 720p: $0.03/sec (audio off), $0.05/sec (audio on) +// 1080p: $0.05/sec (audio off), $0.08/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1LiteFirstLastFrameToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds) + .to_seconds(); + let is_1080p = self.resolution + .unwrap_or(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP) + .is_ten_eighty_p(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on); + veo_3p1_lite_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1LiteFirstLastFrameToVideoRequest { + Veo3p1LiteFirstLastFrameToVideoRequest { + prompt: "test".to_string(), + first_frame_url: "https://example.com/first.png".to_string(), + last_frame_url: "https://example.com/last.png".to_string(), + aspect_ratio: None, + duration, + resolution, + generate_audio, + negative_prompt: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 12), + (Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 20), + (Some(Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 48), + (Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(false), 40), + (Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 64), + // Defaults: 8s, 720p, audio on + (None, None, None, 40), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/raw_request.rs new file mode 100644 index 0000000000..bc487c87b3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/images/veo_3p1_lite/raw_request.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/lite/first-last-frame-to-video` +/// (start + end keyframes). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1LiteFirstLastFrameToVideoInput { + pub prompt: String, + + /// URL of the image used as the first frame. + pub first_frame_url: String, + + /// URL of the image used as the last frame. + pub last_frame_url: String, + + /// Aspect ratio. + /// Possible values: "auto", "16:9", "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1LiteFirstLastFrameToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1LiteFirstLastFrameToVideoOutput { + pub video: Veo3p1LiteFirstLastFrameToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/api.rs new file mode 100644 index 0000000000..995f33728c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/api.rs @@ -0,0 +1,150 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::keyframes::flux_3::raw_request::{ + Flux3Keyframe, Flux3KeyframesToVideoInput, Flux3KeyframesToVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux3KeyframesToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// Keyframe images pinned to frame positions in the generated 24 fps video + /// (1 to 10 entries with unique `frame_index` values, each at most + /// `duration * 24`). + pub keyframes: Vec, + + /// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3KeyframesToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/keyframes-to-video"; + + type RawRequest = Flux3KeyframesToVideoInput; + type RawResponse = Flux3KeyframesToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + keyframes: self.keyframes.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const KEYFRAME_ONE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg"; + const KEYFRAME_TWO_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_keyframes_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3KeyframesToVideoRequest { + prompt: "the scene flows naturally between the pinned keyframes".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: KEYFRAME_ONE_URL.to_string(), frame_index: 0 }, + Flux3Keyframe { image_url: KEYFRAME_TWO_URL.to_string(), frame_index: 96 }, + ], + duration: Some(5), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3KeyframesToVideoRequest { + prompt: "p".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 }, + Flux3Keyframe { image_url: "https://example.com/b.png".to_string(), frame_index: 120 }, + ], + duration: Some(10), + resolution: Some(Flux3Resolution::TenEightyP), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(true), + safety_tolerance: Some(2), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.keyframes.len(), 2); + assert_eq!(raw.keyframes[0].image_url, "https://example.com/a.png"); + assert_eq!(raw.keyframes[0].frame_index, 0); + assert_eq!(raw.keyframes[1].frame_index, 120); + assert_eq!(raw.duration, Some(10)); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3KeyframesToVideoRequest { + prompt: "minimal".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 }, + ], + duration: None, + resolution: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "keyframes": [ + { "image_url": "https://example.com/a.png", "frame_index": 0 }, + ], + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3KeyframesToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/keyframes-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/cost.rs new file mode 100644 index 0000000000..66203e8d9d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::video::keyframes::flux_3::api::Flux3KeyframesToVideoRequest; +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Keyframes-to-video shares the standard Flux 3 per-second pricing (see the +// text module for the canonical rate table): +// 720p: $0.17/sec +// 1080p: $0.29/sec +// Keyframe images are free; pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for Flux3KeyframesToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal default when unset: duration = 5s. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution)); + flux_3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe; + use crate::requests::api::video::text::flux_3::api::Flux3Resolution; + + fn make_request( + duration: Option, + resolution: Option, + keyframe_count: u32, + ) -> Flux3KeyframesToVideoRequest { + Flux3KeyframesToVideoRequest { + prompt: "test".to_string(), + keyframes: (0..keyframe_count) + .map(|i| Flux3Keyframe { + image_url: format!("https://example.com/kf-{i}.png"), + frame_index: i * 24, + }) + .collect(), + duration, + resolution, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(5), Some(Flux3Resolution::SevenTwentyP), 85), + (Some(20), Some(Flux3Resolution::SevenTwentyP), 340), + (Some(5), Some(Flux3Resolution::TenEightyP), 145), + (Some(20), Some(Flux3Resolution::TenEightyP), 580), + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 85), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution, 2).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// The number of keyframes does not affect the bill. + #[test] + fn cost_is_independent_of_keyframe_count() { + let one = make_request(Some(10), None, 1).calculate_cost_in_cents(); + let ten = make_request(Some(10), None, 10).calculate_cost_in_cents(); + assert_eq!(one, ten); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/raw_request.rs new file mode 100644 index 0000000000..6c22ac4931 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3/raw_request.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3KeyframesToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// Keyframe images pinned to frame positions (1 to 10 entries with unique + /// `frame_index` values). + pub keyframes: Vec, + + /// Duration in seconds (5–20). fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +/// A keyframe image pinned to a frame position in the generated 24 fps video. +/// Shared by the full-quality and draft keyframes endpoints. +#[derive(Clone, Debug, Serialize, Deserialize, Default, Eq, PartialEq)] +pub struct Flux3Keyframe { + /// URL of the keyframe image (PNG, JPEG, or WebP). + pub image_url: String, + + /// Frame position of this keyframe in the generated 24 fps video. Must be + /// unique and at most `duration * 24`. + pub frame_index: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3KeyframesToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3KeyframesToVideoOutput { + pub video: Flux3KeyframesToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/api.rs new file mode 100644 index 0000000000..47f94749c7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/api.rs @@ -0,0 +1,147 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe; +use crate::requests::api::video::keyframes::flux_3_draft::raw_request::{ + Flux3DraftKeyframesToVideoInput, Flux3DraftKeyframesToVideoOutput, +}; +use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Flux 3 Draft keyframes-to-video: lower cost, lower fidelity (always 720p). +/// The response includes a `draft_cache` that `draft-enhance` can upgrade to +/// a full-quality 1080p render. +#[derive(Clone, Debug)] +pub struct Flux3DraftKeyframesToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// Keyframe images pinned to frame positions in the generated 24 fps video + /// (1 to 10 entries with unique `frame_index` values, each at most + /// `duration * 24`). + pub keyframes: Vec, + + /// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`. + pub duration: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3DraftKeyframesToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/keyframes-to-video/draft"; + + type RawRequest = Flux3DraftKeyframesToVideoInput; + type RawResponse = Flux3DraftKeyframesToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + keyframes: self.keyframes.clone(), + duration: self.duration, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const KEYFRAME_ONE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg"; + const KEYFRAME_TWO_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_keyframes_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftKeyframesToVideoRequest { + prompt: "the scene flows naturally between the pinned keyframes".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: KEYFRAME_ONE_URL.to_string(), frame_index: 0 }, + Flux3Keyframe { image_url: KEYFRAME_TWO_URL.to_string(), frame_index: 96 }, + ], + duration: Some(5), + aspect_ratio: None, + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3DraftKeyframesToVideoRequest { + prompt: "p".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 48 }, + ], + duration: Some(6), + aspect_ratio: Some(Flux3AspectRatio::TwentyOneByNine), + generate_audio: Some(false), + safety_tolerance: Some(0), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.keyframes.len(), 1); + assert_eq!(raw.keyframes[0].frame_index, 48); + assert_eq!(raw.duration, Some(6)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("21:9")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.safety_tolerance, Some(0)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3DraftKeyframesToVideoRequest { + prompt: "minimal".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 }, + ], + duration: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ + "prompt": "minimal", + "keyframes": [ + { "image_url": "https://example.com/a.png", "frame_index": 0 }, + ], + })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Flux3DraftKeyframesToVideoRequest::ENDPOINT, + "blackforestlabs/flux-3/keyframes-to-video/draft"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/cost.rs new file mode 100644 index 0000000000..0f0a2a2783 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/cost.rs @@ -0,0 +1,54 @@ +use crate::requests::api::video::keyframes::flux_3_draft::api::Flux3DraftKeyframesToVideoRequest; +use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents; +use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Draft keyframes-to-video shares the Flux 3 Draft generation pricing (see +// the draft text module for the canonical rate): +// $0.06 per second of generated draft video (always 720p) + +impl FalRequestCostCalculator for Flux3DraftKeyframesToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal default when unset: duration = 5s. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe; + + fn make_request(duration: Option) -> Flux3DraftKeyframesToVideoRequest { + Flux3DraftKeyframesToVideoRequest { + prompt: "test".to_string(), + keyframes: vec![ + Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 }, + ], + duration, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, expected_cents) — 6¢ × secs; None defaults to 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(5), 30), + (Some(20), 120), + (None, 30), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + let got = make_request(duration).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..eeaf162903 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/flux_3_draft/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video/draft`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3DraftKeyframesToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// Keyframe images pinned to frame positions (1 to 10 entries with unique + /// `frame_index` values). + pub keyframes: Vec, + + /// Duration in seconds (5–20). fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftKeyframesToVideoVideoFile { + pub url: String, +} + +/// Draft outputs carry the 720p draft video plus a durable encrypted cache +/// bundle whose URL can be passed to `draft-enhance` for a full-quality +/// 1080p render. +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftKeyframesToVideoOutput { + pub video: Flux3DraftKeyframesToVideoVideoFile, + pub draft_cache: Flux3DraftKeyframesToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/keyframes/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/keyframes/mod.rs new file mode 100644 index 0000000000..b5ffdeab61 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/keyframes/mod.rs @@ -0,0 +1,2 @@ +pub mod flux_3; +pub mod flux_3_draft; diff --git a/crates/api_clients/fal_client/src/requests/api/video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/mod.rs new file mode 100644 index 0000000000..25e6179b59 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/mod.rs @@ -0,0 +1,8 @@ +pub mod elements; +pub mod enhance; +pub mod extend; +pub mod image; +pub mod images; +pub mod keyframes; +pub mod reference; +pub mod text; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/api.rs new file mode 100644 index 0000000000..6766db26dc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/api.rs @@ -0,0 +1,200 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::reference::minimax_h3::raw_request::{ + MinimaxH3ReferenceToVideoInput, MinimaxH3ReferenceToVideoOutput, +}; +use crate::requests::api::video::text::minimax_h3::api::MinimaxH3Resolution; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct MinimaxH3ReferenceToVideoRequest { + /// Text prompt (1 to 7000 characters). Refer to reference assets by their + /// modality and order in the reference lists: Image 1, Image 2, Video 1, + /// Audio 1, and so on. + pub prompt: String, + + /// URLs of subject/style reference images (at most 9). Reference images, + /// videos, and audio clips must add up to at most 12 files. + pub reference_image_urls: Option>, + + /// URLs of motion/reference video clips (at most 3; 2–15 seconds each, + /// combined duration at most 15 seconds). + pub reference_video_urls: Option>, + + /// URLs of reference audio clips (at most 3; 2–15 seconds each, combined + /// duration at most 15 seconds). Audio cannot be the only reference input. + pub reference_audio_urls: Option>, + + /// Duration in seconds. Valid range 5–15; fal's default is `5` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `2K` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `adaptive` when `None`. + pub aspect_ratio: Option, +} + +/// Reference-to-video aspect ratios. Unlike the text modality, `adaptive` +/// (derive the ratio from the reference assets) is supported and is fal's +/// default. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MinimaxH3ReferenceToVideoAspectRatio { + Adaptive, + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +impl MinimaxH3ReferenceToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::Adaptive => "adaptive", + Self::TwentyOneByNine => "21:9", + Self::SixteenByNine => "16:9", + Self::FourByThree => "4:3", + Self::Square => "1:1", + Self::ThreeByFour => "3:4", + Self::NineBySixteen => "9:16", + } + } +} + +impl FalEndpoint for MinimaxH3ReferenceToVideoRequest { + const ENDPOINT: &str = "minimax/h3/reference-to-video"; + + type RawRequest = MinimaxH3ReferenceToVideoInput; + type RawResponse = MinimaxH3ReferenceToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + reference_image_urls: self.reference_image_urls.clone(), + reference_video_urls: self.reference_video_urls.clone(), + reference_audio_urls: self.reference_audio_urls.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + const EXAMPLE_IMAGE_URL: &str = + "https://storage.googleapis.com/falserverless/example_inputs/hailuo23/pro_i2v_in.jpg"; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = MinimaxH3ReferenceToVideoRequest { + prompt: "Image 1 is the subject. Keep the subject consistent while the camera slowly orbits.".to_string(), + reference_image_urls: Some(vec![EXAMPLE_IMAGE_URL.to_string()]), + reference_video_urls: None, + reference_audio_urls: None, + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + aspect_ratio: Some(MinimaxH3ReferenceToVideoAspectRatio::Adaptive), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = MinimaxH3ReferenceToVideoRequest { + prompt: "Image 1 is the subject. The subject walks through a sunlit garden.".to_string(), + reference_image_urls: Some(vec![EXAMPLE_IMAGE_URL.to_string()]), + reference_video_urls: None, + reference_audio_urls: None, + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + aspect_ratio: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = MinimaxH3ReferenceToVideoRequest { + prompt: "p".to_string(), + reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]), + reference_video_urls: Some(vec!["https://example.com/b.mp4".to_string()]), + reference_audio_urls: Some(vec!["https://example.com/c.mp3".to_string()]), + duration: Some(12), + resolution: Some(MinimaxH3Resolution::TwoK), + aspect_ratio: Some(MinimaxH3ReferenceToVideoAspectRatio::Adaptive), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.reference_image_urls.as_deref(), Some(&["https://example.com/a.png".to_string()][..])); + assert_eq!(raw.reference_video_urls.as_deref(), Some(&["https://example.com/b.mp4".to_string()][..])); + assert_eq!(raw.reference_audio_urls.as_deref(), Some(&["https://example.com/c.mp3".to_string()][..])); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.resolution.as_deref(), Some("2K")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("adaptive")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = MinimaxH3ReferenceToVideoRequest { + prompt: "minimal".to_string(), + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + duration: None, + resolution: None, + aspect_ratio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (MinimaxH3ReferenceToVideoAspectRatio::Adaptive, "adaptive"), + (MinimaxH3ReferenceToVideoAspectRatio::TwentyOneByNine, "21:9"), + (MinimaxH3ReferenceToVideoAspectRatio::SixteenByNine, "16:9"), + (MinimaxH3ReferenceToVideoAspectRatio::FourByThree, "4:3"), + (MinimaxH3ReferenceToVideoAspectRatio::Square, "1:1"), + (MinimaxH3ReferenceToVideoAspectRatio::ThreeByFour, "3:4"), + (MinimaxH3ReferenceToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(MinimaxH3ReferenceToVideoRequest::ENDPOINT, "minimax/h3/reference-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/cost.rs new file mode 100644 index 0000000000..7149c1b620 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/cost.rs @@ -0,0 +1,127 @@ +use crate::requests::api::video::reference::minimax_h3::api::MinimaxH3ReferenceToVideoRequest; +use crate::requests::api::video::text::minimax_h3::api::MinimaxH3Resolution; +use crate::requests::api::video::text::minimax_h3::cost::{ + minimax_h3_rate_cents_per_sec, minimax_h3_video_cost_cents, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Reference-to-video shares the MiniMax H3 per-second pricing (see the text +// module for the canonical rate table): +// 768P: $0.16/sec +// 2K: $0.26/sec +// On top of that, the first 5 reference images are free and each additional +// image costs $0.08. Reference videos and audio clips are free. +const FREE_REFERENCE_IMAGES: u64 = 5; +const EXTRA_REFERENCE_IMAGE_CENTS: u64 = 8; // $0.08 per image beyond the first 5 + +impl FalRequestCostCalculator for MinimaxH3ReferenceToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 2K. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_2k = self.resolution + .unwrap_or(MinimaxH3Resolution::TwoK) + .is_2k(); + + let rate = minimax_h3_rate_cents_per_sec(is_2k); + let video_cents = minimax_h3_video_cost_cents(rate, duration_secs); + + let image_count = self.reference_image_urls.as_ref().map_or(0, |urls| urls.len() as u64); + let extra_image_cents = image_count.saturating_sub(FREE_REFERENCE_IMAGES) + * EXTRA_REFERENCE_IMAGE_CENTS; + + video_cents + extra_image_cents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + image_count: usize, + ) -> MinimaxH3ReferenceToVideoRequest { + let images = (0..image_count) + .map(|i| format!("https://example.com/ref_{i}.png")) + .collect::>(); + MinimaxH3ReferenceToVideoRequest { + prompt: "test".to_string(), + reference_image_urls: if images.is_empty() { None } else { Some(images) }, + reference_video_urls: None, + reference_audio_urls: None, + duration, + resolution, + aspect_ratio: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, image_count, expected_cents) + // Math: rate × secs + 8¢ × max(0, images − 5), where rate (cents) = 16 + // (768P) or 26 (2K). + const COST_TABLE: &[(Option, Option, usize, u64)] = &[ + // 768P → $0.16/s; up to 5 reference images are free + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 0, 80), + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 1, 80), + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 5, 80), + (Some(15), Some(MinimaxH3Resolution::SevenSixtyEightP), 5, 240), + // 2K → $0.26/s + (Some(5), Some(MinimaxH3Resolution::TwoK), 5, 130), + (Some(15), Some(MinimaxH3Resolution::TwoK), 5, 390), + // Each image beyond the 5th adds 8¢ + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 6, 88), + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 9, 112), // 80 + 4×8 + (Some(5), Some(MinimaxH3Resolution::TwoK), 6, 138), + (Some(5), Some(MinimaxH3Resolution::TwoK), 9, 162), // 130 + 4×8 + // Defaults: duration=None→5s, resolution=None→2K + (None, None, 0, 130), + (None, None, 9, 162), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, image_count, expected) in COST_TABLE { + let got = make_request(duration, resolution, image_count).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} images={image_count}"); + } + } + + #[test] + fn first_five_images_are_free() { + let none = make_request(Some(5), Some(MinimaxH3Resolution::TwoK), 0).calculate_cost_in_cents(); + for image_count in 1..=5 { + let cost = make_request(Some(5), Some(MinimaxH3Resolution::TwoK), image_count).calculate_cost_in_cents(); + assert_eq!(cost, none, "images={image_count} should not change the bill"); + } + } + + #[test] + fn each_extra_image_adds_eight_cents() { + let five = make_request(Some(5), Some(MinimaxH3Resolution::TwoK), 5).calculate_cost_in_cents(); + for (image_count, extra) in [(6, 8), (7, 16), (8, 24), (9, 32)] { + let cost = make_request(Some(5), Some(MinimaxH3Resolution::TwoK), image_count).calculate_cost_in_cents(); + assert_eq!(cost, five + extra, "images={image_count}"); + } + } + + /// Reference videos and audio clips do not affect the bill. + #[test] + fn cost_ignores_reference_videos_and_audio() { + let baseline = make_request(Some(5), Some(MinimaxH3Resolution::TwoK), 2).calculate_cost_in_cents(); + let with_extras = MinimaxH3ReferenceToVideoRequest { + reference_video_urls: Some(vec![ + "https://example.com/a.mp4".to_string(), + "https://example.com/b.mp4".to_string(), + ]), + reference_audio_urls: Some(vec!["https://example.com/a.mp3".to_string()]), + ..make_request(Some(5), Some(MinimaxH3Resolution::TwoK), 2) + }.calculate_cost_in_cents(); + assert_eq!(with_extras, baseline); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/raw_request.rs new file mode 100644 index 0000000000..5c6c9dd9a3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/minimax_h3/raw_request.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `minimax/h3/reference-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct MinimaxH3ReferenceToVideoInput { + /// Text prompt (1 to 7000 characters). Refer to reference assets by their + /// modality and order in the reference lists: Image 1, Image 2, Video 1, + /// Audio 1, and so on. + pub prompt: String, + + /// URLs of subject/style reference images (at most 9). Reference images, + /// videos, and audio clips must add up to at most 12 files. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_image_urls: Option>, + + /// URLs of motion/reference video clips (at most 3; 2–15 seconds each, + /// combined duration at most 15 seconds). + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_video_urls: Option>, + + /// URLs of reference audio clips (at most 3; 2–15 seconds each, combined + /// duration at most 15 seconds). Audio cannot be the only reference input. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_audio_urls: Option>, + + /// Duration in seconds. Range 5–15. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "768P", "2K". fal default: "2K". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16". + /// fal default: "adaptive". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinimaxH3ReferenceToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinimaxH3ReferenceToVideoOutput { + pub video: MinimaxH3ReferenceToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/mod.rs new file mode 100644 index 0000000000..fb4e8110f8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/mod.rs @@ -0,0 +1,5 @@ +pub mod minimax_h3; +pub mod veo_3p1; +pub mod veo_3p1_fast; +pub mod vidu_q3_reference_to_video; +pub mod vidu_q3_reference_to_video_mix; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/api.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/api.rs new file mode 100644 index 0000000000..87a17045ea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/api.rs @@ -0,0 +1,275 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::reference::veo_3p1::raw_request::{ + Veo3p1ReferenceToVideoInput, Veo3p1ReferenceToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1ReferenceToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Reference image URLs used for consistent subject appearance. + pub image_urls: Vec, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ReferenceToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1ReferenceToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ReferenceToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1ReferenceToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ReferenceToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1ReferenceToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1ReferenceToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1ReferenceToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1ReferenceToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/reference-to-video"; + + type RawRequest = Veo3p1ReferenceToVideoInput; + type RawResponse = Veo3p1ReferenceToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1ReferenceToVideoRequest { + prompt: "the two dogs run side by side across a sunlit meadow".to_string(), + image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1ReferenceToVideoDuration::FourSeconds), + resolution: Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1ReferenceToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()], + aspect_ratio: None, + duration: Some(Veo3p1ReferenceToVideoDuration::FourSeconds), + resolution: Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1ReferenceToVideoRequest { + prompt: "p".to_string(), + image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()], + aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1ReferenceToVideoDuration::SixSeconds), + resolution: Some(Veo3p1ReferenceToVideoResolution::FourK), + generate_audio: Some(true), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1ReferenceToVideoSafetyTolerance::Level5), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.image_urls.len(), 2); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("4k")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("5")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1ReferenceToVideoRequest { + prompt: "p".to_string(), + image_urls: vec!["https://example.com/a.png".to_string()], + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_urls": ["https://example.com/a.png"] }), + ); + } + + /// This endpoint's schema has no `negative_prompt`/`seed` fields, so they + /// must never appear on the wire regardless of other settings. + #[test] + fn raw_request_never_emits_negative_prompt_or_seed() { + let request = Veo3p1ReferenceToVideoRequest { + prompt: "p".to_string(), + image_urls: vec!["https://example.com/a.png".to_string()], + aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1ReferenceToVideoDuration::EightSeconds), + resolution: Some(Veo3p1ReferenceToVideoResolution::TenEightyP), + generate_audio: Some(true), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1ReferenceToVideoSafetyTolerance::Level4), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert!(json.get("negative_prompt").is_none(), "unexpected negative_prompt: {json}"); + assert!(json.get("seed").is_none(), "unexpected seed: {json}"); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1ReferenceToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/reference-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/cost.rs new file mode 100644 index 0000000000..d89dee06b9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/cost.rs @@ -0,0 +1,112 @@ +use crate::requests::api::video::reference::veo_3p1::api::{ + Veo3p1ReferenceToVideoDuration, Veo3p1ReferenceToVideoRequest, Veo3p1ReferenceToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1::cost::{ + veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Reference-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the +// text module for the canonical rate table). fal's reference-to-video docs +// explicitly document the 4k tier for this endpoint: +// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on) +// 4k: $0.40/sec (audio off), $0.60/sec (audio on) + +impl FalRequestCostCalculator for Veo3p1ReferenceToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1ReferenceToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1ReferenceToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1ReferenceToVideoRequest { + Veo3p1ReferenceToVideoRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/a.png".to_string()], + aspect_ratio: None, + duration, + resolution, + generate_audio, + auto_fix: None, + safety_tolerance: None, + } + } + + /// fal reference-to-video docs: "8 second video at 1080p with audio on will + /// cost $3.20" → 8s × $0.40 = 320¢. + #[test] + fn matches_documented_8s_1080p_audio_on_example() { + let cost = make_request( + Some(Veo3p1ReferenceToVideoDuration::EightSeconds), + Some(Veo3p1ReferenceToVideoResolution::TenEightyP), + Some(true), + ).calculate_cost_in_cents(); + assert_eq!(cost, 320); + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + (Some(Veo3p1ReferenceToVideoDuration::FourSeconds), Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), Some(false), 80), + (Some(Veo3p1ReferenceToVideoDuration::FourSeconds), Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), Some(true), 160), + (Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(false), 160), + (Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true), 320), + (Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::FourK), Some(false), 320), + (Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::FourK), Some(true), 480), + (None, None, None, 320), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + /// The number of reference images does not affect the bill. + #[test] + fn cost_is_independent_of_image_count() { + let one = Veo3p1ReferenceToVideoRequest { + image_urls: vec!["https://example.com/a.png".to_string()], + ..make_request(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true)) + }.calculate_cost_in_cents(); + let many = Veo3p1ReferenceToVideoRequest { + image_urls: vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ], + ..make_request(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true)) + }.calculate_cost_in_cents(); + assert_eq!(one, many); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/raw_request.rs new file mode 100644 index 0000000000..448e3e42c6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1/raw_request.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/reference-to-video`. +/// Reference images drive subject/appearance consistency. +/// fal's schema: +/// +/// NB: unlike the other Veo 3.1 modalities, this endpoint's schema does NOT +/// expose `negative_prompt` or `seed`. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1ReferenceToVideoInput { + pub prompt: String, + + /// URLs of the reference images used for consistent subject appearance. + pub image_urls: Vec, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ReferenceToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ReferenceToVideoOutput { + pub video: Veo3p1ReferenceToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/api.rs new file mode 100644 index 0000000000..0ebf7ec2fc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/api.rs @@ -0,0 +1,275 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::reference::veo_3p1_fast::raw_request::{ + Veo3p1FastReferenceToVideoInput, Veo3p1FastReferenceToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1FastReferenceToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Reference image URLs used for consistent subject appearance. + pub image_urls: Vec, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `false` when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastReferenceToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1FastReferenceToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastReferenceToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1FastReferenceToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastReferenceToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1FastReferenceToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastReferenceToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1FastReferenceToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1FastReferenceToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/fast/reference-to-video"; + + type RawRequest = Veo3p1FastReferenceToVideoInput; + type RawResponse = Veo3p1FastReferenceToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + image_urls: self.image_urls.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastReferenceToVideoRequest { + prompt: "the two dogs run side by side across a sunlit meadow".to_string(), + image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastReferenceToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()], + aspect_ratio: None, + duration: Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), + generate_audio: Some(false), + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1FastReferenceToVideoRequest { + prompt: "p".to_string(), + image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()], + aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1FastReferenceToVideoDuration::SixSeconds), + resolution: Some(Veo3p1FastReferenceToVideoResolution::FourK), + generate_audio: Some(true), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1FastReferenceToVideoSafetyTolerance::Level5), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.image_urls.len(), 2); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("6s")); + assert_eq!(raw.resolution.as_deref(), Some("4k")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("5")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1FastReferenceToVideoRequest { + prompt: "p".to_string(), + image_urls: vec!["https://example.com/a.png".to_string()], + aspect_ratio: None, + duration: None, + resolution: None, + generate_audio: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "image_urls": ["https://example.com/a.png"] }), + ); + } + + /// This endpoint's schema has no `negative_prompt`/`seed` fields, so they + /// must never appear on the wire regardless of other settings. + #[test] + fn raw_request_never_emits_negative_prompt_or_seed() { + let request = Veo3p1FastReferenceToVideoRequest { + prompt: "p".to_string(), + image_urls: vec!["https://example.com/a.png".to_string()], + aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), + resolution: Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), + generate_audio: Some(true), + auto_fix: Some(true), + safety_tolerance: Some(Veo3p1FastReferenceToVideoSafetyTolerance::Level4), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert!(json.get("negative_prompt").is_none(), "unexpected negative_prompt: {json}"); + assert!(json.get("seed").is_none(), "unexpected seed: {json}"); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Veo3p1FastReferenceToVideoRequest::ENDPOINT, + "fal-ai/veo3.1/fast/reference-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..49bfe46c38 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/cost.rs @@ -0,0 +1,109 @@ +use crate::requests::api::video::reference::veo_3p1_fast::api::{ + Veo3p1FastReferenceToVideoDuration, Veo3p1FastReferenceToVideoRequest, + Veo3p1FastReferenceToVideoResolution, +}; +use crate::requests::api::video::text::veo_3p1_fast::cost::{ + veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// PRICING NOTE — reference-to-video: +// +// fal's reference-to-video llms.txt documents a FLAT rate of $0.10/sec (audio +// off) / $0.15/sec (audio on) and does NOT mention a separate 4k tier — even +// though the endpoint exposes a "4k" resolution option. The sibling +// text/image/first-last endpoints DO document the 4k surcharge +// ($0.30/$0.35). Rather than risk undercharging on 4k reference generations, +// we mirror the rest of the Veo 3.1 Fast family and apply the 4k surcharge +// here too. For 720p/1080p this is identical to fal's documented flat rate; +// only 4k differs. If fal confirms reference-to-video is truly flat at 4k, +// drop the `four_k` branch and always bill the SD rate. + +impl FalRequestCostCalculator for Veo3p1FastReferenceToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1FastReferenceToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1FastReferenceToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1FastReferenceToVideoRequest { + Veo3p1FastReferenceToVideoRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/a.png".to_string()], + aspect_ratio: None, + duration, + resolution, + generate_audio, + auto_fix: None, + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + // 720p/1080p match fal's documented flat rate + (Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), Some(false), 40), + (Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), Some(true), 60), + (Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(false), 80), + (Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true), 120), + // 4k applies the family surcharge (see PRICING NOTE) + (Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::FourK), Some(false), 240), + (Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::FourK), Some(true), 280), + (None, None, None, 120), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + /// The number of reference images does not affect the bill. + #[test] + fn cost_is_independent_of_image_count() { + let one = Veo3p1FastReferenceToVideoRequest { + image_urls: vec!["https://example.com/a.png".to_string()], + ..make_request(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true)) + }.calculate_cost_in_cents(); + let many = Veo3p1FastReferenceToVideoRequest { + image_urls: vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ], + ..make_request(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true)) + }.calculate_cost_in_cents(); + assert_eq!(one, many); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/raw_request.rs new file mode 100644 index 0000000000..2b0ebfb229 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/veo_3p1_fast/raw_request.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/fast/reference-to-video`. +/// Reference images drive subject/appearance consistency. +/// fal's schema: +/// +/// NB: unlike the other Veo 3.1 Fast modalities, this endpoint's schema does +/// NOT expose `negative_prompt` or `seed`. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastReferenceToVideoInput { + pub prompt: String, + + /// URLs of the reference images used for consistent subject appearance. + pub image_urls: Vec, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Whether to automatically rewrite prompts that fail moderation. + /// fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastReferenceToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastReferenceToVideoOutput { + pub video: Veo3p1FastReferenceToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/api.rs new file mode 100644 index 0000000000..2996efda80 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/api.rs @@ -0,0 +1,232 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::reference::vidu_q3_reference_to_video::raw_request::{ + ViduQ3ReferenceToVideoInput, ViduQ3ReferenceToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct ViduQ3ReferenceToVideoRequest { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// Reference image URLs (1 to 4) used for subject consistency. + pub reference_image_urls: Vec, + + /// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`. + pub duration: Option, + + /// Seed for reproducibility. Random when `None`. + pub seed: Option, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate audio. fal's server default is `true` when `None`. + pub audio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3ReferenceToVideoAspectRatio { + SixteenByNine, + NineBySixteen, + FourByThree, + ThreeByFour, + Square, +} + +impl ViduQ3ReferenceToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + Self::FourByThree => "4:3", + Self::ThreeByFour => "3:4", + Self::Square => "1:1", + } + } +} + +/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3ReferenceToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +impl ViduQ3ReferenceToVideoResolution { + /// 720p and 1080p are billed at the higher (2.2×) per-second rate. + pub fn is_high_res(&self) -> bool { + matches!(self, Self::SevenTwentyP | Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSixtyP => "360p", + Self::FiveFortyP => "540p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl FalEndpoint for ViduQ3ReferenceToVideoRequest { + const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video"; + + type RawRequest = ViduQ3ReferenceToVideoInput; + type RawResponse = ViduQ3ReferenceToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + reference_image_urls: self.reference_image_urls.clone(), + duration: self.duration, + seed: self.seed, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + audio: self.audio, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3ReferenceToVideoRequest { + prompt: "the two dogs run side by side across a sunlit meadow".to_string(), + reference_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + duration: Some(5), + seed: None, + aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), + audio: Some(false), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3ReferenceToVideoRequest { + prompt: "the dogs play together in a snowy field".to_string(), + reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()], + duration: Some(5), + seed: Some(1234), + aspect_ratio: None, + resolution: Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP), + audio: Some(false), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = ViduQ3ReferenceToVideoRequest { + prompt: "p".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()], + duration: Some(12), + seed: Some(42), + aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::FourByThree), + resolution: Some(ViduQ3ReferenceToVideoResolution::TenEightyP), + audio: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.reference_image_urls.len(), 2); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.audio, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = ViduQ3ReferenceToVideoRequest { + prompt: "p".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string()], + duration: None, + seed: None, + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }), + ); + } + + #[test] + fn seed_serializes_when_set() { + let request = ViduQ3ReferenceToVideoRequest { + prompt: "p".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string()], + duration: None, + seed: Some(777), + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777)); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (ViduQ3ReferenceToVideoAspectRatio::SixteenByNine, "16:9"), + (ViduQ3ReferenceToVideoAspectRatio::NineBySixteen, "9:16"), + (ViduQ3ReferenceToVideoAspectRatio::FourByThree, "4:3"), + (ViduQ3ReferenceToVideoAspectRatio::ThreeByFour, "3:4"), + (ViduQ3ReferenceToVideoAspectRatio::Square, "1:1"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + ViduQ3ReferenceToVideoRequest::ENDPOINT, + "fal-ai/vidu/q3/reference-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/cost.rs new file mode 100644 index 0000000000..a8af3fc70f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/cost.rs @@ -0,0 +1,84 @@ +use crate::requests::api::video::reference::vidu_q3_reference_to_video::api::{ + ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution, +}; +use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Reference-to-video shares the Vidu Q3 per-second pricing (see the text module +// for the canonical rate table): +// 360p / 540p: $0.07/sec +// 720p / 1080p: $0.154/sec (2.2×) +// Pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for ViduQ3ReferenceToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 720p. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_high_res = self.resolution + .unwrap_or(ViduQ3ReferenceToVideoResolution::SevenTwentyP) + .is_high_res(); + + let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res); + vidu_q3_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + ) -> ViduQ3ReferenceToVideoRequest { + ViduQ3ReferenceToVideoRequest { + prompt: "test".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string()], + duration, + seed: None, + aspect_ratio: None, + resolution, + audio: Some(true), + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(5), Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP), 35), + (Some(5), Some(ViduQ3ReferenceToVideoResolution::FiveFortyP), 35), + (Some(5), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 77), + (Some(5), Some(ViduQ3ReferenceToVideoResolution::TenEightyP), 77), + (Some(10), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 154), + (Some(3), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 47), // 46.2 → 47 + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 77), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// The number of reference images does not affect the bill. + #[test] + fn cost_is_independent_of_image_count() { + let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP)).calculate_cost_in_cents(); + let four = ViduQ3ReferenceToVideoRequest { + reference_image_urls: vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + "https://example.com/d.png".to_string(), + ], + ..make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP)) + }.calculate_cost_in_cents(); + assert_eq!(one, four); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/raw_request.rs new file mode 100644 index 0000000000..ec79f129e7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video/raw_request.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ViduQ3ReferenceToVideoInput { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// URLs of the reference images (1 to 4) used for subject consistency. + pub reference_image_urls: Vec, + + /// Duration in seconds. Range 1–16. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Seed for reproducibility. Random when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Output resolution. + /// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3ReferenceToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3ReferenceToVideoOutput { + pub video: ViduQ3ReferenceToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/api.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/api.rs new file mode 100644 index 0000000000..39b57bc87b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/api.rs @@ -0,0 +1,219 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::raw_request::{ + ViduQ3ReferenceToVideoMixInput, ViduQ3ReferenceToVideoMixOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct ViduQ3ReferenceToVideoMixRequest { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// Reference image URLs (1 to 4) used for subject consistency. + pub reference_image_urls: Vec, + + /// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`. + pub duration: Option, + + /// Seed for reproducibility. Random when `None`. + pub seed: Option, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate audio. fal's server default is `true` when `None`. + pub audio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3ReferenceToVideoMixAspectRatio { + SixteenByNine, + NineBySixteen, + FourByThree, + ThreeByFour, + Square, +} + +impl ViduQ3ReferenceToVideoMixAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + Self::FourByThree => "4:3", + Self::ThreeByFour => "3:4", + Self::Square => "1:1", + } + } +} + +/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3ReferenceToVideoMixResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +impl ViduQ3ReferenceToVideoMixResolution { + /// 720p and 1080p are billed at the higher (2.2×) per-second rate. + pub fn is_high_res(&self) -> bool { + matches!(self, Self::SevenTwentyP | Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSixtyP => "360p", + Self::FiveFortyP => "540p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl FalEndpoint for ViduQ3ReferenceToVideoMixRequest { + const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video/mix"; + + type RawRequest = ViduQ3ReferenceToVideoMixInput; + type RawResponse = ViduQ3ReferenceToVideoMixOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + reference_image_urls: self.reference_image_urls.clone(), + duration: self.duration, + seed: self.seed, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + audio: self.audio, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_mix_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3ReferenceToVideoMixRequest { + prompt: "the two dogs run side by side across a sunlit meadow".to_string(), + reference_image_urls: vec![ + TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + JUNO_AT_LAKE_IMAGE_URL.to_string(), + ], + duration: Some(5), + seed: None, + aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine), + resolution: Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), + audio: Some(false), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_reference_to_video_mix_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3ReferenceToVideoMixRequest { + prompt: "the dogs play together in a snowy field".to_string(), + reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()], + duration: Some(5), + seed: Some(1234), + aspect_ratio: None, + resolution: Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP), + audio: Some(false), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = ViduQ3ReferenceToVideoMixRequest { + prompt: "p".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()], + duration: Some(12), + seed: Some(42), + aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::FourByThree), + resolution: Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP), + audio: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.reference_image_urls.len(), 2); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.audio, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = ViduQ3ReferenceToVideoMixRequest { + prompt: "p".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string()], + duration: None, + seed: None, + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }), + ); + } + + #[test] + fn seed_serializes_when_set() { + let request = ViduQ3ReferenceToVideoMixRequest { + prompt: "p".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string()], + duration: None, + seed: Some(777), + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777)); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + ViduQ3ReferenceToVideoMixRequest::ENDPOINT, + "fal-ai/vidu/q3/reference-to-video/mix", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/cost.rs new file mode 100644 index 0000000000..fefb104ac8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/cost.rs @@ -0,0 +1,84 @@ +use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{ + ViduQ3ReferenceToVideoMixRequest, ViduQ3ReferenceToVideoMixResolution, +}; +use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// The "mix" variant shares the Vidu Q3 per-second pricing (see the text module +// for the canonical rate table): +// 360p / 540p: $0.07/sec +// 720p / 1080p: $0.154/sec (2.2×) +// Pricing depends on resolution and duration only. + +impl FalRequestCostCalculator for ViduQ3ReferenceToVideoMixRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 720p. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_high_res = self.resolution + .unwrap_or(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP) + .is_high_res(); + + let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res); + vidu_q3_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request( + duration: Option, + resolution: Option, + ) -> ViduQ3ReferenceToVideoMixRequest { + ViduQ3ReferenceToVideoMixRequest { + prompt: "test".to_string(), + reference_image_urls: vec!["https://example.com/a.png".to_string()], + duration, + seed: None, + aspect_ratio: None, + resolution, + audio: Some(true), + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) — identical to base Vidu Q3. + const COST_TABLE: &[(Option, Option, u64)] = &[ + (Some(5), Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP), 35), + (Some(5), Some(ViduQ3ReferenceToVideoMixResolution::FiveFortyP), 35), + (Some(5), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 77), + (Some(5), Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP), 77), + (Some(10), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 154), + (Some(3), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 47), // 46.2 → 47 + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 77), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// The number of reference images does not affect the bill. + #[test] + fn cost_is_independent_of_image_count() { + let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)).calculate_cost_in_cents(); + let four = ViduQ3ReferenceToVideoMixRequest { + reference_image_urls: vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + "https://example.com/d.png".to_string(), + ], + ..make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)) + }.calculate_cost_in_cents(); + assert_eq!(one, four); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/raw_request.rs new file mode 100644 index 0000000000..0c2df131c6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/reference/vidu_q3_reference_to_video_mix/raw_request.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video/mix`. +/// fal's schema: +/// +/// The "mix" variant shares the base reference-to-video input schema; it +/// differs only in the server-side generation behavior. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ViduQ3ReferenceToVideoMixInput { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// URLs of the reference images (1 to 4) used for subject consistency. + pub reference_image_urls: Vec, + + /// Duration in seconds. Range 1–16. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Seed for reproducibility. Random when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Output resolution. + /// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3ReferenceToVideoMixVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3ReferenceToVideoMixOutput { + pub video: ViduQ3ReferenceToVideoMixVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/api.rs new file mode 100644 index 0000000000..e02a02e05b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/api.rs @@ -0,0 +1,294 @@ +use std::fmt; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::flux_3::raw_request::{ + Flux3TextToVideoInput, Flux3TextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Flux3TextToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// Duration in seconds. fal's default is `auto` when `None` (the model + /// picks a duration that fits the prompt). + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +/// Flux 3 durations: either `auto` (the model decides) or a whole number of +/// seconds in 5–20. Serializes as the string `"auto"` or a bare integer. +/// Shared by every Flux 3 / Flux 3 Draft video modality. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Flux3Duration { + Auto, + Seconds(u8), +} + +/// Flux 3 resolutions. 1080p bills at a higher per-second rate than 720p (see +/// the cost modules). Draft endpoints always render 720p and take no +/// resolution input. Shared by every full-quality Flux 3 video modality. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Flux3Resolution { + SevenTwentyP, + TenEightyP, +} + +/// Flux 3 aspect ratios. `auto` derives the ratio from the inputs (or the +/// prompt for text-to-video). Shared by every Flux 3 / Flux 3 Draft video +/// modality. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Flux3AspectRatio { + Auto, + TwentyOneByNine, + TwoByOne, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +impl Serialize for Flux3Duration { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Auto => serializer.serialize_str("auto"), + Self::Seconds(seconds) => serializer.serialize_u8(*seconds), + } + } +} + +impl<'de> Deserialize<'de> for Flux3Duration { + fn deserialize>(deserializer: D) -> Result { + struct Flux3DurationVisitor; + + impl Visitor<'_> for Flux3DurationVisitor { + type Value = Flux3Duration; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("the string \"auto\" or an integer number of seconds") + } + + fn visit_str(self, value: &str) -> Result { + if value == "auto" { + Ok(Flux3Duration::Auto) + } else { + Err(E::invalid_value(de::Unexpected::Str(value), &self)) + } + } + + fn visit_u64(self, value: u64) -> Result { + u8::try_from(value) + .map(Flux3Duration::Seconds) + .map_err(|_| E::invalid_value(de::Unexpected::Unsigned(value), &self)) + } + + fn visit_i64(self, value: i64) -> Result { + u8::try_from(value) + .map(Flux3Duration::Seconds) + .map_err(|_| E::invalid_value(de::Unexpected::Signed(value), &self)) + } + } + + deserializer.deserialize_any(Flux3DurationVisitor) + } +} + +impl Flux3Resolution { + /// 1080p is billed at the higher per-second rate. + pub fn is_1080p(&self) -> bool { + matches!(self, Self::TenEightyP) + } + + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl Flux3AspectRatio { + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::TwentyOneByNine => "21:9", + Self::TwoByOne => "2:1", + Self::SixteenByNine => "16:9", + Self::FourByThree => "4:3", + Self::Square => "1:1", + Self::ThreeByFour => "3:4", + Self::NineBySixteen => "9:16", + } + } +} + +impl FalEndpoint for Flux3TextToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/text-to-video"; + + type RawRequest = Flux3TextToVideoInput; + type RawResponse = Flux3TextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3TextToVideoRequest { + prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3TextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + resolution: Some(Flux3Resolution::SevenTwentyP), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3TextToVideoRequest { + prompt: "p".to_string(), + duration: Some(Flux3Duration::Seconds(12)), + resolution: Some(Flux3Resolution::TenEightyP), + aspect_ratio: Some(Flux3AspectRatio::TwoByOne), + generate_audio: Some(false), + safety_tolerance: Some(4), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.duration, Some(Flux3Duration::Seconds(12))); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("2:1")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.safety_tolerance, Some(4)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3TextToVideoRequest { + prompt: "minimal".to_string(), + duration: None, + resolution: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn duration_serializes_as_auto_string_or_bare_integer() { + assert_eq!(serde_json::to_value(Flux3Duration::Auto).unwrap(), serde_json::json!("auto")); + assert_eq!(serde_json::to_value(Flux3Duration::Seconds(12)).unwrap(), serde_json::json!(12)); + } + + #[test] + fn duration_deserializes_from_auto_string_or_integer() { + assert_eq!(serde_json::from_value::(serde_json::json!("auto")).unwrap(), Flux3Duration::Auto); + assert_eq!(serde_json::from_value::(serde_json::json!(7)).unwrap(), Flux3Duration::Seconds(7)); + assert!(serde_json::from_value::(serde_json::json!("fast")).is_err()); + } + + #[test] + fn every_resolution_maps_to_wire_string() { + for (variant, expected, is_1080p) in [ + (Flux3Resolution::SevenTwentyP, "720p", false), + (Flux3Resolution::TenEightyP, "1080p", true), + ] { + assert_eq!(variant.to_str(), expected); + assert_eq!(variant.is_1080p(), is_1080p); + } + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Flux3AspectRatio::Auto, "auto"), + (Flux3AspectRatio::TwentyOneByNine, "21:9"), + (Flux3AspectRatio::TwoByOne, "2:1"), + (Flux3AspectRatio::SixteenByNine, "16:9"), + (Flux3AspectRatio::FourByThree, "4:3"), + (Flux3AspectRatio::Square, "1:1"), + (Flux3AspectRatio::ThreeByFour, "3:4"), + (Flux3AspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3TextToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/text-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/cost.rs new file mode 100644 index 0000000000..349243924f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/cost.rs @@ -0,0 +1,136 @@ +use crate::requests::api::video::text::flux_3::api::{ + Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Flux 3 pricing (see https://fal.ai/models/blackforestlabs/flux-3/text-to-video): +// 720p: $0.17 per second of generated video +// 1080p: $0.29 per second of generated video +// +// The same rates apply to the image-to-video, first-last-frame-to-video, and +// keyframes-to-video modalities (extend-video bills higher; see its cost +// module). Audio, aspect ratio, and safety tolerance are free. Both rates are +// exact whole cents. +const RATE_720P_CENTS_PER_SEC: u64 = 17; // $0.17/sec +const RATE_1080P_CENTS_PER_SEC: u64 = 29; // $0.29/sec + +/// Duration assumed by cost estimates when the request leaves duration unset +/// or `auto` — fal bills the actual generated seconds, and 5s (the shortest +/// selectable duration) is the quotable floor. +const ASSUMED_AUTO_DURATION_SECS: u64 = 5; + +/// Per-second rate in whole cents for the standard (non-extend) Flux 3 +/// modalities. `is_1080p` selects the 1080p tier. +pub(crate) fn flux_3_standard_rate_cents_per_sec(is_1080p: bool) -> u64 { + if is_1080p { RATE_1080P_CENTS_PER_SEC } else { RATE_720P_CENTS_PER_SEC } +} + +/// Per-second rate × duration → whole cents (all Flux 3 rates are exact +/// cents, so no rounding is needed). +pub(crate) fn flux_3_video_cost_cents(rate_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + rate_cents_per_sec * duration_secs +} + +/// Billable seconds for a flexible (`auto`-capable) duration. `None` and +/// `Auto` estimate at the 5-second floor. +pub(crate) fn flux_3_estimated_duration_secs(duration: Option) -> u64 { + match duration { + None | Some(Flux3Duration::Auto) => ASSUMED_AUTO_DURATION_SECS, + Some(Flux3Duration::Seconds(seconds)) => u64::from(seconds), + } +} + +/// Whether an optional resolution lands on the 1080p tier (fal defaults to +/// 720p when unset). +pub(crate) fn flux_3_is_1080p(resolution: Option) -> bool { + resolution.map_or(false, |r| r.is_1080p()) +} + +impl FalRequestCostCalculator for Flux3TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = flux_3_estimated_duration_secs(self.duration); + let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution)); + flux_3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + ) -> Flux3TextToVideoRequest { + Flux3TextToVideoRequest { + prompt: "test".to_string(), + duration, + resolution, + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(true), + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + // Math: rate × secs where rate (cents) = 17 (720p) or 29 (1080p); + // None/auto durations are estimated at the 5s floor. + const COST_TABLE: &[(Option, Option, u64)] = &[ + // 720p → $0.17/s + (Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85), + (Some(Flux3Duration::Seconds(10)), Some(Flux3Resolution::SevenTwentyP), 170), + (Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340), + // 1080p → $0.29/s + (Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145), + (Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580), + // Defaults: duration=None/auto→5s estimate, resolution=None→720p + (None, None, 85), + (Some(Flux3Duration::Auto), None, 85), + (Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145), + (None, Some(Flux3Resolution::TenEightyP), 145), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + #[test] + fn ten_eighty_p_is_more_expensive_than_720p() { + let low = make_request(Some(Flux3Duration::Seconds(8)), Some(Flux3Resolution::SevenTwentyP)).calculate_cost_in_cents(); + let high = make_request(Some(Flux3Duration::Seconds(8)), Some(Flux3Resolution::TenEightyP)).calculate_cost_in_cents(); + assert!(high > low, "1080p={high}¢ should exceed 720p={low}¢"); + } + + #[test] + fn cost_scales_linearly_with_duration() { + let five = make_request(Some(Flux3Duration::Seconds(5)), None).calculate_cost_in_cents(); + let ten = make_request(Some(Flux3Duration::Seconds(10)), None).calculate_cost_in_cents(); + assert_eq!(ten, five * 2); + } + + /// Audio, aspect ratio, and safety tolerance do not affect the bill. + #[test] + fn cost_ignores_audio_aspect_ratio_and_safety_tolerance() { + let baseline = make_request(Some(Flux3Duration::Seconds(8)), None).calculate_cost_in_cents(); + for generate_audio in [None, Some(false), Some(true)] { + for safety_tolerance in [None, Some(0), Some(4)] { + let cost = Flux3TextToVideoRequest { + aspect_ratio: Some(Flux3AspectRatio::NineBySixteen), + generate_audio, + safety_tolerance, + ..make_request(Some(Flux3Duration::Seconds(8)), None) + }.calculate_cost_in_cents(); + assert_eq!(cost, baseline, "audio={generate_audio:?} safety={safety_tolerance:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/raw_request.rs new file mode 100644 index 0000000000..08e7992c09 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3/raw_request.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::text::flux_3::api::Flux3Duration; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3TextToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// Duration in seconds ("auto" or 5–20). fal default: "auto" (the model + /// picks a duration that fits the prompt). + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3VideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3TextToVideoOutput { + pub video: Flux3VideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/api.rs new file mode 100644 index 0000000000..782013aa4c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/api.rs @@ -0,0 +1,149 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration}; +use crate::requests::api::video::text::flux_3_draft::raw_request::{ + Flux3DraftTextToVideoInput, Flux3DraftTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Flux 3 Draft text-to-video: lower cost, lower fidelity (always 720p). The +/// response includes a `draft_cache` that `draft-enhance` can upgrade to a +/// full-quality 1080p render. +#[derive(Clone, Debug)] +pub struct Flux3DraftTextToVideoRequest { + /// Text prompt for video generation. + pub prompt: String, + + /// Duration in seconds. fal's default is `auto` when `None` (the model + /// picks a duration that fits the prompt). + pub duration: Option, + + /// Aspect ratio. fal's default is `auto` when `None`. + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal's default is `true` when + /// `None`. Audio does not affect the bill. + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is + /// `2` when `None`. + pub safety_tolerance: Option, +} + +impl FalEndpoint for Flux3DraftTextToVideoRequest { + const ENDPOINT: &str = "blackforestlabs/flux-3/text-to-video/draft"; + + type RawRequest = Flux3DraftTextToVideoInput; + type RawResponse = Flux3DraftTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + duration: self.duration, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + generate_audio: self.generate_audio, + safety_tolerance: self.safety_tolerance, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftTextToVideoRequest { + prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Flux3DraftTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + duration: Some(Flux3Duration::Seconds(5)), + aspect_ratio: Some(Flux3AspectRatio::SixteenByNine), + generate_audio: Some(false), + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Flux3DraftTextToVideoRequest { + prompt: "p".to_string(), + duration: Some(Flux3Duration::Seconds(12)), + aspect_ratio: Some(Flux3AspectRatio::ThreeByFour), + generate_audio: Some(false), + safety_tolerance: Some(4), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.duration, Some(Flux3Duration::Seconds(12))); + assert_eq!(raw.aspect_ratio.as_deref(), Some("3:4")); + assert_eq!(raw.generate_audio, Some(false)); + assert_eq!(raw.safety_tolerance, Some(4)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Flux3DraftTextToVideoRequest { + prompt: "minimal".to_string(), + duration: None, + aspect_ratio: None, + generate_audio: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn draft_output_parses_video_and_draft_cache() { + let json = serde_json::json!({ + "video": { "url": "https://example.com/draft.mp4" }, + "draft_cache": { "url": "https://example.com/cache.bin" }, + "extra_field": true, + }); + let output: Flux3DraftTextToVideoOutput = serde_json::from_value(json).unwrap(); + assert_eq!(output.video.url, "https://example.com/draft.mp4"); + assert_eq!(output.draft_cache.url, "https://example.com/cache.bin"); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Flux3DraftTextToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/text-to-video/draft"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/cost.rs new file mode 100644 index 0000000000..b1cae04fe9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/cost.rs @@ -0,0 +1,70 @@ +use crate::requests::api::video::text::flux_3::cost::{ + flux_3_estimated_duration_secs, flux_3_video_cost_cents, +}; +use crate::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Flux 3 Draft pricing (see https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft): +// $0.06 per second of generated draft video (always 720p) +// +// The same rate applies to the draft image-to-video, first-last-frame, and +// keyframes modalities (draft extend-video bills higher; see its cost +// module). Audio, aspect ratio, and safety tolerance are free. +const DRAFT_RATE_CENTS_PER_SEC: u64 = 6; // $0.06/sec + +/// Per-second rate in whole cents for the standard (non-extend) Flux 3 Draft +/// generation modalities. +pub(crate) fn flux_3_draft_generation_rate_cents_per_sec() -> u64 { + DRAFT_RATE_CENTS_PER_SEC +} + +impl FalRequestCostCalculator for Flux3DraftTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_secs = flux_3_estimated_duration_secs(self.duration); + flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::flux_3::api::Flux3Duration; + + fn make_request(duration: Option) -> Flux3DraftTextToVideoRequest { + Flux3DraftTextToVideoRequest { + prompt: "test".to_string(), + duration, + aspect_ratio: None, + generate_audio: Some(true), + safety_tolerance: None, + } + } + + mod cost_table { + use super::*; + + // (duration, expected_cents) + // Math: 6¢ × secs; None/auto durations are estimated at the 5s floor. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(Flux3Duration::Seconds(5)), 30), + (Some(Flux3Duration::Seconds(10)), 60), + (Some(Flux3Duration::Seconds(20)), 120), + (None, 30), + (Some(Flux3Duration::Auto), 30), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + let got = make_request(duration).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?}"); + } + } + + #[test] + fn draft_is_cheaper_than_full_quality() { + use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec; + assert!(flux_3_draft_generation_rate_cents_per_sec() < flux_3_standard_rate_cents_per_sec(false)); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/raw_request.rs new file mode 100644 index 0000000000..018f862456 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/flux_3_draft/raw_request.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +use crate::requests::api::video::text::flux_3::api::Flux3Duration; + +/// Over-the-wire input shape for `blackforestlabs/flux-3/text-to-video/draft`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux3DraftTextToVideoInput { + /// Text prompt for video generation. + pub prompt: String, + + /// Duration in seconds ("auto" or 5–20). fal default: "auto" (the model + /// picks a duration that fits the prompt). + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio. + /// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", + /// "9:16". fal default: "auto". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate synchronized audio. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2. + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftVideoFile { + pub url: String, +} + +/// Draft outputs carry the 720p draft video plus a durable encrypted cache +/// bundle whose URL can be passed to `draft-enhance` for a full-quality +/// 1080p render. +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux3DraftTextToVideoOutput { + pub video: Flux3DraftVideoFile, + pub draft_cache: Flux3DraftVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs new file mode 100644 index 0000000000..25fd8cef80 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/api.rs @@ -0,0 +1,225 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_1p6_pro_text_to_video::raw_request::{ + Kling1p6ProTextToVideoInput, Kling1p6ProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling1p6ProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 1.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling1p6ProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling1p6ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling1p6ProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v1.6/pro/text-to-video"; + + type RawRequest = Kling1p6ProTextToVideoInput; + type RawResponse = Kling1p6ProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling1p6ProTextToVideoAspectRatio::Square => "1:1", + Kling1p6ProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling1p6ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + duration, + negative_prompt: self.negative_prompt.clone(), + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow, cinematic slow motion".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling1p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling1p6ProTextToVideoAspectRatio::Square, + Kling1p6ProTextToVideoAspectRatio::SixteenByNine, + Kling1p6ProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling1p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling1p6ProTextToVideoDuration::FiveSeconds, + Kling1p6ProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling1p6ProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling1p6ProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: Some(Kling1p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling1p6ProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling1p6ProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v1.6/pro/text-to-video", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..6a300c4375 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/cost.rs @@ -0,0 +1,236 @@ +use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoDuration, Kling1p6ProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling1p6ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 1.6 Pro text-to-video: $0.098/second + // (see https://fal.ai/models/fal-ai/kling-video/v1.6/pro/text-to-video). + // + // Slightly pricier than the image-to-video variant of the same model + // (which is $0.095/sec). Rate held in tenths-of-cents and rounded up to + // whole cents at the end so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling1p6ProTextToVideoDuration::FiveSeconds) + .to_seconds(); + (98u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_1p6_pro_text_to_video::api::{ + Kling1p6ProTextToVideoAspectRatio, Kling1p6ProTextToVideoDuration, + }; + use crate::requests::api::video::image::kling_1p6_pro_image_to_video::api::{ + Kling1p6ProImageToVideoAspectRatio, Kling1p6ProImageToVideoDuration, + Kling1p6ProImageToVideoRequest, + }; + + fn make_request( + duration: Option, + ) -> Kling1p6ProTextToVideoRequest { + Kling1p6ProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio: Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.098/sec = 98 tenths-of-cents/sec. + // Cents per duration = ceil(98 × secs / 10). + + #[test] + fn five_seconds() { + // (98 * 5 + 9) / 10 = 49 (round up from 49.0¢) + assert_eq!( + make_request(Some(Kling1p6ProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 49, + ); + } + + #[test] + fn ten_seconds() { + // (98 * 10 + 9) / 10 = 98 + assert_eq!( + make_request(Some(Kling1p6ProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 98, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 49¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 49); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling1p6ProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling1p6ProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Sanity: text-to-video should be slightly more expensive than + /// image-to-video at every supported duration ($0.098 vs $0.095 per + /// second). Protects against accidentally swapping the rates. + #[test] + fn text_to_video_is_pricier_than_image_to_video_at_each_duration() { + fn i2v(duration: Kling1p6ProImageToVideoDuration) -> u64 { + Kling1p6ProImageToVideoRequest { + prompt: "test".to_string(), + image_url: "https://example.com/i.png".to_string(), + end_image_url: None, + negative_prompt: None, + duration: Some(duration), + aspect_ratio: Some(Kling1p6ProImageToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }.calculate_cost_in_cents() + } + let pairs = [ + (Kling1p6ProTextToVideoDuration::FiveSeconds, Kling1p6ProImageToVideoDuration::FiveSeconds), + (Kling1p6ProTextToVideoDuration::TenSeconds, Kling1p6ProImageToVideoDuration::TenSeconds), + ]; + for (t, i) in pairs { + let text = make_request(Some(t)).calculate_cost_in_cents(); + let image = i2v(i); + assert!(text > image, "text-to-video ({text}¢) must be > image-to-video ({image}¢) at {t:?}"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration for text-to-video. There is no direct legacy + /// equivalent (the legacy `enqueue_kling_v1p6_pro_image_to_video_webhook` + /// is image-to-video at a slightly lower rate), so the parity here is + /// against the canonical $0.098/sec formula. A separate sub-test + /// cross-references the legacy image-to-video function to confirm the + /// expected per-duration premium ($0.003/sec rounded). + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{ + Kling1p6ProAspectRatio as LegacyAspectRatio, + Kling1p6ProDuration as LegacyDuration, + Kling1p6ProRequest as LegacyRequest, + }; + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling1p6ProTextToVideoRequest { + Kling1p6ProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + // (duration, aspect_ratio, expected_cents) + // + // Math: ceil(98 × secs / 10) where 5s → 49¢ and 10s → 98¢. + // duration=None defaults to 5s. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 49¢ + (None, None, 49), + (None, Some(Kling1p6ProTextToVideoAspectRatio::Square), 49), + (None, Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 49), + (None, Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 49), + // duration=5s → 49¢ + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), None, 49), + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::Square), 49), + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 49), + (Some(Kling1p6ProTextToVideoDuration::FiveSeconds), Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 49), + // duration=10s → 98¢ + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), None, 98), + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::Square), 98), + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), 98), + (Some(Kling1p6ProTextToVideoDuration::TenSeconds), Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), 98), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every row in the + /// cost table must produce the same value for any aspect-ratio choice. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling1p6ProTextToVideoAspectRatio::Square), + Some(Kling1p6ProTextToVideoAspectRatio::SixteenByNine), + Some(Kling1p6ProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling1p6ProTextToVideoDuration::FiveSeconds), + Some(Kling1p6ProTextToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// Cross-check against the legacy image-to-video function. Text-to-video + /// is `$0.098/sec` and image-to-video is `$0.095/sec`, so at each + /// duration the cost difference equals `ceil(0.003 × secs)`: + /// 5s: 49¢ − 48¢ = 1¢ (≈ $0.015 difference before rounding) + /// 10s: 98¢ − 95¢ = 3¢ + /// If a future change to the legacy image-to-video rate breaks this + /// invariant, this test calls it out. + #[test] + fn premium_over_legacy_image_to_video_is_as_expected() { + fn legacy_image(secs_variant: LegacyDuration) -> u64 { + LegacyRequest { + image_url: "https://example.com/i.png".to_string(), + end_frame_image_url: None, + prompt: "test".to_string(), + duration: secs_variant, + aspect_ratio: LegacyAspectRatio::WideSixteenNine, + }.calculate_cost_in_cents() + } + + // (text duration, equivalent legacy image duration, expected premium in cents) + let cases = [ + (Kling1p6ProTextToVideoDuration::FiveSeconds, LegacyDuration::FiveSeconds, 1u64), + (Kling1p6ProTextToVideoDuration::TenSeconds, LegacyDuration::TenSeconds, 3u64), + ]; + + for (text_d, legacy_d, expected_premium) in cases { + let text_cost = new_request(Some(text_d), None).calculate_cost_in_cents(); + let image_cost = legacy_image(legacy_d); + let premium = text_cost.saturating_sub(image_cost); + assert_eq!( + premium, expected_premium, + "{text_d:?}: text={text_cost}¢ legacy_image={image_cost}¢ premium={premium}¢ (want {expected_premium}¢)", + ); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..f3d4ec425c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_1p6_pro_text_to_video/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v1.6/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling1p6ProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling1p6ProTextToVideoOutput { + pub video: Kling1p6ProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs new file mode 100644 index 0000000000..8a0e41dea3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/api.rs @@ -0,0 +1,335 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::raw_request::{ + Kling2p5TurboProTextToVideoInput, Kling2p5TurboProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p5TurboProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 2.5 Turbo Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. fal's default is `16:9` when this is `None`. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p5TurboProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p5TurboProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling2p5TurboProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.5-turbo/pro/text-to-video"; + + type RawRequest = Kling2p5TurboProTextToVideoInput; + type RawResponse = Kling2p5TurboProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling2p5TurboProTextToVideoAspectRatio::Square => "1:1", + Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + negative_prompt: self.negative_prompt.clone(), + duration, + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "an owl flies past an abandoned castle at dusk, fireflies dance in the trees".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a candle flickers in a dark stone hall".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling2p5TurboProTextToVideoAspectRatio::Square, + Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine, + Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p5TurboProTextToVideoDuration::FiveSeconds, + Kling2p5TurboProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p5TurboProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.5-turbo/pro/text-to-video", + ); + } + + /// Wire-shape, endpoint, and cost parity vs the legacy modules: + /// `requests_old/http/video/text/http_kling_v2p5_turbo_pro_text_to_video.rs` + /// `requests_old/webhook/video/text/enqueue_kling_v2p5_turbo_pro_text_to_video_webhook.rs` + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::text::http_kling_v2p5_turbo_pro_text_to_video::{ + kling_v2p5_turbo_pro_text_to_video, KlingV2p5TurboProTextToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.5-turbo/pro/text-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p5_turbo_pro_text_to_video(KlingV2p5TurboProTextToVideoInput::default()); + assert_eq!( + Kling2p5TurboProTextToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative fully-populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON. + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "a wave at dawn".to_string(), + negative_prompt: Some("blurry".to_string()), + duration: Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "a wave at dawn".to_string(), + aspect_ratio: Some("9:16".to_string()), + negative_prompt: Some("blurry".to_string()), + duration: Some("10".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// Minimal case (prompt only): unset optionals must be omitted on the + /// wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "minimal".to_string(), + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "minimal".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + assert_eq!(new_json, serde_json::json!({ "prompt": "minimal" })); + } + + /// Cross product over duration × aspect_ratio: every cell's wire JSON + /// must match the equivalent legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some("10")), + ]; + let aspect_ratios = [ + (None, None), + (Some(Kling2p5TurboProTextToVideoAspectRatio::Square), Some("1:1")), + (Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), Some("16:9")), + (Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), Some("9:16")), + ]; + + for (d_new, d_legacy) in durations { + for (ar_new, ar_legacy) in aspect_ratios { + let new = Kling2p5TurboProTextToVideoRequest { + prompt: "p".to_string(), + negative_prompt: None, + duration: d_new, + aspect_ratio: ar_new, + cfg_scale: None, + }; + let legacy = KlingV2p5TurboProTextToVideoInput { + prompt: "p".to_string(), + aspect_ratio: ar_legacy.map(String::from), + negative_prompt: None, + duration: d_legacy.map(String::from), + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} aspect_ratio={ar_new:?}", + ); + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..0a95ef30df --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/cost.rs @@ -0,0 +1,261 @@ +use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoDuration, Kling2p5TurboProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p5TurboProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.5 Turbo Pro text-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video): + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + // + // That works out to a flat $0.07/second rate. Rate is held in + // tenths-of-cents and rounded up to whole cents so the user is never + // undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p5TurboProTextToVideoDuration::FiveSeconds) + .to_seconds(); + (70u64 * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_2p5_turbo_pro_text_to_video::api::{ + Kling2p5TurboProTextToVideoAspectRatio, Kling2p5TurboProTextToVideoDuration, + }; + + fn make_request( + duration: Option, + ) -> Kling2p5TurboProTextToVideoRequest { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: flat $0.07/sec = 70 tenths-of-cents/sec. + // Cents per duration = ceil(70 × secs / 10). + + #[test] + fn five_seconds() { + // (70 * 5 + 9) / 10 = 35 — matches fal's "$0.35 for 5s" base rate + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn ten_seconds() { + // (70 * 10 + 9) / 10 = 70 — matches "$0.35 + 5×$0.07 = $0.70" + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None → 5s → 35¢ + assert_eq!(make_request(None).calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_seconds_costs_more_than_five() { + let five = make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(); + assert!(five < ten, "five={five}¢ < ten={ten}¢"); + } + + /// Exhaustive cost-table tests and cross-checks against the legacy + /// `enqueue_kling_v2p5_turbo_pro_text_to_video_webhook` calculator. + mod legacy_parity { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{ + EnqueueKlingV2p5TurboProTextToVideoAspectRatio as LegacyAspectRatio, + EnqueueKlingV2p5TurboProTextToVideoDurationSeconds as LegacyDuration, + EnqueueKlingV2p5TurboProTextToVideoRequest as LegacyRequest, + }; + + // ── Mapping helpers from the new strongly-typed API to the legacy one ── + + fn legacy_duration_for(d: Option) -> Option { + match d { + None => None, + Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds) => Some(LegacyDuration::Five), + Some(Kling2p5TurboProTextToVideoDuration::TenSeconds) => Some(LegacyDuration::Ten), + } + } + + fn legacy_aspect_ratio_for( + ar: Option, + ) -> Option { + match ar { + None => None, + Some(Kling2p5TurboProTextToVideoAspectRatio::Square) => Some(LegacyAspectRatio::Square), + Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine) => Some(LegacyAspectRatio::SixteenByNine), + Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen) => Some(LegacyAspectRatio::NineBySixteen), + } + } + + fn new_request( + duration: Option, + aspect_ratio: Option, + ) -> Kling2p5TurboProTextToVideoRequest { + Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + fn legacy_request( + duration: Option, + aspect_ratio: Option, + ) -> LegacyRequest { + LegacyRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: legacy_duration_for(duration), + aspect_ratio: legacy_aspect_ratio_for(aspect_ratio), + } + } + + // (new_duration, new_aspect_ratio, expected_cents) + // + // Math: ceil(70 × secs / 10). 5s → 35¢; 10s → 70¢. + // duration=None defaults to 5s in both new and legacy modules. + // Aspect ratio is irrelevant to cost; iterated only to assert it. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default → 35¢ + (None, None, 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 35), + (None, Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 35), + // duration=5s → 35¢ + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), None, 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 35), + (Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 35), + // duration=10s → 70¢ + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), None, 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::Square), 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), 70), + (Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), 70), + ]; + + #[test] + fn new_module_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "new module: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + #[test] + fn legacy_function_matches_cost_table() { + for &(duration, aspect_ratio, expected) in COST_TABLE { + let got = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "legacy function: duration={duration:?} aspect_ratio={aspect_ratio:?}", + ); + } + } + + /// Headline parity check: new vs legacy give identical cents at every + /// permutation. If this fails, pricing has drifted. + #[test] + fn new_module_matches_legacy_at_every_combo() { + for &(duration, aspect_ratio, _) in COST_TABLE { + let new_cost = new_request(duration, aspect_ratio).calculate_cost_in_cents(); + let legacy_cost = legacy_request(duration, aspect_ratio).calculate_cost_in_cents(); + assert_eq!( + new_cost, legacy_cost, + "parity mismatch at duration={duration:?} aspect_ratio={aspect_ratio:?}: \ + new={new_cost}¢ vs legacy={legacy_cost}¢", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every aspect-ratio + /// choice (including `None`) must produce the same value for fixed + /// duration. + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling2p5TurboProTextToVideoAspectRatio::Square), + Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + Some(Kling2p5TurboProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + Some(Kling2p5TurboProTextToVideoDuration::TenSeconds), + ]; + for duration in durations { + let baseline = new_request(duration, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, ar).calculate_cost_in_cents(); + assert_eq!(cost, baseline, "duration={duration:?} ar={ar:?}"); + } + } + } + + /// cfg_scale is not part of the billing formula. + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p5TurboProTextToVideoRequest { + prompt: "test".to_string(), + negative_prompt: None, + duration: Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p5TurboProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 35, "cfg_scale={cfg:?}"); + } + } + + /// Sanity-check fal's documented examples: + /// "For 5s video your request will cost $0.35" + /// "For every additional second you will be charged $0.07" + /// → 10s costs $0.35 + 5 × $0.07 = $0.70 + #[test] + fn matches_documented_examples() { + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::FiveSeconds)) + .calculate_cost_in_cents(), + 35, + "5s should be $0.35 per fal's docs", + ); + assert_eq!( + make_request(Some(Kling2p5TurboProTextToVideoDuration::TenSeconds)) + .calculate_cost_in_cents(), + 70, + "10s should be $0.70 per fal's docs", + ); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..c06edfdeb6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p5_turbo_pro_text_to_video/raw_request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.5-turbo/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p5TurboProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" (fal default: "16:9") + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" (fal default: "5") + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p5TurboProTextToVideoOutput { + pub video: Kling2p5TurboProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs new file mode 100644 index 0000000000..e42cacba13 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/api.rs @@ -0,0 +1,358 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_2p6_pro_text_to_video::raw_request::{ + Kling2p6ProTextToVideoInput, Kling2p6ProTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling2p6ProTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Optional negative prompt. fal's default is + /// `"blur, distort, and low quality"` when this is `None`. + pub negative_prompt: Option, + + /// Video duration. Kling 2.6 Pro supports 5 or 10 seconds only. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// CFG scale. Defaults to fal's `0.5` when `None`. + pub cfg_scale: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProTextToVideoDuration { + FiveSeconds, + TenSeconds, +} + +impl Kling2p6ProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::TenSeconds => 10, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5", + Self::TenSeconds => "10", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling2p6ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalEndpoint for Kling2p6ProTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v2.6/pro/text-to-video"; + + type RawRequest = Kling2p6ProTextToVideoInput; + type RawResponse = Kling2p6ProTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling2p6ProTextToVideoAspectRatio::Square => "1:1", + Kling2p6ProTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling2p6ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio, + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + duration, + cfg_scale: self.cfg_scale, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling2p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling2p6ProTextToVideoAspectRatio::Square, + Kling2p6ProTextToVideoAspectRatio::SixteenByNine, + Kling2p6ProTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling2p6ProTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(ar), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling2p6ProTextToVideoDuration::FiveSeconds, + Kling2p6ProTextToVideoDuration::TenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling2p6ProTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // ── Wire-shape sanity ── + + #[test] + fn raw_request_aspect_ratio_uses_colon_format() { + let request = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: None, + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("10")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: None, + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert!(raw.aspect_ratio.is_none()); + assert!(raw.duration.is_none()); + assert!(raw.generate_audio.is_none()); + assert!(raw.negative_prompt.is_none()); + assert!(raw.cfg_scale.is_none()); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + Kling2p6ProTextToVideoRequest::ENDPOINT, + "fal-ai/kling-video/v2.6/pro/text-to-video", + ); + } + + /// Wire-shape and endpoint parity vs the legacy + /// `requests_old/http/video/text/http_kling_v2p6_pro_text_to_video.rs`. + /// The legacy module has no cost calculator, so cost parity is N/A; + /// these tests verify the *shape sent on the wire* hasn't drifted. + mod legacy_parity { + use super::*; + use crate::requests_old::http::video::text::http_kling_v2p6_pro_text_to_video::{ + kling_v2p6_pro_text_to_video, KlingV2p6ProTextToVideoInput, + }; + + /// Same `fal-ai/kling-video/v2.6/pro/text-to-video` path. + #[test] + fn endpoint_path_matches_legacy() { + let legacy = kling_v2p6_pro_text_to_video(KlingV2p6ProTextToVideoInput::default()); + assert_eq!( + Kling2p6ProTextToVideoRequest::ENDPOINT, + legacy.endpoint, + ); + } + + /// At a representative populated case, the new module's serialized + /// `RawRequest` must equal the legacy `Input`'s JSON byte-for-byte + /// (modulo serde field ordering, which is stable within each struct). + #[test] + fn wire_json_matches_legacy_fully_populated() { + let new = Kling2p6ProTextToVideoRequest { + prompt: "a wave at dawn".to_string(), + generate_audio: Some(true), + negative_prompt: Some("blurry".to_string()), + duration: Some(Kling2p6ProTextToVideoDuration::TenSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + cfg_scale: Some(0.5), + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p6ProTextToVideoInput { + prompt: "a wave at dawn".to_string(), + aspect_ratio: Some("9:16".to_string()), + generate_audio: Some(true), + negative_prompt: Some("blurry".to_string()), + duration: Some("10".to_string()), + cfg_scale: Some(0.5), + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + } + + /// At minimum (prompt only), unset optionals must be omitted on the + /// wire just like the legacy struct. + #[test] + fn wire_json_matches_legacy_minimal() { + let new = Kling2p6ProTextToVideoRequest { + prompt: "minimal".to_string(), + generate_audio: None, + negative_prompt: None, + duration: None, + aspect_ratio: None, + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + + let legacy = KlingV2p6ProTextToVideoInput { + prompt: "minimal".to_string(), + ..Default::default() + }; + let legacy_json = serde_json::to_value(&legacy).unwrap(); + + assert_eq!(new_json, legacy_json); + // And the minimal payload should literally be `{"prompt": "minimal"}`. + assert_eq!(new_json, serde_json::json!({ "prompt": "minimal" })); + } + + /// Cross product over duration × aspect_ratio × audio: every cell's wire + /// JSON must match the equivalent legacy `Input`. + #[test] + fn wire_json_matches_legacy_at_every_combo() { + let durations = [ + (None, None), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some("5")), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some("10")), + ]; + let aspect_ratios = [ + (None, None), + (Some(Kling2p6ProTextToVideoAspectRatio::Square), Some("1:1")), + (Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), Some("16:9")), + (Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), Some("9:16")), + ]; + let audio_options = [None, Some(false), Some(true)]; + + for (d_new, d_legacy) in durations { + for (ar_new, ar_legacy) in aspect_ratios { + for audio in audio_options { + let new = Kling2p6ProTextToVideoRequest { + prompt: "p".to_string(), + generate_audio: audio, + negative_prompt: None, + duration: d_new, + aspect_ratio: ar_new, + cfg_scale: None, + }; + let legacy = KlingV2p6ProTextToVideoInput { + prompt: "p".to_string(), + aspect_ratio: ar_legacy.map(String::from), + generate_audio: audio, + negative_prompt: None, + duration: d_legacy.map(String::from), + cfg_scale: None, + }; + let new_json = serde_json::to_value(new.to_raw_request().unwrap()).unwrap(); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert_eq!( + new_json, legacy_json, + "duration={d_new:?} aspect_ratio={ar_new:?} audio={audio:?}", + ); + } + } + } + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs new file mode 100644 index 0000000000..192e08a0ef --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/cost.rs @@ -0,0 +1,247 @@ +use crate::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoDuration, Kling2p6ProTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling2p6ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 2.6 Pro text-to-video pricing + // (see https://fal.ai/models/fal-ai/kling-video/v2.6/pro/text-to-video): + // $0.07/sec when audio is off + // $0.14/sec when audio is on + // + // generate_audio defaults to `true` on fal's server, so `None` is billed + // as audio-on. Rate stored in tenths-of-cents and rounded up to whole + // cents so the user is never undercharged. + let duration_secs = self.duration + .unwrap_or(Kling2p6ProTextToVideoDuration::FiveSeconds) + .to_seconds(); + + let audio_on = self.generate_audio.unwrap_or(true); + let rate_tenth_cents_per_sec: u64 = if audio_on { 140 } else { 70 }; + + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_2p6_pro_text_to_video::api::{ + Kling2p6ProTextToVideoAspectRatio, Kling2p6ProTextToVideoDuration, + }; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Kling2p6ProTextToVideoRequest { + Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: None, + } + } + + // Pricing: $0.07/sec audio off, $0.14/sec audio on. + // Cents per duration = ceil(rate × secs / 10) where rate is in tenths-of-cents. + + #[test] + fn five_seconds_audio_off() { + // (70 * 5 + 9) / 10 = 35 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(false)) + .calculate_cost_in_cents(), + 35, + ); + } + + #[test] + fn five_seconds_audio_on() { + // (140 * 5 + 9) / 10 = 70 — matches fal's $0.70 example + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_off() { + // (70 * 10 + 9) / 10 = 70 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(false)) + .calculate_cost_in_cents(), + 70, + ); + } + + #[test] + fn ten_seconds_audio_on() { + // (140 * 10 + 9) / 10 = 140 + assert_eq!( + make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(true)) + .calculate_cost_in_cents(), + 140, + ); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None, audio=on (default) → 5s @ $0.14 = 70¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 70); + } + + #[test] + fn default_audio_is_on() { + // duration=5s, audio=None → audio_on=true → 70¢ + let none_default = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), None) + .calculate_cost_in_cents(); + let explicit_on = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(); + assert_eq!(none_default, explicit_on); + } + + #[test] + fn audio_on_costs_twice_as_much_as_off() { + // $0.14 / $0.07 = 2x at every duration + let durations = [ + Kling2p6ProTextToVideoDuration::FiveSeconds, + Kling2p6ProTextToVideoDuration::TenSeconds, + ]; + for d in durations { + let off = make_request(Some(d), Some(false)).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true)).calculate_cost_in_cents(); + assert_eq!(on, off * 2, "duration={d:?}: audio_on={on}¢ should be 2× audio_off={off}¢"); + } + } + + #[test] + fn ten_seconds_costs_more_than_five() { + // At a given audio setting, 10s > 5s. + for audio in [Some(false), Some(true), None] { + let five = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), audio) + .calculate_cost_in_cents(); + let ten = make_request(Some(Kling2p6ProTextToVideoDuration::TenSeconds), audio) + .calculate_cost_in_cents(); + assert!(five < ten, "audio={audio:?}: five={five}¢ < ten={ten}¢"); + } + } + + /// Exhaustive cost-table tests: every permutation of cost-relevant + /// configuration (duration × generate_audio). Aspect ratio is in the cross + /// product as a sanity check — it must not affect the bill. + mod cost_table { + use super::*; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + fn new_request( + duration: Option, + generate_audio: Option, + aspect_ratio: Option, + ) -> Kling2p6ProTextToVideoRequest { + Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio, + cfg_scale: None, + } + } + + // (duration, generate_audio, expected_cents) + // + // Math: ceil(rate × secs / 10) where rate is 70 (audio off) or 140 (audio on). + // duration=None defaults to 5s; generate_audio=None defaults to true. + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + // duration=None → 5s default + (None, Some(false), 35), + (None, Some(true), 70), + (None, None, 70), // audio default = on + // duration=5s + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(false), 35), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true), 70), + (Some(Kling2p6ProTextToVideoDuration::FiveSeconds), None, 70), + // duration=10s + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(false), 70), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), Some(true), 140), + (Some(Kling2p6ProTextToVideoDuration::TenSeconds), None, 140), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = new_request(duration, generate_audio, None).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} generate_audio={generate_audio:?}", + ); + } + } + + /// Aspect ratio is not part of the billing formula — every aspect-ratio + /// choice (including `None`) must produce the same value for fixed + /// (duration, generate_audio). + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + None, + Some(Kling2p6ProTextToVideoAspectRatio::Square), + Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + Some(Kling2p6ProTextToVideoAspectRatio::NineBySixteen), + ]; + let durations = [ + None, + Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + Some(Kling2p6ProTextToVideoDuration::TenSeconds), + ]; + let audio_options = [None, Some(false), Some(true)]; + + for duration in durations { + for audio in audio_options { + let baseline = new_request(duration, audio, aspect_ratios[0]).calculate_cost_in_cents(); + for &ar in &aspect_ratios[1..] { + let cost = new_request(duration, audio, ar).calculate_cost_in_cents(); + assert_eq!( + cost, baseline, + "duration={duration:?} audio={audio:?} ar={ar:?}: cost={cost}¢ != baseline={baseline}¢", + ); + } + } + } + } + + /// cfg_scale is not part of the billing formula — every choice must produce + /// the same value for fixed (duration, generate_audio). + #[test] + fn cost_is_independent_of_cfg_scale() { + for cfg in [None, Some(0.0_f32), Some(0.5), Some(1.0)] { + let req = Kling2p6ProTextToVideoRequest { + prompt: "test".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling2p6ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling2p6ProTextToVideoAspectRatio::SixteenByNine), + cfg_scale: cfg, + }; + assert_eq!(req.calculate_cost_in_cents(), 70, "cfg_scale={cfg:?}"); + } + } + + /// Sanity-check the documented examples from fal: + /// "a 5s video with audio on will cost $0.70" + #[test] + fn matches_documented_examples() { + let cost = make_request(Some(Kling2p6ProTextToVideoDuration::FiveSeconds), Some(true)) + .calculate_cost_in_cents(); + assert_eq!(cost, 70, "5s audio-on should be $0.70 per fal's docs"); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs new file mode 100644 index 0000000000..91347fbb22 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_2p6_pro_text_to_video/raw_request.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/kling-video/v2.6/pro/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling2p6ProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate native audio for the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + /// fal's default: "blur, distort, and low quality" + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling2p6ProTextToVideoOutput { + pub video: Kling2p6ProTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/api.rs new file mode 100644 index 0000000000..5b492e70da --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/api.rs @@ -0,0 +1,275 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::kling_3p0_standard_text_to_video::raw_request::{ + Kling3p0StandardTextToVideoInput, Kling3p0StandardTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Kling3p0StandardTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Whether to generate audio. Defaults to true if not specified. + pub generate_audio: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Video duration. + pub duration: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// Shot type for multi-shot generation. + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardTextToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl Kling3p0StandardTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Kling3p0StandardTextToVideoShotType { + Customize, + Intelligent, +} + +impl FalEndpoint for Kling3p0StandardTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/kling-video/v3/standard/text-to-video"; + + type RawRequest = Kling3p0StandardTextToVideoInput; + type RawResponse = Kling3p0StandardTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + let duration = self.duration.map(|d| d.to_str().to_string()); + + let aspect_ratio = self.aspect_ratio.map(|ar| match ar { + Kling3p0StandardTextToVideoAspectRatio::Square => "1:1", + Kling3p0StandardTextToVideoAspectRatio::SixteenByNine => "16:9", + Kling3p0StandardTextToVideoAspectRatio::NineBySixteen => "9:16", + }.to_string()); + + let shot_type = self.shot_type.map(|st| match st { + Kling3p0StandardTextToVideoShotType::Customize => "customize", + Kling3p0StandardTextToVideoShotType::Intelligent => "intelligent", + }.to_string()); + + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + generate_audio: self.generate_audio, + negative_prompt: self.negative_prompt.clone(), + duration, + aspect_ratio, + shot_type, + cfg_scale: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling3p0StandardTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow, cinematic slow motion".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling3p0StandardTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Kling3p0StandardTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(Kling3p0StandardTextToVideoDuration::ThreeSeconds), + aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let aspect_ratios = [ + Kling3p0StandardTextToVideoAspectRatio::Square, + Kling3p0StandardTextToVideoAspectRatio::SixteenByNine, + Kling3p0StandardTextToVideoAspectRatio::NineBySixteen, + ]; + + for ar in aspect_ratios { + println!("--- aspect ratio: {:?} ---", ar); + let request = Kling3p0StandardTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling3p0StandardTextToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + shot_type: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let durations = [ + Kling3p0StandardTextToVideoDuration::ThreeSeconds, + Kling3p0StandardTextToVideoDuration::FourSeconds, + Kling3p0StandardTextToVideoDuration::FiveSeconds, + Kling3p0StandardTextToVideoDuration::SixSeconds, + Kling3p0StandardTextToVideoDuration::SevenSeconds, + Kling3p0StandardTextToVideoDuration::EightSeconds, + Kling3p0StandardTextToVideoDuration::NineSeconds, + Kling3p0StandardTextToVideoDuration::TenSeconds, + Kling3p0StandardTextToVideoDuration::ElevenSeconds, + Kling3p0StandardTextToVideoDuration::TwelveSeconds, + Kling3p0StandardTextToVideoDuration::ThirteenSeconds, + Kling3p0StandardTextToVideoDuration::FourteenSeconds, + Kling3p0StandardTextToVideoDuration::FifteenSeconds, + ]; + + for dur in durations { + println!("--- duration: {:?} ---", dur); + let request = Kling3p0StandardTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let shot_types = [ + Kling3p0StandardTextToVideoShotType::Customize, + Kling3p0StandardTextToVideoShotType::Intelligent, + ]; + + for st in shot_types { + println!("--- shot type: {:?} ---", st); + let request = Kling3p0StandardTextToVideoRequest { + prompt: "a bird takes flight from a tree branch".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(Kling3p0StandardTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: Some(st), + }; + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/cost.rs new file mode 100644 index 0000000000..3287194f7d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/cost.rs @@ -0,0 +1,120 @@ +use crate::requests::api::video::text::kling_3p0_standard_text_to_video::api::{ + Kling3p0StandardTextToVideoDuration, Kling3p0StandardTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Kling3p0StandardTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 3.0 Standard pricing: + // Audio off: $0.168/second + // Audio on: $0.252/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(Kling3p0StandardTextToVideoDuration::FiveSeconds) + .to_seconds(); + + // Rate in tenths-of-cents per second, rounded up + let rate = if generate_audio { 252u64 } else { 168u64 }; + (rate * duration_secs + 9) / 10 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::kling_3p0_standard_text_to_video::api::{ + Kling3p0StandardTextToVideoAspectRatio, Kling3p0StandardTextToVideoDuration, + }; + + fn make_request( + generate_audio: Option, + duration: Option, + ) -> Kling3p0StandardTextToVideoRequest { + Kling3p0StandardTextToVideoRequest { + prompt: "test".to_string(), + generate_audio, + negative_prompt: None, + duration, + aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + } + } + + mod audio_off { + use super::*; + + // Audio off: $0.168/sec → rate = 168 tenths-of-cents/sec + + #[test] + fn three_seconds() { + // (168 * 3 + 9) / 10 = 51 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::ThreeSeconds)).calculate_cost_in_cents(), 51); + } + + #[test] + fn five_seconds() { + // (168 * 5 + 9) / 10 = 84 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 84); + } + + #[test] + fn ten_seconds() { + // (168 * 10 + 9) / 10 = 168 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 168); + } + + #[test] + fn fifteen_seconds() { + // (168 * 15 + 9) / 10 = 252 + assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 252); + } + } + + mod audio_on { + use super::*; + + // Audio on: $0.252/sec → rate = 252 tenths-of-cents/sec + + #[test] + fn five_seconds() { + // (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardTextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126); + } + + #[test] + fn ten_seconds() { + // (252 * 10 + 9) / 10 = 252 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardTextToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 252); + } + + #[test] + fn fifteen_seconds() { + // (252 * 15 + 9) / 10 = 378 + assert_eq!(make_request(Some(true), Some(Kling3p0StandardTextToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 378); + } + } + + mod defaults { + use super::*; + + #[test] + fn default_audio_is_on() { + // generate_audio=None defaults to true (audio on) + // duration=5s default: (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(None, Some(Kling3p0StandardTextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126); + } + + #[test] + fn default_duration_is_five_seconds() { + // duration=None defaults to 5s + // audio off: (168 * 5 + 9) / 10 = 84 + assert_eq!(make_request(Some(false), None).calculate_cost_in_cents(), 84); + } + + #[test] + fn both_defaults() { + // audio on + 5s: (252 * 5 + 9) / 10 = 126 + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 126); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/raw_request.rs new file mode 100644 index 0000000000..c4cc5e1ca9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/kling_3p0_standard_text_to_video/raw_request.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0StandardTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Whether to generate audio alongside the video. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds (as a string). + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardTextToVideoOutput { + pub video: Kling3p0StandardTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/api.rs new file mode 100644 index 0000000000..cf9e9f3d20 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/api.rs @@ -0,0 +1,192 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::minimax_h3::raw_request::{ + MinimaxH3TextToVideoInput, MinimaxH3TextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct MinimaxH3TextToVideoRequest { + /// Text prompt (1 to 7000 characters). + pub prompt: String, + + /// Duration in seconds. Valid range 5–15; fal's default is `5` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `2K` when `None`. + pub resolution: Option, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, +} + +/// MiniMax H3 (Hailuo-03) resolutions. 2K bills at a higher per-second rate +/// than 768P (see the cost module). Shared by the text + image + reference +/// modalities. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MinimaxH3Resolution { + SevenSixtyEightP, + TwoK, +} + +impl MinimaxH3Resolution { + /// 2K is billed at the higher per-second rate. + pub fn is_2k(&self) -> bool { + matches!(self, Self::TwoK) + } + + pub(crate) fn to_str(&self) -> &'static str { + match self { + Self::SevenSixtyEightP => "768P", + Self::TwoK => "2K", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MinimaxH3TextToVideoAspectRatio { + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +impl MinimaxH3TextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::TwentyOneByNine => "21:9", + Self::SixteenByNine => "16:9", + Self::FourByThree => "4:3", + Self::Square => "1:1", + Self::ThreeByFour => "3:4", + Self::NineBySixteen => "9:16", + } + } +} + +impl FalEndpoint for MinimaxH3TextToVideoRequest { + const ENDPOINT: &str = "minimax/h3/text-to-video"; + + type RawRequest = MinimaxH3TextToVideoInput; + type RawResponse = MinimaxH3TextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| r.to_str().to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = MinimaxH3TextToVideoRequest { + prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(), + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = MinimaxH3TextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = MinimaxH3TextToVideoRequest { + prompt: "p".to_string(), + duration: Some(12), + resolution: Some(MinimaxH3Resolution::TwoK), + aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::FourByThree), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.resolution.as_deref(), Some("2K")); + assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = MinimaxH3TextToVideoRequest { + prompt: "minimal".to_string(), + duration: None, + resolution: None, + aspect_ratio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_resolution_maps_to_wire_string() { + for (variant, expected, is_2k) in [ + (MinimaxH3Resolution::SevenSixtyEightP, "768P", false), + (MinimaxH3Resolution::TwoK, "2K", true), + ] { + assert_eq!(variant.to_str(), expected); + assert_eq!(variant.is_2k(), is_2k); + } + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (MinimaxH3TextToVideoAspectRatio::TwentyOneByNine, "21:9"), + (MinimaxH3TextToVideoAspectRatio::SixteenByNine, "16:9"), + (MinimaxH3TextToVideoAspectRatio::FourByThree, "4:3"), + (MinimaxH3TextToVideoAspectRatio::Square, "1:1"), + (MinimaxH3TextToVideoAspectRatio::ThreeByFour, "3:4"), + (MinimaxH3TextToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(MinimaxH3TextToVideoRequest::ENDPOINT, "minimax/h3/text-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/cost.rs new file mode 100644 index 0000000000..c48efaa965 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/cost.rs @@ -0,0 +1,118 @@ +use crate::requests::api::video::text::minimax_h3::api::{ + MinimaxH3Resolution, MinimaxH3TextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// MiniMax H3 (Hailuo-03) pricing (see https://fal.ai/models/minimax/h3/text-to-video): +// 768P: $0.16 per video second +// 2K: $0.26 per video second +// +// Pricing depends on resolution and duration only (aspect ratio is free). +// Both rates are exact whole cents. Shared by the text + image + reference +// modalities (reference additionally bills extra reference images; see the +// reference cost module). +const RATE_768P_CENTS_PER_SEC: u64 = 16; // $0.16/sec +const RATE_2K_CENTS_PER_SEC: u64 = 26; // $0.26/sec + +/// Per-second rate in whole cents for MiniMax H3. `is_2k` selects the 2K tier. +pub(crate) fn minimax_h3_rate_cents_per_sec(is_2k: bool) -> u64 { + if is_2k { RATE_2K_CENTS_PER_SEC } else { RATE_768P_CENTS_PER_SEC } +} + +/// Per-second rate × duration → whole cents (both rates are exact cents, so no +/// rounding is needed). +pub(crate) fn minimax_h3_video_cost_cents(rate_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + rate_cents_per_sec * duration_secs +} + +impl FalRequestCostCalculator for MinimaxH3TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 2K. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_2k = self.resolution + .unwrap_or(MinimaxH3Resolution::TwoK) + .is_2k(); + + let rate = minimax_h3_rate_cents_per_sec(is_2k); + minimax_h3_video_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::minimax_h3::api::MinimaxH3TextToVideoAspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + ) -> MinimaxH3TextToVideoRequest { + MinimaxH3TextToVideoRequest { + prompt: "test".to_string(), + duration, + resolution, + aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine), + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + // Math: rate × secs where rate (cents) = 16 (768P) or 26 (2K). + const COST_TABLE: &[(Option, Option, u64)] = &[ + // 768P → $0.16/s + (Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 80), + (Some(10), Some(MinimaxH3Resolution::SevenSixtyEightP), 160), + (Some(15), Some(MinimaxH3Resolution::SevenSixtyEightP), 240), + // 2K → $0.26/s + (Some(5), Some(MinimaxH3Resolution::TwoK), 130), + (Some(10), Some(MinimaxH3Resolution::TwoK), 260), + (Some(15), Some(MinimaxH3Resolution::TwoK), 390), + // Defaults: duration=None→5s, resolution=None→2K + (None, None, 130), + (Some(8), None, 208), + (None, Some(MinimaxH3Resolution::SevenSixtyEightP), 80), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + #[test] + fn two_k_is_more_expensive_than_768p() { + let low = make_request(Some(8), Some(MinimaxH3Resolution::SevenSixtyEightP)).calculate_cost_in_cents(); + let high = make_request(Some(8), Some(MinimaxH3Resolution::TwoK)).calculate_cost_in_cents(); + assert!(high > low, "2K={high}¢ should exceed 768P={low}¢"); + } + + #[test] + fn cost_scales_linearly_with_duration() { + let five = make_request(Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP)).calculate_cost_in_cents(); + let ten = make_request(Some(10), Some(MinimaxH3Resolution::SevenSixtyEightP)).calculate_cost_in_cents(); + assert_eq!(ten, five * 2); + } + + /// Aspect ratio does not affect the bill (only resolution + duration do). + #[test] + fn cost_ignores_aspect_ratio() { + let baseline = make_request(Some(8), Some(MinimaxH3Resolution::TwoK)).calculate_cost_in_cents(); + for ar in [ + None, + Some(MinimaxH3TextToVideoAspectRatio::TwentyOneByNine), + Some(MinimaxH3TextToVideoAspectRatio::Square), + Some(MinimaxH3TextToVideoAspectRatio::NineBySixteen), + ] { + let cost = MinimaxH3TextToVideoRequest { + aspect_ratio: ar, + ..make_request(Some(8), Some(MinimaxH3Resolution::TwoK)) + }.calculate_cost_in_cents(); + assert_eq!(cost, baseline, "ar={ar:?}"); + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/raw_request.rs new file mode 100644 index 0000000000..413c92eafd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/minimax_h3/raw_request.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `minimax/h3/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct MinimaxH3TextToVideoInput { + /// Text prompt (1 to 7000 characters). + pub prompt: String, + + /// Duration in seconds. Range 5–15. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution. + /// Possible values: "768P", "2K". fal default: "2K". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio. + /// Possible values: "21:9", "16:9", "4:3", "1:1", "3:4", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinimaxH3TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinimaxH3TextToVideoOutput { + pub video: MinimaxH3TextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs new file mode 100644 index 0000000000..7f3c38e5fa --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/mod.rs @@ -0,0 +1,15 @@ +pub mod flux_3; +pub mod flux_3_draft; +pub mod kling_1p6_pro_text_to_video; +pub mod kling_2p5_turbo_pro_text_to_video; +pub mod kling_2p6_pro_text_to_video; +pub mod kling_3p0_standard_text_to_video; +pub mod minimax_h3; +pub mod veo_2; +pub mod veo_3; +pub mod veo_3_fast; +pub mod veo_3p1; +pub mod veo_3p1_fast; +pub mod veo_3p1_lite; +pub mod vidu_q3; +pub mod vidu_q3_turbo; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/api.rs new file mode 100644 index 0000000000..aeed324e06 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/api.rs @@ -0,0 +1,221 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::veo_2::raw_request::{ + Veo2TextToVideoInput, Veo2TextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo2TextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `5s` when `None`. + pub duration: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to enhance the prompt before generation. fal's default is `true` + /// when `None`. + pub enhance_prompt: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `true` when `None`. + pub auto_fix: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo2TextToVideoAspectRatio { + NineBySixteen, + SixteenByNine, + Square, +} + +impl Veo2TextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::NineBySixteen => "9:16", + Self::SixteenByNine => "16:9", + Self::Square => "1:1", + } + } +} + +/// Veo 2 supports 5–8 second durations (default 5s), unlike the Veo 3 family's +/// 4/6/8. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo2TextToVideoDuration { + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, +} + +impl Veo2TextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FiveSeconds => "5s", + Self::SixSeconds => "6s", + Self::SevenSeconds => "7s", + Self::EightSeconds => "8s", + } + } +} + +impl FalEndpoint for Veo2TextToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo2"; + + type RawRequest = Veo2TextToVideoInput; + type RawResponse = Veo2TextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + enhance_prompt: self.enhance_prompt, + seed: self.seed, + auto_fix: self.auto_fix, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo2TextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo2TextToVideoDuration::FiveSeconds), + negative_prompt: None, + enhance_prompt: None, + seed: None, + auto_fix: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo2TextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo2TextToVideoDuration::FiveSeconds), + negative_prompt: None, + enhance_prompt: None, + seed: None, + auto_fix: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo2TextToVideoRequest { + prompt: "p".to_string(), + aspect_ratio: Some(Veo2TextToVideoAspectRatio::Square), + duration: Some(Veo2TextToVideoDuration::EightSeconds), + negative_prompt: Some("blurry".to_string()), + enhance_prompt: Some(false), + seed: Some(42), + auto_fix: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1")); + assert_eq!(raw.duration.as_deref(), Some("8s")); + assert_eq!(raw.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(raw.enhance_prompt, Some(false)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.auto_fix, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo2TextToVideoRequest { + prompt: "minimal".to_string(), + aspect_ratio: None, + duration: None, + negative_prompt: None, + enhance_prompt: None, + seed: None, + auto_fix: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo2TextToVideoAspectRatio::NineBySixteen, "9:16"), + (Veo2TextToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo2TextToVideoAspectRatio::Square, "1:1"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo2TextToVideoDuration::FiveSeconds, "5s", 5), + (Veo2TextToVideoDuration::SixSeconds, "6s", 6), + (Veo2TextToVideoDuration::SevenSeconds, "7s", 7), + (Veo2TextToVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo2TextToVideoRequest::ENDPOINT, "fal-ai/veo2"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/cost.rs new file mode 100644 index 0000000000..a9681a2c30 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/cost.rs @@ -0,0 +1,110 @@ +use crate::requests::api::video::text::veo_2::api::{Veo2TextToVideoDuration, Veo2TextToVideoRequest}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Veo 2 pricing (see https://fal.ai/models/fal-ai/veo2): +// "For 5s video your request will cost $2.50. For every additional second +// you will be charged $0.50." +// +// This is a flat base + per-extra-second model (NOT the per-second-rate model +// the Veo 3 family uses), and it does not vary by audio, resolution, or aspect +// ratio. All amounts are exact whole cents. Shared by text + image modalities. +const BASE_COST_CENTS: UsdCents = 250; // 5s +const ADDITIONAL_SECOND_COST_CENTS: UsdCents = 50; +const BASE_DURATION_SECS: u64 = 5; + +/// Veo 2 cost in whole cents for a given duration in seconds. +/// `250 + (secs - 5) × 50`; durations below 5s (not reachable via the typed +/// enum) clamp to the base. +pub(crate) fn veo_2_cost_cents(duration_secs: u64) -> UsdCents { + let extra_secs = duration_secs.saturating_sub(BASE_DURATION_SECS); + BASE_COST_CENTS + extra_secs * ADDITIONAL_SECOND_COST_CENTS +} + +impl FalRequestCostCalculator for Veo2TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal default when unset: duration = 5s. + let duration_secs = self.duration + .unwrap_or(Veo2TextToVideoDuration::FiveSeconds) + .to_seconds(); + veo_2_cost_cents(duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::veo_2::api::Veo2TextToVideoAspectRatio; + + fn make_request(duration: Option) -> Veo2TextToVideoRequest { + Veo2TextToVideoRequest { + prompt: "test".to_string(), + aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine), + duration, + negative_prompt: None, + enhance_prompt: None, + seed: None, + auto_fix: None, + } + } + + /// fal: "For 5s video your request will cost $2.50" → 250¢. + #[test] + fn matches_documented_5s_example() { + assert_eq!(make_request(Some(Veo2TextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 250); + } + + /// fal i2v example: a 6-second video costs $3.00 ($2.50 + $0.50) → 300¢. + #[test] + fn matches_documented_6s_example() { + assert_eq!(make_request(Some(Veo2TextToVideoDuration::SixSeconds)).calculate_cost_in_cents(), 300); + } + + mod cost_table { + use super::*; + + // (duration, expected_cents): 250 base + 50 per second over 5s. + const COST_TABLE: &[(Option, u64)] = &[ + (Some(Veo2TextToVideoDuration::FiveSeconds), 250), + (Some(Veo2TextToVideoDuration::SixSeconds), 300), + (Some(Veo2TextToVideoDuration::SevenSeconds), 350), + (Some(Veo2TextToVideoDuration::EightSeconds), 400), + // Default: duration=None → 5s + (None, 250), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, expected) in COST_TABLE { + assert_eq!( + make_request(duration).calculate_cost_in_cents(), + expected, + "duration={duration:?}", + ); + } + } + + #[test] + fn each_extra_second_adds_fifty_cents() { + let five = make_request(Some(Veo2TextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(); + let six = make_request(Some(Veo2TextToVideoDuration::SixSeconds)).calculate_cost_in_cents(); + assert_eq!(six - five, 50); + } + + /// Aspect ratio, negative prompt, enhance_prompt, seed, and auto_fix must + /// not affect the bill. + #[test] + fn cost_ignores_non_billing_fields() { + let baseline = make_request(Some(Veo2TextToVideoDuration::EightSeconds)).calculate_cost_in_cents(); + let embellished = Veo2TextToVideoRequest { + prompt: "different".to_string(), + aspect_ratio: Some(Veo2TextToVideoAspectRatio::Square), + duration: Some(Veo2TextToVideoDuration::EightSeconds), + negative_prompt: Some("noise".to_string()), + enhance_prompt: Some(false), + seed: Some(99), + auto_fix: Some(false), + }.calculate_cost_in_cents(); + assert_eq!(baseline, embellished); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/raw_request.rs new file mode 100644 index 0000000000..2e735f104d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_2/raw_request.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo2` (text-to-video). +/// fal's schema: +/// +/// NB: Veo 2 has no `generate_audio`, `resolution`, or `safety_tolerance` +/// fields (unlike the Veo 3 family), and it *does* carry `enhance_prompt`. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo2TextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "9:16", "16:9", "1:1". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "5s", "6s", "7s", "8s". fal default: "5s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Whether to enhance the prompt before generation. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub enhance_prompt: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail content policy / + /// validation checks. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2TextToVideoOutput { + pub video: Veo2TextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/api.rs new file mode 100644 index 0000000000..f4237e1eeb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/api.rs @@ -0,0 +1,262 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::veo_3::raw_request::{ + Veo3TextToVideoInput, Veo3TextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3TextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `true` for text-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3TextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3TextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3TextToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3TextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3 exposes only 720p/1080p (no 4k tier). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3TextToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3TextToVideoResolution { + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3TextToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3TextToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3TextToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3"; + + type RawRequest = Veo3TextToVideoInput; + type RawResponse = Veo3TextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3TextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + aspect_ratio: Some(Veo3TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3TextToVideoDuration::FourSeconds), + resolution: Some(Veo3TextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(true), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3TextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + aspect_ratio: Some(Veo3TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3TextToVideoDuration::FourSeconds), + resolution: Some(Veo3TextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3TextToVideoRequest { + prompt: "p".to_string(), + aspect_ratio: Some(Veo3TextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3TextToVideoDuration::EightSeconds), + resolution: Some(Veo3TextToVideoResolution::TenEightyP), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(true), + seed: Some(42), + auto_fix: Some(false), + safety_tolerance: Some(Veo3TextToVideoSafetyTolerance::Level6), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("8s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("6")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3TextToVideoRequest { + prompt: "minimal".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo3TextToVideoDuration::FourSeconds, "4s", 4), + (Veo3TextToVideoDuration::SixSeconds, "6s", 6), + (Veo3TextToVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo3TextToVideoRequest::ENDPOINT, "fal-ai/veo3"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/cost.rs new file mode 100644 index 0000000000..6e40b3fa65 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/cost.rs @@ -0,0 +1,140 @@ +use crate::requests::api::video::text::veo_3::api::{ + Veo3TextToVideoDuration, Veo3TextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Veo 3 (non-fast) pricing (see https://fal.ai/models/fal-ai/veo3): +// $0.20/sec (audio off), $0.40/sec (audio on). Flat — Veo 3 has no 4k tier +// and resolution (720p/1080p) does not change the price. +// +// Rates are stored in tenths-of-a-cent per second, multiplied by duration and +// rounded UP to whole cents so the user is never undercharged. These helpers +// are the canonical Veo 3 rate table, reused by the image-to-video modality. +const RATE_AUDIO_OFF: u64 = 200; // $0.20/sec +const RATE_AUDIO_ON: u64 = 400; // $0.40/sec + +/// Per-second rate in tenths-of-a-cent for the Veo 3 (non-fast) family. +pub(crate) fn veo_3_rate_tenth_cents_per_sec(audio_on: bool) -> u64 { + if audio_on { RATE_AUDIO_ON } else { RATE_AUDIO_OFF } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn veo_3_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for Veo3TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, audio = on. Resolution does not + // affect price. + let duration_secs = self.duration + .unwrap_or(Veo3TextToVideoDuration::EightSeconds) + .to_seconds(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3_rate_tenth_cents_per_sec(audio_on); + veo_3_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::veo_3::api::{ + Veo3TextToVideoAspectRatio, Veo3TextToVideoResolution, Veo3TextToVideoSafetyTolerance, + }; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Veo3TextToVideoRequest { + Veo3TextToVideoRequest { + prompt: "test".to_string(), + aspect_ratio: Some(Veo3TextToVideoAspectRatio::SixteenByNine), + duration, + resolution: None, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + /// fal: "a 5s video with audio on will cost $2" → 5s × $0.40 = 200¢. + #[test] + fn matches_documented_5s_audio_on_example() { + let rate = veo_3_rate_tenth_cents_per_sec(true); + assert_eq!(veo_3_cost_cents(rate, 5), 200); + } + + mod cost_table { + use super::*; + + // (duration, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + (Some(Veo3TextToVideoDuration::FourSeconds), Some(false), 80), + (Some(Veo3TextToVideoDuration::FourSeconds), Some(true), 160), + (Some(Veo3TextToVideoDuration::SixSeconds), Some(true), 240), + (Some(Veo3TextToVideoDuration::EightSeconds), Some(false), 160), + (Some(Veo3TextToVideoDuration::EightSeconds), Some(true), 320), + // Defaults: duration=None→8s, audio=None→on + (None, None, 320), + (None, Some(false), 160), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, generate_audio).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}"); + } + } + + #[test] + fn audio_on_costs_twice_as_much_as_off() { + for d in [Veo3TextToVideoDuration::FourSeconds, Veo3TextToVideoDuration::EightSeconds] { + let off = make_request(Some(d), Some(false)).calculate_cost_in_cents(); + let on = make_request(Some(d), Some(true)).calculate_cost_in_cents(); + assert_eq!(on, off * 2, "duration={d:?}"); + } + } + + /// Resolution (720p vs 1080p) must not change the bill for Veo 3. + #[test] + fn cost_is_independent_of_resolution() { + for audio in [Some(false), Some(true), None] { + let base = make_request(Some(Veo3TextToVideoDuration::EightSeconds), audio).calculate_cost_in_cents(); + for res in [Veo3TextToVideoResolution::SevenTwentyP, Veo3TextToVideoResolution::TenEightyP] { + let cost = Veo3TextToVideoRequest { + resolution: Some(res), + ..make_request(Some(Veo3TextToVideoDuration::EightSeconds), audio) + }.calculate_cost_in_cents(); + assert_eq!(cost, base, "audio={audio:?} res={res:?}"); + } + } + } + + /// Neither aspect ratio, seed, auto_fix, nor safety tolerance affect the bill. + #[test] + fn cost_ignores_non_billing_fields() { + let baseline = make_request(Some(Veo3TextToVideoDuration::EightSeconds), Some(true)).calculate_cost_in_cents(); + let embellished = Veo3TextToVideoRequest { + prompt: "different".to_string(), + aspect_ratio: Some(Veo3TextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3TextToVideoDuration::EightSeconds), + resolution: Some(Veo3TextToVideoResolution::TenEightyP), + negative_prompt: Some("noise".to_string()), + generate_audio: Some(true), + seed: Some(99), + auto_fix: Some(false), + safety_tolerance: Some(Veo3TextToVideoSafetyTolerance::Level1), + }.calculate_cost_in_cents(); + assert_eq!(baseline, embellished); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/raw_request.rs new file mode 100644 index 0000000000..100773d2bf --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3` (text-to-video). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3TextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail content policy / + /// validation checks. fal default: true (text-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance for content moderation, "1" (strictest) .. "6" + /// (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3TextToVideoOutput { + pub video: Veo3TextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/api.rs new file mode 100644 index 0000000000..b4c54e8b05 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/api.rs @@ -0,0 +1,250 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::veo_3_fast::raw_request::{ + Veo3FastTextToVideoInput, Veo3FastTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3FastTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `true` for text-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastTextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3FastTextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastTextToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3FastTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3 Fast exposes only 720p/1080p (no 4k tier). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastTextToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3FastTextToVideoResolution { + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3FastTextToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3FastTextToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3FastTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3/fast"; + + type RawRequest = Veo3FastTextToVideoInput; + type RawResponse = Veo3FastTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3FastTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + aspect_ratio: Some(Veo3FastTextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3FastTextToVideoDuration::FourSeconds), + resolution: Some(Veo3FastTextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(true), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3FastTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + aspect_ratio: Some(Veo3FastTextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3FastTextToVideoDuration::FourSeconds), + resolution: Some(Veo3FastTextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3FastTextToVideoRequest { + prompt: "p".to_string(), + aspect_ratio: Some(Veo3FastTextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3FastTextToVideoDuration::EightSeconds), + resolution: Some(Veo3FastTextToVideoResolution::TenEightyP), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(true), + seed: Some(42), + auto_fix: Some(false), + safety_tolerance: Some(Veo3FastTextToVideoSafetyTolerance::Level6), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("8s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("6")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3FastTextToVideoRequest { + prompt: "minimal".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo3FastTextToVideoRequest::ENDPOINT, "fal-ai/veo3/fast"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/cost.rs new file mode 100644 index 0000000000..f52b7ed61b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/cost.rs @@ -0,0 +1,129 @@ +use crate::requests::api::video::text::veo_3_fast::api::{ + Veo3FastTextToVideoDuration, Veo3FastTextToVideoRequest, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Veo 3 Fast pricing (see https://fal.ai/models/fal-ai/veo3/fast): +// $0.10/sec (audio off), $0.15/sec (audio on). Flat — Veo 3 Fast has no 4k +// tier and resolution (720p/1080p) does not change the price. +// +// Rates are stored in tenths-of-a-cent per second, multiplied by duration and +// rounded UP to whole cents so the user is never undercharged. These helpers +// are the canonical Veo 3 Fast rate table, reused by the image-to-video modality. +const RATE_AUDIO_OFF: u64 = 100; // $0.10/sec +const RATE_AUDIO_ON: u64 = 150; // $0.15/sec + +/// Per-second rate in tenths-of-a-cent for the Veo 3 Fast family. +pub(crate) fn veo_3_fast_rate_tenth_cents_per_sec(audio_on: bool) -> u64 { + if audio_on { RATE_AUDIO_ON } else { RATE_AUDIO_OFF } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn veo_3_fast_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for Veo3FastTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, audio = on. Resolution does not + // affect price. + let duration_secs = self.duration + .unwrap_or(Veo3FastTextToVideoDuration::EightSeconds) + .to_seconds(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3_fast_rate_tenth_cents_per_sec(audio_on); + veo_3_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::veo_3_fast::api::{ + Veo3FastTextToVideoAspectRatio, Veo3FastTextToVideoResolution, Veo3FastTextToVideoSafetyTolerance, + }; + + fn make_request( + duration: Option, + generate_audio: Option, + ) -> Veo3FastTextToVideoRequest { + Veo3FastTextToVideoRequest { + prompt: "test".to_string(), + aspect_ratio: Some(Veo3FastTextToVideoAspectRatio::SixteenByNine), + duration, + resolution: None, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + /// fal: "a 5s video with audio on will cost $0.75" → 5s × $0.15 = 75¢. + #[test] + fn matches_documented_5s_audio_on_example() { + let rate = veo_3_fast_rate_tenth_cents_per_sec(true); + assert_eq!(veo_3_fast_cost_cents(rate, 5), 75); + } + + mod cost_table { + use super::*; + + // (duration, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + u64, + )] = &[ + (Some(Veo3FastTextToVideoDuration::FourSeconds), Some(false), 40), + (Some(Veo3FastTextToVideoDuration::FourSeconds), Some(true), 60), + (Some(Veo3FastTextToVideoDuration::SixSeconds), Some(false), 60), + (Some(Veo3FastTextToVideoDuration::EightSeconds), Some(false), 80), + (Some(Veo3FastTextToVideoDuration::EightSeconds), Some(true), 120), + // Defaults: duration=None→8s, audio=None→on + (None, None, 120), + (None, Some(false), 80), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, generate_audio).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}"); + } + } + + #[test] + fn cost_is_independent_of_resolution() { + for audio in [Some(false), Some(true), None] { + let base = make_request(Some(Veo3FastTextToVideoDuration::EightSeconds), audio).calculate_cost_in_cents(); + for res in [Veo3FastTextToVideoResolution::SevenTwentyP, Veo3FastTextToVideoResolution::TenEightyP] { + let cost = Veo3FastTextToVideoRequest { + resolution: Some(res), + ..make_request(Some(Veo3FastTextToVideoDuration::EightSeconds), audio) + }.calculate_cost_in_cents(); + assert_eq!(cost, base, "audio={audio:?} res={res:?}"); + } + } + } + + #[test] + fn cost_ignores_non_billing_fields() { + let baseline = make_request(Some(Veo3FastTextToVideoDuration::EightSeconds), Some(true)).calculate_cost_in_cents(); + let embellished = Veo3FastTextToVideoRequest { + prompt: "different".to_string(), + aspect_ratio: Some(Veo3FastTextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3FastTextToVideoDuration::EightSeconds), + resolution: Some(Veo3FastTextToVideoResolution::TenEightyP), + negative_prompt: Some("noise".to_string()), + generate_audio: Some(true), + seed: Some(99), + auto_fix: Some(false), + safety_tolerance: Some(Veo3FastTextToVideoSafetyTolerance::Level1), + }.calculate_cost_in_cents(); + assert_eq!(baseline, embellished); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/raw_request.rs new file mode 100644 index 0000000000..c84ae7819c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3_fast/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3/fast` (text-to-video). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3FastTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail content policy / + /// validation checks. fal default: true (text-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance for content moderation, "1" (strictest) .. "6" + /// (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3FastTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3FastTextToVideoOutput { + pub video: Veo3FastTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/api.rs new file mode 100644 index 0000000000..460260b429 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/api.rs @@ -0,0 +1,290 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::veo_3p1::raw_request::{ + Veo3p1TextToVideoInput, Veo3p1TextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1TextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `true` for text-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1TextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1TextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1TextToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1TextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1TextToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1TextToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1TextToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1TextToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1TextToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1"; + + type RawRequest = Veo3p1TextToVideoInput; + type RawResponse = Veo3p1TextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1TextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + aspect_ratio: Some(Veo3p1TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1TextToVideoDuration::FourSeconds), + resolution: Some(Veo3p1TextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(true), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1TextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + aspect_ratio: Some(Veo3p1TextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1TextToVideoDuration::FourSeconds), + resolution: Some(Veo3p1TextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1TextToVideoRequest { + prompt: "p".to_string(), + aspect_ratio: Some(Veo3p1TextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1TextToVideoDuration::EightSeconds), + resolution: Some(Veo3p1TextToVideoResolution::FourK), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(true), + seed: Some(42), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1TextToVideoSafetyTolerance::Level6), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("8s")); + assert_eq!(raw.resolution.as_deref(), Some("4k")); + assert_eq!(raw.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("6")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1TextToVideoRequest { + prompt: "minimal".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3p1TextToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3p1TextToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo3p1TextToVideoDuration::FourSeconds, "4s", 4), + (Veo3p1TextToVideoDuration::SixSeconds, "6s", 6), + (Veo3p1TextToVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn every_resolution_maps_to_wire_string() { + for (variant, expected, four_k) in [ + (Veo3p1TextToVideoResolution::SevenTwentyP, "720p", false), + (Veo3p1TextToVideoResolution::TenEightyP, "1080p", false), + (Veo3p1TextToVideoResolution::FourK, "4k", true), + ] { + assert_eq!(variant.to_str(), expected); + assert_eq!(variant.is_four_k(), four_k); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo3p1TextToVideoRequest::ENDPOINT, "fal-ai/veo3.1"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/cost.rs new file mode 100644 index 0000000000..7704e46ab8 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/cost.rs @@ -0,0 +1,187 @@ +use crate::requests::api::video::text::veo_3p1::api::{ + Veo3p1TextToVideoDuration, Veo3p1TextToVideoRequest, Veo3p1TextToVideoResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Veo 3.1 (non-fast) pricing (see https://fal.ai/models/fal-ai/veo3.1): +// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on) +// 4k: $0.40/sec (audio off), $0.60/sec (audio on) +// +// Rates are stored in tenths-of-a-cent per second, multiplied by duration and +// rounded UP to whole cents so the user is never undercharged. These helpers +// are the canonical Veo 3.1 rate table, reused by the image/images/reference/ +// extend modalities. +const RATE_SD_AUDIO_OFF: u64 = 200; // $0.20/sec +const RATE_SD_AUDIO_ON: u64 = 400; // $0.40/sec +const RATE_4K_AUDIO_OFF: u64 = 400; // $0.40/sec +const RATE_4K_AUDIO_ON: u64 = 600; // $0.60/sec + +/// Per-second rate in tenths-of-a-cent for the Veo 3.1 (non-fast) family. +/// `four_k` selects the higher 4k tier; `audio_on` selects the audio surcharge. +pub(crate) fn veo_3p1_rate_tenth_cents_per_sec(four_k: bool, audio_on: bool) -> u64 { + match (four_k, audio_on) { + (false, false) => RATE_SD_AUDIO_OFF, + (false, true) => RATE_SD_AUDIO_ON, + (true, false) => RATE_4K_AUDIO_OFF, + (true, true) => RATE_4K_AUDIO_ON, + } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn veo_3p1_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for Veo3p1TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1TextToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1TextToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::veo_3p1::api::{ + Veo3p1TextToVideoAspectRatio, Veo3p1TextToVideoSafetyTolerance, + }; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1TextToVideoRequest { + Veo3p1TextToVideoRequest { + prompt: "test".to_string(), + aspect_ratio: Some(Veo3p1TextToVideoAspectRatio::SixteenByNine), + duration, + resolution, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + /// fal: "a 5 second video at 1080p with audio on will cost $2.00". + /// The enum has no 5s variant, but the same $0.40/sec (1080p audio-on) rate + /// must reproduce it: 5s × 400 tenth-cents / 10 = 200¢. + #[test] + fn matches_documented_5s_1080p_audio_on_example() { + let rate = veo_3p1_rate_tenth_cents_per_sec(false, true); + assert_eq!(veo_3p1_cost_cents(rate, 5), 200); + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + // 720p, audio off ($0.20/s) + (Some(Veo3p1TextToVideoDuration::FourSeconds), Some(Veo3p1TextToVideoResolution::SevenTwentyP), Some(false), 80), + (Some(Veo3p1TextToVideoDuration::SixSeconds), Some(Veo3p1TextToVideoResolution::SevenTwentyP), Some(false), 120), + (Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::SevenTwentyP), Some(false), 160), + // 720p, audio on ($0.40/s) + (Some(Veo3p1TextToVideoDuration::FourSeconds), Some(Veo3p1TextToVideoResolution::SevenTwentyP), Some(true), 160), + (Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::SevenTwentyP), Some(true), 320), + // 1080p bills the same as 720p + (Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::TenEightyP), Some(false), 160), + (Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::TenEightyP), Some(true), 320), + // 4k, audio off ($0.40/s) and on ($0.60/s) + (Some(Veo3p1TextToVideoDuration::FourSeconds), Some(Veo3p1TextToVideoResolution::FourK), Some(false), 160), + (Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::FourK), Some(false), 320), + (Some(Veo3p1TextToVideoDuration::FourSeconds), Some(Veo3p1TextToVideoResolution::FourK), Some(true), 240), + (Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::FourK), Some(true), 480), + // Defaults: duration=None→8s, resolution=None→720p, audio=None→on + (None, None, None, 320), + (None, None, Some(false), 160), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + #[test] + fn audio_on_is_more_expensive_than_off() { + for res in [ + Veo3p1TextToVideoResolution::SevenTwentyP, + Veo3p1TextToVideoResolution::TenEightyP, + Veo3p1TextToVideoResolution::FourK, + ] { + let off = make_request(Some(Veo3p1TextToVideoDuration::EightSeconds), Some(res), Some(false)) + .calculate_cost_in_cents(); + let on = make_request(Some(Veo3p1TextToVideoDuration::EightSeconds), Some(res), Some(true)) + .calculate_cost_in_cents(); + assert!(on > off, "resolution={res:?}: audio_on={on}¢ should exceed audio_off={off}¢"); + } + } + + #[test] + fn four_k_is_more_expensive_than_sd() { + for audio in [Some(false), Some(true)] { + let sd = make_request(Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::TenEightyP), audio) + .calculate_cost_in_cents(); + let four_k = make_request(Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::FourK), audio) + .calculate_cost_in_cents(); + assert!(four_k > sd, "audio={audio:?}: 4k={four_k}¢ should exceed 1080p={sd}¢"); + } + } + + #[test] + fn seven_twenty_and_ten_eighty_cost_the_same() { + for audio in [Some(false), Some(true), None] { + let sd = make_request(Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::SevenTwentyP), audio) + .calculate_cost_in_cents(); + let hd = make_request(Some(Veo3p1TextToVideoDuration::EightSeconds), Some(Veo3p1TextToVideoResolution::TenEightyP), audio) + .calculate_cost_in_cents(); + assert_eq!(sd, hd, "audio={audio:?}: 720p and 1080p should bill the same"); + } + } + + /// Neither aspect ratio, seed, auto_fix, nor safety tolerance affect the bill. + #[test] + fn cost_ignores_non_billing_fields() { + let baseline = make_request( + Some(Veo3p1TextToVideoDuration::EightSeconds), + Some(Veo3p1TextToVideoResolution::TenEightyP), + Some(true), + ).calculate_cost_in_cents(); + + let embellished = Veo3p1TextToVideoRequest { + prompt: "different prompt".to_string(), + aspect_ratio: Some(Veo3p1TextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1TextToVideoDuration::EightSeconds), + resolution: Some(Veo3p1TextToVideoResolution::TenEightyP), + negative_prompt: Some("noise".to_string()), + generate_audio: Some(true), + seed: Some(99), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1TextToVideoSafetyTolerance::Level1), + }.calculate_cost_in_cents(); + + assert_eq!(baseline, embellished); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/raw_request.rs new file mode 100644 index 0000000000..b9198dda36 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1` (text-to-video). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1TextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail content policy / + /// validation checks. fal default: true (text-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance for content moderation, "1" (strictest) .. "6" + /// (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1TextToVideoOutput { + pub video: Veo3p1TextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/api.rs new file mode 100644 index 0000000000..8ad17d4ae3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/api.rs @@ -0,0 +1,291 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::veo_3p1_fast::raw_request::{ + Veo3p1FastTextToVideoInput, Veo3p1FastTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1FastTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `true` for text-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastTextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1FastTextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastTextToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1FastTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastTextToVideoResolution { + SevenTwentyP, + TenEightyP, + FourK, +} + +impl Veo3p1FastTextToVideoResolution { + /// 4k output is billed at a higher per-second rate than 720p/1080p. + pub fn is_four_k(&self) -> bool { + matches!(self, Self::FourK) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1FastTextToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1FastTextToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1FastTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/fast"; + + type RawRequest = Veo3p1FastTextToVideoInput; + type RawResponse = Veo3p1FastTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + aspect_ratio: Some(Veo3p1FastTextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastTextToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(true), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1FastTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + aspect_ratio: Some(Veo3p1FastTextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1FastTextToVideoDuration::FourSeconds), + resolution: Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1FastTextToVideoRequest { + prompt: "p".to_string(), + aspect_ratio: Some(Veo3p1FastTextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1FastTextToVideoDuration::EightSeconds), + resolution: Some(Veo3p1FastTextToVideoResolution::FourK), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(true), + seed: Some(42), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1FastTextToVideoSafetyTolerance::Level6), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("8s")); + assert_eq!(raw.resolution.as_deref(), Some("4k")); + assert_eq!(raw.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("6")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1FastTextToVideoRequest { + prompt: "minimal".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + // Minimal payload must be literally `{"prompt": "minimal"}`. + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (Veo3p1FastTextToVideoAspectRatio::SixteenByNine, "16:9"), + (Veo3p1FastTextToVideoAspectRatio::NineBySixteen, "9:16"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo3p1FastTextToVideoDuration::FourSeconds, "4s", 4), + (Veo3p1FastTextToVideoDuration::SixSeconds, "6s", 6), + (Veo3p1FastTextToVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn every_resolution_maps_to_wire_string() { + for (variant, expected, four_k) in [ + (Veo3p1FastTextToVideoResolution::SevenTwentyP, "720p", false), + (Veo3p1FastTextToVideoResolution::TenEightyP, "1080p", false), + (Veo3p1FastTextToVideoResolution::FourK, "4k", true), + ] { + assert_eq!(variant.to_str(), expected); + assert_eq!(variant.is_four_k(), four_k); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo3p1FastTextToVideoRequest::ENDPOINT, "fal-ai/veo3.1/fast"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/cost.rs new file mode 100644 index 0000000000..417e271438 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/cost.rs @@ -0,0 +1,195 @@ +use crate::requests::api::video::text::veo_3p1_fast::api::{ + Veo3p1FastTextToVideoDuration, Veo3p1FastTextToVideoRequest, Veo3p1FastTextToVideoResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Veo 3.1 Fast pricing (see https://fal.ai/models/fal-ai/veo3.1/fast): +// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on) +// 4k: $0.30/sec (audio off), $0.35/sec (audio on) +// +// Rates are stored in tenths-of-a-cent per second so the per-second price is +// exact, then multiplied by duration and rounded UP to whole cents so the +// user is never undercharged. +const RATE_SD_AUDIO_OFF: u64 = 100; // $0.10/sec +const RATE_SD_AUDIO_ON: u64 = 150; // $0.15/sec +const RATE_4K_AUDIO_OFF: u64 = 300; // $0.30/sec +const RATE_4K_AUDIO_ON: u64 = 350; // $0.35/sec + +/// Per-second rate in tenths-of-a-cent for the Veo 3.1 Fast family. +/// `four_k` selects the higher 4k tier; `audio_on` selects the audio surcharge. +pub(crate) fn veo_3p1_fast_rate_tenth_cents_per_sec(four_k: bool, audio_on: bool) -> u64 { + match (four_k, audio_on) { + (false, false) => RATE_SD_AUDIO_OFF, + (false, true) => RATE_SD_AUDIO_ON, + (true, false) => RATE_4K_AUDIO_OFF, + (true, true) => RATE_4K_AUDIO_ON, + } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn veo_3p1_fast_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for Veo3p1FastTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1FastTextToVideoDuration::EightSeconds) + .to_seconds(); + let four_k = self.resolution + .unwrap_or(Veo3p1FastTextToVideoResolution::SevenTwentyP) + .is_four_k(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on); + veo_3p1_fast_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::veo_3p1_fast::api::{ + Veo3p1FastTextToVideoAspectRatio, Veo3p1FastTextToVideoSafetyTolerance, + }; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1FastTextToVideoRequest { + Veo3p1FastTextToVideoRequest { + prompt: "test".to_string(), + aspect_ratio: Some(Veo3p1FastTextToVideoAspectRatio::SixteenByNine), + duration, + resolution, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + // ── Documented example ── + + /// fal: "a 5 second video at 1080p with audio on will cost $0.75". + /// The enum has no 5s variant, but the same $0.15/sec (1080p audio-on) rate + /// must reproduce it: 5s × 150 tenth-cents / 10 = 75¢. + #[test] + fn matches_documented_5s_1080p_audio_on_example() { + let rate = veo_3p1_fast_rate_tenth_cents_per_sec(false, true); + assert_eq!(veo_3p1_fast_cost_cents(rate, 5), 75); + } + + // ── Cost table over every cost-relevant permutation ── + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + // 720p, audio off ($0.10/s) + (Some(Veo3p1FastTextToVideoDuration::FourSeconds), Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), Some(false), 40), + (Some(Veo3p1FastTextToVideoDuration::SixSeconds), Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), Some(false), 60), + (Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), Some(false), 80), + // 720p, audio on ($0.15/s) + (Some(Veo3p1FastTextToVideoDuration::FourSeconds), Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), Some(true), 60), + (Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), Some(true), 120), + // 1080p bills the same as 720p + (Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::TenEightyP), Some(false), 80), + (Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::TenEightyP), Some(true), 120), + // 4k, audio off ($0.30/s) and on ($0.35/s) + (Some(Veo3p1FastTextToVideoDuration::FourSeconds), Some(Veo3p1FastTextToVideoResolution::FourK), Some(false), 120), + (Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::FourK), Some(false), 240), + (Some(Veo3p1FastTextToVideoDuration::FourSeconds), Some(Veo3p1FastTextToVideoResolution::FourK), Some(true), 140), + (Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::FourK), Some(true), 280), + // Defaults: duration=None→8s, resolution=None→720p, audio=None→on + (None, None, None, 120), + (None, None, Some(false), 80), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + #[test] + fn defaults_match_8s_720p_audio_on() { + assert_eq!(make_request(None, None, None).calculate_cost_in_cents(), 120); + } + + #[test] + fn audio_on_is_more_expensive_than_off() { + for res in [ + Veo3p1FastTextToVideoResolution::SevenTwentyP, + Veo3p1FastTextToVideoResolution::TenEightyP, + Veo3p1FastTextToVideoResolution::FourK, + ] { + let off = make_request(Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(res), Some(false)) + .calculate_cost_in_cents(); + let on = make_request(Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(res), Some(true)) + .calculate_cost_in_cents(); + assert!(on > off, "resolution={res:?}: audio_on={on}¢ should exceed audio_off={off}¢"); + } + } + + #[test] + fn four_k_is_more_expensive_than_sd() { + for audio in [Some(false), Some(true)] { + let sd = make_request(Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::TenEightyP), audio) + .calculate_cost_in_cents(); + let four_k = make_request(Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::FourK), audio) + .calculate_cost_in_cents(); + assert!(four_k > sd, "audio={audio:?}: 4k={four_k}¢ should exceed 1080p={sd}¢"); + } + } + + #[test] + fn seven_twenty_and_ten_eighty_cost_the_same() { + for audio in [Some(false), Some(true), None] { + let sd = make_request(Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::SevenTwentyP), audio) + .calculate_cost_in_cents(); + let hd = make_request(Some(Veo3p1FastTextToVideoDuration::EightSeconds), Some(Veo3p1FastTextToVideoResolution::TenEightyP), audio) + .calculate_cost_in_cents(); + assert_eq!(sd, hd, "audio={audio:?}: 720p and 1080p should bill the same"); + } + } + + /// Neither aspect ratio, seed, auto_fix, nor safety tolerance affect the bill. + #[test] + fn cost_ignores_non_billing_fields() { + let baseline = make_request( + Some(Veo3p1FastTextToVideoDuration::EightSeconds), + Some(Veo3p1FastTextToVideoResolution::TenEightyP), + Some(true), + ).calculate_cost_in_cents(); + + let embellished = Veo3p1FastTextToVideoRequest { + prompt: "different prompt".to_string(), + aspect_ratio: Some(Veo3p1FastTextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1FastTextToVideoDuration::EightSeconds), + resolution: Some(Veo3p1FastTextToVideoResolution::TenEightyP), + negative_prompt: Some("noise".to_string()), + generate_audio: Some(true), + seed: Some(99), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1FastTextToVideoSafetyTolerance::Level1), + }.calculate_cost_in_cents(); + + assert_eq!(baseline, embellished); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/raw_request.rs new file mode 100644 index 0000000000..0cea3fd31e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_fast/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/fast` (text-to-video). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p", "4k". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail content policy / + /// validation checks. fal default: true (text-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance for content moderation, "1" (strictest) .. "6" + /// (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastTextToVideoOutput { + pub video: Veo3p1FastTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/api.rs new file mode 100644 index 0000000000..f8009cc4d0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/api.rs @@ -0,0 +1,280 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::veo_3p1_lite::raw_request::{ + Veo3p1LiteTextToVideoInput, Veo3p1LiteTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct Veo3p1LiteTextToVideoRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Video duration. fal's default is `8s` when `None`. + pub duration: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Optional negative prompt. + pub negative_prompt: Option, + + /// Whether to generate native audio. fal's server default is `true` when + /// this is `None`. + pub generate_audio: Option, + + /// Seed for the random number generator. + pub seed: Option, + + /// Whether to auto-rewrite prompts that fail moderation. fal's default is + /// `true` for text-to-video when `None`. + pub auto_fix: Option, + + /// Safety tolerance. fal's default is `4` when `None`. + pub safety_tolerance: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteTextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, +} + +impl Veo3p1LiteTextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteTextToVideoDuration { + FourSeconds, + SixSeconds, + EightSeconds, +} + +impl Veo3p1LiteTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::FourSeconds => 4, + Self::SixSeconds => 6, + Self::EightSeconds => 8, + } + } + + fn to_str(&self) -> &'static str { + match self { + Self::FourSeconds => "4s", + Self::SixSeconds => "6s", + Self::EightSeconds => "8s", + } + } +} + +/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND +/// audio affect the per-second price, so `is_ten_eighty_p()` feeds the cost +/// calculator. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteTextToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl Veo3p1LiteTextToVideoResolution { + /// 1080p is billed at a higher per-second rate than 720p. + pub fn is_ten_eighty_p(&self) -> bool { + matches!(self, Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Veo3p1LiteTextToVideoSafetyTolerance { + /// "1" — strictest. + Level1, + Level2, + Level3, + /// "4" — fal default. + Level4, + Level5, + /// "6" — least strict. + Level6, +} + +impl Veo3p1LiteTextToVideoSafetyTolerance { + fn to_str(&self) -> &'static str { + match self { + Self::Level1 => "1", + Self::Level2 => "2", + Self::Level3 => "3", + Self::Level4 => "4", + Self::Level5 => "5", + Self::Level6 => "6", + } + } +} + +impl FalEndpoint for Veo3p1LiteTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/veo3.1/lite"; + + type RawRequest = Veo3p1LiteTextToVideoInput; + type RawResponse = Veo3p1LiteTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + duration: self.duration.map(|d| d.to_str().to_string()), + negative_prompt: self.negative_prompt.clone(), + resolution: self.resolution.map(|r| r.to_str().to_string()), + generate_audio: self.generate_audio, + seed: self.seed, + auto_fix: self.auto_fix, + safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1LiteTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + aspect_ratio: Some(Veo3p1LiteTextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1LiteTextToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(true), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Veo3p1LiteTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + aspect_ratio: Some(Veo3p1LiteTextToVideoAspectRatio::SixteenByNine), + duration: Some(Veo3p1LiteTextToVideoDuration::FourSeconds), + resolution: Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), + negative_prompt: None, + generate_audio: Some(false), + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Veo3p1LiteTextToVideoRequest { + prompt: "p".to_string(), + aspect_ratio: Some(Veo3p1LiteTextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1LiteTextToVideoDuration::EightSeconds), + resolution: Some(Veo3p1LiteTextToVideoResolution::TenEightyP), + negative_prompt: Some("blurry".to_string()), + generate_audio: Some(true), + seed: Some(42), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1LiteTextToVideoSafetyTolerance::Level6), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16")); + assert_eq!(raw.duration.as_deref(), Some("8s")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(raw.generate_audio, Some(true)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.auto_fix, Some(false)); + assert_eq!(raw.safety_tolerance.as_deref(), Some("6")); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Veo3p1LiteTextToVideoRequest { + prompt: "minimal".to_string(), + aspect_ratio: None, + duration: None, + resolution: None, + negative_prompt: None, + generate_audio: None, + seed: None, + auto_fix: None, + safety_tolerance: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_duration_maps_to_wire_string_and_seconds() { + for (variant, s, secs) in [ + (Veo3p1LiteTextToVideoDuration::FourSeconds, "4s", 4), + (Veo3p1LiteTextToVideoDuration::SixSeconds, "6s", 6), + (Veo3p1LiteTextToVideoDuration::EightSeconds, "8s", 8), + ] { + assert_eq!(variant.to_str(), s); + assert_eq!(variant.to_seconds(), secs); + } + } + + #[test] + fn every_resolution_maps_to_wire_string() { + for (variant, expected, hd) in [ + (Veo3p1LiteTextToVideoResolution::SevenTwentyP, "720p", false), + (Veo3p1LiteTextToVideoResolution::TenEightyP, "1080p", true), + ] { + assert_eq!(variant.to_str(), expected); + assert_eq!(variant.is_ten_eighty_p(), hd); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Veo3p1LiteTextToVideoRequest::ENDPOINT, "fal-ai/veo3.1/lite"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/cost.rs new file mode 100644 index 0000000000..f45cec90df --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/cost.rs @@ -0,0 +1,169 @@ +use crate::requests::api::video::text::veo_3p1_lite::api::{ + Veo3p1LiteTextToVideoDuration, Veo3p1LiteTextToVideoRequest, Veo3p1LiteTextToVideoResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Veo 3.1 Lite pricing (see https://fal.ai/models/fal-ai/veo3.1/lite): +// 720p: $0.03/sec (audio off), $0.05/sec (audio on) +// 1080p: $0.05/sec (audio off), $0.08/sec (audio on) +// +// Unlike the other Veo models, BOTH resolution and audio move the per-second +// price (and there is no 4k tier). Rates are stored in tenths-of-a-cent per +// second, multiplied by duration and rounded UP to whole cents so the user is +// never undercharged. These helpers are the canonical Veo 3.1 Lite rate table, +// reused by the image/images modalities. +const RATE_720P_AUDIO_OFF: u64 = 30; // $0.03/sec +const RATE_720P_AUDIO_ON: u64 = 50; // $0.05/sec +const RATE_1080P_AUDIO_OFF: u64 = 50; // $0.05/sec +const RATE_1080P_AUDIO_ON: u64 = 80; // $0.08/sec + +/// Per-second rate in tenths-of-a-cent for Veo 3.1 Lite. +/// `is_1080p` selects the 1080p tier; `audio_on` selects the audio surcharge. +pub(crate) fn veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p: bool, audio_on: bool) -> u64 { + match (is_1080p, audio_on) { + (false, false) => RATE_720P_AUDIO_OFF, + (false, true) => RATE_720P_AUDIO_ON, + (true, false) => RATE_1080P_AUDIO_OFF, + (true, true) => RATE_1080P_AUDIO_ON, + } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn veo_3p1_lite_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for Veo3p1LiteTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 8s, resolution = 720p, audio = on. + let duration_secs = self.duration + .unwrap_or(Veo3p1LiteTextToVideoDuration::EightSeconds) + .to_seconds(); + let is_1080p = self.resolution + .unwrap_or(Veo3p1LiteTextToVideoResolution::SevenTwentyP) + .is_ten_eighty_p(); + let audio_on = self.generate_audio.unwrap_or(true); + + let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on); + veo_3p1_lite_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::veo_3p1_lite::api::{ + Veo3p1LiteTextToVideoAspectRatio, Veo3p1LiteTextToVideoSafetyTolerance, + }; + + fn make_request( + duration: Option, + resolution: Option, + generate_audio: Option, + ) -> Veo3p1LiteTextToVideoRequest { + Veo3p1LiteTextToVideoRequest { + prompt: "test".to_string(), + aspect_ratio: Some(Veo3p1LiteTextToVideoAspectRatio::SixteenByNine), + duration, + resolution, + negative_prompt: None, + generate_audio, + seed: None, + auto_fix: None, + safety_tolerance: None, + } + } + + /// fal: "a 4 second video at 720p with audio will cost $0.20". + #[test] + fn matches_documented_4s_720p_audio_on_example() { + let cost = make_request( + Some(Veo3p1LiteTextToVideoDuration::FourSeconds), + Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), + Some(true), + ).calculate_cost_in_cents(); + assert_eq!(cost, 20); + } + + mod cost_table { + use super::*; + + // (duration, resolution, generate_audio, expected_cents) + // Math: ceil(rate × secs / 10) where rate (tenth-cents) is + // 720p: 30 off / 50 on; 1080p: 50 off / 80 on. + const COST_TABLE: &[( + Option, + Option, + Option, + u64, + )] = &[ + // 720p audio off ($0.03/s) + (Some(Veo3p1LiteTextToVideoDuration::FourSeconds), Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), Some(false), 12), + (Some(Veo3p1LiteTextToVideoDuration::SixSeconds), Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), Some(false), 18), + (Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), Some(false), 24), + // 720p audio on ($0.05/s) + (Some(Veo3p1LiteTextToVideoDuration::FourSeconds), Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), Some(true), 20), + (Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), Some(true), 40), + // 1080p audio off ($0.05/s) + (Some(Veo3p1LiteTextToVideoDuration::FourSeconds), Some(Veo3p1LiteTextToVideoResolution::TenEightyP), Some(false), 20), + (Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(Veo3p1LiteTextToVideoResolution::TenEightyP), Some(false), 40), + // 1080p audio on ($0.08/s) + (Some(Veo3p1LiteTextToVideoDuration::FourSeconds), Some(Veo3p1LiteTextToVideoResolution::TenEightyP), Some(true), 32), + (Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(Veo3p1LiteTextToVideoResolution::TenEightyP), Some(true), 64), + // Defaults: duration=None→8s, resolution=None→720p, audio=None→on + (None, None, None, 40), + (None, None, Some(false), 24), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, generate_audio, expected) in COST_TABLE { + let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents(); + assert_eq!( + got, expected, + "duration={duration:?} resolution={resolution:?} audio={generate_audio:?}", + ); + } + } + + #[test] + fn audio_on_is_more_expensive_than_off() { + for res in [Veo3p1LiteTextToVideoResolution::SevenTwentyP, Veo3p1LiteTextToVideoResolution::TenEightyP] { + let off = make_request(Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(res), Some(false)).calculate_cost_in_cents(); + let on = make_request(Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(res), Some(true)).calculate_cost_in_cents(); + assert!(on > off, "res={res:?}: on={on}¢ should exceed off={off}¢"); + } + } + + #[test] + fn ten_eighty_p_is_more_expensive_than_seven_twenty() { + for audio in [Some(false), Some(true)] { + let sd = make_request(Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP), audio).calculate_cost_in_cents(); + let hd = make_request(Some(Veo3p1LiteTextToVideoDuration::EightSeconds), Some(Veo3p1LiteTextToVideoResolution::TenEightyP), audio).calculate_cost_in_cents(); + assert!(hd > sd, "audio={audio:?}: 1080p={hd}¢ should exceed 720p={sd}¢"); + } + } + + /// Neither aspect ratio, seed, auto_fix, nor safety tolerance affect the bill. + #[test] + fn cost_ignores_non_billing_fields() { + let baseline = make_request( + Some(Veo3p1LiteTextToVideoDuration::EightSeconds), + Some(Veo3p1LiteTextToVideoResolution::TenEightyP), + Some(true), + ).calculate_cost_in_cents(); + let embellished = Veo3p1LiteTextToVideoRequest { + prompt: "different".to_string(), + aspect_ratio: Some(Veo3p1LiteTextToVideoAspectRatio::NineBySixteen), + duration: Some(Veo3p1LiteTextToVideoDuration::EightSeconds), + resolution: Some(Veo3p1LiteTextToVideoResolution::TenEightyP), + negative_prompt: Some("noise".to_string()), + generate_audio: Some(true), + seed: Some(99), + auto_fix: Some(false), + safety_tolerance: Some(Veo3p1LiteTextToVideoSafetyTolerance::Level1), + }.calculate_cost_in_cents(); + assert_eq!(baseline, embellished); + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/raw_request.rs new file mode 100644 index 0000000000..6e259e525d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/veo_3p1_lite/raw_request.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/veo3.1/lite` (text-to-video). +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1LiteTextToVideoInput { + pub prompt: String, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (as a string). + /// Possible values: "4s", "6s", "8s". fal default: "8s". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Output resolution. + /// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate native audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Seed for the random number generator. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to automatically rewrite prompts that fail content policy / + /// validation checks. fal default: true (text-to-video). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Safety tolerance for content moderation, "1" (strictest) .. "6" + /// (least strict). fal default: "4". + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1LiteTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1LiteTextToVideoOutput { + pub video: Veo3p1LiteTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/api.rs new file mode 100644 index 0000000000..1bcd3e5fb5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/api.rs @@ -0,0 +1,225 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::vidu_q3::raw_request::{ + ViduQ3TextToVideoInput, ViduQ3TextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct ViduQ3TextToVideoRequest { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`. + pub duration: Option, + + /// Seed for reproducibility. Random when `None`. + pub seed: Option, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate audio. fal's server default is `true` when `None`. + pub audio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3TextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, + FourByThree, + ThreeByFour, + Square, +} + +impl ViduQ3TextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + Self::FourByThree => "4:3", + Self::ThreeByFour => "3:4", + Self::Square => "1:1", + } + } +} + +/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3TextToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +impl ViduQ3TextToVideoResolution { + /// 720p and 1080p are billed at the higher (2.2×) per-second rate. + pub fn is_high_res(&self) -> bool { + matches!(self, Self::SevenTwentyP | Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSixtyP => "360p", + Self::FiveFortyP => "540p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl FalEndpoint for ViduQ3TextToVideoRequest { + const ENDPOINT: &str = "fal-ai/vidu/q3/text-to-video"; + + type RawRequest = ViduQ3TextToVideoInput; + type RawResponse = ViduQ3TextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + duration: self.duration, + seed: self.seed, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + audio: self.audio, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3TextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + duration: Some(5), + seed: None, + aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3TextToVideoResolution::SevenTwentyP), + audio: Some(true), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3TextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + duration: Some(5), + seed: Some(1234), + aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3TextToVideoResolution::ThreeSixtyP), + audio: Some(false), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = ViduQ3TextToVideoRequest { + prompt: "p".to_string(), + duration: Some(12), + seed: Some(42), + aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::FourByThree), + resolution: Some(ViduQ3TextToVideoResolution::TenEightyP), + audio: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.audio, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = ViduQ3TextToVideoRequest { + prompt: "minimal".to_string(), + duration: None, + seed: None, + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn seed_serializes_when_set() { + let request = ViduQ3TextToVideoRequest { + prompt: "p".to_string(), + duration: None, + seed: Some(777), + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777)); + } + + #[test] + fn every_aspect_ratio_maps_to_wire_string() { + for (variant, expected) in [ + (ViduQ3TextToVideoAspectRatio::SixteenByNine, "16:9"), + (ViduQ3TextToVideoAspectRatio::NineBySixteen, "9:16"), + (ViduQ3TextToVideoAspectRatio::FourByThree, "4:3"), + (ViduQ3TextToVideoAspectRatio::ThreeByFour, "3:4"), + (ViduQ3TextToVideoAspectRatio::Square, "1:1"), + ] { + assert_eq!(variant.to_str(), expected); + } + } + + #[test] + fn every_resolution_maps_to_wire_string() { + for (variant, expected, high) in [ + (ViduQ3TextToVideoResolution::ThreeSixtyP, "360p", false), + (ViduQ3TextToVideoResolution::FiveFortyP, "540p", false), + (ViduQ3TextToVideoResolution::SevenTwentyP, "720p", true), + (ViduQ3TextToVideoResolution::TenEightyP, "1080p", true), + ] { + assert_eq!(variant.to_str(), expected); + assert_eq!(variant.is_high_res(), high); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(ViduQ3TextToVideoRequest::ENDPOINT, "fal-ai/vidu/q3/text-to-video"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/cost.rs new file mode 100644 index 0000000000..5c79c8b99d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/cost.rs @@ -0,0 +1,125 @@ +use crate::requests::api::video::text::vidu_q3::api::{ + ViduQ3TextToVideoRequest, ViduQ3TextToVideoResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Vidu Q3 pricing (see https://fal.ai/models/fal-ai/vidu/q3/text-to-video): +// "0.07 $ per video second for 360p and 540p, cost will be 2.2x for 720p and +// 1080p resolution." +// 360p / 540p: $0.07/sec +// 720p / 1080p: $0.154/sec (2.2 × $0.07) +// +// Pricing depends on resolution and duration only (audio/aspect ratio are +// free). Rates are stored in tenths-of-a-cent per second (both exact), then +// rounded UP to whole cents so the user is never undercharged. Shared by the +// text + image modalities. +const RATE_LOW_RES_TENTH_CENTS: u64 = 70; // $0.07/sec (360p/540p) +const RATE_HIGH_RES_TENTH_CENTS: u64 = 154; // $0.154/sec (720p/1080p) + +/// Per-second rate in tenths-of-a-cent for Vidu Q3. `is_high_res` selects the +/// 2.2× 720p/1080p tier. +pub(crate) fn vidu_q3_rate_tenth_cents_per_sec(is_high_res: bool) -> u64 { + if is_high_res { RATE_HIGH_RES_TENTH_CENTS } else { RATE_LOW_RES_TENTH_CENTS } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn vidu_q3_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for ViduQ3TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 720p. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_high_res = self.resolution + .unwrap_or(ViduQ3TextToVideoResolution::SevenTwentyP) + .is_high_res(); + + let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res); + vidu_q3_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::vidu_q3::api::ViduQ3TextToVideoAspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + audio: Option, + ) -> ViduQ3TextToVideoRequest { + ViduQ3TextToVideoRequest { + prompt: "test".to_string(), + duration, + seed: None, + aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine), + resolution, + audio, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + // Math: ceil(rate × secs / 10) where rate (tenth-cents) = 70 (360p/540p) + // or 154 (720p/1080p). + const COST_TABLE: &[(Option, Option, u64)] = &[ + // 360p / 540p → $0.07/s + (Some(5), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), 35), + (Some(5), Some(ViduQ3TextToVideoResolution::FiveFortyP), 35), + (Some(16), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), 112), + // 720p / 1080p → $0.154/s + (Some(5), Some(ViduQ3TextToVideoResolution::SevenTwentyP), 77), + (Some(5), Some(ViduQ3TextToVideoResolution::TenEightyP), 77), + (Some(10), Some(ViduQ3TextToVideoResolution::SevenTwentyP), 154), + (Some(3), Some(ViduQ3TextToVideoResolution::SevenTwentyP), 47), // 46.2 → 47 + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 77), + (Some(1), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), 7), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution, Some(true)).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + #[test] + fn high_res_is_more_expensive_than_low_res() { + let low = make_request(Some(8), Some(ViduQ3TextToVideoResolution::FiveFortyP), Some(true)).calculate_cost_in_cents(); + let high = make_request(Some(8), Some(ViduQ3TextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents(); + assert!(high > low, "high={high}¢ should exceed low={low}¢"); + } + + #[test] + fn cost_scales_linearly_with_duration() { + let five = make_request(Some(5), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), Some(true)).calculate_cost_in_cents(); + let ten = make_request(Some(10), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), Some(true)).calculate_cost_in_cents(); + assert_eq!(ten, five * 2); + } + + /// Audio and aspect ratio do not affect the bill (only resolution + duration do). + #[test] + fn cost_ignores_audio_and_aspect_ratio() { + let baseline = make_request(Some(8), Some(ViduQ3TextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents(); + for audio in [None, Some(false), Some(true)] { + for ar in [ + ViduQ3TextToVideoAspectRatio::SixteenByNine, + ViduQ3TextToVideoAspectRatio::Square, + ViduQ3TextToVideoAspectRatio::ThreeByFour, + ] { + let cost = ViduQ3TextToVideoRequest { + aspect_ratio: Some(ar), + ..make_request(Some(8), Some(ViduQ3TextToVideoResolution::SevenTwentyP), audio) + }.calculate_cost_in_cents(); + assert_eq!(cost, baseline, "audio={audio:?} ar={ar:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/raw_request.rs new file mode 100644 index 0000000000..e5e476b40a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3/raw_request.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/vidu/q3/text-to-video`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ViduQ3TextToVideoInput { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// Duration in seconds. Range 1–16. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Seed for reproducibility. Random when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Output resolution. + /// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3TextToVideoOutput { + pub video: ViduQ3TextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/api.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/api.rs new file mode 100644 index 0000000000..6e9d5fc7f2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/api.rs @@ -0,0 +1,202 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::video::text::vidu_q3_turbo::raw_request::{ + ViduQ3TurboTextToVideoInput, ViduQ3TurboTextToVideoOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +#[derive(Clone, Debug)] +pub struct ViduQ3TurboTextToVideoRequest { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`. + pub duration: Option, + + /// Seed for reproducibility. Random when `None`. + pub seed: Option, + + /// Aspect ratio. fal's default is `16:9` when `None`. + pub aspect_ratio: Option, + + /// Output resolution. fal's default is `720p` when `None`. + pub resolution: Option, + + /// Whether to generate audio. fal's server default is `true` when `None`. + pub audio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3TurboTextToVideoAspectRatio { + SixteenByNine, + NineBySixteen, + FourByThree, + ThreeByFour, + Square, +} + +impl ViduQ3TurboTextToVideoAspectRatio { + fn to_str(&self) -> &'static str { + match self { + Self::SixteenByNine => "16:9", + Self::NineBySixteen => "9:16", + Self::FourByThree => "4:3", + Self::ThreeByFour => "3:4", + Self::Square => "1:1", + } + } +} + +/// Vidu Q3 Turbo resolutions. 720p/1080p bill at 2.2× the 360p/540p rate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ViduQ3TurboTextToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +impl ViduQ3TurboTextToVideoResolution { + /// 720p and 1080p are billed at the higher (2.2×) per-second rate. + pub fn is_high_res(&self) -> bool { + matches!(self, Self::SevenTwentyP | Self::TenEightyP) + } + + fn to_str(&self) -> &'static str { + match self { + Self::ThreeSixtyP => "360p", + Self::FiveFortyP => "540p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +impl FalEndpoint for ViduQ3TurboTextToVideoRequest { + const ENDPOINT: &str = "fal-ai/vidu/q3/text-to-video/turbo"; + + type RawRequest = ViduQ3TurboTextToVideoInput; + type RawResponse = ViduQ3TurboTextToVideoOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + prompt: self.prompt.clone(), + duration: self.duration, + seed: self.seed, + aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()), + resolution: self.resolution.map(|r| r.to_str().to_string()), + audio: self.audio, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3TurboTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(), + duration: Some(5), + seed: None, + aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), + audio: Some(true), + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_text_to_video_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = ViduQ3TurboTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + duration: Some(5), + seed: Some(1234), + aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine), + resolution: Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), + audio: Some(false), + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = ViduQ3TurboTextToVideoRequest { + prompt: "p".to_string(), + duration: Some(12), + seed: Some(42), + aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::FourByThree), + resolution: Some(ViduQ3TurboTextToVideoResolution::TenEightyP), + audio: Some(false), + }; + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.prompt, "p"); + assert_eq!(raw.duration, Some(12)); + assert_eq!(raw.seed, Some(42)); + assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3")); + assert_eq!(raw.resolution.as_deref(), Some("1080p")); + assert_eq!(raw.audio, Some(false)); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = ViduQ3TurboTextToVideoRequest { + prompt: "minimal".to_string(), + duration: None, + seed: None, + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn seed_serializes_when_set() { + let request = ViduQ3TurboTextToVideoRequest { + prompt: "p".to_string(), + duration: None, + seed: Some(777), + aspect_ratio: None, + resolution: None, + audio: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777)); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!( + ViduQ3TurboTextToVideoRequest::ENDPOINT, + "fal-ai/vidu/q3/text-to-video/turbo", + ); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/cost.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/cost.rs new file mode 100644 index 0000000000..886b46ce6f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/cost.rs @@ -0,0 +1,131 @@ +use crate::requests::api::video::text::vidu_q3_turbo::api::{ + ViduQ3TurboTextToVideoRequest, ViduQ3TurboTextToVideoResolution, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +// Vidu Q3 Turbo pricing (see https://fal.ai/models/fal-ai/vidu/q3/text-to-video/turbo): +// "0.035 $ per video second for 360p and 540p, cost will be 2.2x for 720p +// and 1080p resolution." +// 360p / 540p: $0.035/sec +// 720p / 1080p: $0.077/sec (2.2 × $0.035) +// +// Half the Vidu Q3 rate. Pricing depends on resolution and duration only. +// Rates are stored in tenths-of-a-cent per second (both exact), then rounded +// UP to whole cents so the user is never undercharged. Shared by text + image. +const RATE_LOW_RES_TENTH_CENTS: u64 = 35; // $0.035/sec (360p/540p) +const RATE_HIGH_RES_TENTH_CENTS: u64 = 77; // $0.077/sec (720p/1080p) + +/// Per-second rate in tenths-of-a-cent for Vidu Q3 Turbo. `is_high_res` +/// selects the 2.2× 720p/1080p tier. +pub(crate) fn vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res: bool) -> u64 { + if is_high_res { RATE_HIGH_RES_TENTH_CENTS } else { RATE_LOW_RES_TENTH_CENTS } +} + +/// ceil(rate_tenth_cents × seconds / 10) → whole cents. +pub(crate) fn vidu_q3_turbo_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents { + (rate_tenth_cents_per_sec * duration_secs + 9) / 10 +} + +impl FalRequestCostCalculator for ViduQ3TurboTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // fal defaults when unset: duration = 5s, resolution = 720p. + let duration_secs = u64::from(self.duration.unwrap_or(5)); + let is_high_res = self.resolution + .unwrap_or(ViduQ3TurboTextToVideoResolution::SevenTwentyP) + .is_high_res(); + + let rate = vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res); + vidu_q3_turbo_cost_cents(rate, duration_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::text::vidu_q3_turbo::api::ViduQ3TurboTextToVideoAspectRatio; + + fn make_request( + duration: Option, + resolution: Option, + audio: Option, + ) -> ViduQ3TurboTextToVideoRequest { + ViduQ3TurboTextToVideoRequest { + prompt: "test".to_string(), + duration, + seed: None, + aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine), + resolution, + audio, + } + } + + mod cost_table { + use super::*; + + // (duration, resolution, expected_cents) + // Math: ceil(rate × secs / 10) where rate (tenth-cents) = 35 (360p/540p) + // or 77 (720p/1080p). + const COST_TABLE: &[(Option, Option, u64)] = &[ + // 360p / 540p → $0.035/s (rounds up) + (Some(5), Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), 18), // 17.5 → 18 + (Some(5), Some(ViduQ3TurboTextToVideoResolution::FiveFortyP), 18), + (Some(16), Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), 56), + (Some(1), Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), 4), // 3.5 → 4 + // 720p / 1080p → $0.077/s (rounds up) + (Some(5), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), 39), // 38.5 → 39 + (Some(5), Some(ViduQ3TurboTextToVideoResolution::TenEightyP), 39), + (Some(10), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), 77), + (Some(3), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), 24), // 23.1 → 24 + // Defaults: duration=None→5s, resolution=None→720p + (None, None, 39), + ]; + + #[test] + fn matches_cost_table() { + for &(duration, resolution, expected) in COST_TABLE { + let got = make_request(duration, resolution, Some(true)).calculate_cost_in_cents(); + assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}"); + } + } + + /// Turbo is roughly half the price of Q3 at the same settings (both round + /// up to whole cents, so it is not exactly half at every cell). + #[test] + fn turbo_high_res_rate_is_half_of_q3() { + use crate::requests::api::video::text::vidu_q3::cost::vidu_q3_rate_tenth_cents_per_sec; + assert_eq!( + vidu_q3_turbo_rate_tenth_cents_per_sec(true) * 2, + vidu_q3_rate_tenth_cents_per_sec(true), + ); + assert_eq!( + vidu_q3_turbo_rate_tenth_cents_per_sec(false) * 2, + vidu_q3_rate_tenth_cents_per_sec(false), + ); + } + + #[test] + fn high_res_is_more_expensive_than_low_res() { + let low = make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::FiveFortyP), Some(true)).calculate_cost_in_cents(); + let high = make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents(); + assert!(high > low, "high={high}¢ should exceed low={low}¢"); + } + + /// Audio and aspect ratio do not affect the bill. + #[test] + fn cost_ignores_audio_and_aspect_ratio() { + let baseline = make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents(); + for audio in [None, Some(false), Some(true)] { + for ar in [ + ViduQ3TurboTextToVideoAspectRatio::SixteenByNine, + ViduQ3TurboTextToVideoAspectRatio::Square, + ] { + let cost = ViduQ3TurboTextToVideoRequest { + aspect_ratio: Some(ar), + ..make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), audio) + }.calculate_cost_in_cents(); + assert_eq!(cost, baseline, "audio={audio:?} ar={ar:?}"); + } + } + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/mod.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/raw_request.rs new file mode 100644 index 0000000000..b259e559d5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/video/text/vidu_q3_turbo/raw_request.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/vidu/q3/text-to-video/turbo`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ViduQ3TurboTextToVideoInput { + /// Text prompt (max 2000 characters). + pub prompt: String, + + /// Duration in seconds. Range 1–16. fal default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Seed for reproducibility. Random when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Aspect ratio. + /// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9". + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Output resolution. + /// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Whether to generate audio for the video. fal default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3TurboTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViduQ3TurboTextToVideoOutput { + pub video: ViduQ3TurboTextToVideoVideoFile, +} diff --git a/crates/api_clients/fal_client/src/requests/core_api/fal_request.rs b/crates/api_clients/fal_client/src/requests/core_api/fal_request.rs new file mode 100644 index 0000000000..01a9eb3d2f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/core_api/fal_request.rs @@ -0,0 +1,486 @@ +use std::marker::PhantomData; + +use reqwest::{IntoUrl, Url}; +use serde::{de::DeserializeOwned, Serialize}; +use log::{debug, info}; + +use crate::error::fal_error::FalError; +use crate::requests::core_api::queue::Queue; +use crate::requests::core_api::queue_response::QueueResponse; +use crate::requests::core_api::webhook_response::WebhookResponse; + +/// Host for synchronous ("run and wait") requests. +const DEFAULT_SYNC_BASE_URL: &str = "https://fal.run"; + +/// Host for the asynchronous queue API (polling and webhooks). +const DEFAULT_QUEUE_BASE_URL: &str = "https://queue.fal.run"; + +/// A request to the FAL API. +/// +/// Originally from the vendored `fal` crate (`fal::request::FalRequest`). +/// Copied here so `fal_client` can be independent of the vendored crate. +/// +/// NB: `reqwest::RequestBuilder::json()` already sets `Content-Type: application/json`. +/// Do NOT add another `Content-Type` header after it: `RequestBuilder::header()` *appends*, +/// so the request would carry two `Content-Type` headers. Fal's workers then treat the body +/// as opaque bytes and fail every job with +/// "Input should be a valid dictionary or object to extract fields from" (2026-09-08 outage). +#[derive(Debug)] +pub struct FalRequest { + /// The Reqwest Client to use to make requests + pub client: reqwest::Client, + /// The endpoint to make the request to + pub endpoint: String, + /// The parameters to send to the endpoint + pub params: Params, + /// The API key to use to make the request. + /// If not provided, the `FAL_API_KEY` environment variable will be used. + pub api_key: Option, + /// Base URL for synchronous requests (`send`). Defaults to `https://fal.run`. + pub sync_base_url: String, + /// Base URL for queue requests. Defaults to `https://queue.fal.run`. + pub queue_base_url: String, + phantom: PhantomData, +} + +impl FalRequest { + pub fn new(endpoint: impl Into, params: Params) -> Self { + Self { + client: reqwest::Client::new(), + endpoint: endpoint.into(), + params, + api_key: std::env::var("FAL_API_KEY").ok(), + sync_base_url: DEFAULT_SYNC_BASE_URL.to_string(), + queue_base_url: DEFAULT_QUEUE_BASE_URL.to_string(), + phantom: PhantomData, + } + } + + /// Use a specific Reqwest Client to make requests + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } + + /// Use a specific API key to make requests + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + /// Override the host used for synchronous requests (tests, proxies). + pub fn with_sync_base_url(mut self, base_url: impl Into) -> Self { + self.sync_base_url = base_url.into(); + self + } + + /// Override the host used for queue requests (tests, proxies). + pub fn with_queue_base_url(mut self, base_url: impl Into) -> Self { + self.queue_base_url = base_url.into(); + self + } + + /// Send the request and wait for the response + pub async fn send(self) -> Result { + let response = self + .client + .post(format!("{}/{}", self.sync_base_url, self.endpoint)) + .json(&self.params) + .header( + "Authorization", + format!( + "Key {}", + self.api_key.expect( + "No fal API key provided, and FAL_API_KEY environment variable is not set" + ) + ), + ) + .send() + .await?; + + if response.status() != 200 { + let error = response.text().await?; + return Err(error.into()); + } + + Ok(response.error_for_status()?.json().await?) + } + + /// Submit the request to the Fal queue system. + pub async fn queue(self) -> Result, FalError> { + let key = self + .api_key + .expect("No fal API key provided, and FAL_API_KEY environment variable is not set"); + + let response = self + .client + .post(format!("{}/{}", self.queue_base_url, self.endpoint)) + .json(&self.params) + .header("Authorization", format!("Key {}", &key)) + .send() + .await?; + + if response.status() != 200 { + let error = response.text().await?; + return Err(error.into()); + } + + let payload: QueueResponse = response.error_for_status()?.json().await?; + + Ok(Queue::new(self.client, self.endpoint, key, payload)) + } + + /// Submit the request to the Fal queue with a webhook callback URL. + /// + /// NB: The `fal_webhook` query value MUST be percent-encoded. Since Fal's 2026-09-08 gateway + /// change, a raw `fal_webhook=https://host/path` value is silently ignored: the job completes + /// but no webhook is ever delivered. `query_pairs_mut` encodes it for us. + pub async fn queue_webhook(self, url: U) -> Result { + let key = self + .api_key + .expect("No fal API key provided, and FAL_API_KEY environment variable is not set"); + + let webhook_url = url.into_url()?; + + let mut request_url = Url::parse(&format!("{}/{}", self.queue_base_url, self.endpoint)) + .map_err(|err| FalError::Other(format!("invalid fal queue url: {}", err)))?; + request_url + .query_pairs_mut() + .append_pair("fal_webhook", webhook_url.as_str()); + + info!("Sending request to FAL queue webhook: {}", request_url); + + let response = self + .client + .post(request_url) + .json(&self.params) + .header("Authorization", format!("Key {}", &key)) + .send() + .await?; + + if response.status() != 200 { + let error = response.text().await?; + return Err(error.into()); + } + + let payload: WebhookResponse = response.error_for_status()?.json().await?; + + Ok(payload) + } + + /// Submit the request to the Fal queue system. + pub async fn queue_request(self) -> Result { + let key = self + .api_key + .expect("No fal API key provided, and FAL_API_KEY environment variable is not set"); + + let response = self + .client + .post(format!("{}/{}", self.queue_base_url, self.endpoint)) + .json(&self.params) + .header("Authorization", format!("Key {}", &key)) + .send() + .await?; + + if response.status() != 200 { + let error = response.text().await?; + return Err(error.into()); + } + + let body = response.text().await?; + + debug!("Queue response: {}", body); + + let payload: QueueResponse = serde_json::from_str(&body)?; + + Ok(payload) + } +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + use serde_json::{json, Value}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + + use super::*; + + const API_KEY: &str = "test-key-id:test-key-secret"; + const ENDPOINT: &str = "fal-ai/nano-banana-pro"; + const WEBHOOK_URL: &str = "https://example.com/v1/webhooks/fal"; + + const QUEUE_RESPONSE_BODY: &str = r#"{ + "status": "IN_QUEUE", + "request_id": "01a082d7-767c-7fb3-bae3-ce9cd1d0a360", + "response_url": "https://queue.fal.run/fal-ai/nano-banana-pro/requests/01a082d7", + "status_url": "https://queue.fal.run/fal-ai/nano-banana-pro/requests/01a082d7/status", + "cancel_url": "https://queue.fal.run/fal-ai/nano-banana-pro/requests/01a082d7/cancel" + }"#; + + const SYNC_RESPONSE_BODY: &str = r#"{"ok": true}"#; + + #[derive(Debug, Deserialize)] + struct SyncOutput { + ok: bool, + } + + /// Regression tests for the 2026-09-08 fal outage: a second `Content-Type` header made fal + /// treat the JSON body as binary, failing every job with + /// "Input should be a valid dictionary or object to extract fields from". + mod single_content_type_header_tests { + use super::*; + + #[tokio::test] + async fn queue_sends_exactly_one_json_content_type() { + let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await; + + let queue = new_request() + .with_queue_base_url(base_url) + .queue() + .await + .expect("queue should succeed"); + + assert_eq!(queue.payload.request_id, "01a082d7-767c-7fb3-bae3-ce9cd1d0a360"); + assert_single_json_content_type_with_params(&captured.await.unwrap()); + } + + #[tokio::test] + async fn queue_webhook_sends_exactly_one_json_content_type() { + let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await; + + let response = new_request() + .with_queue_base_url(base_url) + .queue_webhook(WEBHOOK_URL) + .await + .expect("queue_webhook should succeed"); + + assert_eq!(response.request_id.as_deref(), Some("01a082d7-767c-7fb3-bae3-ce9cd1d0a360")); + + let raw = captured.await.unwrap(); + assert!(raw.request_line.contains("fal_webhook="), "webhook url missing: {}", raw.request_line); + assert_single_json_content_type_with_params(&raw); + } + + #[tokio::test] + async fn queue_request_sends_exactly_one_json_content_type() { + let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await; + + let response = new_request() + .with_queue_base_url(base_url) + .queue_request() + .await + .expect("queue_request should succeed"); + + assert_eq!(response.request_id, "01a082d7-767c-7fb3-bae3-ce9cd1d0a360"); + assert_single_json_content_type_with_params(&captured.await.unwrap()); + } + + #[tokio::test] + async fn send_sends_exactly_one_json_content_type() { + let (base_url, captured) = spawn_capture_server(SYNC_RESPONSE_BODY).await; + + let response = new_request() + .with_sync_base_url(base_url) + .send() + .await + .expect("send should succeed"); + + assert!(response.ok); + assert_single_json_content_type_with_params(&captured.await.unwrap()); + } + } + + /// Regression tests for the 2026-09-09 webhook silence: Fal ignores an unencoded + /// `fal_webhook` value, so the callback URL must be percent-encoded in the query string. + mod webhook_url_encoding_tests { + use super::*; + + const ENCODED_WEBHOOK_URL: &str = "https%3A%2F%2Fexample.com%2Fv1%2Fwebhooks%2Ffal"; + + #[tokio::test] + async fn webhook_url_is_percent_encoded_in_query_string() { + let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await; + + new_request() + .with_queue_base_url(base_url) + .queue_webhook(WEBHOOK_URL) + .await + .expect("queue_webhook should succeed"); + + let raw = captured.await.unwrap(); + assert!( + raw.request_line.contains(&format!("?fal_webhook={}", ENCODED_WEBHOOK_URL)), + "webhook url not percent-encoded: {}", raw.request_line, + ); + assert!( + !raw.request_line.contains("fal_webhook=https://"), + "webhook url sent raw: {}", raw.request_line, + ); + } + + #[tokio::test] + async fn encoded_webhook_url_decodes_back_to_the_original() { + let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await; + + new_request() + .with_queue_base_url(base_url) + .queue_webhook(WEBHOOK_URL) + .await + .expect("queue_webhook should succeed"); + + let raw = captured.await.unwrap(); + let path_and_query = raw.request_line.split_whitespace().nth(1).expect("request target"); + let parsed = Url::parse(&format!("http://localhost{}", path_and_query)).unwrap(); + let decoded = parsed.query_pairs() + .find(|(k, _)| k == "fal_webhook") + .map(|(_, v)| v.into_owned()) + .expect("fal_webhook param"); + assert_eq!(decoded, WEBHOOK_URL); + } + } + + mod base_url_tests { + use super::*; + + #[test] + fn defaults_point_at_fal() { + let request = new_request(); + assert_eq!(request.sync_base_url, "https://fal.run"); + assert_eq!(request.queue_base_url, "https://queue.fal.run"); + } + + #[tokio::test] + async fn queue_path_is_base_url_plus_endpoint() { + let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await; + + new_request() + .with_queue_base_url(base_url) + .queue_request() + .await + .expect("queue_request should succeed"); + + let raw = captured.await.unwrap(); + assert!( + raw.request_line.starts_with(&format!("POST /{} HTTP/1.1", ENDPOINT)), + "unexpected request line: {}", + raw.request_line + ); + } + } + + // ---- Helpers ---- + + /// Everything the fake fal server observed on the wire. + struct CapturedRequest { + request_line: String, + /// Header (name lowercased, value) pairs in wire order. + headers: Vec<(String, String)>, + body: String, + } + + fn new_request() -> FalRequest { + FalRequest::new(ENDPOINT, test_params()).with_api_key(API_KEY) + } + + fn test_params() -> Value { + json!({ + "prompt": "a corgi wearing sunglasses at the beach", + "num_images": 1, + "output_format": "png" + }) + } + + fn assert_single_json_content_type_with_params(raw: &CapturedRequest) { + let content_types: Vec<&str> = raw + .headers + .iter() + .filter(|(name, _)| name == "content-type") + .map(|(_, value)| value.as_str()) + .collect(); + + assert_eq!( + content_types, + vec!["application/json"], + "expected exactly one Content-Type header, got {:?}", + content_types + ); + + let authorization = header_value(raw, "authorization"); + assert_eq!(authorization, Some(format!("Key {}", API_KEY))); + + let body: Value = serde_json::from_str(&raw.body).expect("body must be JSON"); + assert!(body.is_object(), "fal requires a JSON object body, got {}", body); + assert_eq!(body, test_params()); + } + + fn header_value(raw: &CapturedRequest, name: &str) -> Option { + raw + .headers + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + } + + /// Bind a one-shot HTTP/1.1 server on localhost. It records the raw request it receives, + /// replies `200 OK` with `response_body`, and hands the capture back through the JoinHandle. + async fn spawn_capture_server(response_body: &'static str) -> (String, JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind localhost"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + + let handle = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + + let mut buffer = Vec::new(); + let header_end = loop { + let mut chunk = [0u8; 4096]; + let n = socket.read(&mut chunk).await.expect("read"); + assert!(n > 0, "connection closed before headers finished"); + buffer.extend_from_slice(&chunk[..n]); + if let Some(pos) = find_header_end(&buffer) { + break pos; + } + }; + + let head = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + let mut lines = head.split("\r\n"); + let request_line = lines.next().unwrap_or_default().to_string(); + let headers: Vec<(String, String)> = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect(); + + let content_length: usize = headers + .iter() + .find(|(name, _)| name == "content-length") + .and_then(|(_, value)| value.parse().ok()) + .expect("reqwest sends Content-Length for a JSON body"); + + let body_start = header_end + 4; + while buffer.len() < body_start + content_length { + let mut chunk = [0u8; 4096]; + let n = socket.read(&mut chunk).await.expect("read body"); + assert!(n > 0, "connection closed before body finished"); + buffer.extend_from_slice(&chunk[..n]); + } + let body = String::from_utf8_lossy(&buffer[body_start..body_start + content_length]).to_string(); + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + socket.write_all(response.as_bytes()).await.expect("write response"); + socket.shutdown().await.ok(); + + CapturedRequest { request_line, headers, body } + }); + + (base_url, handle) + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") + } +} diff --git a/crates/api_clients/fal_client/src/requests/core_api/mod.rs b/crates/api_clients/fal_client/src/requests/core_api/mod.rs new file mode 100644 index 0000000000..6aee79d029 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/core_api/mod.rs @@ -0,0 +1,5 @@ +pub mod fal_request; +pub mod queue; +pub mod queue_response; +pub mod queue_status; +pub mod webhook_response; diff --git a/crates/api_clients/fal_client/src/requests/core_api/queue.rs b/crates/api_clients/fal_client/src/requests/core_api/queue.rs new file mode 100644 index 0000000000..b87cc0a9e6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/core_api/queue.rs @@ -0,0 +1,86 @@ +use std::marker::PhantomData; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::error::fal_error::FalError; +use crate::requests::core_api::queue_response::QueueResponse; +use crate::requests::core_api::queue_status::QueueStatus; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Queue { + #[serde(skip)] + pub client: Option, + pub endpoint: String, + pub api_key: String, + pub payload: QueueResponse, + phantom: PhantomData, +} + +impl Queue { + pub fn new( + client: reqwest::Client, + endpoint: impl Into, + api_key: String, + payload: QueueResponse, + ) -> Self { + Self { + client: Some(client), + endpoint: endpoint.into(), + api_key, + payload, + phantom: PhantomData, + } + } + + /// Get the status of the Queue request + pub async fn status(&self, show_logs: bool) -> Result { + let response = self + .client + .as_ref() + .unwrap() + .get(&self.payload.status_url) + .query(&[("logs", if show_logs { "1" } else { "0" })]) + .header("Authorization", format!("Key {}", self.api_key)) + .header("Content-Type", "application/json") + .send() + .await?; + + Ok(response.error_for_status()?.json().await?) + } + + /// Get the response of the Queue request, if the request is Completed + pub async fn response(&self) -> Result { + let response = self + .client + .as_ref() + .unwrap() + .get(&self.payload.response_url) + .header("Authorization", format!("Key {}", self.api_key)) + .header("Content-Type", "application/json") + .send() + .await?; + + if response.status() != 200 { + let error = response.text().await?; + return Err(error.into()); + } + + Ok(response.error_for_status()?.json().await?) + } + + /// Cancel the Queue request + pub async fn cancel(&self) -> Result<(), FalError> { + let response = self + .client + .as_ref() + .unwrap() + .put(&self.payload.cancel_url) + .header("Authorization", format!("Key {}", self.api_key)) + .send() + .await?; + + response.error_for_status()?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/core_api/queue_response.rs b/crates/api_clients/fal_client/src/requests/core_api/queue_response.rs new file mode 100644 index 0000000000..8bc56d07b7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/core_api/queue_response.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueueResponse { + pub request_id: String, + pub response_url: String, + pub status_url: String, + pub cancel_url: String, +} diff --git a/crates/api_clients/fal_client/src/requests/core_api/queue_status.rs b/crates/api_clients/fal_client/src/requests/core_api/queue_status.rs new file mode 100644 index 0000000000..eb629ab5b7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/core_api/queue_status.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Status { + InQueue, + InProgress, + Completed, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RequestLog { + pub timestamp: String, + pub level: Option, + pub source: Option, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueueStatus { + /// The status of the Queue request + pub status: Status, + /// The position of the request in the queue + pub queue_position: Option, + /// The URL of the response + pub response_url: String, + /// The logs of the request, if `show_logs` is `true` + pub logs: Option>, +} diff --git a/crates/api_clients/fal_client/src/requests/core_api/webhook_response.rs b/crates/api_clients/fal_client/src/requests/core_api/webhook_response.rs new file mode 100644 index 0000000000..40dbc30bd5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/core_api/webhook_response.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WebhookResponse { + pub status: Option, + pub request_id: Option, + pub gateway_request_id: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/mod.rs b/crates/api_clients/fal_client/src/requests/mod.rs new file mode 100644 index 0000000000..079b1044aa --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod core_api; +pub mod traits; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests/traits/fal_endpoint_trait.rs b/crates/api_clients/fal_client/src/requests/traits/fal_endpoint_trait.rs new file mode 100644 index 0000000000..eacc3489eb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/traits/fal_endpoint_trait.rs @@ -0,0 +1,45 @@ +use reqwest::IntoUrl; +use crate::creds::fal_api_key::FalApiKey; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::core_api::fal_request::FalRequest; +use crate::requests::core_api::queue_response::QueueResponse; +use crate::requests::core_api::webhook_response::WebhookResponse; +use serde::de::DeserializeOwned; +use serde::Serialize; + +pub trait FalEndpoint { + /// Fal endpoint, eg. `fal-ai/flux-2-lora-gallery/multiple-angles` + const ENDPOINT : &'static str; + + /// Shape of the Fal request for the endpoint + type RawRequest : Serialize; + + /// Shape of the Fal response for the endpoint + type RawResponse : DeserializeOwned; + + fn get_endpoint() -> &'static str { + Self::ENDPOINT + } + + async fn send_webhook_request(&self, api_key: &FalApiKey, webhook_url: U) -> Result { + let request = self.to_raw_request()?; + let request = FalRequest::::new(Self::ENDPOINT, request); + let result = request.with_api_key(&api_key.0) + .queue_webhook(webhook_url) + .await?; + Ok(result) + } + + async fn send_queue_request(&self, api_key: &FalApiKey) -> Result { + let request = self.to_raw_request()?; + let request = FalRequest::::new(Self::ENDPOINT, request); + let result = request.with_api_key(&api_key.0) + .queue_request() + .await?; + Ok(result) + } + + /// Convert request to the over-the-wire representation + /// This allows us to change the shape, types, etc. + fn to_raw_request(&self) -> Result; +} diff --git a/crates/api_clients/fal_client/src/requests/traits/fal_request_cost_calculator_trait.rs b/crates/api_clients/fal_client/src/requests/traits/fal_request_cost_calculator_trait.rs new file mode 100644 index 0000000000..7982946ecb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/traits/fal_request_cost_calculator_trait.rs @@ -0,0 +1,9 @@ + +/// Cost in pennies. +pub type UsdCents = u64; + +pub trait FalRequestCostCalculator { + + /// Calculate the cost of the request. + fn calculate_cost_in_cents(&self) -> UsdCents; +} diff --git a/crates/api_clients/fal_client/src/requests/traits/mod.rs b/crates/api_clients/fal_client/src/requests/traits/mod.rs new file mode 100644 index 0000000000..7e1b7e0eca --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/traits/mod.rs @@ -0,0 +1,2 @@ +pub mod fal_endpoint_trait; +pub mod fal_request_cost_calculator_trait; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/angle/http_flux_2_lora_edit_image_angle.rs b/crates/api_clients/fal_client/src/requests_old/http/image/angle/http_flux_2_lora_edit_image_angle.rs new file mode 100644 index 0000000000..71989a62bc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/angle/http_flux_2_lora_edit_image_angle.rs @@ -0,0 +1,82 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux2LoraEditImageAngleInput { + /// The URL of the image to adjust camera angle for. + pub image_urls: Vec, + + /// Horizontal rotation angle around the object in degrees. + /// 0°=front view, 90°=right side, 180°=back view, 270°=left side. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub horizontal_angle: Option, + + /// Vertical camera angle in degrees. + /// 0°=eye-level shot, 30°=elevated shot, 60°=high-angle shot. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub vertical_angle: Option, + + /// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up. + /// Default: 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, + + /// The strength of the multiple angles effect. + /// Default: 1, range: 0-2 + #[serde(skip_serializing_if = "Option::is_none")] + pub lora_scale: Option, + + /// The size of the generated image. + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default: 2.5, range: 0-20 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// The number of inference steps to perform. + /// Default: 40, range: 1-50 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Random seed for reproducibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Number of images to generate. + /// Default: 1, max: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Whether to enable the safety checker. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Output format: png, jpeg, webp. + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux2LoraEditImageAngleFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux2LoraEditImageAngleOutput { + pub images: Vec, + pub seed: u64, + pub prompt: Option, +} + +pub fn http_flux_2_lora_edit_image_angle( + params: Flux2LoraEditImageAngleInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux-2-lora-gallery/multiple-angles", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs b/crates/api_clients/fal_client/src/requests_old/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs new file mode 100644 index 0000000000..2bd64cb5d4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/angle/http_qwen_edit_2511_edit_image_angle.rs @@ -0,0 +1,88 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct QwenEdit2511EditImageAngleInput { + /// The URL of the image to adjust camera angle for. + pub image_urls: Vec, + + /// Horizontal rotation angle around the object in degrees. 0°=front view. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub horizontal_angle: Option, + + /// Vertical camera angle in degrees. -30°=low-angle shot, 30°=high-angle shot. + /// Default: 0 + #[serde(skip_serializing_if = "Option::is_none")] + pub vertical_angle: Option, + + /// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up. + /// Default: 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, + + /// Additional text to append to the automatically generated prompt. + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_prompt: Option, + + /// The scale factor for the LoRA model. Controls the strength of the effect. + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub lora_scale: Option, + + /// The size of the generated image. + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default: 4.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// The number of inference steps to perform. + /// Default: 28 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// The negative prompt for the generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Random seed for reproducibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Number of images to generate. + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Whether to enable the safety checker. + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Output format: png, jpeg, webp. + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenEdit2511EditImageAngleFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenEdit2511EditImageAngleOutput { + pub images: Vec, + pub seed: u64, + pub prompt: Option, +} + +pub fn http_qwen_edit_2511_edit_image_angle( + params: QwenEdit2511EditImageAngleInput, +) -> FalRequest { + FalRequest::new("fal-ai/qwen-image-edit-2511-multiple-angles", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/angle/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/image/angle/mod.rs new file mode 100644 index 0000000000..c5f7d8ee64 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod http_flux_2_lora_edit_image_angle; +pub mod http_qwen_edit_2511_edit_image_angle; diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/background/http_rembg_remove_background.rs b/crates/api_clients/fal_client/src/requests_old/http/image/background/http_rembg_remove_background.rs new file mode 100644 index 0000000000..023c9a2bea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/background/http_rembg_remove_background.rs @@ -0,0 +1,29 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct RembgRemoveBackgroundInput { + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub crop_to_bbox: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RembgRemoveBackgroundFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RembgRemoveBackgroundOutput { + pub image: RembgRemoveBackgroundFile, +} + +pub fn rembg_remove_background( + params: RembgRemoveBackgroundInput, +) -> FalRequest { + FalRequest::new("fal-ai/imageutils/rembg", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/background/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/image/background/mod.rs new file mode 100644 index 0000000000..3aad3bc754 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/background/mod.rs @@ -0,0 +1 @@ +pub mod http_rembg_remove_background; diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_1_dev_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_1_dev_edit_image.rs new file mode 100644 index 0000000000..23f7d14c04 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_1_dev_edit_image.rs @@ -0,0 +1,54 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1DevEditImageInput { + pub prompt: String, + + pub image_url: String, + + /// 0.0 - 1.0 + /// Default: 0.95 + #[serde(skip_serializing_if = "Option::is_none")] + pub strength: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: jpeg, png + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevEditImageOutput { + pub images: Vec, +} + +pub fn flux_1_dev_edit_image( + params: Flux1DevEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux/dev/image-to-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_1_schnell_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_1_schnell_edit_image.rs new file mode 100644 index 0000000000..9c6b3c8027 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_1_schnell_edit_image.rs @@ -0,0 +1,48 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1SchnellEditImageInput { + pub image_url: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: jpeg, png + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellEditImageOutput { + pub images: Vec, +} + +pub fn flux_1_schnell_edit_image( + params: Flux1SchnellEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux/schnell/redux", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_pro_kontext_max_edit.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_pro_kontext_max_edit.rs new file mode 100644 index 0000000000..233ebe93a2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_flux_pro_kontext_max_edit.rs @@ -0,0 +1,50 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct FluxProKontextMaxEditInput { + pub prompt: String, + + pub image_url: String, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "png" or "jpeg" + /// Default: "jpeg" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// Aspect ratio for the generated image. + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// The safety tolerance level for content moderation. + /// "1" (most strict) to "5" (most permissive). + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxProKontextMaxEditFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxProKontextMaxEditOutput { + pub images: Vec, +} + +pub fn flux_pro_kontext_max_edit( + params: FluxProKontextMaxEditInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux-pro/kontext/max", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gemini_25_flash_edit.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gemini_25_flash_edit.rs new file mode 100644 index 0000000000..98c580bf12 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gemini_25_flash_edit.rs @@ -0,0 +1,41 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Gemini25FlashEditInput { + pub prompt: String, + + pub image_urls: Vec, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg" or "png" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// Aspect ratio for the generated image. + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Gemini25FlashEditFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Gemini25FlashEditOutput { + pub images: Vec, +} + +pub fn gemini_25_flash_edit( + params: Gemini25FlashEditInput, +) -> FalRequest { + FalRequest::new("fal-ai/gemini-25-flash-image/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1_byok_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1_byok_edit_image.rs new file mode 100644 index 0000000000..3ad2643412 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1_byok_edit_image.rs @@ -0,0 +1,32 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1ByokEditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + pub image_size: String, + + /// 1 - 4 + pub num_images: u8, + + /// "auto", "low", "medium", "high" + pub quality: String, + + /// BYOK - caller supplies their own OpenAI API key + pub openai_api_key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1ByokEditImageOutput { + // Response fields parsed elsewhere +} + +pub fn gpt_image_1_byok_edit_image( + params: GptImage1ByokEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gpt-image-1/edit-image/byok", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1_edit_image.rs new file mode 100644 index 0000000000..49b7b61d11 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1_edit_image.rs @@ -0,0 +1,57 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +/// Non-BYOK edit-image (image-to-image) binding for +/// `fal-ai/gpt-image-1/edit-image`. Fal hosts this model directly; pricing is +/// billed by Fal at their published rates. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_image_url: Option, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// "low", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub input_fidelity: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1EditImageOutput { + pub images: Vec, +} + +pub fn gpt_image_1_edit_image( + params: GptImage1EditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gpt-image-1/edit-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1p5_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1p5_edit_image.rs new file mode 100644 index 0000000000..5399ee1b1e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_1p5_edit_image.rs @@ -0,0 +1,54 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1p5EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_image_url: Option, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// "low", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub input_fidelity: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5EditImageOutput { + pub images: Vec, +} + +pub fn gpt_image_1p5_edit_image( + params: GptImage1p5EditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gpt-image-1.5/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_2_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_2_edit_image.rs new file mode 100644 index 0000000000..4a89040462 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_gpt_image_2_edit_image.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage2EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// The URL of the mask image to use for the generation. This indicates what part of the image to edit. + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_url: Option, + + /// Possible enum values: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2EditImageOutput { + pub images: Vec, +} + +pub fn gpt_image_2_edit_image( + params: GptImage2EditImageInput, +) -> FalRequest { + FalRequest::new("openai/gpt-image-2/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_nano_banana_2_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_nano_banana_2_edit_image.rs new file mode 100644 index 0000000000..3563fcfeea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_nano_banana_2_edit_image.rs @@ -0,0 +1,79 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBanana2EditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// Eg. "auto", "16:9", "1:1", etc. + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Eg. "0.5K", "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// The safety tolerance level for content moderation. + /// 1 is the most strict (blocks most content), 6 is the least strict. + /// Default value: "4" + /// + /// Possible enum values: 1, 2, 3, 4, 5, 6 + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// Experimental parameter to limit the number of generations from each round + /// of prompting to 1. Set to True to to disregard any instructions in the + /// prompt regarding the number of images to generate and ignore any + /// intermediate images generated by the model. This may affect generation + /// quality. + /// + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_generations: Option, + + /// Enable web search for the image generation task. This will allow + /// the model to use the latest information from the web to generate + /// the image. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_web_search: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2EditImageFile { + pub url: String, + //pub content_type: Option, + //pub file_name: Option, + //pub file_size: Option, + //pub width: Option, + //pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2EditImageOutput { + pub images: Vec, + //pub description: String, +} + +pub fn nano_banana_2_edit_image( + params: NanoBanana2EditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/nano-banana-2/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_nano_banana_pro_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_nano_banana_pro_edit_image.rs new file mode 100644 index 0000000000..6198cf613e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_nano_banana_pro_edit_image.rs @@ -0,0 +1,46 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBananaProEditImageInput { + pub prompt: String, + + pub image_urls: Vec, + + /// Eg. "auto", "16:9", "1:1", etc. + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Eg. "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProEditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProEditImageOutput { + pub images: Vec, +} + +pub fn nano_banana_pro_edit_image( + params: NanoBananaProEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/nano-banana-pro/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_qwen_image_edit.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_qwen_image_edit.rs new file mode 100644 index 0000000000..f747f0bbc2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_qwen_image_edit.rs @@ -0,0 +1,62 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct QwenImageEditInput { + pub prompt: String, + + pub image_url: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg" or "png" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Default: 30 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Default: 4.0 + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Options: "none", "regular", "high" + /// Default: "none" + #[serde(skip_serializing_if = "Option::is_none")] + pub acceleration: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenImageEditFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QwenImageEditOutput { + pub images: Vec, +} + +pub fn qwen_image_edit( + params: QwenImageEditInput, +) -> FalRequest { + FalRequest::new("fal-ai/qwen-image-edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seededit_v3_edit.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seededit_v3_edit.rs new file mode 100644 index 0000000000..bf7847a4f1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seededit_v3_edit.rs @@ -0,0 +1,31 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedEditV3EditInput { + pub prompt: String, + + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedEditV3EditFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedEditV3EditOutput { + pub image: Vec, +} + +pub fn seededit_v3_edit( + params: SeedEditV3EditInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seededit/v3/edit-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_4_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_4_edit_image.rs new file mode 100644 index 0000000000..0544d0f15c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_4_edit_image.rs @@ -0,0 +1,50 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedreamV4EditImageInput { + pub prompt: String, + + /// Maximum 10 images. Excess images are ignored. + pub image_urls: Vec, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto, auto_2K, auto_4K + /// Default: "auto_2K" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub max_images: Option, + + /// Enum: "standard", "fast" + /// Default: "standard" + #[serde(skip_serializing_if = "Option::is_none")] + pub enhance_prompt_mode: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4EditImageOutput { + pub images: Vec, +} + +pub fn seedream_4_edit_image( + params: SeedreamV4EditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedream/v4/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_4p5_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_4p5_edit_image.rs new file mode 100644 index 0000000000..c24c534c95 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_4p5_edit_image.rs @@ -0,0 +1,48 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedreamV4p5EditImageInput { + pub prompt: String, + + /// Maximum 10 images. Excess images are ignored. + pub image_urls: Vec, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto_2K, auto_4K + /// Default: "auto_2K" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub max_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4p5EditImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4p5EditImageOutput { + pub images: Vec, +} + +pub fn seedream_4p5_edit_image( + params: SeedreamV4p5EditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedream/v4.5/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_5_edit_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_5_edit_image.rs new file mode 100644 index 0000000000..5b5485a340 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/http_seedream_5_edit_image.rs @@ -0,0 +1,49 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedreamV5LiteEditImageInput { + pub prompt: String, + + /// Maximum 10 images. Excess images are ignored. + pub image_urls: Vec, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto_2K, auto_3K + /// Default: "auto_2K" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub max_images: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV5LiteEditImageFile { + pub url: String, + //pub content_type: Option, + //pub file_name: Option, + //pub file_size: Option, + //pub width: Option, + //pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV5LiteEditImageOutput { + pub images: Vec, + pub seed: u64, +} + +pub fn http_seedream_5_edit_image( + params: SeedreamV5LiteEditImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedream/v5/lite/edit", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/edit/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/image/edit/mod.rs new file mode 100644 index 0000000000..326291db24 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/edit/mod.rs @@ -0,0 +1,15 @@ +pub mod http_flux_1_dev_edit_image; +pub mod http_flux_1_schnell_edit_image; +pub mod http_flux_pro_kontext_max_edit; +pub mod http_gemini_25_flash_edit; +pub mod http_gpt_image_1_byok_edit_image; +pub mod http_gpt_image_1_edit_image; +pub mod http_gpt_image_1p5_edit_image; +pub mod http_gpt_image_2_edit_image; +pub mod http_nano_banana_2_edit_image; +pub mod http_nano_banana_pro_edit_image; +pub mod http_qwen_image_edit; +pub mod http_seedream_4_edit_image; +pub mod http_seedream_4p5_edit_image; +pub mod http_seedream_5_edit_image; +pub mod http_seededit_v3_edit; diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/infill/http_flux_dev_juggernaut_infill.rs b/crates/api_clients/fal_client/src/requests_old/http/image/infill/http_flux_dev_juggernaut_infill.rs new file mode 100644 index 0000000000..0637732af5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/infill/http_flux_dev_juggernaut_infill.rs @@ -0,0 +1,59 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct FluxDevJuggernautInfillInput { + pub prompt: String, + + pub image_url: String, + + pub mask_url: String, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "png" or "jpeg" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// 0.0 - 1.0. 1.0 completely remakes the image, 0.0 preserves the original. + #[serde(skip_serializing_if = "Option::is_none")] + pub strength: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxDevJuggernautInfillFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxDevJuggernautInfillOutput { + pub images: Vec, +} + +pub fn flux_dev_juggernaut_infill( + params: FluxDevJuggernautInfillInput, +) -> FalRequest { + FalRequest::new("rundiffusion-fal/juggernaut-flux-lora/inpainting", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/infill/http_flux_pro_1_infill.rs b/crates/api_clients/fal_client/src/requests_old/http/image/infill/http_flux_pro_1_infill.rs new file mode 100644 index 0000000000..cb3d91ab11 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/infill/http_flux_pro_1_infill.rs @@ -0,0 +1,46 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct FluxPro1InfillInput { + pub prompt: String, + + pub image_url: String, + + pub mask_url: String, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "1" (most strict) to "5" (most permissive) + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// "png" or "jpeg" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxPro1InfillFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxPro1InfillOutput { + pub images: Vec, +} + +pub fn flux_pro_1_infill( + params: FluxPro1InfillInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux-pro/v1/fill", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/infill/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/image/infill/mod.rs new file mode 100644 index 0000000000..755a49e584 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/infill/mod.rs @@ -0,0 +1,2 @@ +pub mod http_flux_dev_juggernaut_infill; +pub mod http_flux_pro_1_infill; diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/image/mod.rs new file mode 100644 index 0000000000..50c1d0b2fc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/mod.rs @@ -0,0 +1,5 @@ +pub mod angle; +pub mod background; +pub mod edit; +pub mod infill; +pub mod text; diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_1_dev_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_1_dev_text_to_image.rs new file mode 100644 index 0000000000..c2a73e21fb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_1_dev_text_to_image.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1DevTextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1DevTextToImageOutput { + pub images: Vec, +} + +pub fn flux_1_dev_text_to_image( + params: Flux1DevTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux/dev", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_1_schnell_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_1_schnell_text_to_image.rs new file mode 100644 index 0000000000..8fc6376071 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_1_schnell_text_to_image.rs @@ -0,0 +1,44 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Flux1SchnellTextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Flux1SchnellTextToImageOutput { + pub images: Vec, +} + +pub fn flux_1_schnell_text_to_image( + params: Flux1SchnellTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux/schnell", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_pro_11_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_pro_11_text_to_image.rs new file mode 100644 index 0000000000..180f972673 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_pro_11_text_to_image.rs @@ -0,0 +1,50 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct FluxPro11TextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// "1" (most strict) to "5" (most permissive) + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// "png" or "jpeg" + /// Default: "jpeg" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxPro11TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxPro11TextToImageOutput { + pub images: Vec, +} + +pub fn flux_pro_11_text_to_image( + params: FluxPro11TextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux-pro/v1.1", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_pro_11_ultra_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_pro_11_ultra_text_to_image.rs new file mode 100644 index 0000000000..082ebe382b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_flux_pro_11_ultra_text_to_image.rs @@ -0,0 +1,55 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct FluxPro11UltraTextToImageInput { + pub prompt: String, + + /// Options: 21:9, 16:9, 4:3, 3:2, 1:1, 2:3, 3:4, 9:16, 9:21 + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Generate less processed, more natural-looking images. + /// Default: false + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// "1" (most strict) to "5" (most permissive) + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// "png" or "jpeg" + /// Default: "jpeg" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxPro11UltraTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FluxPro11UltraTextToImageOutput { + pub images: Vec, +} + +pub fn flux_pro_11_ultra_text_to_image( + params: FluxPro11UltraTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/flux-pro/v1.1-ultra", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gemini_25_flash_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gemini_25_flash_text_to_image.rs new file mode 100644 index 0000000000..839539dd83 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gemini_25_flash_text_to_image.rs @@ -0,0 +1,38 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Gemini25FlashTextToImageInput { + pub prompt: String, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg" or "png" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// Options: 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Gemini25FlashTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Gemini25FlashTextToImageOutput { + pub images: Vec, +} + +pub fn gemini_25_flash_text_to_image( + params: Gemini25FlashTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gemini-25-flash-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1_byok_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1_byok_text_to_image.rs new file mode 100644 index 0000000000..6dec979733 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1_byok_text_to_image.rs @@ -0,0 +1,30 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1ByokTextToImageInput { + pub prompt: String, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + pub image_size: String, + + /// 1 - 4 + pub num_images: u8, + + /// "auto", "low", "medium", "high" + pub quality: String, + + /// BYOK - caller supplies their own OpenAI API key + pub openai_api_key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1ByokTextToImageOutput { + // Response fields parsed elsewhere +} + +pub fn gpt_image_1_byok_text_to_image( + params: GptImage1ByokTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gpt-image-1/text-to-image/byok", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1_text_to_image.rs new file mode 100644 index 0000000000..f564a7257c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1_text_to_image.rs @@ -0,0 +1,49 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +/// Non-BYOK text-to-image binding for `fal-ai/gpt-image-1/text-to-image`. +/// +/// Fal hosts this model directly (no caller-supplied OpenAI API key); pricing +/// is billed by Fal at their published rates. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1TextToImageInput { + pub prompt: String, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1TextToImageOutput { + pub images: Vec, +} + +pub fn gpt_image_1_text_to_image( + params: GptImage1TextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gpt-image-1/text-to-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1p5_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1p5_text_to_image.rs new file mode 100644 index 0000000000..2dc14fca59 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_1p5_text_to_image.rs @@ -0,0 +1,45 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage1p5TextToImageInput { + pub prompt: String, + + /// "auto", "1024x1024", "1536x1024", "1024x1536" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "auto", "transparent", "opaque" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage1p5TextToImageOutput { + pub images: Vec, +} + +pub fn gpt_image_1p5_text_to_image( + params: GptImage1p5TextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/gpt-image-1.5", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_2_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_2_text_to_image.rs new file mode 100644 index 0000000000..29ef05405a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_gpt_image_2_text_to_image.rs @@ -0,0 +1,41 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GptImage2TextToImageInput { + pub prompt: String, + + /// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9 + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// "low", "medium", "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2TextToImageOutput { + pub images: Vec, +} + +pub fn gpt_image_2_text_to_image( + params: GptImage2TextToImageInput, +) -> FalRequest { + FalRequest::new("openai/gpt-image-2", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_nano_banana_2_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_nano_banana_2_text_to_image.rs new file mode 100644 index 0000000000..47a9b69bcb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_nano_banana_2_text_to_image.rs @@ -0,0 +1,77 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBanana2TextToImageInput { + pub prompt: String, + + /// Eg. "auto", "16:9", "1:1", etc. + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Eg. "0.5K", "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// The safety tolerance level for content moderation. + /// 1 is the most strict (blocks most content), 6 is the least strict. + /// Default value: "4" + /// + /// Possible enum values: 1, 2, 3, 4, 5, 6 + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_tolerance: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Experimental parameter to limit the number of generations from each round + /// of prompting to 1. Set to True to to disregard any instructions in the + /// prompt regarding the number of images to generate and ignore any + /// intermediate images generated by the model. This may affect generation + /// quality. + /// + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_generations: Option, + + /// Enable web search for the image generation task. This will allow + /// the model to use the latest information from the web to generate + /// the image. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_web_search: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2ImageFile { + pub url: String, + //pub content_type: Option, + //pub file_name: Option, + //pub file_size: Option, + //pub width: Option, + //pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBanana2TextToImageOutput { + pub images: Vec, + //pub description: String, +} + +pub fn nano_banana_2_text_to_image( + params: NanoBanana2TextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/nano-banana-2", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_nano_banana_pro_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_nano_banana_pro_text_to_image.rs new file mode 100644 index 0000000000..75908f65ad --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_nano_banana_pro_text_to_image.rs @@ -0,0 +1,43 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NanoBananaProTextToImageInput { + pub prompt: String, + + /// Options: auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// "1K", "2K", "4K" + /// Default: "1K" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProTextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NanoBananaProTextToImageOutput { + pub images: Vec, +} + +pub fn nano_banana_pro_text_to_image( + params: NanoBananaProTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/nano-banana-pro", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_4_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_4_text_to_image.rs new file mode 100644 index 0000000000..0bfe99dffb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_4_text_to_image.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedreamV4TextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto, auto_2K, auto_4K + /// Default: "auto_2K" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub max_images: Option, + + /// Enum: "standard", "fast" + /// Default: "standard" + #[serde(skip_serializing_if = "Option::is_none")] + pub enhance_prompt_mode: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4TextToImageOutput { + pub images: Vec, +} + +pub fn seedream_4_text_to_image( + params: SeedreamV4TextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedream/v4/text-to-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_4p5_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_4p5_text_to_image.rs new file mode 100644 index 0000000000..a2295e3c9a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_4p5_text_to_image.rs @@ -0,0 +1,45 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedreamV4p5TextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto_2K, auto_4K + /// Default: "auto_2K" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// 1 - 4 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub max_images: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4p5TextToImageFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV4p5TextToImageOutput { + pub images: Vec, +} + +pub fn seedream_4p5_text_to_image( + params: SeedreamV4p5TextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedream/v4.5/text-to-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_5_text_to_image.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_5_text_to_image.rs new file mode 100644 index 0000000000..e2b7dcdf53 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/http_seedream_5_text_to_image.rs @@ -0,0 +1,46 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SeedreamV5LiteTextToImageInput { + pub prompt: String, + + /// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto_2K, auto_3K + /// Default: "auto_2K" + #[serde(skip_serializing_if = "Option::is_none")] + pub image_size: Option, + + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub max_images: Option, + + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV5LiteImageFile { + pub url: String, + //pub content_type: Option, + //pub file_name: Option, + //pub file_size: Option, + //pub width: Option, + //pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SeedreamV5LiteTextToImageOutput { + pub images: Vec, + pub seed: u64, +} + +pub fn http_seedream_5_text_to_image( + params: SeedreamV5LiteTextToImageInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedream/v5/lite/text-to-image", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/image/text/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/image/text/mod.rs new file mode 100644 index 0000000000..3c7f5da210 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/image/text/mod.rs @@ -0,0 +1,14 @@ +pub mod http_flux_1_dev_text_to_image; +pub mod http_flux_1_schnell_text_to_image; +pub mod http_flux_pro_11_text_to_image; +pub mod http_flux_pro_11_ultra_text_to_image; +pub mod http_gemini_25_flash_text_to_image; +pub mod http_gpt_image_1_text_to_image; +pub mod http_gpt_image_1_byok_text_to_image; +pub mod http_gpt_image_1p5_text_to_image; +pub mod http_gpt_image_2_text_to_image; +pub mod http_nano_banana_2_text_to_image; +pub mod http_nano_banana_pro_text_to_image; +pub mod http_seedream_4_text_to_image; +pub mod http_seedream_4p5_text_to_image; +pub mod http_seedream_5_text_to_image; diff --git a/crates/api_clients/fal_client/src/requests_old/http/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/mod.rs new file mode 100644 index 0000000000..0027a16df1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/mod.rs @@ -0,0 +1,3 @@ +pub mod image; +pub mod object; +pub mod video; diff --git a/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v21_image_to_3d.rs b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v21_image_to_3d.rs new file mode 100644 index 0000000000..33b0109630 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v21_image_to_3d.rs @@ -0,0 +1,31 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3dV21ImageTo3dInput { + pub input_image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub textured_mesh: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub octree_resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3dV21ImageTo3dOutput {} + +pub fn hunyuan3d_v21_image_to_3d( + params: Hunyuan3dV21ImageTo3dInput, +) -> FalRequest { + FalRequest::new("fal-ai/hunyuan3d-v21", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v2_image_to_3d.rs b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v2_image_to_3d.rs new file mode 100644 index 0000000000..bf0871c442 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v2_image_to_3d.rs @@ -0,0 +1,31 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3dV2ImageTo3dInput { + pub input_image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub textured_mesh: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub octree_resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3dV2ImageTo3dOutput {} + +pub fn hunyuan3d_v2_image_to_3d( + params: Hunyuan3dV2ImageTo3dInput, +) -> FalRequest { + FalRequest::new("fal-ai/hunyuan3d/v2", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_image_to_3d.rs b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_image_to_3d.rs new file mode 100644 index 0000000000..ca5829291b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_image_to_3d.rs @@ -0,0 +1,39 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3dV3ImageTo3dInput { + pub input_image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub back_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub left_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub right_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Options: "Normal", "LowPoly", "Geometry" + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Options: "triangle", "quadrilateral" + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3dV3ImageTo3dOutput {} + +pub fn hunyuan3d_v3_image_to_3d( + params: Hunyuan3dV3ImageTo3dInput, +) -> FalRequest { + FalRequest::new("fal-ai/hunyuan3d-v3/image-to-3d", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_sketch_to_3d.rs b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_sketch_to_3d.rs new file mode 100644 index 0000000000..306a2b17a5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_sketch_to_3d.rs @@ -0,0 +1,32 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3dV3SketchTo3dInput { + pub prompt: String, + + pub input_image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Options: "Normal", "LowPoly", "Geometry" + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Options: "triangle", "quadrilateral" + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3dV3SketchTo3dOutput {} + +pub fn hunyuan3d_v3_sketch_to_3d( + params: Hunyuan3dV3SketchTo3dInput, +) -> FalRequest { + FalRequest::new("fal-ai/hunyuan3d-v3/sketch-to-3d", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_text_to_3d.rs b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_text_to_3d.rs new file mode 100644 index 0000000000..15073be201 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/object/http_hunyuan3d_v3_text_to_3d.rs @@ -0,0 +1,30 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3dV3TextTo3dInput { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Options: "Normal", "LowPoly", "Geometry" + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_type: Option, + + /// Options: "triangle", "quadrilateral" + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3dV3TextTo3dOutput {} + +pub fn hunyuan3d_v3_text_to_3d( + params: Hunyuan3dV3TextTo3dInput, +) -> FalRequest { + FalRequest::new("fal-ai/hunyuan3d-v3/text-to-3d", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/object/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/object/mod.rs new file mode 100644 index 0000000000..b488ef9c3b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/object/mod.rs @@ -0,0 +1,5 @@ +pub mod http_hunyuan3d_v2_image_to_3d; +pub mod http_hunyuan3d_v21_image_to_3d; +pub mod http_hunyuan3d_v3_image_to_3d; +pub mod http_hunyuan3d_v3_sketch_to_3d; +pub mod http_hunyuan3d_v3_text_to_3d; diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_hailuo_v2p3_fast_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_hailuo_v2p3_fast_pro_image_to_video.rs new file mode 100644 index 0000000000..da56711bce --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_hailuo_v2p3_fast_pro_image_to_video.rs @@ -0,0 +1,28 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct HailuoV2p3FastProImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_optimizer: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HailuoV2p3FastProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HailuoV2p3FastProImageToVideoOutput { + pub video: HailuoV2p3FastProImageToVideoVideoFile, +} + +pub fn hailuo_v2p3_fast_pro_image_to_video( + params: HailuoV2p3FastProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/minimax/hailuo-2.3-fast/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_hailuo_v2p3_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_hailuo_v2p3_pro_image_to_video.rs new file mode 100644 index 0000000000..da12f55b25 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_hailuo_v2p3_pro_image_to_video.rs @@ -0,0 +1,28 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct HailuoV2p3ProImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_optimizer: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HailuoV2p3ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HailuoV2p3ProImageToVideoOutput { + pub video: HailuoV2p3ProImageToVideoVideoFile, +} + +pub fn hailuo_v2p3_pro_image_to_video( + params: HailuoV2p3ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/minimax/hailuo-2.3/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_3p0_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_3p0_pro_image_to_video.rs new file mode 100644 index 0000000000..d6c2553dce --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_3p0_pro_image_to_video.rs @@ -0,0 +1,58 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0ProImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL + pub image_url: String, + + /// Optional end frame image URL + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Aspect ratio + /// Possible enum values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Generate audio + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible enum values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0ProImageToVideoOutput { + pub video: Kling3p0ProImageToVideoVideoFile, +} + +pub fn kling_3p0_pro_image_to_video( + params: Kling3p0ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v3/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_3p0_standard_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_3p0_standard_image_to_video.rs new file mode 100644 index 0000000000..e4a18d2866 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_3p0_standard_image_to_video.rs @@ -0,0 +1,58 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0StandardImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL + pub image_url: String, + + /// Optional end frame image URL + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Aspect ratio + /// Possible enum values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Generate audio + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible enum values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardImageToVideoOutput { + pub video: Kling3p0StandardImageToVideoVideoFile, +} + +pub fn kling_3p0_standard_image_to_video( + params: Kling3p0StandardImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v3/standard/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v1p6_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v1p6_pro_image_to_video.rs new file mode 100644 index 0000000000..4b50d8f024 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v1p6_pro_image_to_video.rs @@ -0,0 +1,42 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV1p6ProImageToVideoInput { + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + pub prompt: String, + + /// Options: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV1p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV1p6ProImageToVideoOutput { + pub video: KlingV1p6ProImageToVideoVideoFile, +} + +pub fn kling_v1p6_pro_image_to_video( + params: KlingV1p6ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v1.6/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p1_master_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p1_master_image_to_video.rs new file mode 100644 index 0000000000..1bedb79f5b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p1_master_image_to_video.rs @@ -0,0 +1,42 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p1MasterImageToVideoInput { + pub image_url: String, + + pub prompt: String, + + /// Options: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p1MasterImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p1MasterImageToVideoOutput { + pub video: KlingV2p1MasterImageToVideoVideoFile, +} + +pub fn kling_v2p1_master_image_to_video( + params: KlingV2p1MasterImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.1/master/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p1_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p1_pro_image_to_video.rs new file mode 100644 index 0000000000..15162b61ce --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p1_pro_image_to_video.rs @@ -0,0 +1,42 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p1ProImageToVideoInput { + pub image_url: String, + + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + /// Options: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p1ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p1ProImageToVideoOutput { + pub video: KlingV2p1ProImageToVideoVideoFile, +} + +pub fn kling_v2p1_pro_image_to_video( + params: KlingV2p1ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.1/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p5_turbo_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p5_turbo_pro_image_to_video.rs new file mode 100644 index 0000000000..c2ff7616e0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p5_turbo_pro_image_to_video.rs @@ -0,0 +1,38 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p5TurboProImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tail_image_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p5TurboProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p5TurboProImageToVideoOutput { + pub video: KlingV2p5TurboProImageToVideoVideoFile, +} + +pub fn kling_v2p5_turbo_pro_image_to_video( + params: KlingV2p5TurboProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.5-turbo/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p5_turbo_standard_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p5_turbo_standard_image_to_video.rs new file mode 100644 index 0000000000..47a4bcfe98 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p5_turbo_standard_image_to_video.rs @@ -0,0 +1,35 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p5TurboStandardImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p5TurboStandardImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p5TurboStandardImageToVideoOutput { + pub video: KlingV2p5TurboStandardImageToVideoVideoFile, +} + +pub fn kling_v2p5_turbo_standard_image_to_video( + params: KlingV2p5TurboStandardImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.5-turbo/standard/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p6_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p6_pro_image_to_video.rs new file mode 100644 index 0000000000..98d9e15389 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_kling_v2p6_pro_image_to_video.rs @@ -0,0 +1,35 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p6ProImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p6ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p6ProImageToVideoOutput { + pub video: KlingV2p6ProImageToVideoVideoFile, +} + +pub fn kling_v2p6_pro_image_to_video( + params: KlingV2p6ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.6/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_pixverse_v5_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_pixverse_v5_image_to_video.rs new file mode 100644 index 0000000000..6b6801aee6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_pixverse_v5_image_to_video.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct PixverseV5ImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + /// Options: "5", "8" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Options: "16:9", "4:3", "1:1", "3:4", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "360p", "540p", "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Options: "anime", "3d_animation", "clay", "comic", "cyberpunk" + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PixverseV5ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PixverseV5ImageToVideoOutput { + pub video: PixverseV5ImageToVideoVideoFile, +} + +pub fn pixverse_v5_image_to_video( + params: PixverseV5ImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/pixverse/v5/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1_lite_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1_lite_image_to_video.rs new file mode 100644 index 0000000000..f4b2eb14e9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1_lite_image_to_video.rs @@ -0,0 +1,48 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Seedance1LiteImageToVideoInput { + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + pub prompt: String, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "480p", "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Options: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub camera_fixed: Option, + + pub seed: i64, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1LiteImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1LiteImageToVideoOutput { + pub video: Seedance1LiteImageToVideoVideoFile, +} + +pub fn seedance_1_lite_image_to_video( + params: Seedance1LiteImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedance/v1/lite/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1_pro_image_to_video.rs new file mode 100644 index 0000000000..81f7b9f99c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1_pro_image_to_video.rs @@ -0,0 +1,39 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Seedance1ProImageToVideoInput { + pub image_url: String, + + pub prompt: String, + + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "480p", "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub camera_fixed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1ProImageToVideoOutput { + pub video: Seedance1ProImageToVideoVideoFile, +} + +pub fn seedance_1_pro_image_to_video( + params: Seedance1ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedance/v1/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1p5_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1p5_pro_image_to_video.rs new file mode 100644 index 0000000000..6b0d175fd7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_seedance_1p5_pro_image_to_video.rs @@ -0,0 +1,63 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Seedance1p5ProImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + /// URL of the final frame (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub end_image_url: Option, + + /// Possible enum values: 480p, 720p, 1080p + /// Default value: "720p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Possible enum values: 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, auto + /// Default value: "16:9" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds. + /// Possible enum values: 4, 5, 6, 7, 8, 9, 10, 11, 12 + /// Default value: "5" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Fix camera position during animation. + #[serde(skip_serializing_if = "Option::is_none")] + pub camera_fixed: Option, + + /// Random seed. Use -1 for random generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Enable content safety filtering. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Generate accompanying audio. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1p5ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1p5ProImageToVideoOutput { + pub video: Seedance1p5ProImageToVideoVideoFile, +} + +pub fn seedance_1p5_pro_image_to_video( + params: Seedance1p5ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedance/v1.5/pro/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_sora_2_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_sora_2_image_to_video.rs new file mode 100644 index 0000000000..c016d99073 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_sora_2_image_to_video.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Sora2ImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Possible enum values: auto, 720p + /// Default value: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Possible enum values: auto, 9:16, 16:9 + /// Default value: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds. + /// Possible enum values: 4, 8, 12 + /// Default value: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Whether to delete the video after generation for privacy reasons. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_video: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2ImageToVideoOutput { + pub video: Sora2ImageToVideoVideoFile, +} + +pub fn sora_2_image_to_video( + params: Sora2ImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/sora-2/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_sora_2_pro_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_sora_2_pro_image_to_video.rs new file mode 100644 index 0000000000..c15f69101a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_sora_2_pro_image_to_video.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Sora2ProImageToVideoInput { + pub prompt: String, + + /// Starting frame image URL. + pub image_url: String, + + /// Possible enum values: auto, 720p, 1080p + /// Default value: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Possible enum values: auto, 9:16, 16:9 + /// Default value: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds. + /// Possible enum values: 4, 8, 12 + /// Default value: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Whether to delete the video after generation for privacy reasons. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_video: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2ProImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2ProImageToVideoOutput { + pub video: Sora2ProImageToVideoVideoFile, +} + +pub fn sora_2_pro_image_to_video( + params: Sora2ProImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/sora-2/image-to-video/pro", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_2_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_2_image_to_video.rs new file mode 100644 index 0000000000..aeec5ec8f5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_2_image_to_video.rs @@ -0,0 +1,29 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo2ImageToVideoInput { + pub image_url: String, + + pub prompt: String, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2ImageToVideoOutput { + pub video: Veo2ImageToVideoVideoFile, +} + +pub fn veo_2_image_to_video( + params: Veo2ImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo2/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3_fast_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3_fast_image_to_video.rs new file mode 100644 index 0000000000..e753186625 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3_fast_image_to_video.rs @@ -0,0 +1,40 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3FastImageToVideoInput { + pub image_url: String, + + pub prompt: String, + + /// Options: "auto", "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3FastImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3FastImageToVideoOutput { + pub video: Veo3FastImageToVideoVideoFile, +} + +pub fn veo_3_fast_image_to_video( + params: Veo3FastImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3/fast/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3_image_to_video.rs new file mode 100644 index 0000000000..bd40b96ac6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3_image_to_video.rs @@ -0,0 +1,40 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3ImageToVideoInput { + pub image_url: String, + + pub prompt: String, + + /// Options: "auto", "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3ImageToVideoOutput { + pub video: Veo3ImageToVideoVideoFile, +} + +pub fn veo_3_image_to_video( + params: Veo3ImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_fast_first_last_frame_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_fast_first_last_frame_image_to_video.rs new file mode 100644 index 0000000000..30385dfa49 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_fast_first_last_frame_image_to_video.rs @@ -0,0 +1,42 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastFirstLastFrameImageToVideoInput { + pub prompt: String, + + pub first_frame_url: String, + + pub last_frame_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "auto", "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastFirstLastFrameImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastFirstLastFrameImageToVideoOutput { + pub video: Veo3p1FastFirstLastFrameImageToVideoVideoFile, +} + +pub fn veo_3p1_fast_first_last_frame_image_to_video( + params: Veo3p1FastFirstLastFrameImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3.1/fast/first-last-frame-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_fast_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_fast_image_to_video.rs new file mode 100644 index 0000000000..c36635a3c0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_fast_image_to_video.rs @@ -0,0 +1,40 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "auto", "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastImageToVideoOutput { + pub video: Veo3p1FastImageToVideoVideoFile, +} + +pub fn veo_3p1_fast_image_to_video( + params: Veo3p1FastImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3.1/fast/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_first_last_frame_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_first_last_frame_image_to_video.rs new file mode 100644 index 0000000000..baa9aba160 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_first_last_frame_image_to_video.rs @@ -0,0 +1,42 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FirstLastFrameImageToVideoInput { + pub prompt: String, + + pub first_frame_url: String, + + pub last_frame_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "auto", "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FirstLastFrameImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FirstLastFrameImageToVideoOutput { + pub video: Veo3p1FirstLastFrameImageToVideoVideoFile, +} + +pub fn veo_3p1_first_last_frame_image_to_video( + params: Veo3p1FirstLastFrameImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3.1/first-last-frame-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_image_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_image_to_video.rs new file mode 100644 index 0000000000..0cdcf08c8f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/http_veo_3p1_image_to_video.rs @@ -0,0 +1,40 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1ImageToVideoInput { + pub prompt: String, + + pub image_url: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "auto", "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ImageToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1ImageToVideoOutput { + pub video: Veo3p1ImageToVideoVideoFile, +} + +pub fn veo_3p1_image_to_video( + params: Veo3p1ImageToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3.1/image-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/image/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/video/image/mod.rs new file mode 100644 index 0000000000..e0165baf0a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/image/mod.rs @@ -0,0 +1,23 @@ +pub mod http_hailuo_v2p3_fast_pro_image_to_video; +pub mod http_hailuo_v2p3_pro_image_to_video; +pub mod http_kling_3p0_pro_image_to_video; +pub mod http_kling_3p0_standard_image_to_video; +pub mod http_kling_v1p6_pro_image_to_video; +pub mod http_kling_v2p1_master_image_to_video; +pub mod http_kling_v2p1_pro_image_to_video; +pub mod http_kling_v2p5_turbo_pro_image_to_video; +pub mod http_kling_v2p5_turbo_standard_image_to_video; +pub mod http_kling_v2p6_pro_image_to_video; +pub mod http_pixverse_v5_image_to_video; +pub mod http_seedance_1_lite_image_to_video; +pub mod http_seedance_1_pro_image_to_video; +pub mod http_seedance_1p5_pro_image_to_video; +pub mod http_sora_2_image_to_video; +pub mod http_sora_2_pro_image_to_video; +pub mod http_veo_2_image_to_video; +pub mod http_veo_3_fast_image_to_video; +pub mod http_veo_3_image_to_video; +pub mod http_veo_3p1_fast_first_last_frame_image_to_video; +pub mod http_veo_3p1_fast_image_to_video; +pub mod http_veo_3p1_first_last_frame_image_to_video; +pub mod http_veo_3p1_image_to_video; diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/video/mod.rs new file mode 100644 index 0000000000..ab1ed42573 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/mod.rs @@ -0,0 +1,2 @@ +pub mod image; +pub mod text; diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_hailuo_v2p3_pro_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_hailuo_v2p3_pro_text_to_video.rs new file mode 100644 index 0000000000..23f96dd9b7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_hailuo_v2p3_pro_text_to_video.rs @@ -0,0 +1,26 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct HailuoV2p3ProTextToVideoInput { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_optimizer: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HailuoV2p3ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HailuoV2p3ProTextToVideoOutput { + pub video: HailuoV2p3ProTextToVideoVideoFile, +} + +pub fn hailuo_v2p3_pro_text_to_video( + params: HailuoV2p3ProTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/minimax/hailuo-2.3/pro/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_3p0_pro_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_3p0_pro_text_to_video.rs new file mode 100644 index 0000000000..436764d227 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_3p0_pro_text_to_video.rs @@ -0,0 +1,51 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0ProTextToVideoInput { + pub prompt: String, + + /// Aspect ratio + /// Possible enum values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Generate audio + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible enum values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0ProTextToVideoOutput { + pub video: Kling3p0ProTextToVideoVideoFile, +} + +pub fn kling_3p0_pro_text_to_video( + params: Kling3p0ProTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v3/pro/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_3p0_standard_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_3p0_standard_text_to_video.rs new file mode 100644 index 0000000000..82f38ee42f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_3p0_standard_text_to_video.rs @@ -0,0 +1,51 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Kling3p0StandardTextToVideoInput { + pub prompt: String, + + /// Aspect ratio + /// Possible enum values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Generate audio + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "3" through "15" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Shot type for multi-shot video generation. + /// Possible enum values: "customize", "intelligent" + #[serde(skip_serializing_if = "Option::is_none")] + pub shot_type: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Kling3p0StandardTextToVideoOutput { + pub video: Kling3p0StandardTextToVideoVideoFile, +} + +pub fn kling_3p0_standard_text_to_video( + params: Kling3p0StandardTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v3/standard/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_v2p5_turbo_pro_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_v2p5_turbo_pro_text_to_video.rs new file mode 100644 index 0000000000..7bcfaf94ce --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_v2p5_turbo_pro_text_to_video.rs @@ -0,0 +1,41 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p5TurboProTextToVideoInput { + pub prompt: String, + + /// Possible enum values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p5TurboProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p5TurboProTextToVideoOutput { + pub video: KlingV2p5TurboProTextToVideoVideoFile, +} + +pub fn kling_v2p5_turbo_pro_text_to_video( + params: KlingV2p5TurboProTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.5-turbo/pro/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_v2p6_pro_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_v2p6_pro_text_to_video.rs new file mode 100644 index 0000000000..906c1b98f4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_kling_v2p6_pro_text_to_video.rs @@ -0,0 +1,45 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct KlingV2p6ProTextToVideoInput { + pub prompt: String, + + /// Possible enum values: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Generate audio + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "5", "10" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The CFG (Classifier Free Guidance) scale. + /// Default value: 0.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub cfg_scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p6ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KlingV2p6ProTextToVideoOutput { + pub video: KlingV2p6ProTextToVideoVideoFile, +} + +pub fn kling_v2p6_pro_text_to_video( + params: KlingV2p6ProTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/kling-video/v2.6/pro/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_pixverse_v5_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_pixverse_v5_text_to_video.rs new file mode 100644 index 0000000000..8585cebb33 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_pixverse_v5_text_to_video.rs @@ -0,0 +1,47 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct PixverseV5TextToVideoInput { + pub prompt: String, + + /// Options: "360p", "540p", "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Options: "16:9", "4:3", "1:1", "3:4", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Duration in seconds + /// Options: "5", "8" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Options: "anime", "3d_animation", "clay", "comic", "cyberpunk" + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PixverseV5TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PixverseV5TextToVideoOutput { + pub video: PixverseV5TextToVideoVideoFile, +} + +pub fn pixverse_v5_text_to_video( + params: PixverseV5TextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/pixverse/v5/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_seedance_1p5_pro_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_seedance_1p5_pro_text_to_video.rs new file mode 100644 index 0000000000..d020eee031 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_seedance_1p5_pro_text_to_video.rs @@ -0,0 +1,57 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Seedance1p5ProTextToVideoInput { + pub prompt: String, + + /// Possible enum values: 480p, 720p, 1080p + /// Default value: "720p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Possible enum values: 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, auto + /// Default value: "16:9" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds. + /// Possible enum values: 4, 5, 6, 7, 8, 9, 10, 11, 12 + /// Default value: "5" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Fix camera position during animation. + #[serde(skip_serializing_if = "Option::is_none")] + pub camera_fixed: Option, + + /// Random seed. Use -1 for random generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Enable content safety filtering. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_safety_checker: Option, + + /// Generate accompanying audio. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1p5ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance1p5ProTextToVideoOutput { + pub video: Seedance1p5ProTextToVideoVideoFile, +} + +pub fn seedance_1p5_pro_text_to_video( + params: Seedance1p5ProTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/bytedance/seedance/v1.5/pro/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_sora_2_pro_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_sora_2_pro_text_to_video.rs new file mode 100644 index 0000000000..d522eca094 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_sora_2_pro_text_to_video.rs @@ -0,0 +1,44 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Sora2ProTextToVideoInput { + pub prompt: String, + + /// Possible enum values: 720p, 1080p + /// Default value: "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Possible enum values: 9:16, 16:9 + /// Default value: "16:9" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds. + /// Possible enum values: 4, 8, 12 + /// Default value: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Whether to delete the video after generation for privacy reasons. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_video: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2ProTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2ProTextToVideoOutput { + pub video: Sora2ProTextToVideoVideoFile, +} + +pub fn sora_2_pro_text_to_video( + params: Sora2ProTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/sora-2/text-to-video/pro", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_sora_2_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_sora_2_text_to_video.rs new file mode 100644 index 0000000000..ae4709fbb9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_sora_2_text_to_video.rs @@ -0,0 +1,44 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Sora2TextToVideoInput { + pub prompt: String, + + /// Possible enum values: 720p + /// Default value: "720p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Possible enum values: 9:16, 16:9 + /// Default value: "16:9" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds. + /// Possible enum values: 4, 8, 12 + /// Default value: 4 + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Whether to delete the video after generation for privacy reasons. + /// Default value: true + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_video: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sora2TextToVideoOutput { + pub video: Sora2TextToVideoVideoFile, +} + +pub fn sora_2_text_to_video( + params: Sora2TextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/sora-2/text-to-video", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_2_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_2_text_to_video.rs new file mode 100644 index 0000000000..5b61bce533 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_2_text_to_video.rs @@ -0,0 +1,34 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo2TextToVideoInput { + pub prompt: String, + + /// Options: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "5s", "6s", "7s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2TextToVideoOutput { + pub video: Veo2TextToVideoVideoFile, +} + +pub fn veo_2_text_to_video( + params: Veo2TextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo2", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3_text_to_video.rs new file mode 100644 index 0000000000..63c93d10f9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3_text_to_video.rs @@ -0,0 +1,41 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3TextToVideoInput { + pub prompt: String, + + /// Options: "16:9", "9:16" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3TextToVideoOutput { + pub video: Veo3TextToVideoVideoFile, +} + +pub fn veo_3_text_to_video( + params: Veo3TextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3p1_fast_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3p1_fast_text_to_video.rs new file mode 100644 index 0000000000..192ad369fc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3p1_fast_text_to_video.rs @@ -0,0 +1,51 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1FastTextToVideoInput { + pub prompt: String, + + /// Options: "auto", "9:16", "16:9", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enhance_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastTextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1FastTextToVideoOutput { + pub video: Veo3p1FastTextToVideoVideoFile, +} + +pub fn veo_3p1_fast_text_to_video( + params: Veo3p1FastTextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3.1/fast", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3p1_text_to_video.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3p1_text_to_video.rs new file mode 100644 index 0000000000..6c52b1cb07 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/http_veo_3p1_text_to_video.rs @@ -0,0 +1,51 @@ +use crate::requests::core_api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo3p1TextToVideoInput { + pub prompt: String, + + /// Options: "auto", "9:16", "16:9", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "4s", "6s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional negative prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enhance_prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_fix: Option, + + /// Options: "720p", "1080p" + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo3p1TextToVideoOutput { + pub video: Veo3p1TextToVideoVideoFile, +} + +pub fn veo_3p1_text_to_video( + params: Veo3p1TextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo3.1", params) +} diff --git a/crates/api_clients/fal_client/src/requests_old/http/video/text/mod.rs b/crates/api_clients/fal_client/src/requests_old/http/video/text/mod.rs new file mode 100644 index 0000000000..03891ea1a4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/http/video/text/mod.rs @@ -0,0 +1,13 @@ +pub mod http_hailuo_v2p3_pro_text_to_video; +pub mod http_kling_3p0_pro_text_to_video; +pub mod http_kling_3p0_standard_text_to_video; +pub mod http_kling_v2p5_turbo_pro_text_to_video; +pub mod http_kling_v2p6_pro_text_to_video; +pub mod http_pixverse_v5_text_to_video; +pub mod http_seedance_1p5_pro_text_to_video; +pub mod http_sora_2_pro_text_to_video; +pub mod http_sora_2_text_to_video; +pub mod http_veo_2_text_to_video; +pub mod http_veo_3_text_to_video; +pub mod http_veo_3p1_fast_text_to_video; +pub mod http_veo_3p1_text_to_video; diff --git a/crates/api_clients/fal_client/src/requests_old/mod.rs b/crates/api_clients/fal_client/src/requests_old/mod.rs new file mode 100644 index 0000000000..5a7eeb3ccb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod webhook; diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs new file mode 100644 index 0000000000..8ea94e1436 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/enqueue_flux_2_lora_edit_image_angle_webhook.rs @@ -0,0 +1,189 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::angle::http_flux_2_lora_edit_image_angle::{http_flux_2_lora_edit_image_angle, Flux2LoraEditImageAngleInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueFlux2LoraEditImageAngleArgs<'a, R: IntoUrl> { + pub request: EnqueueFlux2LoraEditImageAngleRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueFlux2LoraEditImageAngleRequest { + // Request required + pub image_urls: Vec, + + // Camera parameters + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + + // Optional args + pub num_images: Option, + pub image_size: Option, + pub lora_scale: Option, + pub guidance_scale: Option, + pub num_inference_steps: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueFlux2LoraAngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueFlux2LoraAngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +impl FalRequestCostCalculator for EnqueueFlux2LoraEditImageAngleRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.021 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~2 cents per image. + let unit_cost = 2; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueFlux2LoraAngleNumImages::One) => unit_cost, + Some(EnqueueFlux2LoraAngleNumImages::Two) => unit_cost * 2, + Some(EnqueueFlux2LoraAngleNumImages::Three) => unit_cost * 3, + Some(EnqueueFlux2LoraAngleNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_flux_2_lora_edit_image_angle_webhook( + args: EnqueueFlux2LoraEditImageAngleArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = req.num_images + .map(|n| match n { + EnqueueFlux2LoraAngleNumImages::One => 1, + EnqueueFlux2LoraAngleNumImages::Two => 2, + EnqueueFlux2LoraAngleNumImages::Three => 3, + EnqueueFlux2LoraAngleNumImages::Four => 4, + }); + + let image_size = req.image_size + .map(|s| match s { + EnqueueFlux2LoraAngleImageSize::Square => "square", + EnqueueFlux2LoraAngleImageSize::SquareHd => "square_hd", + EnqueueFlux2LoraAngleImageSize::PortraitFourThree => "portrait_4_3", + EnqueueFlux2LoraAngleImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueFlux2LoraAngleImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueFlux2LoraAngleImageSize::LandscapeSixteenNine => "landscape_16_9", + }) + .map(|s| s.to_string()); + + let request = Flux2LoraEditImageAngleInput { + image_urls: req.image_urls, + horizontal_angle: req.horizontal_angle, + vertical_angle: req.vertical_angle, + zoom: req.zoom, + lora_scale: req.lora_scale, + image_size, + guidance_scale: req.guidance_scale, + num_inference_steps: req.num_inference_steps, + num_images, + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Unused + seed: None, + }; + + let result = http_flux_2_lora_edit_image_angle(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs, EnqueueFlux2LoraEditImageAngleRequest, EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraAngleImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test_single() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueFlux2LoraEditImageAngleArgs { + request: EnqueueFlux2LoraEditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + num_images: Some(EnqueueFlux2LoraAngleNumImages::One), + image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_flux_2_lora_edit_image_angle_webhook(args).await?; + Ok(()) + } + + /// Enqueues 27 angle combinations: 3 horizontal x 3 vertical x 3 zoom levels. + #[tokio::test] + #[ignore] + async fn test_angle_grid() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0]; + let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0]; + let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0]; + + for &h in &horizontal_angles { + for &v in &vertical_angles { + for &z in &zoom_levels { + let args = EnqueueFlux2LoraEditImageAngleArgs { + request: EnqueueFlux2LoraEditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(h), + vertical_angle: Some(v), + zoom: Some(z), + num_images: Some(EnqueueFlux2LoraAngleNumImages::One), + image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_flux_2_lora_edit_image_angle_webhook(args).await?; + println!("Enqueued: h={}, v={}, z={}", h, v, z); + } + } + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs new file mode 100644 index 0000000000..b388645544 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/enqueue_qwen_edit_2511_edit_image_angle_webhook.rs @@ -0,0 +1,196 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::angle::http_qwen_edit_2511_edit_image_angle::{http_qwen_edit_2511_edit_image_angle, QwenEdit2511EditImageAngleInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueQwenEdit2511EditImageAngleArgs<'a, R: IntoUrl> { + pub request: EnqueueQwenEdit2511EditImageAngleRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueQwenEdit2511EditImageAngleRequest { + // Request required + pub image_urls: Vec, + + // Camera parameters + pub horizontal_angle: Option, + pub vertical_angle: Option, + pub zoom: Option, + + // Optional args + pub additional_prompt: Option, + pub num_images: Option, + pub image_size: Option, + pub lora_scale: Option, + pub guidance_scale: Option, + pub num_inference_steps: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueQwenEdit2511AngleNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueQwenEdit2511AngleImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, +} + +impl FalRequestCostCalculator for EnqueueQwenEdit2511EditImageAngleRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.035 per megapixel. + // For a 1024x1024 image (~1 MP), that's ~4 cents per image. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueQwenEdit2511AngleNumImages::One) => unit_cost, + Some(EnqueueQwenEdit2511AngleNumImages::Two) => unit_cost * 2, + Some(EnqueueQwenEdit2511AngleNumImages::Three) => unit_cost * 3, + Some(EnqueueQwenEdit2511AngleNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_qwen_edit_2511_edit_image_angle_webhook( + args: EnqueueQwenEdit2511EditImageAngleArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = req.num_images + .map(|n| match n { + EnqueueQwenEdit2511AngleNumImages::One => 1, + EnqueueQwenEdit2511AngleNumImages::Two => 2, + EnqueueQwenEdit2511AngleNumImages::Three => 3, + EnqueueQwenEdit2511AngleNumImages::Four => 4, + }); + + let image_size = req.image_size + .map(|s| match s { + EnqueueQwenEdit2511AngleImageSize::Square => "square", + EnqueueQwenEdit2511AngleImageSize::SquareHd => "square_hd", + EnqueueQwenEdit2511AngleImageSize::PortraitFourThree => "portrait_4_3", + EnqueueQwenEdit2511AngleImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueQwenEdit2511AngleImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueQwenEdit2511AngleImageSize::LandscapeSixteenNine => "landscape_16_9", + }) + .map(|s| s.to_string()); + + let request = QwenEdit2511EditImageAngleInput { + image_urls: req.image_urls, + horizontal_angle: req.horizontal_angle, + vertical_angle: req.vertical_angle, + zoom: req.zoom, + additional_prompt: req.additional_prompt, + lora_scale: req.lora_scale, + image_size, + guidance_scale: req.guidance_scale, + num_inference_steps: req.num_inference_steps, + num_images, + // Constants + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + // Unused + negative_prompt: None, + seed: None, + }; + + let result = http_qwen_edit_2511_edit_image_angle(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs, EnqueueQwenEdit2511EditImageAngleRequest, EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511AngleImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test_single() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueQwenEdit2511EditImageAngleArgs { + request: EnqueueQwenEdit2511EditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(45.0), + vertical_angle: Some(15.0), + zoom: Some(5.0), + additional_prompt: Some("cinematic lighting".to_string()), + num_images: Some(EnqueueQwenEdit2511AngleNumImages::One), + image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_qwen_edit_2511_edit_image_angle_webhook(args).await?; + Ok(()) + } + + /// Enqueues 9 angle combinations: 3 horizontal x 3 vertical x 3 zoom levels. + /// Each combination generates one image, for a total of 27 enqueued requests. + #[tokio::test] + #[ignore] + async fn test_angle_grid() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0]; + let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0]; + let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0]; + + for &h in &horizontal_angles { + for &v in &vertical_angles { + for &z in &zoom_levels { + let args = EnqueueQwenEdit2511EditImageAngleArgs { + request: EnqueueQwenEdit2511EditImageAngleRequest { + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + horizontal_angle: Some(h), + vertical_angle: Some(v), + zoom: Some(z), + additional_prompt: None, + num_images: Some(EnqueueQwenEdit2511AngleNumImages::One), + image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd), + lora_scale: None, + guidance_scale: None, + num_inference_steps: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_qwen_edit_2511_edit_image_angle_webhook(args).await?; + println!("Enqueued: h={}, v={}, z={}", h, v, z); + } + } + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/mod.rs new file mode 100644 index 0000000000..6cf5971a96 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/angle/mod.rs @@ -0,0 +1,2 @@ +pub mod enqueue_flux_2_lora_edit_image_angle_webhook; +pub mod enqueue_qwen_edit_2511_edit_image_angle_webhook; diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/background/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/background/mod.rs new file mode 100644 index 0000000000..ce42b62464 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/background/mod.rs @@ -0,0 +1 @@ +pub mod remove_background_rembg_webhook; diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/background/remove_background_rembg_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/background/remove_background_rembg_webhook.rs new file mode 100644 index 0000000000..7c291fa8c4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/background/remove_background_rembg_webhook.rs @@ -0,0 +1,68 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::background::http_rembg_remove_background::{rembg_remove_background, RembgRemoveBackgroundInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct RemoveBackgroundRembgWebhookArgs<'a, V: IntoUrl> { + pub request: RemoveBackgroundRembgWebhookRequest, + pub webhook_url: V, + pub api_key: &'a FalApiKey +} + +#[derive(Clone, Debug)] +pub struct RemoveBackgroundRembgWebhookRequest { + pub image_url: String, +} + +pub async fn remove_background_rembg_webhook( + args: RemoveBackgroundRembgWebhookArgs<'_, V> +) -> Result { + + let req = args.request; + + let request = RembgRemoveBackgroundInput { + image_url: req.image_url, + crop_to_bbox: None, + sync_mode: None + }; + + let result = rembg_remove_background(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::background::remove_background_rembg_webhook::remove_background_rembg_webhook; + use crate::requests_old::webhook::image::background::remove_background_rembg_webhook::{RemoveBackgroundRembgWebhookArgs, RemoveBackgroundRembgWebhookRequest}; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] // NB: Manually test, don't run in CI! + async fn test() -> anyhow::Result<()> { + // XXX: Don't commit secrets! + let api_key = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&api_key); + + let args = RemoveBackgroundRembgWebhookArgs { + request: RemoveBackgroundRembgWebhookRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + }, + webhook_url: "https://api.storyteller.ai/webhook", + api_key: &api_key, + }; + + let response = remove_background_rembg_webhook(args).await?; + + println!("{:?}", response); + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v4_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v4_edit_image_webhook.rs new file mode 100644 index 0000000000..287cca08d7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v4_edit_image_webhook.rs @@ -0,0 +1,149 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_seedream_4_edit_image::{seedream_4_edit_image, SeedreamV4EditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueBytedanceSeedreamV4EditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueBytedanceSeedreamV4EditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueBytedanceSeedreamV4EditImageRequest { + pub prompt: String, + pub image_urls: Vec, + pub num_images: Option, + pub max_images: Option, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4EditImageMaxImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4EditImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, + Auto, + Auto2k, + Auto4k, +} + +impl FalRequestCostCalculator for EnqueueBytedanceSeedreamV4EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.03 per image. + let unit_cost = 3; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::One) => unit_cost, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Two) => unit_cost * 2, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Three) => unit_cost * 3, + Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_bytedance_seedream_v4_edit_image_webhook( + args: EnqueueBytedanceSeedreamV4EditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = req.num_images + .map(|n| match n { + EnqueueBytedanceSeedreamV4EditImageNumImages::One => 1, + EnqueueBytedanceSeedreamV4EditImageNumImages::Two => 2, + EnqueueBytedanceSeedreamV4EditImageNumImages::Three => 3, + EnqueueBytedanceSeedreamV4EditImageNumImages::Four => 4, + }); + + let max_images = req.max_images + .map(|n| match n { + EnqueueBytedanceSeedreamV4EditImageMaxImages::One => 1, + EnqueueBytedanceSeedreamV4EditImageMaxImages::Two => 2, + EnqueueBytedanceSeedreamV4EditImageMaxImages::Three => 3, + EnqueueBytedanceSeedreamV4EditImageMaxImages::Four => 4, + }); + + let image_size = req.image_size + .map(|s| match s { + EnqueueBytedanceSeedreamV4EditImageSize::Square => "square", + EnqueueBytedanceSeedreamV4EditImageSize::SquareHd => "square_hd", + EnqueueBytedanceSeedreamV4EditImageSize::PortraitFourThree => "portrait_4_3", + EnqueueBytedanceSeedreamV4EditImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueBytedanceSeedreamV4EditImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueBytedanceSeedreamV4EditImageSize::LandscapeSixteenNine => "landscape_16_9", + EnqueueBytedanceSeedreamV4EditImageSize::Auto => "auto", + EnqueueBytedanceSeedreamV4EditImageSize::Auto2k => "auto_2K", + EnqueueBytedanceSeedreamV4EditImageSize::Auto4k => "auto_4K", + }) + .map(|s| s.to_string()); + + let input = SeedreamV4EditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images, + max_images, + image_size, + enhance_prompt_mode: Some("standard".to_string()), + enable_safety_checker: Some(false), + }; + + seedream_4_edit_image(input) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await + .map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueBytedanceSeedreamV4EditImageArgs { + request: EnqueueBytedanceSeedreamV4EditImageRequest { + prompt: "add the ghost to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![GHOST_IMAGE_URL.to_string(), TREX_SKELETON_IMAGE_URL.to_string()], + num_images: Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Two), + max_images: Some(EnqueueBytedanceSeedreamV4EditImageMaxImages::Two), + image_size: Some(EnqueueBytedanceSeedreamV4EditImageSize::Auto2k), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_bytedance_seedream_v4_edit_image_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v4p5_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v4p5_edit_image_webhook.rs new file mode 100644 index 0000000000..b6746c44ab --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v4p5_edit_image_webhook.rs @@ -0,0 +1,147 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_seedream_4p5_edit_image::{seedream_4p5_edit_image, SeedreamV4p5EditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueBytedanceSeedreamV4p5EditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueBytedanceSeedreamV4p5EditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueBytedanceSeedreamV4p5EditImageRequest { + pub prompt: String, + pub image_urls: Vec, + pub num_images: Option, + pub max_images: Option, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4p5EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4p5EditImageMaxImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4p5EditImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, + Auto2k, + Auto4k, +} + +impl FalRequestCostCalculator for EnqueueBytedanceSeedreamV4p5EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.04 per image. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::One) => unit_cost, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two) => unit_cost * 2, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Three) => unit_cost * 3, + Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_bytedance_seedream_v4p5_edit_image_webhook( + args: EnqueueBytedanceSeedreamV4p5EditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = req.num_images + .map(|n| match n { + EnqueueBytedanceSeedreamV4p5EditImageNumImages::One => 1, + EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two => 2, + EnqueueBytedanceSeedreamV4p5EditImageNumImages::Three => 3, + EnqueueBytedanceSeedreamV4p5EditImageNumImages::Four => 4, + }); + + let max_images = req.max_images + .map(|n| match n { + EnqueueBytedanceSeedreamV4p5EditImageMaxImages::One => 1, + EnqueueBytedanceSeedreamV4p5EditImageMaxImages::Two => 2, + EnqueueBytedanceSeedreamV4p5EditImageMaxImages::Three => 3, + EnqueueBytedanceSeedreamV4p5EditImageMaxImages::Four => 4, + }); + + let image_size = req.image_size + .map(|s| match s { + EnqueueBytedanceSeedreamV4p5EditImageSize::Square => "square", + EnqueueBytedanceSeedreamV4p5EditImageSize::SquareHd => "square_hd", + EnqueueBytedanceSeedreamV4p5EditImageSize::PortraitFourThree => "portrait_4_3", + EnqueueBytedanceSeedreamV4p5EditImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueBytedanceSeedreamV4p5EditImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueBytedanceSeedreamV4p5EditImageSize::LandscapeSixteenNine => "landscape_16_9", + EnqueueBytedanceSeedreamV4p5EditImageSize::Auto2k => "auto_2K", + EnqueueBytedanceSeedreamV4p5EditImageSize::Auto4k => "auto_4K", + }) + .map(|s| s.to_string()); + + let input = SeedreamV4p5EditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images, + max_images, + image_size, + enable_safety_checker: Some(false), + seed: None, + }; + + seedream_4p5_edit_image(input) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await + .map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueBytedanceSeedreamV4p5EditImageArgs { + request: EnqueueBytedanceSeedreamV4p5EditImageRequest { + prompt: "add the ghost to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![GHOST_IMAGE_URL.to_string(), TREX_SKELETON_IMAGE_URL.to_string()], + num_images: Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two), + max_images: Some(EnqueueBytedanceSeedreamV4p5EditImageMaxImages::Two), + image_size: Some(EnqueueBytedanceSeedreamV4p5EditImageSize::Auto2k), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_bytedance_seedream_v4p5_edit_image_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v5_lite_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v5_lite_edit_image_webhook.rs new file mode 100644 index 0000000000..7fb7724ae7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_bytedance_seedream_v5_lite_edit_image_webhook.rs @@ -0,0 +1,146 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::edit::http_seedream_5_edit_image::{http_seedream_5_edit_image, SeedreamV5LiteEditImageInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueBytedanceSeedreamV5LiteEditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueBytedanceSeedreamV5LiteEditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueBytedanceSeedreamV5LiteEditImageRequest { + pub prompt: String, + pub image_urls: Vec, + pub num_images: Option, + pub max_images: Option, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV5LiteEditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV5LiteEditImageMaxImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV5LiteEditImageSize { + Square, + SquareHd, + PortraitFourThree, + PortraitSixteenNine, + LandscapeFourThree, + LandscapeSixteenNine, + Auto2k, + Auto3k, // NB: v5 uses auto_3K instead of v4's auto_4K +} + +impl FalRequestCostCalculator for EnqueueBytedanceSeedreamV5LiteEditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // TODO(bt): Verify actual pricing for Seedream v5 Lite on fal.ai. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::One) => unit_cost, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Two) => unit_cost * 2, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Three) => unit_cost * 3, + Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_bytedance_seedream_v5_lite_edit_image_webhook( + args: EnqueueBytedanceSeedreamV5LiteEditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = req.num_images + .map(|n| match n { + EnqueueBytedanceSeedreamV5LiteEditImageNumImages::One => 1, + EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Two => 2, + EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Three => 3, + EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Four => 4, + }); + + let max_images = req.max_images + .map(|n| match n { + EnqueueBytedanceSeedreamV5LiteEditImageMaxImages::One => 1, + EnqueueBytedanceSeedreamV5LiteEditImageMaxImages::Two => 2, + EnqueueBytedanceSeedreamV5LiteEditImageMaxImages::Three => 3, + EnqueueBytedanceSeedreamV5LiteEditImageMaxImages::Four => 4, + }); + + let image_size = req.image_size + .map(|s| match s { + EnqueueBytedanceSeedreamV5LiteEditImageSize::Square => "square", + EnqueueBytedanceSeedreamV5LiteEditImageSize::SquareHd => "square_hd", + EnqueueBytedanceSeedreamV5LiteEditImageSize::PortraitFourThree => "portrait_4_3", + EnqueueBytedanceSeedreamV5LiteEditImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueBytedanceSeedreamV5LiteEditImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueBytedanceSeedreamV5LiteEditImageSize::LandscapeSixteenNine => "landscape_16_9", + EnqueueBytedanceSeedreamV5LiteEditImageSize::Auto2k => "auto_2K", + EnqueueBytedanceSeedreamV5LiteEditImageSize::Auto3k => "auto_3K", + }) + .map(|s| s.to_string()); + + let input = SeedreamV5LiteEditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images, + max_images, + image_size, + enable_safety_checker: Some(false), + }; + + http_seedream_5_edit_image(input) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await + .map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueBytedanceSeedreamV5LiteEditImageArgs { + request: EnqueueBytedanceSeedreamV5LiteEditImageRequest { + prompt: "add the ghost to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![GHOST_IMAGE_URL.to_string(), TREX_SKELETON_IMAGE_URL.to_string()], + num_images: Some(EnqueueBytedanceSeedreamV5LiteEditImageNumImages::Two), + max_images: Some(EnqueueBytedanceSeedreamV5LiteEditImageMaxImages::Two), + image_size: Some(EnqueueBytedanceSeedreamV5LiteEditImageSize::Auto2k), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_bytedance_seedream_v5_lite_edit_image_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_1_dev_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_1_dev_edit_image_webhook.rs new file mode 100644 index 0000000000..4861a47d32 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_1_dev_edit_image_webhook.rs @@ -0,0 +1,110 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::edit::http_flux_1_dev_edit_image::{flux_1_dev_edit_image, Flux1DevEditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Flux1DevEditImageArgs<'a, U: IntoUrl> { + pub request: Flux1DevEditImageRequest, + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Flux1DevEditImageRequest { + pub prompt: String, + pub image_url: String, + pub num_images: Flux1DevEditImageNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevEditImageNumImages { + One, + Two, + Three, + Four, +} + +pub async fn enqueue_flux_1_dev_edit_image_webhook( + args: Flux1DevEditImageArgs<'_, U> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + Flux1DevEditImageNumImages::One => 1, + Flux1DevEditImageNumImages::Two => 2, + Flux1DevEditImageNumImages::Three => 3, + Flux1DevEditImageNumImages::Four => 4, + }; + + let request = Flux1DevEditImageInput { + prompt: req.prompt, + image_url: req.image_url, + num_images: Some(num_images), + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + ..Default::default() + }; + + let result = flux_1_dev_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::{ + enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs, Flux1DevEditImageNumImages, + Flux1DevEditImageRequest, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::GHOST_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_single_image() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1DevEditImageArgs { + request: Flux1DevEditImageRequest { + prompt: "make this image look like a watercolor painting".to_string(), + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::One, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_dev_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_batch_two() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1DevEditImageArgs { + request: Flux1DevEditImageRequest { + prompt: "turn this into a cyberpunk scene with neon lights".to_string(), + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1DevEditImageNumImages::Two, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_dev_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_1_schnell_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_1_schnell_edit_image_webhook.rs new file mode 100644 index 0000000000..d04d679f23 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_1_schnell_edit_image_webhook.rs @@ -0,0 +1,129 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::edit::http_flux_1_schnell_edit_image::{flux_1_schnell_edit_image, Flux1SchnellEditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Flux1SchnellEditImageArgs<'a, U: IntoUrl> { + pub request: Flux1SchnellEditImageRequest, + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Flux1SchnellEditImageRequest { + pub image_url: String, + pub num_images: Flux1SchnellEditImageNumImages, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellEditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellEditImageSize { + Square, + SquareHd, + LandscapeFourByThree, + LandscapeSixteenByNine, + PortraitThreeByFour, + PortraitNineBySixteen, +} + +pub async fn enqueue_flux_1_schnell_edit_image_webhook( + args: Flux1SchnellEditImageArgs<'_, U> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + Flux1SchnellEditImageNumImages::One => 1, + Flux1SchnellEditImageNumImages::Two => 2, + Flux1SchnellEditImageNumImages::Three => 3, + Flux1SchnellEditImageNumImages::Four => 4, + }; + + let image_size = req.image_size.map(|s| match s { + Flux1SchnellEditImageSize::Square => "square", + Flux1SchnellEditImageSize::SquareHd => "square_hd", + Flux1SchnellEditImageSize::LandscapeFourByThree => "landscape_4_3", + Flux1SchnellEditImageSize::LandscapeSixteenByNine => "landscape_16_9", + Flux1SchnellEditImageSize::PortraitThreeByFour => "portrait_4_3", + Flux1SchnellEditImageSize::PortraitNineBySixteen => "portrait_16_9", + }.to_string()); + + let request = Flux1SchnellEditImageInput { + image_url: req.image_url, + num_images: Some(num_images), + image_size, + enable_safety_checker: Some(false), + output_format: Some("png".to_string()), + ..Default::default() + }; + + let result = flux_1_schnell_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{ + enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs, + Flux1SchnellEditImageNumImages, Flux1SchnellEditImageRequest, Flux1SchnellEditImageSize, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::GHOST_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_single_image_no_size() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1SchnellEditImageArgs { + request: Flux1SchnellEditImageRequest { + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::One, + image_size: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_schnell_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real Fal API request + async fn test_with_landscape_size() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1SchnellEditImageArgs { + request: Flux1SchnellEditImageRequest { + image_url: GHOST_IMAGE_URL.to_string(), + num_images: Flux1SchnellEditImageNumImages::Two, + image_size: Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_schnell_edit_image_webhook(args).await?; + assert!(result.request_id.is_some()); + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_pro_kontext_max_edit_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_pro_kontext_max_edit_webhook.rs new file mode 100644 index 0000000000..9964098517 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_flux_pro_kontext_max_edit_webhook.rs @@ -0,0 +1,94 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::edit::http_flux_pro_kontext_max_edit::{flux_pro_kontext_max_edit, FluxProKontextMaxEditInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct FluxProKontextMaxArgs<'a, R: IntoUrl> { + pub request: FluxProKontextMaxRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct FluxProKontextMaxRequest { + pub prompt: String, + pub image_url: String, + pub num_images: FluxProKontextMaxNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum FluxProKontextMaxNumImages { + One, // Default + Two, + Three, + Four, +} + +pub async fn enqueue_flux_pro_kontext_max_edit_webhook( + args: FluxProKontextMaxArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + FluxProKontextMaxNumImages::One => 1, + FluxProKontextMaxNumImages::Two => 2, + FluxProKontextMaxNumImages::Three => 3, + FluxProKontextMaxNumImages::Four => 4, + }; + + let request = FluxProKontextMaxEditInput { + prompt: req.prompt, + image_url: req.image_url, + num_images: Some(num_images), + + // Maybe expose + aspect_ratio: None, + safety_tolerance: Some("5".to_string()), // NB: 5 is most tolerant + output_format: Some("png".to_string()), // png or jpeg + seed: None, + + // Constants + sync_mode: None, // Synchronous / slow + }; + + let result = flux_pro_kontext_max_edit(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_flux_pro_kontext_max_edit_webhook::{enqueue_flux_pro_kontext_max_edit_webhook, FluxProKontextMaxArgs, FluxProKontextMaxNumImages, FluxProKontextMaxRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TALL_MOCHI_WITH_GLASSES_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = FluxProKontextMaxArgs { + request: FluxProKontextMaxRequest { + prompt: "turn the glasses into sunglasses, make them sleek sunglasses with black rims, square shaped".to_string(), + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + num_images: FluxProKontextMaxNumImages::One, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_pro_kontext_max_edit_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gemini_25_flash_edit_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gemini_25_flash_edit_webhook.rs new file mode 100644 index 0000000000..848677c523 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gemini_25_flash_edit_webhook.rs @@ -0,0 +1,151 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_gemini_25_flash_edit::{gemini_25_flash_edit, Gemini25FlashEditInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Gemini25FlashEditArgs<'a, R: IntoUrl> { + pub request: Gemini25FlashEditRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Gemini25FlashEditRequest { + pub prompt: String, + pub image_urls: Vec, + pub num_images: Gemini25FlashEditNumImages, + + // Optional + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Gemini25FlashEditNumImages { + One, + Two, + Three, + Four, +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +/// Default is "auto" +#[derive(Copy, Clone, Debug)] +pub enum Gemini25FlashEditAspectRatio { + // Automatic (default) + Auto, + // Square + OneByOne, + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, // NB: No NineByTwentyOne ? +} + + +impl FalRequestCostCalculator for Gemini25FlashEditRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.039 per image. For $1.00, you can run this model 25 times. + match self.num_images { + Gemini25FlashEditNumImages::One => 4, + Gemini25FlashEditNumImages::Two => 8, + Gemini25FlashEditNumImages::Three => 12, + Gemini25FlashEditNumImages::Four => 16, + } + } +} + + +pub async fn enqueue_gemini_25_flash_edit_webhook( + args: Gemini25FlashEditArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + Gemini25FlashEditNumImages::One => 1, + Gemini25FlashEditNumImages::Two => 2, + Gemini25FlashEditNumImages::Three => 3, + Gemini25FlashEditNumImages::Four => 4, + }; + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + // Auto + Gemini25FlashEditAspectRatio::Auto => "auto", + // Square + Gemini25FlashEditAspectRatio::OneByOne => "1:1", + // Wide + Gemini25FlashEditAspectRatio::FiveByFour => "5:4", + Gemini25FlashEditAspectRatio::FourByThree => "4:3", + Gemini25FlashEditAspectRatio::ThreeByTwo => "3:2", + Gemini25FlashEditAspectRatio::SixteenByNine => "16:9", + Gemini25FlashEditAspectRatio::TwentyOneByNine => "21:9", + // Tall + Gemini25FlashEditAspectRatio::FourByFive => "4:5", + Gemini25FlashEditAspectRatio::ThreeByFour => "3:4", + Gemini25FlashEditAspectRatio::TwoByThree => "2:3", + Gemini25FlashEditAspectRatio::NineBySixteen => "9:16", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let request = Gemini25FlashEditInput { + prompt: req.prompt, + image_urls: req.image_urls, + aspect_ratio, + // Constants + num_images: Some(num_images), + output_format: Some("png".to_string()), + }; + + let result = gemini_25_flash_edit(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs, Gemini25FlashEditNumImages, Gemini25FlashEditRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Gemini25FlashEditArgs { + request: Gemini25FlashEditRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ], + num_images: Gemini25FlashEditNumImages::Two, + aspect_ratio: None, + prompt: "add the ghost to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_gemini_25_flash_edit_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1_byok_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1_byok_edit_image_webhook.rs new file mode 100644 index 0000000000..224bb738ea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1_byok_edit_image_webhook.rs @@ -0,0 +1,166 @@ +//! Deprecated BYOK (bring-your-own-OpenAI-key) edit-image binding for +//! `fal-ai/gpt-image-1/edit-image/byok`. The non-BYOK replacement lives in +//! `enqueue_gpt_image_1_edit_image_webhook.rs`. Kept for the legacy +//! `gpt_image_1_edit_image_handler` storyteller-web endpoint. + +use crate::creds::fal_api_key::FalApiKey; +use crate::creds::open_ai_api_key::OpenAiApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_gpt_image_1_byok_edit_image::{gpt_image_1_byok_edit_image, GptImage1ByokEditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct GptEditImageByokArgs<'a, V: IntoUrl> { + pub request: GptEditImageByokRequest, + + // Fulfillment + pub api_key: &'a FalApiKey, + pub openai_api_key: &'a OpenAiApiKey, + pub webhook_url: V, +} + +#[derive(Clone, Debug)] +pub struct GptEditImageByokRequest { + pub image_urls: Vec, + pub prompt: String, + pub image_size: GptEditImageSize, + pub num_images: GptEditImageNumImages, + pub quality: GptEditImageQuality, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptEditImageSize { + Auto, + Square, + Horizontal, + Vertical, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptEditImageQuality { + Auto, + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptEditImageNumImages{ + One, + Two, + Three, + Four, +} + + +// NB: These are BYOK, so they're not Fal's prices +impl FalRequestCostCalculator for GptEditImageByokRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let base_cost = match self.quality { + GptEditImageQuality::Auto => 17, + GptEditImageQuality::Low => 1, + GptEditImageQuality::Medium => 4, + GptEditImageQuality::High => 17, + }; + let cost = match self.num_images { + GptEditImageNumImages::One => base_cost, + GptEditImageNumImages::Two => base_cost * 2, + GptEditImageNumImages::Three => base_cost * 3, + GptEditImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_gpt_image_1_byok_edit_image_webhook( + args: GptEditImageByokArgs<'_, V> +) -> Result { + let req = args.request; + + let image_size = match req.image_size { + GptEditImageSize::Auto => "auto", + GptEditImageSize::Square => "1024x1024", + GptEditImageSize::Horizontal => "1536x1024", + GptEditImageSize::Vertical => "1024x1536", + }; + + let quality = match req.quality { + GptEditImageQuality::Auto => "auto", + GptEditImageQuality::Low => "low", + GptEditImageQuality::Medium => "medium", + GptEditImageQuality::High => "high", + }; + + let num_images = match req.num_images { + GptEditImageNumImages::One => 1, + GptEditImageNumImages::Two => 2, + GptEditImageNumImages::Three => 3, + GptEditImageNumImages::Four => 4, + }; + + let request = GptImage1ByokEditImageInput { + image_urls: req.image_urls, + prompt: req.prompt, + image_size: image_size.to_string(), + num_images, + quality: quality.to_string(), + openai_api_key: args.openai_api_key.0.to_string(), + }; + + let result = gpt_image_1_byok_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::creds::open_ai_api_key::OpenAiApiKey; + use crate::requests_old::webhook::image::edit::enqueue_gpt_image_1_byok_edit_image_webhook::{ + enqueue_gpt_image_1_byok_edit_image_webhook, GptEditImageByokArgs, GptEditImageByokRequest, + GptEditImageNumImages, GptEditImageQuality, GptEditImageSize, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ + ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL, + }; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let fal_secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let fal_api_key = FalApiKey::from_str(&fal_secret); + + let openai_secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?; + let openai_api_key = OpenAiApiKey::from_str(&openai_secret); + + let args = GptEditImageByokArgs { + request: GptEditImageByokRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_size: GptEditImageSize::Horizontal, + num_images: GptEditImageNumImages::One, + quality: GptEditImageQuality::High, + }, + api_key: &fal_api_key, + openai_api_key: &openai_api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_gpt_image_1_byok_edit_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1_edit_image_webhook.rs new file mode 100644 index 0000000000..87ce6fc1c1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1_edit_image_webhook.rs @@ -0,0 +1,240 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::core_api::webhook_response::WebhookResponse; +use crate::requests_old::http::image::edit::http_gpt_image_1_edit_image::{ + gpt_image_1_edit_image, GptImage1EditImageInput, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{ + FalRequestCostCalculator, UsdCents, +}; +use reqwest::IntoUrl; + +/// Typed args for the non-BYOK `fal-ai/gpt-image-1/edit-image` endpoint. +/// +/// (The BYOK variant lives in `enqueue_gpt_image_1_byok_edit_image_webhook.rs` +/// and is deprecated.) +pub struct EnqueueGptImage1EditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueGptImage1EditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueGptImage1EditImageRequest { + // Required + pub prompt: String, + pub image_urls: Vec, + pub num_images: EnqueueGptImage1EditImageNumImages, + + // Optional + pub mask_image_url: Option, + pub image_size: Option, + pub quality: Option, + pub input_fidelity: Option, + pub background: Option, + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1EditImageSize { + Auto, + /// 1024x1024 + Square, + /// 1536x1024 + Horizontal, + /// 1024x1536 + Vertical, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1EditImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1EditImageInputFidelity { + Low, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1EditImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1EditImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalRequestCostCalculator for EnqueueGptImage1EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Per fal docs (fal-ai/gpt-image-1/edit-image), pricing matches + // text-to-image (input image tokens are billed separately and not included + // in this base estimate): + // Low: $0.011 (1024x1024) / $0.016 (other) per image + // Medium: $0.042 (1024x1024) / $0.063 (other) per image + // High: $0.167 (1024x1024) / $0.25 (other) per image + let use_quality = self + .quality + .unwrap_or(EnqueueGptImage1EditImageQuality::Medium); + let is_square = matches!( + self.image_size, + None | Some(EnqueueGptImage1EditImageSize::Square) | Some(EnqueueGptImage1EditImageSize::Auto) + ); + let base_cost: u64 = match (use_quality, is_square) { + (EnqueueGptImage1EditImageQuality::Low, true) => 2, + (EnqueueGptImage1EditImageQuality::Low, false) => 2, + (EnqueueGptImage1EditImageQuality::Medium, true) => 5, + (EnqueueGptImage1EditImageQuality::Medium, false) => 7, + (EnqueueGptImage1EditImageQuality::High, true) => 17, + (EnqueueGptImage1EditImageQuality::High, false) => 25, + }; + let n: u64 = match self.num_images { + EnqueueGptImage1EditImageNumImages::One => 1, + EnqueueGptImage1EditImageNumImages::Two => 2, + EnqueueGptImage1EditImageNumImages::Three => 3, + EnqueueGptImage1EditImageNumImages::Four => 4, + }; + base_cost * n + } +} + +pub async fn enqueue_gpt_image_1_edit_image_webhook( + args: EnqueueGptImage1EditImageArgs<'_, R>, +) -> Result { + let req = args.request; + + let num_images: u8 = match req.num_images { + EnqueueGptImage1EditImageNumImages::One => 1, + EnqueueGptImage1EditImageNumImages::Two => 2, + EnqueueGptImage1EditImageNumImages::Three => 3, + EnqueueGptImage1EditImageNumImages::Four => 4, + }; + + let image_size = req.image_size.map(|s| { + match s { + EnqueueGptImage1EditImageSize::Auto => "auto", + EnqueueGptImage1EditImageSize::Square => "1024x1024", + EnqueueGptImage1EditImageSize::Horizontal => "1536x1024", + EnqueueGptImage1EditImageSize::Vertical => "1024x1536", + } + .to_string() + }); + + let quality = req.quality.map(|q| { + match q { + EnqueueGptImage1EditImageQuality::Low => "low", + EnqueueGptImage1EditImageQuality::Medium => "medium", + EnqueueGptImage1EditImageQuality::High => "high", + } + .to_string() + }); + + let input_fidelity = req.input_fidelity.map(|f| { + match f { + EnqueueGptImage1EditImageInputFidelity::Low => "low", + EnqueueGptImage1EditImageInputFidelity::High => "high", + } + .to_string() + }); + + let background = req.background.map(|b| { + match b { + EnqueueGptImage1EditImageBackground::Auto => "auto", + EnqueueGptImage1EditImageBackground::Transparent => "transparent", + EnqueueGptImage1EditImageBackground::Opaque => "opaque", + } + .to_string() + }); + + let output_format = req.output_format.map(|f| { + match f { + EnqueueGptImage1EditImageOutputFormat::Jpeg => "jpeg", + EnqueueGptImage1EditImageOutputFormat::Png => "png", + EnqueueGptImage1EditImageOutputFormat::Webp => "webp", + } + .to_string() + }); + + let request = GptImage1EditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + mask_image_url: req.mask_image_url, + num_images: Some(num_images), + image_size, + quality, + input_fidelity, + background, + output_format, + }; + + let result = gpt_image_1_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{ + enqueue_gpt_image_1_edit_image_webhook, EnqueueGptImage1EditImageArgs, + EnqueueGptImage1EditImageNumImages, EnqueueGptImage1EditImageQuality, + EnqueueGptImage1EditImageRequest, EnqueueGptImage1EditImageSize, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ + ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL, + }; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueGptImage1EditImageArgs { + request: EnqueueGptImage1EditImageRequest { + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + num_images: EnqueueGptImage1EditImageNumImages::One, + mask_image_url: None, + image_size: Some(EnqueueGptImage1EditImageSize::Horizontal), + quality: Some(EnqueueGptImage1EditImageQuality::Medium), + input_fidelity: None, + background: None, + output_format: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_gpt_image_1_edit_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1p5_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1p5_edit_image_webhook.rs new file mode 100644 index 0000000000..c0e62bef3a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_1p5_edit_image_webhook.rs @@ -0,0 +1,218 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_gpt_image_1p5_edit_image::{gpt_image_1p5_edit_image, GptImage1p5EditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueGptImage1p5EditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueGptImage1p5EditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueGptImage1p5EditImageRequest { + // Required + pub prompt: String, + pub image_urls: Vec, + pub num_images: EnqueueGptImage1p5EditImageNumImages, + + // Optional + pub mask_image_url: Option, + pub image_size: Option, + pub background: Option, + pub quality: Option, + pub input_fidelity: Option, + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5EditImageSize { + /// 1024x1024 + Square, + /// 1536x1024 + Wide, + /// 1024x1536 + Tall, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5EditImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5EditImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5EditImageInputFidelity { + Low, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5EditImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalRequestCostCalculator for EnqueueGptImage1p5EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost different amounts based on the number of images, quality, and size. + // For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image. + // For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image. + // For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image. + let use_quality = self.quality.unwrap_or(EnqueueGptImage1p5EditImageQuality::Medium); + let use_size = self.image_size.unwrap_or(EnqueueGptImage1p5EditImageSize::Square); + let base_cost = match (use_quality, use_size) { + (EnqueueGptImage1p5EditImageQuality::Low, EnqueueGptImage1p5EditImageSize::Square) => 1, + (EnqueueGptImage1p5EditImageQuality::Low, _) => 1, + (EnqueueGptImage1p5EditImageQuality::Medium, EnqueueGptImage1p5EditImageSize::Square) => 3, + (EnqueueGptImage1p5EditImageQuality::Medium, _) => 5, + (EnqueueGptImage1p5EditImageQuality::High, EnqueueGptImage1p5EditImageSize::Square) => 13, + (EnqueueGptImage1p5EditImageQuality::High, _) => 20, + }; + let cost = match self.num_images { + EnqueueGptImage1p5EditImageNumImages::One => base_cost, + EnqueueGptImage1p5EditImageNumImages::Two => base_cost * 2, + EnqueueGptImage1p5EditImageNumImages::Three => base_cost * 3, + EnqueueGptImage1p5EditImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_gpt_image_1p5_image_edit_webhook( + args: EnqueueGptImage1p5EditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + EnqueueGptImage1p5EditImageNumImages::One => 1, + EnqueueGptImage1p5EditImageNumImages::Two => 2, + EnqueueGptImage1p5EditImageNumImages::Three => 3, + EnqueueGptImage1p5EditImageNumImages::Four => 4, + }; + + let image_size = req.image_size + .map(|s| match s { + EnqueueGptImage1p5EditImageSize::Square => "1024x1024", + EnqueueGptImage1p5EditImageSize::Wide => "1536x1024", + EnqueueGptImage1p5EditImageSize::Tall => "1024x1536", + }) + .map(|resolution| resolution.to_string()); + + let background = req.background + .map(|s| match s { + EnqueueGptImage1p5EditImageBackground::Auto => "auto", + EnqueueGptImage1p5EditImageBackground::Transparent => "transparent", + EnqueueGptImage1p5EditImageBackground::Opaque => "opaque", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let quality = req.quality + .map(|s| match s { + EnqueueGptImage1p5EditImageQuality::Low => "low", + EnqueueGptImage1p5EditImageQuality::Medium => "medium", + EnqueueGptImage1p5EditImageQuality::High => "high", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let input_fidelity = req.input_fidelity + .map(|s| match s { + EnqueueGptImage1p5EditImageInputFidelity::Low => "low", + EnqueueGptImage1p5EditImageInputFidelity::High => "high", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let output_format = req.output_format + .map(|s| match s { + EnqueueGptImage1p5EditImageOutputFormat::Jpeg => "jpeg", + EnqueueGptImage1p5EditImageOutputFormat::Png => "png", + EnqueueGptImage1p5EditImageOutputFormat::Webp => "webp", + }) + .map(|aspect_ratio| aspect_ratio.to_string()) + .unwrap_or_else(|| "png".to_string()); + + let request = GptImage1p5EditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images: Some(num_images), + output_format: Some(output_format), + // Optionals + mask_image_url: req.mask_image_url, + image_size, + background, + quality, + input_fidelity, + }; + + let result = gpt_image_1p5_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_gpt_image_1p5_edit_image_webhook::{ + enqueue_gpt_image_1p5_image_edit_webhook, EnqueueGptImage1p5EditImageArgs, + EnqueueGptImage1p5EditImageNumImages, EnqueueGptImage1p5EditImageRequest, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueGptImage1p5EditImageArgs { + request: EnqueueGptImage1p5EditImageRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + num_images: EnqueueGptImage1p5EditImageNumImages::Two, + mask_image_url: None, + image_size: None, + background: None, + quality: None, + input_fidelity: None, + output_format: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_gpt_image_1p5_image_edit_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_2_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_2_edit_image_webhook.rs new file mode 100644 index 0000000000..c6642cee8d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_gpt_image_2_edit_image_webhook.rs @@ -0,0 +1,205 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_gpt_image_2_edit_image::{gpt_image_2_edit_image, GptImage2EditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; +use crate::requests_old::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::EnqueueGptImage2EditImageQuality::{High, Medium}; +use crate::requests_old::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::EnqueueGptImage2EditImageSize::SquareHd; + +pub struct EnqueueGptImage2EditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueGptImage2EditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueGptImage2EditImageRequest { + // Required + pub prompt: String, + pub image_urls: Vec, + pub num_images: EnqueueGptImage2EditImageNumImages, + + // Optional + pub mask_url: Option, + pub image_size: Option, + pub quality: Option, + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2EditImageSize { + SquareHd, + Square, + Portrait4x3, + Portrait16x9, + Landscape4x3, + Landscape16x9, + Auto, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2EditImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2EditImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalRequestCostCalculator for EnqueueGptImage2EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Cost table (per image) by approximate pixel dimensions: + // + // landscape_4_3 (~1024x768): low=$0.01, medium=$0.04, high=$0.15 + // square (~1024x1024): low=$0.01, medium=$0.06, high=$0.22 + // portrait_4_3 (~768x1024): low=$0.01, medium=$0.04, high=$0.15 + // landscape_16_9(~1920x1080): low=$0.01, medium=$0.04, high=$0.16 + // portrait_16_9 (~1080x1920): low=$0.01, medium=$0.04, high=$0.16 + // square_hd (~2048x2048): low=$0.01, medium=$0.06, high=$0.23 + // auto (varies): estimated as square + let use_quality = self.quality.unwrap_or(EnqueueGptImage2EditImageQuality::High); + let use_size = self.image_size.unwrap_or(EnqueueGptImage2EditImageSize::Square); + + use EnqueueGptImage2EditImageQuality::*; + use EnqueueGptImage2EditImageSize::*; + + let base_cost = match (use_quality, use_size) { + (Low, _) => 1, + (Medium, Landscape4x3 | Portrait4x3 | Landscape16x9 | Portrait16x9) => 4, + (Medium, Square) => 6, + (Medium, SquareHd) => 6, + (Medium, Auto) => 6, // TODO(bt): Unknown + (High, Landscape4x3 | Portrait4x3) => 15, + (High, Landscape16x9 | Portrait16x9) => 16, + (High, Square) => 22, + (High, SquareHd) => 23, + (High, Auto) => 23, // TODO(bt): Unknown + }; + + let cost = match self.num_images { + EnqueueGptImage2EditImageNumImages::One => base_cost, + EnqueueGptImage2EditImageNumImages::Two => base_cost * 2, + EnqueueGptImage2EditImageNumImages::Three => base_cost * 3, + EnqueueGptImage2EditImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_gpt_image_2_edit_image_webhook( + args: EnqueueGptImage2EditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + EnqueueGptImage2EditImageNumImages::One => 1, + EnqueueGptImage2EditImageNumImages::Two => 2, + EnqueueGptImage2EditImageNumImages::Three => 3, + EnqueueGptImage2EditImageNumImages::Four => 4, + }; + + let image_size = req.image_size + .map(|s| match s { + EnqueueGptImage2EditImageSize::SquareHd => "square_hd", + EnqueueGptImage2EditImageSize::Square => "square", + EnqueueGptImage2EditImageSize::Portrait4x3 => "portrait_4_3", + EnqueueGptImage2EditImageSize::Portrait16x9 => "portrait_16_9", + EnqueueGptImage2EditImageSize::Landscape4x3 => "landscape_4_3", + EnqueueGptImage2EditImageSize::Landscape16x9 => "landscape_16_9", + EnqueueGptImage2EditImageSize::Auto => "auto", + }) + .map(|size| size.to_string()); + + let quality = req.quality + .map(|s| match s { + EnqueueGptImage2EditImageQuality::Low => "low", + EnqueueGptImage2EditImageQuality::Medium => "medium", + EnqueueGptImage2EditImageQuality::High => "high", + }) + .map(|quality| quality.to_string()); + + let output_format = req.output_format + .map(|s| match s { + EnqueueGptImage2EditImageOutputFormat::Jpeg => "jpeg", + EnqueueGptImage2EditImageOutputFormat::Png => "png", + EnqueueGptImage2EditImageOutputFormat::Webp => "webp", + }) + .map(|format| format.to_string()) + .unwrap_or_else(|| "png".to_string()); + + let request = GptImage2EditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images: Some(num_images), + output_format: Some(output_format), + // Optionals + mask_url: req.mask_url, + image_size, + quality, + }; + + let result = gpt_image_2_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::{ + enqueue_gpt_image_2_edit_image_webhook, EnqueueGptImage2EditImageArgs, + EnqueueGptImage2EditImageNumImages, EnqueueGptImage2EditImageRequest, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueGptImage2EditImageArgs { + request: EnqueueGptImage2EditImageRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + num_images: EnqueueGptImage2EditImageNumImages::Two, + mask_url: None, + image_size: None, + quality: None, + output_format: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_gpt_image_2_edit_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_nano_banana_2_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_nano_banana_2_edit_image_webhook.rs new file mode 100644 index 0000000000..9777f4c56a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_nano_banana_2_edit_image_webhook.rs @@ -0,0 +1,218 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::edit::http_nano_banana_2_edit_image::{nano_banana_2_edit_image, NanoBanana2EditImageInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueNanoBanana2EditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueNanoBanana2EditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueNanoBanana2EditImageRequest { + // Required + pub prompt: String, + pub image_urls: Vec, + pub num_images: EnqueueNanoBanana2EditImageNumImages, + + // Optional + pub resolution: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBanana2EditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBanana2EditImageResolution { + HalfK, // "0.5K" + OneK, // "1K" (default) + TwoK, // "2K" + FourK, // "4K" +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +/// Default: "auto" +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBanana2EditImageAspectRatio { + // Automatic (default) + Auto, + // Square + OneByOne, + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + +impl FalRequestCostCalculator for EnqueueNanoBanana2EditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // TODO(bt): Verify actual pricing for Nano Banana 2 on fal.ai. + // 4K outputs may be charged at double the standard rate. + let cost = match self.resolution { + None => 15, + Some(EnqueueNanoBanana2EditImageResolution::HalfK) => 8, + Some(EnqueueNanoBanana2EditImageResolution::OneK) => 15, + Some(EnqueueNanoBanana2EditImageResolution::TwoK) => 15, + Some(EnqueueNanoBanana2EditImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + EnqueueNanoBanana2EditImageNumImages::One => cost, + EnqueueNanoBanana2EditImageNumImages::Two => cost * 2, + EnqueueNanoBanana2EditImageNumImages::Three => cost * 3, + EnqueueNanoBanana2EditImageNumImages::Four => cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_nano_banana_2_edit_image_webhook( + args: EnqueueNanoBanana2EditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + EnqueueNanoBanana2EditImageNumImages::One => 1, + EnqueueNanoBanana2EditImageNumImages::Two => 2, + EnqueueNanoBanana2EditImageNumImages::Three => 3, + EnqueueNanoBanana2EditImageNumImages::Four => 4, + }; + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueNanoBanana2EditImageResolution::HalfK => "0.5K", + EnqueueNanoBanana2EditImageResolution::OneK => "1K", + EnqueueNanoBanana2EditImageResolution::TwoK => "2K", + EnqueueNanoBanana2EditImageResolution::FourK => "4K", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + // Auto + EnqueueNanoBanana2EditImageAspectRatio::Auto => "auto", + // Square + EnqueueNanoBanana2EditImageAspectRatio::OneByOne => "1:1", + // Wide + EnqueueNanoBanana2EditImageAspectRatio::FiveByFour => "5:4", + EnqueueNanoBanana2EditImageAspectRatio::FourByThree => "4:3", + EnqueueNanoBanana2EditImageAspectRatio::ThreeByTwo => "3:2", + EnqueueNanoBanana2EditImageAspectRatio::SixteenByNine => "16:9", + EnqueueNanoBanana2EditImageAspectRatio::TwentyOneByNine => "21:9", + // Tall + EnqueueNanoBanana2EditImageAspectRatio::FourByFive => "4:5", + EnqueueNanoBanana2EditImageAspectRatio::ThreeByFour => "3:4", + EnqueueNanoBanana2EditImageAspectRatio::TwoByThree => "2:3", + EnqueueNanoBanana2EditImageAspectRatio::NineBySixteen => "9:16", + }) + .map(|ar| ar.to_string()); + + let request = NanoBanana2EditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images: Some(num_images), + // Optionals + aspect_ratio, + resolution, + // Constants + output_format: Some("png".to_string()), + safety_tolerance: Some("6".to_string()), + limit_generations: None, + enable_web_search: None, + seed: None, + }; + + let result = nano_banana_2_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_nano_banana_2_edit_image_webhook::{ + enqueue_nano_banana_2_edit_image_webhook, EnqueueNanoBanana2EditImageArgs, + EnqueueNanoBanana2EditImageAspectRatio, EnqueueNanoBanana2EditImageNumImages, + EnqueueNanoBanana2EditImageRequest, EnqueueNanoBanana2EditImageResolution, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueNanoBanana2EditImageArgs { + request: EnqueueNanoBanana2EditImageRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + num_images: EnqueueNanoBanana2EditImageNumImages::Two, + aspect_ratio: Some(EnqueueNanoBanana2EditImageAspectRatio::SixteenByNine), + resolution: Some(EnqueueNanoBanana2EditImageResolution::TwoK), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_nano_banana_2_edit_image_webhook(args).await?; + + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_2() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueNanoBanana2EditImageArgs { + request: EnqueueNanoBanana2EditImageRequest { + image_urls: vec![ + WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "Put the scared man and the t-rex in front of the white house scene. Make the man afraid of the t-rex.".to_string(), + num_images: EnqueueNanoBanana2EditImageNumImages::Two, + aspect_ratio: Some(EnqueueNanoBanana2EditImageAspectRatio::SixteenByNine), + resolution: Some(EnqueueNanoBanana2EditImageResolution::TwoK), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_nano_banana_2_edit_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_nano_banana_pro_edit_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_nano_banana_pro_edit_image_webhook.rs new file mode 100644 index 0000000000..3dcb3410b2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_nano_banana_pro_edit_image_webhook.rs @@ -0,0 +1,181 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_nano_banana_pro_edit_image::{nano_banana_pro_edit_image, NanoBananaProEditImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueNanoBananaProEditImageArgs<'a, R: IntoUrl> { + pub request: EnqueueNanoBananaProEditImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueNanoBananaProEditImageRequest { + pub prompt: String, + pub image_urls: Vec, + pub num_images: EnqueueNanoBananaProEditImageNumImages, + + // Optional args + pub resolution: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBananaProEditImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBananaProEditImageResolution { + OneK, // Default value "1K" + TwoK, + FourK, +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +/// Default is "auto" +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBananaProEditImageAspectRatio { + // Automatic (default) + Auto, + // Square + OneByOne, + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, // NB: No NineByTwentyOne ? +} + + +impl FalRequestCostCalculator for EnqueueNanoBananaProEditImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.15 per image. + // For $1.00, you can run this model 7 times. + // 4K outputs will be charged at double the standard rate. + // If web search is used, an additional $0.015 will be charged. + // Note: Pricing may change in the future. + let cost = match self.resolution { + None => 15, + Some(EnqueueNanoBananaProEditImageResolution::OneK) => 15, + Some(EnqueueNanoBananaProEditImageResolution::TwoK) => 15, + Some(EnqueueNanoBananaProEditImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + EnqueueNanoBananaProEditImageNumImages::One => cost, + EnqueueNanoBananaProEditImageNumImages::Two => cost * 2, + EnqueueNanoBananaProEditImageNumImages::Three => cost * 3, + EnqueueNanoBananaProEditImageNumImages::Four => cost * 4, + }; + cost as UsdCents + } +} + +pub async fn enqueue_nano_banana_pro_image_edit_webhook( + args: EnqueueNanoBananaProEditImageArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = match req.num_images { + EnqueueNanoBananaProEditImageNumImages::One => 1, + EnqueueNanoBananaProEditImageNumImages::Two => 2, + EnqueueNanoBananaProEditImageNumImages::Three => 3, + EnqueueNanoBananaProEditImageNumImages::Four => 4, + }; + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueNanoBananaProEditImageResolution::OneK => "1K", + EnqueueNanoBananaProEditImageResolution::TwoK => "2K", + EnqueueNanoBananaProEditImageResolution::FourK => "4K", + }) + .map(|resolution| resolution.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + // Auto + EnqueueNanoBananaProEditImageAspectRatio::Auto => "auto", + // Square + EnqueueNanoBananaProEditImageAspectRatio::OneByOne => "1:1", + // Wide + EnqueueNanoBananaProEditImageAspectRatio::FiveByFour => "5:4", + EnqueueNanoBananaProEditImageAspectRatio::FourByThree => "4:3", + EnqueueNanoBananaProEditImageAspectRatio::ThreeByTwo => "3:2", + EnqueueNanoBananaProEditImageAspectRatio::SixteenByNine => "16:9", + EnqueueNanoBananaProEditImageAspectRatio::TwentyOneByNine => "21:9", + // Tall + EnqueueNanoBananaProEditImageAspectRatio::FourByFive => "4:5", + EnqueueNanoBananaProEditImageAspectRatio::ThreeByFour => "3:4", + EnqueueNanoBananaProEditImageAspectRatio::TwoByThree => "2:3", + EnqueueNanoBananaProEditImageAspectRatio::NineBySixteen => "9:16", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let request = NanoBananaProEditImageInput { + prompt: req.prompt, + image_urls: req.image_urls, + num_images: Some(num_images), + // Optionals + aspect_ratio, + resolution, + // Constants + output_format: Some("png".to_string()), + }; + + let result = nano_banana_pro_edit_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{enqueue_nano_banana_pro_image_edit_webhook, EnqueueNanoBananaProEditImageArgs, EnqueueNanoBananaProEditImageAspectRatio, EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageRequest, EnqueueNanoBananaProEditImageResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueNanoBananaProEditImageArgs { + request: EnqueueNanoBananaProEditImageRequest { + image_urls: vec![ + GHOST_IMAGE_URL.to_string(), + TREX_SKELETON_IMAGE_URL.to_string(), + ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + ], + prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(), + num_images: EnqueueNanoBananaProEditImageNumImages::Two, + aspect_ratio: Some(EnqueueNanoBananaProEditImageAspectRatio::SixteenByNine), + resolution: Some(EnqueueNanoBananaProEditImageResolution::TwoK), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_nano_banana_pro_image_edit_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_qwen_image_edit_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_qwen_image_edit_webhook.rs new file mode 100644 index 0000000000..0c699fa015 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_qwen_image_edit_webhook.rs @@ -0,0 +1,152 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::edit::http_qwen_image_edit::{qwen_image_edit, QwenImageEditInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct QwenImageEditArgs<'a, R: IntoUrl> { + pub request: QwenImageEditRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct QwenImageEditRequest { + pub prompt: String, + pub image_url: String, + + // Request optional + pub num_images: Option, + pub image_size: Option, + + pub negative_prompt: Option, + + /// Acceleration level for image generation. + /// Options: 'none', 'regular'. Higher acceleration increases speed. + /// 'regular' balances speed and quality. Default value: "none" + pub acceleration: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum QwenImageEditNumImages { + One, // Default + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum QwenImageEditSize { + Square, // 1:1 + SquareHd, // 1:1 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + + +impl FalRequestCostCalculator for QwenImageEditRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.03 per megapixel. -- Ugh, not calculating that + let base_cost = 3; + let cost = match self.num_images { + None => base_cost, + Some(QwenImageEditNumImages::One) => base_cost, + Some(QwenImageEditNumImages::Two) => base_cost * 2, + Some(QwenImageEditNumImages::Three) => base_cost * 3, + Some(QwenImageEditNumImages::Four) => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_qwen_image_edit_webhook( + args: QwenImageEditArgs<'_, R> +) -> Result { + let req = args.request; + + let num_images = req.num_images + .map(|num| match num { + QwenImageEditNumImages::One => 1, + QwenImageEditNumImages::Two => 2, + QwenImageEditNumImages::Three => 3, + QwenImageEditNumImages::Four => 4, + }); + + let image_size = req.image_size + .map(|size| match size { + QwenImageEditSize::Square => "square", + QwenImageEditSize::SquareHd => "square_hd", + QwenImageEditSize::LandscapeFourByThree => "landscape_4_3", + QwenImageEditSize::LandscapeSixteenByNine => "landscape_16_9", + QwenImageEditSize::PortraitThreeByFour => "portrait_4_3", // NB: I think they made a mistake + QwenImageEditSize::PortraitNineBySixteen => "portrait_16_9", // NB: I think they made a mistake + }) + .map(|size| size.to_string()); + + let request = QwenImageEditInput { + prompt: req.prompt, + image_url: req.image_url, + image_size, + num_images, + + // Maybe expose + output_format: Some("png".to_string()), // png or jpeg + negative_prompt: None, + seed: None, + + // Constants + //sync_mode: None, // Synchronous / slow + num_inference_steps: None, + guidance_scale: None, + enable_safety_checker: None, + acceleration: None, + }; + + let result = qwen_image_edit(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_qwen_image_edit_webhook::{enqueue_qwen_image_edit_webhook, QwenImageEditArgs, QwenImageEditNumImages, QwenImageEditRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = QwenImageEditArgs { + request: QwenImageEditRequest { + prompt: "put christmas lights on the tree, add snow to the mountains".to_string(), + image_url: MOUNTAIN_TREE_IMAGE_URL.to_string(), + num_images: Some(QwenImageEditNumImages::One), + image_size: None, + negative_prompt: None, + acceleration: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_qwen_image_edit_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_seededit_v3_edit_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_seededit_v3_edit_webhook.rs new file mode 100644 index 0000000000..159a95badb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/enqueue_seededit_v3_edit_webhook.rs @@ -0,0 +1,71 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::edit::http_seededit_v3_edit::{seededit_v3_edit, SeedEditV3EditInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct SeedEditV3EditArgs<'a, R: IntoUrl> { + pub request: SeedEditV3EditRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct SeedEditV3EditRequest { + pub prompt: String, + pub image_url: String, +} + +pub async fn enqueue_seededit_v3_edit_webhook( + args: SeedEditV3EditArgs<'_, R> +) -> Result { + let req = args.request; + + let request = SeedEditV3EditInput { + prompt: req.prompt, + image_url: req.image_url, + + // Constants + guidance_scale: None, + enable_safety_checker: None, + }; + + let result = seededit_v3_edit(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::edit::enqueue_seededit_v3_edit_webhook::{enqueue_seededit_v3_edit_webhook, SeedEditV3EditArgs, SeedEditV3EditRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = SeedEditV3EditArgs { + request: SeedEditV3EditRequest { + prompt: "put christmas lights on the tree, add snow to the mountains".to_string(), + image_url: MOUNTAIN_TREE_IMAGE_URL.to_string(), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_seededit_v3_edit_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/mod.rs new file mode 100644 index 0000000000..34016527f9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/edit/mod.rs @@ -0,0 +1,15 @@ +pub mod enqueue_flux_1_dev_edit_image_webhook; +pub mod enqueue_flux_1_schnell_edit_image_webhook; +pub mod enqueue_bytedance_seedream_v4_edit_image_webhook; +pub mod enqueue_bytedance_seedream_v4p5_edit_image_webhook; +pub mod enqueue_bytedance_seedream_v5_lite_edit_image_webhook; +pub mod enqueue_nano_banana_2_edit_image_webhook; +pub mod enqueue_flux_pro_kontext_max_edit_webhook; +pub mod enqueue_gemini_25_flash_edit_webhook; +pub mod enqueue_gpt_image_1_byok_edit_image_webhook; +pub mod enqueue_gpt_image_1_edit_image_webhook; +pub mod enqueue_gpt_image_1p5_edit_image_webhook; +pub mod enqueue_gpt_image_2_edit_image_webhook; +pub mod enqueue_nano_banana_pro_edit_image_webhook; +pub mod enqueue_qwen_image_edit_webhook; +pub mod enqueue_seededit_v3_edit_webhook; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/enqueue_flux_dev_juggernaut_infill_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/enqueue_flux_dev_juggernaut_infill_webhook.rs new file mode 100644 index 0000000000..f0a80140e6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/enqueue_flux_dev_juggernaut_infill_webhook.rs @@ -0,0 +1,101 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::infill::http_flux_dev_juggernaut_infill::{flux_dev_juggernaut_infill, FluxDevJuggernautInfillInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct FluxDevJuggernautInfillArgs<'a, R: IntoUrl> { + pub request: FluxDevJuggernautInfillRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct FluxDevJuggernautInfillRequest { + pub prompt: String, + pub image_url: String, + pub mask_url: String, + pub num_images: FluxDevJuggernautInfillNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum FluxDevJuggernautInfillNumImages { + One, // Default + Two, + Three, + Four, +} + +pub async fn enqueue_flux_dev_juggernaut_infill_webhook( + args: FluxDevJuggernautInfillArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + FluxDevJuggernautInfillNumImages::One => 1, + FluxDevJuggernautInfillNumImages::Two => 2, + FluxDevJuggernautInfillNumImages::Three => 3, + FluxDevJuggernautInfillNumImages::Four => 4, + }; + + let request = FluxDevJuggernautInfillInput { + prompt: req.prompt, + image_url: req.image_url, + mask_url: req.mask_url, + num_images: Some(num_images), + + // Maybe expose + output_format: Some("png".to_string()), // png or jpeg + seed: None, + strength: None, + num_inference_steps: None, + image_size: None, + enable_safety_checker: None, + guidance_scale: None, + + // Constants + sync_mode: None, // Synchronous / slow + }; + + let result = flux_dev_juggernaut_infill(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::infill::enqueue_flux_dev_juggernaut_infill_webhook::{enqueue_flux_dev_juggernaut_infill_webhook, FluxDevJuggernautInfillArgs, FluxDevJuggernautInfillRequest, FluxDevJuggernautInfillNumImages}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = FluxDevJuggernautInfillArgs { + request: FluxDevJuggernautInfillRequest { + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + mask_url: TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL.to_string(), + prompt: "slick sunglasses, cool glasses, reflection in glasses lenses".to_string(), + num_images: FluxDevJuggernautInfillNumImages::Two, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_dev_juggernaut_infill_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/enqueue_flux_pro_1_infill_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/enqueue_flux_pro_1_infill_webhook.rs new file mode 100644 index 0000000000..e05fd37080 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/enqueue_flux_pro_1_infill_webhook.rs @@ -0,0 +1,97 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::infill::http_flux_pro_1_infill::{flux_pro_1_infill, FluxPro1InfillInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct FluxPro1InfillArgs<'a, R: IntoUrl> { + pub request: FluxPro1InfillRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct FluxPro1InfillRequest { + pub prompt: String, + pub image_url: String, + pub mask_url: String, + pub num_images: FluxPro1InfillNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum FluxPro1InfillNumImages { + One, // Default + Two, + Three, + Four, +} + +pub async fn enqueue_flux_pro_1_infill_webhook( + args: FluxPro1InfillArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + FluxPro1InfillNumImages::One => 1, + FluxPro1InfillNumImages::Two => 2, + FluxPro1InfillNumImages::Three => 3, + FluxPro1InfillNumImages::Four => 4, + }; + + let request = FluxPro1InfillInput { + prompt: req.prompt, + image_url: req.image_url, + mask_url: req.mask_url, + num_images: Some(num_images), + + // Maybe expose + safety_tolerance: Some("5".to_string()), // NB: 5 is most tolerant + output_format: Some("png".to_string()), // png or jpeg + seed: None, + + // Constants + sync_mode: None, // Synchronous / slow + }; + + let result = flux_pro_1_infill(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::infill::enqueue_flux_pro_1_infill_webhook::{enqueue_flux_pro_1_infill_webhook, FluxPro1InfillArgs, FluxPro1InfillRequest, FluxPro1InfillNumImages}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = FluxPro1InfillArgs { + request: FluxPro1InfillRequest { + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + mask_url: TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL.to_string(), + prompt: "slick sunglasses, cool glasses, reflection in glasses lenses".to_string(), + num_images: FluxPro1InfillNumImages::One, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_pro_1_infill_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/mod.rs new file mode 100644 index 0000000000..9bf0945eee --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/infill/mod.rs @@ -0,0 +1,2 @@ +pub mod enqueue_flux_dev_juggernaut_infill_webhook; +pub mod enqueue_flux_pro_1_infill_webhook; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/mod.rs new file mode 100644 index 0000000000..50c1d0b2fc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/mod.rs @@ -0,0 +1,5 @@ +pub mod angle; +pub mod background; +pub mod edit; +pub mod infill; +pub mod text; diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v4_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v4_text_to_image_webhook.rs new file mode 100644 index 0000000000..d37f8cb809 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v4_text_to_image_webhook.rs @@ -0,0 +1,162 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::text::http_seedream_4_text_to_image::{seedream_4_text_to_image, SeedreamV4TextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueBytedanceSeedreamV4TextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueBytedanceSeedreamV4TextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueBytedanceSeedreamV4TextToImageRequest { + // Request required + pub prompt: String, + + // Optional args + pub num_images: Option, + pub max_images: Option, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4TextToImageMaxImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4TextToImageSize { + // Square + Square, + SquareHd, + // Tall + PortraitFourThree, + PortraitSixteenNine, + // Wide + LandscapeFourThree, + LandscapeSixteenNine, + // Auto + Auto, + Auto2k, + Auto4k, +} + + +impl FalRequestCostCalculator for EnqueueBytedanceSeedreamV4TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Copied from edit image version + // Your request will cost $0.03 per image. + let unit_cost = 3; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::One) => unit_cost, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two) => unit_cost * 2, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Three) => unit_cost * 3, + Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_bytedance_seedream_v4_text_to_image_webhook( + args: EnqueueBytedanceSeedreamV4TextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = req.num_images + .map(|num_images| match num_images { + EnqueueBytedanceSeedreamV4TextToImageNumImages::One => 1, + EnqueueBytedanceSeedreamV4TextToImageNumImages::Two => 2, + EnqueueBytedanceSeedreamV4TextToImageNumImages::Three => 3, + EnqueueBytedanceSeedreamV4TextToImageNumImages::Four => 4, + }); + + let max_images = req.max_images + .map(|num_images| match num_images { + EnqueueBytedanceSeedreamV4TextToImageMaxImages::One => 1, + EnqueueBytedanceSeedreamV4TextToImageMaxImages::Two => 2, + EnqueueBytedanceSeedreamV4TextToImageMaxImages::Three => 3, + EnqueueBytedanceSeedreamV4TextToImageMaxImages::Four => 4, + }); + + let image_size = req.image_size + .map(|image_size| match image_size { + EnqueueBytedanceSeedreamV4TextToImageSize::Square => "square", + EnqueueBytedanceSeedreamV4TextToImageSize::SquareHd => "square_hd", + EnqueueBytedanceSeedreamV4TextToImageSize::PortraitFourThree => "portrait_4_3", + EnqueueBytedanceSeedreamV4TextToImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueBytedanceSeedreamV4TextToImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueBytedanceSeedreamV4TextToImageSize::LandscapeSixteenNine => "landscape_16_9", + EnqueueBytedanceSeedreamV4TextToImageSize::Auto => "auto", + EnqueueBytedanceSeedreamV4TextToImageSize::Auto2k => "auto_2K", + EnqueueBytedanceSeedreamV4TextToImageSize::Auto4k => "auto_4K", + }) + .map(|resolution| resolution.to_string()); + + let request = SeedreamV4TextToImageInput { + prompt: req.prompt, + // Optionals + num_images, + max_images, + image_size, + // Constants + enhance_prompt_mode: Some("standard".to_string()), + enable_safety_checker: Some(false), + }; + + let result = seedream_4_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::{enqueue_bytedance_seedream_v4_text_to_image_webhook, EnqueueBytedanceSeedreamV4TextToImageArgs, EnqueueBytedanceSeedreamV4TextToImageMaxImages, EnqueueBytedanceSeedreamV4TextToImageNumImages, EnqueueBytedanceSeedreamV4TextToImageRequest, EnqueueBytedanceSeedreamV4TextToImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueBytedanceSeedreamV4TextToImageArgs { + request: EnqueueBytedanceSeedreamV4TextToImageRequest { + prompt: "an anime girl is riding a t-rex in the forest".to_string(), + num_images: Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two), + max_images: Some(EnqueueBytedanceSeedreamV4TextToImageMaxImages::Two), + image_size: Some(EnqueueBytedanceSeedreamV4TextToImageSize::LandscapeSixteenNine), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_bytedance_seedream_v4_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v4p5_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v4p5_text_to_image_webhook.rs new file mode 100644 index 0000000000..67a1271a7b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v4p5_text_to_image_webhook.rs @@ -0,0 +1,161 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_seedream_4p5_text_to_image::{seedream_4p5_text_to_image, SeedreamV4p5TextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::{EnqueueBytedanceSeedreamV4p5EditImageArgs, EnqueueBytedanceSeedreamV4p5EditImageNumImages}; + +pub struct EnqueueBytedanceSeedreamV4p5TextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueBytedanceSeedreamV4p5TextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueBytedanceSeedreamV4p5TextToImageRequest { + // Request required + pub prompt: String, + + // Optional args + pub num_images: Option, + pub max_images: Option, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4p5TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4p5TextToImageMaxImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV4p5TextToImageSize { + // Square + Square, + SquareHd, + // Tall + PortraitFourThree, + PortraitSixteenNine, + // Wide + LandscapeFourThree, + LandscapeSixteenNine, + // Auto + Auto2k, + Auto4k, +} + + +impl FalRequestCostCalculator for EnqueueBytedanceSeedreamV4p5TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // NB: Copied from edit image costs + // Your request will cost $0.04 per image. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One) => unit_cost, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two) => unit_cost * 2, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Three) => unit_cost * 3, + Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_bytedance_seedream_v4p5_text_to_image_webhook( + args: EnqueueBytedanceSeedreamV4p5TextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = req.num_images + .map(|num_images| match num_images { + EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One => 1, + EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two => 2, + EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Three => 3, + EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Four => 4, + }); + + let max_images = req.max_images + .map(|num_images| match num_images { + EnqueueBytedanceSeedreamV4p5TextToImageMaxImages::One => 1, + EnqueueBytedanceSeedreamV4p5TextToImageMaxImages::Two => 2, + EnqueueBytedanceSeedreamV4p5TextToImageMaxImages::Three => 3, + EnqueueBytedanceSeedreamV4p5TextToImageMaxImages::Four => 4, + }); + + let image_size = req.image_size + .map(|image_size| match image_size { + EnqueueBytedanceSeedreamV4p5TextToImageSize::Square => "square", + EnqueueBytedanceSeedreamV4p5TextToImageSize::SquareHd => "square_hd", + EnqueueBytedanceSeedreamV4p5TextToImageSize::PortraitFourThree => "portrait_4_3", + EnqueueBytedanceSeedreamV4p5TextToImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueBytedanceSeedreamV4p5TextToImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueBytedanceSeedreamV4p5TextToImageSize::LandscapeSixteenNine => "landscape_16_9", + EnqueueBytedanceSeedreamV4p5TextToImageSize::Auto2k => "auto_2K", + EnqueueBytedanceSeedreamV4p5TextToImageSize::Auto4k => "auto_4K", + }) + .map(|resolution| resolution.to_string()); + + let request = SeedreamV4p5TextToImageInput { + prompt: req.prompt, + // Optionals + num_images, + max_images, + image_size, + // Constants + enable_safety_checker: Some(false), + seed: None, + }; + + let result = seedream_4p5_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::{enqueue_bytedance_seedream_v4p5_text_to_image_webhook, EnqueueBytedanceSeedreamV4p5TextToImageArgs, EnqueueBytedanceSeedreamV4p5TextToImageMaxImages, EnqueueBytedanceSeedreamV4p5TextToImageNumImages, EnqueueBytedanceSeedreamV4p5TextToImageRequest, EnqueueBytedanceSeedreamV4p5TextToImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueBytedanceSeedreamV4p5TextToImageArgs { + request: EnqueueBytedanceSeedreamV4p5TextToImageRequest { + prompt: "an anime girl is riding a t-rex in the forest".to_string(), + num_images: Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two), + max_images: Some(EnqueueBytedanceSeedreamV4p5TextToImageMaxImages::Two), + image_size: Some(EnqueueBytedanceSeedreamV4p5TextToImageSize::LandscapeSixteenNine), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_bytedance_seedream_v4p5_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v5_lite_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v5_lite_text_to_image_webhook.rs new file mode 100644 index 0000000000..9b08002f6b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_bytedance_seedream_v5_lite_text_to_image_webhook.rs @@ -0,0 +1,158 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_seedream_5_text_to_image::{http_seedream_5_text_to_image, SeedreamV5LiteTextToImageInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueBytedanceSeedreamV5LiteTextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueBytedanceSeedreamV5LiteTextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + // Request required + pub prompt: String, + + // Optional args + pub num_images: Option, + pub max_images: Option, + pub image_size: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV5LiteTextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueBytedanceSeedreamV5LiteTextToImageSize { + // Square + Square, + SquareHd, + // Tall + PortraitFourThree, + PortraitSixteenNine, + // Wide + LandscapeFourThree, + LandscapeSixteenNine, + // Auto + Auto2k, + Auto3k, // NB: v5 uses auto_3K instead of v4's auto_4K +} + + +impl FalRequestCostCalculator for EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // TODO(bt): Verify actual pricing for Seedream v5 Lite on fal.ai. + let unit_cost = 4; + let cost = match self.num_images { + None => unit_cost, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::One) => unit_cost, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two) => unit_cost * 2, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Three) => unit_cost * 3, + Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Four) => unit_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_bytedance_seedream_v5_lite_text_to_image_webhook( + args: EnqueueBytedanceSeedreamV5LiteTextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = req.num_images + .map(|n| match n { + EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::One => 1, + EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two => 2, + EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Three => 3, + EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Four => 4, + }); + + let max_images = req.max_images + .map(|n| match n { + EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages::One => 1, + EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages::Two => 2, + EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages::Three => 3, + EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages::Four => 4, + }); + + let image_size = req.image_size + .map(|s| match s { + EnqueueBytedanceSeedreamV5LiteTextToImageSize::Square => "square", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::SquareHd => "square_hd", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::PortraitFourThree => "portrait_4_3", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::PortraitSixteenNine => "portrait_16_9", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::LandscapeFourThree => "landscape_4_3", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::LandscapeSixteenNine => "landscape_16_9", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::Auto2k => "auto_2K", + EnqueueBytedanceSeedreamV5LiteTextToImageSize::Auto3k => "auto_3K", + }) + .map(|s| s.to_string()); + + let request = SeedreamV5LiteTextToImageInput { + prompt: req.prompt, + // Optionals + num_images, + max_images, + image_size, + // Constants + enable_safety_checker: Some(false), + }; + + let result = http_seedream_5_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::{enqueue_bytedance_seedream_v5_lite_text_to_image_webhook, EnqueueBytedanceSeedreamV5LiteTextToImageArgs, EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages, EnqueueBytedanceSeedreamV5LiteTextToImageNumImages, EnqueueBytedanceSeedreamV5LiteTextToImageRequest, EnqueueBytedanceSeedreamV5LiteTextToImageSize}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueBytedanceSeedreamV5LiteTextToImageArgs { + request: EnqueueBytedanceSeedreamV5LiteTextToImageRequest { + prompt: "an anime girl is riding a t-rex in the forest".to_string(), + num_images: Some(EnqueueBytedanceSeedreamV5LiteTextToImageNumImages::Two), + max_images: Some(EnqueueBytedanceSeedreamV5LiteTextToImageMaxImages::Two), + image_size: Some(EnqueueBytedanceSeedreamV5LiteTextToImageSize::LandscapeSixteenNine), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_bytedance_seedream_v5_lite_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_1_dev_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_1_dev_text_to_image_webhook.rs new file mode 100644 index 0000000000..985c998b9c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_1_dev_text_to_image_webhook.rs @@ -0,0 +1,113 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_flux_1_dev_text_to_image::{flux_1_dev_text_to_image, Flux1DevTextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Flux1DevArgs<'a, U: IntoUrl> { + pub request: Flux1DevRequest, + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Flux1DevRequest { + pub prompt: String, + pub aspect_ratio: Flux1DevAspectRatio, + pub num_images: Flux1DevNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevAspectRatio { + Square, // 1:1 + SquareHd, // 1:1 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1DevNumImages { + One, // Default + Two, + Three, + Four, +} + +pub async fn enqueue_flux_1_dev_text_to_image_webhook( + args: Flux1DevArgs<'_, U> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + Flux1DevNumImages::One => 1, + Flux1DevNumImages::Two => 2, + Flux1DevNumImages::Three => 3, + Flux1DevNumImages::Four => 4, + }; + + let image_size = match req.aspect_ratio { + Flux1DevAspectRatio::Square => "square", + Flux1DevAspectRatio::SquareHd => "square_hd", + Flux1DevAspectRatio::LandscapeFourByThree => "landscape_4_3", + Flux1DevAspectRatio::LandscapeSixteenByNine => "landscape_16_9", + Flux1DevAspectRatio::PortraitThreeByFour => "portrait_4_3", + Flux1DevAspectRatio::PortraitNineBySixteen => "portrait_16_9", + }; + + let request = Flux1DevTextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + image_size: Some(image_size.to_string()), + // Maybe abstract + enable_safety_checker: Some(false), + // Maybe expose + seed: None, + guidance_scale: None, + num_inference_steps: None, + // Constants + sync_mode: None, // Synchronous / slow + }; + + let result = flux_1_dev_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::{enqueue_flux_1_dev_text_to_image_webhook, Flux1DevArgs, Flux1DevAspectRatio, Flux1DevNumImages, Flux1DevRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1DevArgs { + request: Flux1DevRequest { + prompt: "a giant robot fighting a dragon in a futuristic city".to_string(), + num_images: Flux1DevNumImages::One, + aspect_ratio: Flux1DevAspectRatio::LandscapeFourByThree, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_dev_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_1_schnell_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_1_schnell_text_to_image_webhook.rs new file mode 100644 index 0000000000..b2e0e81be2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_1_schnell_text_to_image_webhook.rs @@ -0,0 +1,112 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_flux_1_schnell_text_to_image::{flux_1_schnell_text_to_image, Flux1SchnellTextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Flux1SchnellArgs<'a, U: IntoUrl> { + pub request: Flux1SchnellRequest, + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Flux1SchnellRequest { + pub prompt: String, + pub aspect_ratio: Flux1SchnellAspectRatio, + pub num_images: Flux1SchnellNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellAspectRatio { + Square, // 1:1 + SquareHd, // 1:1 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug)] +pub enum Flux1SchnellNumImages { + One, // Default + Two, + Three, + Four, +} + +pub async fn enqueue_flux_1_schnell_text_to_image_webhook( + args: Flux1SchnellArgs<'_, U> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + Flux1SchnellNumImages::One => 1, + Flux1SchnellNumImages::Two => 2, + Flux1SchnellNumImages::Three => 3, + Flux1SchnellNumImages::Four => 4, + }; + + let image_size = match req.aspect_ratio { + Flux1SchnellAspectRatio::Square => "square", + Flux1SchnellAspectRatio::SquareHd => "square_hd", + Flux1SchnellAspectRatio::LandscapeFourByThree => "landscape_4_3", + Flux1SchnellAspectRatio::LandscapeSixteenByNine => "landscape_16_9", + Flux1SchnellAspectRatio::PortraitThreeByFour => "portrait_4_3", + Flux1SchnellAspectRatio::PortraitNineBySixteen => "portrait_16_9", + }; + + let request = Flux1SchnellTextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + image_size: Some(image_size.to_string()), + // Maybe abstract + enable_safety_checker: Some(false), + // Maybe expose + seed: None, + num_inference_steps: None, + // Constants + sync_mode: None, // Synchronous / slow + }; + + let result = flux_1_schnell_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::{enqueue_flux_1_schnell_text_to_image_webhook, Flux1SchnellArgs, Flux1SchnellAspectRatio, Flux1SchnellNumImages, Flux1SchnellRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Flux1SchnellArgs { + request: Flux1SchnellRequest { + prompt: "a giant robot fighting a dragon in a futuristic city".to_string(), + num_images: Flux1SchnellNumImages::One, + aspect_ratio: Flux1SchnellAspectRatio::LandscapeSixteenByNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_1_schnell_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_pro_11_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_pro_11_text_to_image_webhook.rs new file mode 100644 index 0000000000..37d9375c1b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_pro_11_text_to_image_webhook.rs @@ -0,0 +1,132 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::text::http_flux_pro_11_text_to_image::{flux_pro_11_text_to_image, FluxPro11TextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct FluxPro11Args<'a, U: IntoUrl> { + pub request: FluxPro11Request, + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct FluxPro11Request { + pub prompt: String, + pub aspect_ratio: FluxPro11AspectRatio, + pub num_images: FluxPro11NumImages, +} + +// TODO(bt,2026-01-01): This seems to disagree between Fal.ai and Fal.rs client libraries. +#[derive(Copy, Clone, Debug)] +pub enum FluxPro11AspectRatio { + Square, // 1:1 + SquareHd, // 1:1 (TODO: Is this in the API? I checked recently and don't see it.) + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug)] +pub enum FluxPro11NumImages { + One, // Default + Two, + Three, + Four, +} + + +impl FalRequestCostCalculator for FluxPro11Request { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.04 per megapixel, billed by rounding up to the nearest + // megapixel. Default image_size values are ~1MP, so 4 cents per image. + let base_cost = 4; + let cost = match self.num_images { + FluxPro11NumImages::One => base_cost, + FluxPro11NumImages::Two => base_cost * 2, + FluxPro11NumImages::Three => base_cost * 3, + FluxPro11NumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_flux_pro_11_text_to_image_webhook( + args: FluxPro11Args<'_, U> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + FluxPro11NumImages::One => 1, + FluxPro11NumImages::Two => 2, + FluxPro11NumImages::Three => 3, + FluxPro11NumImages::Four => 4, + }; + + let image_size = match req.aspect_ratio { + FluxPro11AspectRatio::Square => "square", + FluxPro11AspectRatio::SquareHd => "square_hd", + FluxPro11AspectRatio::LandscapeFourByThree => "landscape_4_3", + FluxPro11AspectRatio::LandscapeSixteenByNine => "landscape_16_9", + FluxPro11AspectRatio::PortraitThreeByFour => "portrait_4_3", + FluxPro11AspectRatio::PortraitNineBySixteen => "portrait_16_9", + }; + + let request = FluxPro11TextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + image_size: Some(image_size.to_string()), + // Maybe expose + seed: None, + // Maybe abstract + enable_safety_checker: Some(false), + safety_tolerance: Some("5".to_string()), // 1 is most strict, 5 is most permissive + // Constants + output_format: Some("png".to_string()), + sync_mode: None, // Synchronous / slow + }; + + let result = flux_pro_11_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{enqueue_flux_pro_11_text_to_image_webhook, FluxPro11Args, FluxPro11AspectRatio, FluxPro11NumImages, FluxPro11Request}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = FluxPro11Args { + request: FluxPro11Request { + prompt: "a giant red panda fighting a dragon in a futuristic city".to_string(), + num_images: FluxPro11NumImages::One, + aspect_ratio: FluxPro11AspectRatio::LandscapeSixteenByNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_pro_11_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_pro_11_ultra_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_pro_11_ultra_text_to_image_webhook.rs new file mode 100644 index 0000000000..8e0cfc23d6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_flux_pro_11_ultra_text_to_image_webhook.rs @@ -0,0 +1,137 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::text::http_flux_pro_11_ultra_text_to_image::{flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct FluxPro11UltraArgs<'a, U: IntoUrl> { + pub request: FluxPro11UltraRequest, + pub webhook_url: U, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct FluxPro11UltraRequest { + pub prompt: String, + pub aspect_ratio: FluxPro11UltraAspectRatio, + pub num_images: FluxPro11UltraNumImages, +} + +#[derive(Copy, Clone, Debug)] +pub enum FluxPro11UltraAspectRatio { + Square, // 1:1 + LandscapeThreeByTwo, // 3:2 + LandscapeFourByThree, // 4:3 + LandscapeSixteenByNine, // 16:9 + LandscapeTwentyOneByNine, // 21:9 + PortraitTwoByThree, // 2:3 + PortraitThreeByFour, // 3:4 + PortraitNineBySixteen, // 9:16 + PortraitNineByTwentyOne, // 9:21 + //Custom { width: u32, height: u32 }, // TODO +} + +#[derive(Copy, Clone, Debug)] +pub enum FluxPro11UltraNumImages { + One, // Default + Two, + Three, + Four, +} + + +impl FalRequestCostCalculator for FluxPro11UltraRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.06 per image. + let base_cost = 6; + let cost = match self.num_images { + FluxPro11UltraNumImages::One => base_cost, + FluxPro11UltraNumImages::Two => base_cost * 2, + FluxPro11UltraNumImages::Three => base_cost * 3, + FluxPro11UltraNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_flux_pro_11_ultra_text_to_image_webhook( + args: FluxPro11UltraArgs<'_, U> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + FluxPro11UltraNumImages::One => 1, + FluxPro11UltraNumImages::Two => 2, + FluxPro11UltraNumImages::Three => 3, + FluxPro11UltraNumImages::Four => 4, + }; + + let aspect_ratio = match req.aspect_ratio { + FluxPro11UltraAspectRatio::Square => "1:1", + FluxPro11UltraAspectRatio::LandscapeThreeByTwo => "3:2", + FluxPro11UltraAspectRatio::LandscapeFourByThree => "4:3", + FluxPro11UltraAspectRatio::LandscapeSixteenByNine => "16:9", + FluxPro11UltraAspectRatio::LandscapeTwentyOneByNine => "21:9", + FluxPro11UltraAspectRatio::PortraitTwoByThree => "2:3", + FluxPro11UltraAspectRatio::PortraitThreeByFour => "3:4", + FluxPro11UltraAspectRatio::PortraitNineBySixteen => "9:16", + FluxPro11UltraAspectRatio::PortraitNineByTwentyOne => "9:21", + }; + + let request = FluxPro11UltraTextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + aspect_ratio: Some(aspect_ratio.to_string()), + // Maybe expose + seed: None, + raw: Some(true), // Generate less processed, more natural-looking images. Default is false. + // Maybe abstract + enable_safety_checker: Some(false), + safety_tolerance: Some("5".to_string()), // 1 is most strict, 5 is most permissive + // Constants + output_format: Some("png".to_string()), + sync_mode: None, // Synchronous / slow + }; + + let result = flux_pro_11_ultra_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{enqueue_flux_pro_11_ultra_text_to_image_webhook, FluxPro11UltraArgs, FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, FluxPro11UltraRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = FluxPro11UltraArgs { + request: FluxPro11UltraRequest { + prompt: "a giant robot fighting a dragon in a futuristic city".to_string(), + num_images: FluxPro11UltraNumImages::One, + aspect_ratio: FluxPro11UltraAspectRatio::LandscapeSixteenByNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_flux_pro_11_ultra_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gemini_25_flash_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gemini_25_flash_text_to_image_webhook.rs new file mode 100644 index 0000000000..eb5fb97620 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gemini_25_flash_text_to_image_webhook.rs @@ -0,0 +1,144 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_gemini_25_flash_text_to_image::{gemini_25_flash_text_to_image, Gemini25FlashTextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{Gemini25FlashEditArgs, Gemini25FlashEditNumImages}; + +pub struct Gemini25FlashTextToImageArgs<'a, R: IntoUrl> { + pub request: Gemini25FlashTextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Gemini25FlashTextToImageRequest { + // Required + pub prompt: String, + pub num_images: Gemini25FlashTextToImageNumImages, + + // Optional + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Gemini25FlashTextToImageNumImages { + One, + Two, + Three, + Four, +} + +/// 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +/// Default is "1:1" +#[derive(Copy, Clone, Debug)] +pub enum Gemini25FlashTextToImageAspectRatio { + // Square + OneByOne, + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, // NB: No NineByTwentyOne ? +} + + +impl FalRequestCostCalculator for Gemini25FlashTextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // NB: This was copied from the image-to-image case. + // Your request will cost $0.039 per image. For $1.00, you can run this model 25 times. + match self.num_images { + Gemini25FlashTextToImageNumImages::One => 4, + Gemini25FlashTextToImageNumImages::Two => 8, + Gemini25FlashTextToImageNumImages::Three => 12, + Gemini25FlashTextToImageNumImages::Four => 16, + } + } +} + + +pub async fn enqueue_gemini_25_flash_text_to_image_webhook( + args: Gemini25FlashTextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + Gemini25FlashTextToImageNumImages::One => 1, + Gemini25FlashTextToImageNumImages::Two => 2, + Gemini25FlashTextToImageNumImages::Three => 3, + Gemini25FlashTextToImageNumImages::Four => 4, + }; + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + // Square + Gemini25FlashTextToImageAspectRatio::OneByOne => "1:1", + // Wide + Gemini25FlashTextToImageAspectRatio::FiveByFour => "5:4", + Gemini25FlashTextToImageAspectRatio::FourByThree => "4:3", + Gemini25FlashTextToImageAspectRatio::ThreeByTwo => "3:2", + Gemini25FlashTextToImageAspectRatio::SixteenByNine => "16:9", + Gemini25FlashTextToImageAspectRatio::TwentyOneByNine => "21:9", + // Tall + Gemini25FlashTextToImageAspectRatio::FourByFive => "4:5", + Gemini25FlashTextToImageAspectRatio::ThreeByFour => "3:4", + Gemini25FlashTextToImageAspectRatio::TwoByThree => "2:3", + Gemini25FlashTextToImageAspectRatio::NineBySixteen => "9:16", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let request = Gemini25FlashTextToImageInput { + prompt: req.prompt, + aspect_ratio, + // Constants + num_images: Some(num_images), + output_format: Some("png".to_string()), + }; + + let result = gemini_25_flash_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{enqueue_gemini_25_flash_text_to_image_webhook, Gemini25FlashTextToImageArgs, Gemini25FlashTextToImageNumImages, Gemini25FlashTextToImageRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Gemini25FlashTextToImageArgs { + request: Gemini25FlashTextToImageRequest { + prompt: "a warrior on the field of battle, final fantasy style, lots of hungry raptors rushing the heroes".to_string(), + num_images: Gemini25FlashTextToImageNumImages::Two, + aspect_ratio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_gemini_25_flash_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1_byok_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1_byok_text_to_image_webhook.rs new file mode 100644 index 0000000000..68660baaae --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1_byok_text_to_image_webhook.rs @@ -0,0 +1,163 @@ +//! Deprecated BYOK (bring-your-own-OpenAI-key) text-to-image binding for +//! `fal-ai/gpt-image-1/text-to-image/byok`. The non-BYOK replacement lives in +//! `enqueue_gpt_image_1_text_to_image_webhook.rs`. Kept for the legacy +//! `generate_gpt_image_1_text_to_image_handler` storyteller-web endpoint. + +use crate::creds::fal_api_key::FalApiKey; +use crate::creds::open_ai_api_key::OpenAiApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::text::http_gpt_image_1_byok_text_to_image::{gpt_image_1_byok_text_to_image, GptImage1ByokTextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct GptTextToImageByokArgs<'a, V: IntoUrl> { + pub request: GptTextToImageByokRequest, + + // Fulfillment + pub api_key: &'a FalApiKey, + pub openai_api_key: &'a OpenAiApiKey, + pub webhook_url: V, +} + +#[derive(Clone, Debug)] +pub struct GptTextToImageByokRequest { + pub prompt: String, + pub image_size: GptTextToImageSize, + pub num_images: GptTextToImageNumImages, + pub quality: GptTextToImageQuality, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptTextToImageSize{ + Auto, + Square, + Horizontal, + Vertical, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptTextToImageQuality { + Auto, + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum GptTextToImageNumImages{ + One, + Two, + Three, + Four, +} + + +// NB: These are BYOK, so they're not Fal's prices +impl FalRequestCostCalculator for GptTextToImageByokRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/ + // Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium), + // and $0.17 (high) for square images. + // We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us. + let base_cost = match self.quality { + GptTextToImageQuality::Auto => 17, + GptTextToImageQuality::Low => 1, + GptTextToImageQuality::Medium => 4, + GptTextToImageQuality::High => 17, + }; + let cost = match self.num_images { + GptTextToImageNumImages::One => base_cost, + GptTextToImageNumImages::Two => base_cost * 2, + GptTextToImageNumImages::Three => base_cost * 3, + GptTextToImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_gpt_image_1_byok_text_to_image_webhook( + args: GptTextToImageByokArgs<'_, V> +) -> Result { + + let req = args.request; + + // auto, 1024x1024, 1536x1024, 1024x1536 + let image_size = match req.image_size { + GptTextToImageSize::Auto => "auto", + GptTextToImageSize::Square => "1024x1024", + GptTextToImageSize::Horizontal => "1536x1024", + GptTextToImageSize::Vertical => "1024x1536", + }; + + let quality = match req.quality { + GptTextToImageQuality::Auto => "auto", + GptTextToImageQuality::Low => "low", + GptTextToImageQuality::Medium => "medium", + GptTextToImageQuality::High => "high", + }; + + let num_images = match req.num_images { + GptTextToImageNumImages::One => 1, + GptTextToImageNumImages::Two => 2, + GptTextToImageNumImages::Three => 3, + GptTextToImageNumImages::Four => 4, + }; + + let request = GptImage1ByokTextToImageInput { + prompt: req.prompt, + image_size: image_size.to_string(), + num_images, + quality: quality.to_string(), + openai_api_key: args.openai_api_key.0.to_string(), + }; + + let result = gpt_image_1_byok_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::creds::open_ai_api_key::OpenAiApiKey; + use crate::requests_old::webhook::image::text::enqueue_gpt_image_1_byok_text_to_image_webhook::{ + enqueue_gpt_image_1_byok_text_to_image_webhook, GptTextToImageByokArgs, + GptTextToImageByokRequest, GptTextToImageNumImages, GptTextToImageQuality, + GptTextToImageSize, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let fal_secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let fal_api_key = FalApiKey::from_str(&fal_secret); + + let openai_secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?; + let openai_api_key = OpenAiApiKey::from_str(&openai_secret); + + let args = GptTextToImageByokArgs { + request: GptTextToImageByokRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + image_size: GptTextToImageSize::Horizontal, + num_images: GptTextToImageNumImages::One, + quality: GptTextToImageQuality::High, + }, + api_key: &fal_api_key, + openai_api_key: &openai_api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_gpt_image_1_byok_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1_text_to_image_webhook.rs new file mode 100644 index 0000000000..3c25a30c37 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1_text_to_image_webhook.rs @@ -0,0 +1,209 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::core_api::webhook_response::WebhookResponse; +use crate::requests_old::http::image::text::http_gpt_image_1_text_to_image::{ + gpt_image_1_text_to_image, GptImage1TextToImageInput, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{ + FalRequestCostCalculator, UsdCents, +}; +use reqwest::IntoUrl; + +/// Typed args for the non-BYOK `fal-ai/gpt-image-1/text-to-image` endpoint. +/// +/// (The BYOK variant lives in `enqueue_gpt_image_1_byok_text_to_image_webhook.rs` +/// and is deprecated.) +pub struct EnqueueGptImage1TextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueGptImage1TextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueGptImage1TextToImageRequest { + // Required + pub prompt: String, + pub num_images: EnqueueGptImage1TextToImageNumImages, + + // Optional + pub image_size: Option, + pub quality: Option, + pub background: Option, + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1TextToImageSize { + Auto, + /// 1024x1024 + Square, + /// 1536x1024 + Horizontal, + /// 1024x1536 + Vertical, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1TextToImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1TextToImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1TextToImageOutputFormat { + Jpeg, + Png, + Webp, +} + +impl FalRequestCostCalculator for EnqueueGptImage1TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Per fal docs (fal-ai/gpt-image-1/text-to-image): + // Low: $0.011 (1024x1024) / $0.016 (other) per image + // Medium: $0.042 (1024x1024) / $0.063 (other) per image + // High: $0.167 (1024x1024) / $0.25 (other) per image + // Default quality is Medium when unspecified, square when size unspecified. + let use_quality = self + .quality + .unwrap_or(EnqueueGptImage1TextToImageQuality::Medium); + let is_square = matches!( + self.image_size, + None | Some(EnqueueGptImage1TextToImageSize::Square) | Some(EnqueueGptImage1TextToImageSize::Auto) + ); + let base_cost: u64 = match (use_quality, is_square) { + (EnqueueGptImage1TextToImageQuality::Low, true) => 2, + (EnqueueGptImage1TextToImageQuality::Low, false) => 2, + (EnqueueGptImage1TextToImageQuality::Medium, true) => 5, + (EnqueueGptImage1TextToImageQuality::Medium, false) => 7, + (EnqueueGptImage1TextToImageQuality::High, true) => 17, + (EnqueueGptImage1TextToImageQuality::High, false) => 25, + }; + let n: u64 = match self.num_images { + EnqueueGptImage1TextToImageNumImages::One => 1, + EnqueueGptImage1TextToImageNumImages::Two => 2, + EnqueueGptImage1TextToImageNumImages::Three => 3, + EnqueueGptImage1TextToImageNumImages::Four => 4, + }; + base_cost * n + } +} + +pub async fn enqueue_gpt_image_1_text_to_image_webhook( + args: EnqueueGptImage1TextToImageArgs<'_, R>, +) -> Result { + let req = args.request; + + let num_images: u8 = match req.num_images { + EnqueueGptImage1TextToImageNumImages::One => 1, + EnqueueGptImage1TextToImageNumImages::Two => 2, + EnqueueGptImage1TextToImageNumImages::Three => 3, + EnqueueGptImage1TextToImageNumImages::Four => 4, + }; + + let image_size = req.image_size.map(|s| { + match s { + EnqueueGptImage1TextToImageSize::Auto => "auto", + EnqueueGptImage1TextToImageSize::Square => "1024x1024", + EnqueueGptImage1TextToImageSize::Horizontal => "1536x1024", + EnqueueGptImage1TextToImageSize::Vertical => "1024x1536", + } + .to_string() + }); + + let quality = req.quality.map(|q| { + match q { + EnqueueGptImage1TextToImageQuality::Low => "low", + EnqueueGptImage1TextToImageQuality::Medium => "medium", + EnqueueGptImage1TextToImageQuality::High => "high", + } + .to_string() + }); + + let background = req.background.map(|b| { + match b { + EnqueueGptImage1TextToImageBackground::Auto => "auto", + EnqueueGptImage1TextToImageBackground::Transparent => "transparent", + EnqueueGptImage1TextToImageBackground::Opaque => "opaque", + } + .to_string() + }); + + let output_format = req.output_format.map(|f| { + match f { + EnqueueGptImage1TextToImageOutputFormat::Jpeg => "jpeg", + EnqueueGptImage1TextToImageOutputFormat::Png => "png", + EnqueueGptImage1TextToImageOutputFormat::Webp => "webp", + } + .to_string() + }); + + let request = GptImage1TextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + image_size, + quality, + background, + output_format, + }; + + let result = gpt_image_1_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{ + enqueue_gpt_image_1_text_to_image_webhook, EnqueueGptImage1TextToImageArgs, + EnqueueGptImage1TextToImageNumImages, EnqueueGptImage1TextToImageQuality, + EnqueueGptImage1TextToImageRequest, EnqueueGptImage1TextToImageSize, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueGptImage1TextToImageArgs { + request: EnqueueGptImage1TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: EnqueueGptImage1TextToImageNumImages::One, + image_size: Some(EnqueueGptImage1TextToImageSize::Horizontal), + quality: Some(EnqueueGptImage1TextToImageQuality::Medium), + background: None, + output_format: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_gpt_image_1_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1p5_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1p5_text_to_image_webhook.rs new file mode 100644 index 0000000000..55b6c7dc97 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_1p5_text_to_image_webhook.rs @@ -0,0 +1,192 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::text::http_gpt_image_1p5_text_to_image::{gpt_image_1p5_text_to_image, GptImage1p5TextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueGptImage1p5TextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueGptImage1p5TextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueGptImage1p5TextToImageRequest { + // Required + pub prompt: String, + pub num_images: EnqueueGptImage1p5TextToImageNumImages, + + // Optional + pub image_size: Option, + pub background: Option, + pub quality: Option, + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5TextToImageSize { + /// 1024x1024 + Square, + /// 1536x1024 + Wide, + /// 1024x1536 + Tall, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5TextToImageBackground { + Auto, + Transparent, + Opaque, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5TextToImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage1p5TextToImageOutputFormat { + Jpeg, + Png, + Webp, +} + + +impl FalRequestCostCalculator for EnqueueGptImage1p5TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // NB: copied from edit image case - should be correct, but not verified. + // Your request will cost different amounts based on the number of images, quality, and size. + // For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image. + // For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image. + // For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image. + let use_quality = self.quality.unwrap_or(EnqueueGptImage1p5TextToImageQuality::Medium); + let use_size = self.image_size.unwrap_or(EnqueueGptImage1p5TextToImageSize::Square); + let base_cost = match (use_quality, use_size) { + (EnqueueGptImage1p5TextToImageQuality::Low, EnqueueGptImage1p5TextToImageSize::Square) => 1, + (EnqueueGptImage1p5TextToImageQuality::Low, _) => 1, + (EnqueueGptImage1p5TextToImageQuality::Medium, EnqueueGptImage1p5TextToImageSize::Square) => 3, + (EnqueueGptImage1p5TextToImageQuality::Medium, _) => 5, + (EnqueueGptImage1p5TextToImageQuality::High, EnqueueGptImage1p5TextToImageSize::Square) => 13, + (EnqueueGptImage1p5TextToImageQuality::High, _) => 20, + }; + let cost = match self.num_images { + EnqueueGptImage1p5TextToImageNumImages::One => base_cost, + EnqueueGptImage1p5TextToImageNumImages::Two => base_cost * 2, + EnqueueGptImage1p5TextToImageNumImages::Three => base_cost * 3, + EnqueueGptImage1p5TextToImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_gpt_image_1p5_text_to_image_webhook( + args: EnqueueGptImage1p5TextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + EnqueueGptImage1p5TextToImageNumImages::One => 1, + EnqueueGptImage1p5TextToImageNumImages::Two => 2, + EnqueueGptImage1p5TextToImageNumImages::Three => 3, + EnqueueGptImage1p5TextToImageNumImages::Four => 4, + }; + + let image_size = req.image_size + .map(|s| match s { + EnqueueGptImage1p5TextToImageSize::Square => "1024x1024", + EnqueueGptImage1p5TextToImageSize::Wide => "1536x1024", + EnqueueGptImage1p5TextToImageSize::Tall => "1024x1536", + }) + .map(|resolution| resolution.to_string()); + + let background = req.background + .map(|s| match s { + EnqueueGptImage1p5TextToImageBackground::Auto => "auto", + EnqueueGptImage1p5TextToImageBackground::Transparent => "transparent", + EnqueueGptImage1p5TextToImageBackground::Opaque => "opaque", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let quality = req.quality + .map(|s| match s { + EnqueueGptImage1p5TextToImageQuality::Low => "low", + EnqueueGptImage1p5TextToImageQuality::Medium => "medium", + EnqueueGptImage1p5TextToImageQuality::High => "high", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let output_format = req.output_format + .map(|s| match s { + EnqueueGptImage1p5TextToImageOutputFormat::Jpeg => "jpeg", + EnqueueGptImage1p5TextToImageOutputFormat::Png => "png", + EnqueueGptImage1p5TextToImageOutputFormat::Webp => "webp", + }) + .map(|aspect_ratio| aspect_ratio.to_string()) + .unwrap_or_else(|| "png".to_string()); + + let request = GptImage1p5TextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + output_format: Some(output_format), + // Optionals + image_size, + background, + quality, + }; + + let result = gpt_image_1p5_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_gpt_image_1p5_text_to_image_webhook::{enqueue_gpt_image_1p5_text_to_image_webhook, EnqueueGptImage1p5TextToImageArgs, EnqueueGptImage1p5TextToImageNumImages, EnqueueGptImage1p5TextToImageRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueGptImage1p5TextToImageArgs { + request: EnqueueGptImage1p5TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: EnqueueGptImage1p5TextToImageNumImages::Two, + image_size: None, + background: None, + quality: None, + output_format: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_gpt_image_1p5_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_2_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_2_text_to_image_webhook.rs new file mode 100644 index 0000000000..50d1c5fe1c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_gpt_image_2_text_to_image_webhook.rs @@ -0,0 +1,188 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::image::text::http_gpt_image_2_text_to_image::{gpt_image_2_text_to_image, GptImage2TextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueGptImage2TextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueGptImage2TextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueGptImage2TextToImageRequest { + // Required + pub prompt: String, + pub num_images: EnqueueGptImage2TextToImageNumImages, + + // Optional + pub image_size: Option, + pub quality: Option, + pub output_format: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2TextToImageSize { + SquareHd, + Square, + Portrait4x3, + Portrait16x9, + Landscape4x3, + Landscape16x9, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2TextToImageQuality { + Low, + Medium, + High, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueGptImage2TextToImageOutputFormat { + Jpeg, + Png, + Webp, +} + + +impl FalRequestCostCalculator for EnqueueGptImage2TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Cost table (per image) by approximate pixel dimensions: + // + // landscape_4_3 (~1024x768): low=$0.01, medium=$0.04, high=$0.15 + // square (~1024x1024): low=$0.01, medium=$0.06, high=$0.22 + // portrait_4_3 (~768x1024): low=$0.01, medium=$0.04, high=$0.15 + // landscape_16_9(~1920x1080): low=$0.01, medium=$0.04, high=$0.16 + // portrait_16_9 (~1080x1920): low=$0.01, medium=$0.04, high=$0.16 + // square_hd (~2048x2048): low=$0.01, medium=$0.06, high=$0.23 + // auto (varies): estimated as square + let use_quality = self.quality.unwrap_or(EnqueueGptImage2TextToImageQuality::High); + let use_size = self.image_size.unwrap_or(EnqueueGptImage2TextToImageSize::Square); + + use EnqueueGptImage2TextToImageQuality::*; + use EnqueueGptImage2TextToImageSize::*; + + let base_cost = match (use_quality, use_size) { + (Low, _) => 1, + (Medium, Landscape4x3 | Portrait4x3 | Landscape16x9 | Portrait16x9) => 4, + (Medium, Square) => 6, + (Medium, SquareHd) => 6, + (High, Landscape4x3 | Portrait4x3) => 15, + (High, Landscape16x9 | Portrait16x9) => 16, + (High, Square) => 22, + (High, SquareHd) => 23, + }; + + let cost = match self.num_images { + EnqueueGptImage2TextToImageNumImages::One => base_cost, + EnqueueGptImage2TextToImageNumImages::Two => base_cost * 2, + EnqueueGptImage2TextToImageNumImages::Three => base_cost * 3, + EnqueueGptImage2TextToImageNumImages::Four => base_cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_gpt_image_2_text_to_image_webhook( + args: EnqueueGptImage2TextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + EnqueueGptImage2TextToImageNumImages::One => 1, + EnqueueGptImage2TextToImageNumImages::Two => 2, + EnqueueGptImage2TextToImageNumImages::Three => 3, + EnqueueGptImage2TextToImageNumImages::Four => 4, + }; + + let image_size = req.image_size + .map(|s| match s { + EnqueueGptImage2TextToImageSize::SquareHd => "square_hd", + EnqueueGptImage2TextToImageSize::Square => "square", + EnqueueGptImage2TextToImageSize::Portrait4x3 => "portrait_4_3", + EnqueueGptImage2TextToImageSize::Portrait16x9 => "portrait_16_9", + EnqueueGptImage2TextToImageSize::Landscape4x3 => "landscape_4_3", + EnqueueGptImage2TextToImageSize::Landscape16x9 => "landscape_16_9", + }) + .map(|size| size.to_string()); + + let quality = req.quality + .map(|s| match s { + EnqueueGptImage2TextToImageQuality::Low => "low", + EnqueueGptImage2TextToImageQuality::Medium => "medium", + EnqueueGptImage2TextToImageQuality::High => "high", + }) + .map(|quality| quality.to_string()); + + let output_format = req.output_format + .map(|s| match s { + EnqueueGptImage2TextToImageOutputFormat::Jpeg => "jpeg", + EnqueueGptImage2TextToImageOutputFormat::Png => "png", + EnqueueGptImage2TextToImageOutputFormat::Webp => "webp", + }) + .map(|format| format.to_string()) + .unwrap_or_else(|| "png".to_string()); + + let request = GptImage2TextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + output_format: Some(output_format), + // Optionals + image_size, + quality, + }; + + let result = gpt_image_2_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_gpt_image_2_text_to_image_webhook::{enqueue_gpt_image_2_text_to_image_webhook, EnqueueGptImage2TextToImageArgs, EnqueueGptImage2TextToImageNumImages, EnqueueGptImage2TextToImageRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueGptImage2TextToImageArgs { + request: EnqueueGptImage2TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: EnqueueGptImage2TextToImageNumImages::Two, + image_size: None, + quality: None, + output_format: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_gpt_image_2_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_nano_banana_2_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_nano_banana_2_text_to_image_webhook.rs new file mode 100644 index 0000000000..105dbf6c24 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_nano_banana_2_text_to_image_webhook.rs @@ -0,0 +1,180 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_nano_banana_2_text_to_image::{nano_banana_2_text_to_image, NanoBanana2TextToImageInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueNanoBanana2TextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueNanoBanana2TextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueNanoBanana2TextToImageRequest { + // Required + pub prompt: String, + pub num_images: EnqueueNanoBanana2TextToImageNumImages, + + // Optional + pub resolution: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBanana2TextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBanana2TextToImageResolution { + HalfK, // "0.5K" + OneK, // "1K" (default) + TwoK, // "2K" + FourK, // "4K" +} + +/// auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +/// Default: "auto" +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBanana2TextToImageAspectRatio { + // Automatic (default) + Auto, + // Square + OneByOne, + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, +} + + +impl FalRequestCostCalculator for EnqueueNanoBanana2TextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // TODO(bt): Verify actual pricing for Nano Banana 2 on fal.ai. + // 4K outputs may be charged at double the standard rate. + let cost = match self.resolution { + None => 15, + Some(EnqueueNanoBanana2TextToImageResolution::HalfK) => 8, + Some(EnqueueNanoBanana2TextToImageResolution::OneK) => 15, + Some(EnqueueNanoBanana2TextToImageResolution::TwoK) => 15, + Some(EnqueueNanoBanana2TextToImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + EnqueueNanoBanana2TextToImageNumImages::One => cost, + EnqueueNanoBanana2TextToImageNumImages::Two => cost * 2, + EnqueueNanoBanana2TextToImageNumImages::Three => cost * 3, + EnqueueNanoBanana2TextToImageNumImages::Four => cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_nano_banana_2_text_to_image_webhook( + args: EnqueueNanoBanana2TextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + EnqueueNanoBanana2TextToImageNumImages::One => 1, + EnqueueNanoBanana2TextToImageNumImages::Two => 2, + EnqueueNanoBanana2TextToImageNumImages::Three => 3, + EnqueueNanoBanana2TextToImageNumImages::Four => 4, + }; + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueNanoBanana2TextToImageResolution::HalfK => "0.5K", + EnqueueNanoBanana2TextToImageResolution::OneK => "1K", + EnqueueNanoBanana2TextToImageResolution::TwoK => "2K", + EnqueueNanoBanana2TextToImageResolution::FourK => "4K", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + // Auto + EnqueueNanoBanana2TextToImageAspectRatio::Auto => "auto", + // Square + EnqueueNanoBanana2TextToImageAspectRatio::OneByOne => "1:1", + // Wide + EnqueueNanoBanana2TextToImageAspectRatio::FiveByFour => "5:4", + EnqueueNanoBanana2TextToImageAspectRatio::FourByThree => "4:3", + EnqueueNanoBanana2TextToImageAspectRatio::ThreeByTwo => "3:2", + EnqueueNanoBanana2TextToImageAspectRatio::SixteenByNine => "16:9", + EnqueueNanoBanana2TextToImageAspectRatio::TwentyOneByNine => "21:9", + // Tall + EnqueueNanoBanana2TextToImageAspectRatio::FourByFive => "4:5", + EnqueueNanoBanana2TextToImageAspectRatio::ThreeByFour => "3:4", + EnqueueNanoBanana2TextToImageAspectRatio::TwoByThree => "2:3", + EnqueueNanoBanana2TextToImageAspectRatio::NineBySixteen => "9:16", + }) + .map(|ar| ar.to_string()); + + let request = NanoBanana2TextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + // Optionals + aspect_ratio, + resolution, + // Constants + output_format: Some("png".to_string()), + limit_generations: None, + safety_tolerance: Some("6".to_string()), + enable_web_search: None, + seed: None, + }; + + let result = nano_banana_2_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_nano_banana_2_text_to_image_webhook::{enqueue_nano_banana_2_text_to_image_webhook, EnqueueNanoBanana2TextToImageArgs, EnqueueNanoBanana2TextToImageAspectRatio, EnqueueNanoBanana2TextToImageNumImages, EnqueueNanoBanana2TextToImageRequest, EnqueueNanoBanana2TextToImageResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueNanoBanana2TextToImageArgs { + request: EnqueueNanoBanana2TextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: EnqueueNanoBanana2TextToImageNumImages::One, + aspect_ratio: Some(EnqueueNanoBanana2TextToImageAspectRatio::SixteenByNine), + resolution: Some(EnqueueNanoBanana2TextToImageResolution::TwoK), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_nano_banana_2_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_nano_banana_pro_text_to_image_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_nano_banana_pro_text_to_image_webhook.rs new file mode 100644 index 0000000000..0f3432681b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/enqueue_nano_banana_pro_text_to_image_webhook.rs @@ -0,0 +1,173 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::image::text::http_nano_banana_pro_text_to_image::{nano_banana_pro_text_to_image, NanoBananaProTextToImageInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{EnqueueNanoBananaProEditImageArgs, EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageResolution}; + +pub struct EnqueueNanoBananaProTextToImageArgs<'a, R: IntoUrl> { + pub request: EnqueueNanoBananaProTextToImageRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueNanoBananaProTextToImageRequest { + // Required + pub prompt: String, + pub num_images: EnqueueNanoBananaProTextToImageNumImages, + + // Optional + pub resolution: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBananaProTextToImageNumImages { + One, + Two, + Three, + Four, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBananaProTextToImageResolution { + OneK, + TwoK, + FourK, +} + +/// 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16 +/// Default is "1:1" +#[derive(Copy, Clone, Debug)] +pub enum EnqueueNanoBananaProTextToImageAspectRatio { + // Square + OneByOne, + // Wide + FiveByFour, + FourByThree, + ThreeByTwo, + SixteenByNine, + TwentyOneByNine, + // Tall + FourByFive, + ThreeByFour, + TwoByThree, + NineBySixteen, // NB: No NineByTwentyOne ? +} + + +impl FalRequestCostCalculator for EnqueueNanoBananaProTextToImageRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Your request will cost $0.15 per image. + // For $1.00, you can run this model 7 times. + // 4K outputs will be charged at double the standard rate. + // If web search is used, an additional $0.015 will be charged. + // Note: Pricing may change in the future. + let cost = match self.resolution { + None => 15, + Some(EnqueueNanoBananaProTextToImageResolution::OneK) => 15, + Some(EnqueueNanoBananaProTextToImageResolution::TwoK) => 15, + Some(EnqueueNanoBananaProTextToImageResolution::FourK) => 30, + }; + let cost = match self.num_images { + EnqueueNanoBananaProTextToImageNumImages::One => cost, + EnqueueNanoBananaProTextToImageNumImages::Two => cost * 2, + EnqueueNanoBananaProTextToImageNumImages::Three => cost * 3, + EnqueueNanoBananaProTextToImageNumImages::Four => cost * 4, + }; + cost as UsdCents + } +} + + +pub async fn enqueue_nano_banana_pro_text_to_image_webhook( + args: EnqueueNanoBananaProTextToImageArgs<'_, R> +) -> Result { + + let req = args.request; + + let num_images = match req.num_images { + EnqueueNanoBananaProTextToImageNumImages::One => 1, + EnqueueNanoBananaProTextToImageNumImages::Two => 2, + EnqueueNanoBananaProTextToImageNumImages::Three => 3, + EnqueueNanoBananaProTextToImageNumImages::Four => 4, + }; + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueNanoBananaProTextToImageResolution::OneK => "1K", + EnqueueNanoBananaProTextToImageResolution::TwoK => "2K", + EnqueueNanoBananaProTextToImageResolution::FourK => "4K", + }) + .map(|resolution| resolution.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + // Square + EnqueueNanoBananaProTextToImageAspectRatio::OneByOne => "1:1", + // Wide + EnqueueNanoBananaProTextToImageAspectRatio::FiveByFour => "5:4", + EnqueueNanoBananaProTextToImageAspectRatio::FourByThree => "4:3", + EnqueueNanoBananaProTextToImageAspectRatio::ThreeByTwo => "3:2", + EnqueueNanoBananaProTextToImageAspectRatio::SixteenByNine => "16:9", + EnqueueNanoBananaProTextToImageAspectRatio::TwentyOneByNine => "21:9", + // Tall + EnqueueNanoBananaProTextToImageAspectRatio::FourByFive => "4:5", + EnqueueNanoBananaProTextToImageAspectRatio::ThreeByFour => "3:4", + EnqueueNanoBananaProTextToImageAspectRatio::TwoByThree => "2:3", + EnqueueNanoBananaProTextToImageAspectRatio::NineBySixteen => "9:16", + }) + .map(|aspect_ratio| aspect_ratio.to_string()); + + let request = NanoBananaProTextToImageInput { + prompt: req.prompt, + num_images: Some(num_images), + // Optionals + aspect_ratio, + resolution, + // Constants + output_format: Some("png".to_string()), + }; + + let result = nano_banana_pro_text_to_image(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::image::text::enqueue_nano_banana_pro_text_to_image_webhook::{enqueue_nano_banana_pro_text_to_image_webhook, EnqueueNanoBananaProTextToImageArgs, EnqueueNanoBananaProTextToImageAspectRatio, EnqueueNanoBananaProTextToImageNumImages, EnqueueNanoBananaProTextToImageRequest, EnqueueNanoBananaProTextToImageResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueNanoBananaProTextToImageArgs { + request: EnqueueNanoBananaProTextToImageRequest { + prompt: "an anime girl riding on the back of a t-rex".to_string(), + num_images: EnqueueNanoBananaProTextToImageNumImages::One, + aspect_ratio: Some(EnqueueNanoBananaProTextToImageAspectRatio::SixteenByNine), + resolution: Some(EnqueueNanoBananaProTextToImageResolution::TwoK), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_nano_banana_pro_text_to_image_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/image/text/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/mod.rs new file mode 100644 index 0000000000..3199122984 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/image/text/mod.rs @@ -0,0 +1,14 @@ +pub mod enqueue_bytedance_seedream_v4_text_to_image_webhook; +pub mod enqueue_bytedance_seedream_v4p5_text_to_image_webhook; +pub mod enqueue_bytedance_seedream_v5_lite_text_to_image_webhook; +pub mod enqueue_nano_banana_2_text_to_image_webhook; +pub mod enqueue_flux_1_dev_text_to_image_webhook; +pub mod enqueue_flux_1_schnell_text_to_image_webhook; +pub mod enqueue_flux_pro_11_text_to_image_webhook; +pub mod enqueue_flux_pro_11_ultra_text_to_image_webhook; +pub mod enqueue_gemini_25_flash_text_to_image_webhook; +pub mod enqueue_gpt_image_1_byok_text_to_image_webhook; +pub mod enqueue_gpt_image_1_text_to_image_webhook; +pub mod enqueue_gpt_image_1p5_text_to_image_webhook; +pub mod enqueue_gpt_image_2_text_to_image_webhook; +pub mod enqueue_nano_banana_pro_text_to_image_webhook; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/mod.rs new file mode 100644 index 0000000000..c70c64e67d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/mod.rs @@ -0,0 +1,3 @@ +pub mod image; +pub mod object; +pub mod video; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_image_to_3d_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_image_to_3d_webhook.rs new file mode 100644 index 0000000000..3a8bec7f9b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_image_to_3d_webhook.rs @@ -0,0 +1,155 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::object::http_hunyuan3d_v3_image_to_3d::{hunyuan3d_v3_image_to_3d, Hunyuan3dV3ImageTo3dInput}; + +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueHunyuan3dV3ImageTo3dArgs<'a, R: IntoUrl> { + pub request: EnqueueHunyuan3dV3ImageTo3dRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueHunyuan3dV3ImageTo3dRequest { + // Required + pub image_url: String, + + // Optional + pub back_image_url: Option, + pub left_image_url: Option, + pub right_image_url: Option, + pub face_count: Option, + pub generate_type: Option, + pub polygon_type: Option, + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueHunyuan3dV3ImageTo3dGenerateType { + Normal, + LowPoly, + Geometry, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueHunyuan3dV3ImageTo3dPolygonType { + Triangle, + Quadrilateral, +} + +impl FalRequestCostCalculator for EnqueueHunyuan3dV3ImageTo3dRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "Your request will cost $0.375 per generation. + // For $1.00, you can run this model 2 times. + // Generation types: Normal costs $0.375, + // LowPoly costs $0.45, + // Geometry costs $0.225. + // Enabling PBR materials adds $0.15. + // Using multi-view images (back/left/right) adds $0.15. + // Custom face count adds $0.15. + let mut cost = match self.generate_type{ + None => 38, // Round up from $0.375 + Some(EnqueueHunyuan3dV3ImageTo3dGenerateType::Normal) => 38, // Round up + Some(EnqueueHunyuan3dV3ImageTo3dGenerateType::Geometry) => 23, // Round up + Some(EnqueueHunyuan3dV3ImageTo3dGenerateType::LowPoly) => 45, + }; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + let use_multi_view = self.left_image_url.is_some() + || self.right_image_url.is_some() + || self.back_image_url.is_some(); + if use_multi_view { + cost += 15; + } + cost + } +} + + +/// Veo 3.1 Image-to-Video +/// https://fal.ai/models/fal-ai/veo3.1/image-to-video +pub async fn enqueue_hunyuan3d_v3_image_to_3d_webhook( + args: EnqueueHunyuan3dV3ImageTo3dArgs<'_, R> +) -> Result { + + let req = args.request; + + let generate_type = req.generate_type + .map(|t| match t { + EnqueueHunyuan3dV3ImageTo3dGenerateType::Normal => "Normal", + EnqueueHunyuan3dV3ImageTo3dGenerateType::LowPoly => "LowPoly", + EnqueueHunyuan3dV3ImageTo3dGenerateType::Geometry => "Geometry", + }) + .map(|s| s.to_string()); + + let polygon_type = req.polygon_type + .map(|t| match t { + EnqueueHunyuan3dV3ImageTo3dPolygonType::Triangle => "triangle", + EnqueueHunyuan3dV3ImageTo3dPolygonType::Quadrilateral => "quadrilateral", + }) + .map(|s| s.to_string()); + + let request = Hunyuan3dV3ImageTo3dInput { + input_image_url: req.image_url, + // Optionals + back_image_url: req.back_image_url, + left_image_url: req.left_image_url, + right_image_url: req.right_image_url, + face_count: req.face_count, + generate_type, + polygon_type, + enable_pbr: req.enable_pbr, + }; + + let result = hunyuan3d_v3_image_to_3d(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::object::enqueue_hunyuan3d_v3_image_to_3d_webhook::{enqueue_hunyuan3d_v3_image_to_3d_webhook, EnqueueHunyuan3dV3ImageTo3dArgs, EnqueueHunyuan3dV3ImageTo3dRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueHunyuan3dV3ImageTo3dArgs { + request: EnqueueHunyuan3dV3ImageTo3dRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hunyuan3d_v3_image_to_3d_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_sketch_to_3d_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_sketch_to_3d_webhook.rs new file mode 100644 index 0000000000..d8ee3ad828 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_sketch_to_3d_webhook.rs @@ -0,0 +1,140 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::object::http_hunyuan3d_v3_sketch_to_3d::{hunyuan3d_v3_sketch_to_3d, Hunyuan3dV3SketchTo3dInput}; + +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueHunyuan3dV3SketchTo3dArgs<'a, R: IntoUrl> { + pub request: EnqueueHunyuan3dV3SketchTo3dRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueHunyuan3dV3SketchTo3dRequest { + // Required + pub prompt: String, + pub image_url: String, + + // Optional + pub face_count: Option, + pub generate_type: Option, + pub polygon_type: Option, + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueHunyuan3dV3SketchTo3dGenerateType { + Normal, + LowPoly, + Geometry, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueHunyuan3dV3SketchTo3dPolygonType { + Triangle, + Quadrilateral, +} + +impl FalRequestCostCalculator for EnqueueHunyuan3dV3SketchTo3dRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Generation types: Normal costs $0.375, + // LowPoly costs $0.45, + // Geometry costs $0.225. + // Enabling PBR materials adds $0.15. + // Custom face count adds $0.15. + let mut cost = match self.generate_type { + None => 38, // Round up from $0.375 + Some(EnqueueHunyuan3dV3SketchTo3dGenerateType::Normal) => 38, // Round up + Some(EnqueueHunyuan3dV3SketchTo3dGenerateType::Geometry) => 23, // Round up + Some(EnqueueHunyuan3dV3SketchTo3dGenerateType::LowPoly) => 45, + }; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + cost + } +} + + +/// Hunyuan3D V3 Sketch-to-3D +/// https://fal.ai/models/fal-ai/hunyuan3d-v3/sketch-to-3d +pub async fn enqueue_hunyuan3d_v3_sketch_to_3d_webhook( + args: EnqueueHunyuan3dV3SketchTo3dArgs<'_, R> +) -> Result { + + let req = args.request; + + let generate_type = req.generate_type + .map(|t| match t { + EnqueueHunyuan3dV3SketchTo3dGenerateType::Normal => "Normal", + EnqueueHunyuan3dV3SketchTo3dGenerateType::LowPoly => "LowPoly", + EnqueueHunyuan3dV3SketchTo3dGenerateType::Geometry => "Geometry", + }) + .map(|s| s.to_string()); + + let polygon_type = req.polygon_type + .map(|t| match t { + EnqueueHunyuan3dV3SketchTo3dPolygonType::Triangle => "triangle", + EnqueueHunyuan3dV3SketchTo3dPolygonType::Quadrilateral => "quadrilateral", + }) + .map(|s| s.to_string()); + + let request = Hunyuan3dV3SketchTo3dInput { + prompt: req.prompt, + input_image_url: req.image_url, + // Optionals + face_count: req.face_count, + generate_type, + polygon_type, + enable_pbr: req.enable_pbr, + }; + + let result = hunyuan3d_v3_sketch_to_3d(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::object::enqueue_hunyuan3d_v3_sketch_to_3d_webhook::{enqueue_hunyuan3d_v3_sketch_to_3d_webhook, EnqueueHunyuan3dV3SketchTo3dArgs, EnqueueHunyuan3dV3SketchTo3dRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueHunyuan3dV3SketchTo3dArgs { + request: EnqueueHunyuan3dV3SketchTo3dRequest { + prompt: "A cute robot".to_string(), + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hunyuan3d_v3_sketch_to_3d_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_text_to_3d_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_text_to_3d_webhook.rs new file mode 100644 index 0000000000..29f4d133ec --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan3d_v3_text_to_3d_webhook.rs @@ -0,0 +1,136 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::object::http_hunyuan3d_v3_text_to_3d::{hunyuan3d_v3_text_to_3d, Hunyuan3dV3TextTo3dInput}; + +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueHunyuan3dV3TextTo3dArgs<'a, R: IntoUrl> { + pub request: EnqueueHunyuan3dV3TextTo3dRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueHunyuan3dV3TextTo3dRequest { + // Required + pub prompt: String, + + // Optional + pub face_count: Option, + pub generate_type: Option, + pub polygon_type: Option, + pub enable_pbr: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueHunyuan3dV3TextTo3dGenerateType { + Normal, + LowPoly, + Geometry, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueHunyuan3dV3TextTo3dPolygonType { + Triangle, + Quadrilateral, +} + +impl FalRequestCostCalculator for EnqueueHunyuan3dV3TextTo3dRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Generation types: Normal costs $0.375, + // LowPoly costs $0.45, + // Geometry costs $0.225. + // Enabling PBR materials adds $0.15. + // Custom face count adds $0.15. + let mut cost = match self.generate_type { + None => 38, // Round up from $0.375 + Some(EnqueueHunyuan3dV3TextTo3dGenerateType::Normal) => 38, // Round up + Some(EnqueueHunyuan3dV3TextTo3dGenerateType::Geometry) => 23, // Round up + Some(EnqueueHunyuan3dV3TextTo3dGenerateType::LowPoly) => 45, + }; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + cost + } +} + + +/// Hunyuan3D V3 Text-to-3D +/// https://fal.ai/models/fal-ai/hunyuan3d-v3/text-to-3d +pub async fn enqueue_hunyuan3d_v3_text_to_3d_webhook( + args: EnqueueHunyuan3dV3TextTo3dArgs<'_, R> +) -> Result { + + let req = args.request; + + let generate_type = req.generate_type + .map(|t| match t { + EnqueueHunyuan3dV3TextTo3dGenerateType::Normal => "Normal", + EnqueueHunyuan3dV3TextTo3dGenerateType::LowPoly => "LowPoly", + EnqueueHunyuan3dV3TextTo3dGenerateType::Geometry => "Geometry", + }) + .map(|s| s.to_string()); + + let polygon_type = req.polygon_type + .map(|t| match t { + EnqueueHunyuan3dV3TextTo3dPolygonType::Triangle => "triangle", + EnqueueHunyuan3dV3TextTo3dPolygonType::Quadrilateral => "quadrilateral", + }) + .map(|s| s.to_string()); + + let request = Hunyuan3dV3TextTo3dInput { + prompt: req.prompt, + // Optionals + face_count: req.face_count, + generate_type, + polygon_type, + enable_pbr: req.enable_pbr, + }; + + let result = hunyuan3d_v3_text_to_3d(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::object::enqueue_hunyuan3d_v3_text_to_3d_webhook::{enqueue_hunyuan3d_v3_text_to_3d_webhook, EnqueueHunyuan3dV3TextTo3dArgs, EnqueueHunyuan3dV3TextTo3dRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueHunyuan3dV3TextTo3dArgs { + request: EnqueueHunyuan3dV3TextTo3dRequest { + prompt: "A velociraptor with an open mouth full of sharp teeth. Large claws, ready to strike.".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hunyuan3d_v3_text_to_3d_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan_3d_21_image_to_3d_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan_3d_21_image_to_3d_webhook.rs new file mode 100644 index 0000000000..34d1e0954c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan_3d_21_image_to_3d_webhook.rs @@ -0,0 +1,71 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::object::http_hunyuan3d_v21_image_to_3d::{hunyuan3d_v21_image_to_3d, Hunyuan3dV21ImageTo3dInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Hunyuan3d21Args<'a, R: IntoUrl> { + pub request: Hunyuan3d21Request, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Hunyuan3d21Request { + pub image_url: String, +} + +pub async fn enqueue_hunyuan_3d_2_1_image_to_3d_webhook( + args: Hunyuan3d21Args<'_, R> +) -> Result { + let req = args.request; + + let request = Hunyuan3dV21ImageTo3dInput { + input_image_url: req.image_url, + textured_mesh: Some(true), + // TODO: Maybe expose these later + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = hunyuan3d_v21_image_to_3d(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::object::enqueue_hunyuan_3d_21_image_to_3d_webhook::{enqueue_hunyuan_3d_2_1_image_to_3d_webhook, Hunyuan3d21Args, Hunyuan3d21Request}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test_hunyuan3d_21() -> AnyhowResult<()> { + let image_url = "https://cdn-2.fakeyou.com/media/p/a/c/7/j/pac7jgp2tkehm7j7sm4sky1fpnkrnbve/image_pac7jgp2tkehm7j7sm4sky1fpnkrnbve.png"; + + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Hunyuan3d21Args { + request: Hunyuan3d21Request { + image_url: image_url.to_string(), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hunyuan_3d_2_1_image_to_3d_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan_3d_2_image_to_3d_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan_3d_2_image_to_3d_webhook.rs new file mode 100644 index 0000000000..377c07a296 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/object/enqueue_hunyuan_3d_2_image_to_3d_webhook.rs @@ -0,0 +1,39 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::object::http_hunyuan3d_v2_image_to_3d::{hunyuan3d_v2_image_to_3d, Hunyuan3dV2ImageTo3dInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Hunyuan3d2Args<'a, R: IntoUrl> { + pub request: Hunyuan3d2Request, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Hunyuan3d2Request { + pub image_url: String, +} + +pub async fn enqueue_hunyuan_3d_2_image_to_3d_webhook( + args: Hunyuan3d2Args<'_, R> +) -> Result { + let req = args.request; + + let request = Hunyuan3dV2ImageTo3dInput { + input_image_url: req.image_url, + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = hunyuan3d_v2_image_to_3d(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/object/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/object/mod.rs new file mode 100644 index 0000000000..c1c8ed493b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/object/mod.rs @@ -0,0 +1,5 @@ +pub mod enqueue_hunyuan_3d_2_image_to_3d_webhook; +pub mod enqueue_hunyuan_3d_21_image_to_3d_webhook; +pub mod enqueue_hunyuan3d_v3_image_to_3d_webhook; +pub mod enqueue_hunyuan3d_v3_sketch_to_3d_webhook; +pub mod enqueue_hunyuan3d_v3_text_to_3d_webhook; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..3e4e81f450 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook.rs @@ -0,0 +1,87 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_hailuo_v2p3_fast_pro_image_to_video::{hailuo_v2p3_fast_pro_image_to_video, HailuoV2p3FastProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueHailuoV2p3FastProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueHailuoV2p3FastProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueHailuoV2p3FastProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub prompt_optimizer: Option, +} + +impl FalRequestCostCalculator for EnqueueHailuoV2p3FastProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + /// "Your request will cost $0.33 per video." + 33 + } +} + + +/// Hailuo 2.3 Fast Pro Image-to-Video +/// https://fal.ai/models/fal-ai/minimax/hailuo-2.3-fast/pro/image-to-video +pub async fn enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook( + args: EnqueueHailuoV2p3FastProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + let prompt_optimizer = req.prompt_optimizer.unwrap_or(true); + + let request = HailuoV2p3FastProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + // Optionals + prompt_optimizer: Some(prompt_optimizer), + }; + + let result = hailuo_v2p3_fast_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook::{enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook, EnqueueHailuoV2p3FastProImageToVideoArgs, EnqueueHailuoV2p3FastProImageToVideoRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueHailuoV2p3FastProImageToVideoArgs { + request: EnqueueHailuoV2p3FastProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + prompt_optimizer: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_hailuo_v2p3_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_hailuo_v2p3_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..4be35887d5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_hailuo_v2p3_pro_image_to_video_webhook.rs @@ -0,0 +1,87 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_hailuo_v2p3_pro_image_to_video::{hailuo_v2p3_pro_image_to_video, HailuoV2p3ProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueHailuoV2p3ProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueHailuoV2p3ProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueHailuoV2p3ProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub prompt_optimizer: Option, +} + +impl FalRequestCostCalculator for EnqueueHailuoV2p3ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + /// "Your request will cost $0.49 per video generation." + 49 + } +} + + +/// Hailuo 2.3 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/minimax/hailuo-2.3/pro/image-to-video +pub async fn enqueue_hailuo_v2p3_pro_image_to_video_webhook( + args: EnqueueHailuoV2p3ProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + let prompt_optimizer = req.prompt_optimizer.unwrap_or(true); + + let request = HailuoV2p3ProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + // Optionals + prompt_optimizer: Some(prompt_optimizer), + }; + + let result = hailuo_v2p3_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_hailuo_v2p3_pro_image_to_video_webhook::{enqueue_hailuo_v2p3_pro_image_to_video_webhook, EnqueueHailuoV2p3ProImageToVideoArgs, EnqueueHailuoV2p3ProImageToVideoRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueHailuoV2p3ProImageToVideoArgs { + request: EnqueueHailuoV2p3ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + prompt_optimizer: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hailuo_v2p3_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_3p0_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_3p0_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..9ffee9971b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_3p0_pro_image_to_video_webhook.rs @@ -0,0 +1,319 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_3p0_pro_image_to_video::{kling_3p0_pro_image_to_video, Kling3p0ProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKling3p0ProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKling3p0ProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKling3p0ProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub end_image_url: Option, + pub generate_audio: Option, + pub negative_prompt: Option, + pub duration: Option, + pub aspect_ratio: Option, + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0ProImageToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl EnqueueKling3p0ProImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + pub fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0ProImageToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0ProImageToVideoShotType { + Customize, + Intelligent, +} + +impl FalRequestCostCalculator for EnqueueKling3p0ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Same pricing as text-to-video for Kling 3.0 Pro: + // Audio off: $0.224/second + // Audio on: $0.336/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(EnqueueKling3p0ProImageToVideoDuration::FiveSeconds) + .to_seconds(); + + let rate = if generate_audio { 336u64 } else { 224u64 }; + (rate * duration_secs + 9) / 10 + } +} + +/// Kling 3.0 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v3/pro/image-to-video +pub async fn enqueue_kling_3p0_pro_image_to_video_webhook( + args: EnqueueKling3p0ProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| d.to_str().to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueKling3p0ProImageToVideoAspectRatio::Square => "1:1", + EnqueueKling3p0ProImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueKling3p0ProImageToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let shot_type = req.shot_type + .map(|st| match st { + EnqueueKling3p0ProImageToVideoShotType::Customize => "customize", + EnqueueKling3p0ProImageToVideoShotType::Intelligent => "intelligent", + }) + .map(|s| s.to_string()); + + let request = Kling3p0ProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + end_image_url: req.end_image_url, + aspect_ratio, + generate_audio: req.generate_audio, + duration, + negative_prompt: req.negative_prompt, + shot_type, + cfg_scale: None, + }; + + let result = kling_3p0_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[test] + fn test_cost() { + let mut req = EnqueueKling3p0ProImageToVideoRequest { + prompt: "the t-rex skeleton leaps off the podium and charges".to_string(), + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + end_image_url: None, + generate_audio: Some(false), + negative_prompt: None, + duration: Some(EnqueueKling3p0ProImageToVideoDuration::FiveSeconds), + aspect_ratio: None, + shot_type: None, + }; + + // Audio off: $0.224/sec + // 5s: (224 * 5 + 9) / 10 = 1129 / 10 = 112 + assert_eq!(req.calculate_cost_in_cents(), 112); + + // 10s: (224 * 10 + 9) / 10 = 2249 / 10 = 224 + req.duration = Some(EnqueueKling3p0ProImageToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 224); + + // 15s: (224 * 15 + 9) / 10 = 3369 / 10 = 336 + req.duration = Some(EnqueueKling3p0ProImageToVideoDuration::FifteenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 336); + + // Audio on: $0.336/sec + req.generate_audio = Some(true); + + // 5s: (336 * 5 + 9) / 10 = 1689 / 10 = 168 + req.duration = Some(EnqueueKling3p0ProImageToVideoDuration::FiveSeconds); + assert_eq!(req.calculate_cost_in_cents(), 168); + + // 10s: (336 * 10 + 9) / 10 = 3369 / 10 = 336 + req.duration = Some(EnqueueKling3p0ProImageToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 336); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKling3p0ProImageToVideoArgs { + request: EnqueueKling3p0ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera, then bites".to_string(), + duration: Some(EnqueueKling3p0ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0ProImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_3p0_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueKling3p0ProImageToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueKling3p0ProImageToVideoArgs { + request: EnqueueKling3p0ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton comes alive and roars at the camera".to_string(), + duration: Some(EnqueueKling3p0ProImageToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueKling3p0ProImageToVideoDuration::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueKling3p0ProImageToVideoArgs { + request: EnqueueKling3p0ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head and roars".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueKling3p0ProImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for st in EnqueueKling3p0ProImageToVideoShotType::iter() { + println!("--- shot type: {:?} ---", st); + let args = EnqueueKling3p0ProImageToVideoArgs { + request: EnqueueKling3p0ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton lurches forward and snaps its jaws".to_string(), + duration: Some(EnqueueKling3p0ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0ProImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: Some(st), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_3p0_standard_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_3p0_standard_image_to_video_webhook.rs new file mode 100644 index 0000000000..3c10a68223 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_3p0_standard_image_to_video_webhook.rs @@ -0,0 +1,319 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_3p0_standard_image_to_video::{kling_3p0_standard_image_to_video, Kling3p0StandardImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKling3p0StandardImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKling3p0StandardImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKling3p0StandardImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub end_image_url: Option, + pub generate_audio: Option, + pub negative_prompt: Option, + pub duration: Option, + pub aspect_ratio: Option, + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0StandardImageToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl EnqueueKling3p0StandardImageToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + pub fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0StandardImageToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0StandardImageToVideoShotType { + Customize, + Intelligent, +} + +impl FalRequestCostCalculator for EnqueueKling3p0StandardImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Same pricing as text-to-video for Kling 3.0 Standard: + // Audio off: $0.168/second + // Audio on: $0.252/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(EnqueueKling3p0StandardImageToVideoDuration::FiveSeconds) + .to_seconds(); + + let rate = if generate_audio { 252u64 } else { 168u64 }; + (rate * duration_secs + 9) / 10 + } +} + +/// Kling 3.0 Standard Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v3/standard/image-to-video +pub async fn enqueue_kling_3p0_standard_image_to_video_webhook( + args: EnqueueKling3p0StandardImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| d.to_str().to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueKling3p0StandardImageToVideoAspectRatio::Square => "1:1", + EnqueueKling3p0StandardImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueKling3p0StandardImageToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let shot_type = req.shot_type + .map(|st| match st { + EnqueueKling3p0StandardImageToVideoShotType::Customize => "customize", + EnqueueKling3p0StandardImageToVideoShotType::Intelligent => "intelligent", + }) + .map(|s| s.to_string()); + + let request = Kling3p0StandardImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + end_image_url: req.end_image_url, + aspect_ratio, + generate_audio: req.generate_audio, + duration, + negative_prompt: req.negative_prompt, + shot_type, + cfg_scale: None, + }; + + let result = kling_3p0_standard_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[test] + fn test_cost() { + let mut req = EnqueueKling3p0StandardImageToVideoRequest { + prompt: "the t-rex skeleton comes alive and roars".to_string(), + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + end_image_url: None, + generate_audio: Some(false), + negative_prompt: None, + duration: Some(EnqueueKling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: None, + shot_type: None, + }; + + // Audio off: $0.168/sec + // 5s: (168 * 5 + 9) / 10 = 849 / 10 = 84 + assert_eq!(req.calculate_cost_in_cents(), 84); + + // 10s: (168 * 10 + 9) / 10 = 1689 / 10 = 168 + req.duration = Some(EnqueueKling3p0StandardImageToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 168); + + // 15s: (168 * 15 + 9) / 10 = 2529 / 10 = 252 + req.duration = Some(EnqueueKling3p0StandardImageToVideoDuration::FifteenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 252); + + // Audio on: $0.252/sec + req.generate_audio = Some(true); + + // 5s: (252 * 5 + 9) / 10 = 1269 / 10 = 126 + req.duration = Some(EnqueueKling3p0StandardImageToVideoDuration::FiveSeconds); + assert_eq!(req.calculate_cost_in_cents(), 126); + + // 10s: (252 * 10 + 9) / 10 = 2529 / 10 = 252 + req.duration = Some(EnqueueKling3p0StandardImageToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 252); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKling3p0StandardImageToVideoArgs { + request: EnqueueKling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking toward the camera".to_string(), + duration: Some(EnqueueKling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_3p0_standard_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueKling3p0StandardImageToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueKling3p0StandardImageToVideoArgs { + request: EnqueueKling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(EnqueueKling3p0StandardImageToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_standard_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueKling3p0StandardImageToVideoDuration::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueKling3p0StandardImageToVideoArgs { + request: EnqueueKling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head and opens its jaw".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueKling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(false), + negative_prompt: None, + end_image_url: None, + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_standard_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for st in EnqueueKling3p0StandardImageToVideoShotType::iter() { + println!("--- shot type: {:?} ---", st); + let args = EnqueueKling3p0StandardImageToVideoArgs { + request: EnqueueKling3p0StandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton lurches forward dramatically".to_string(), + duration: Some(EnqueueKling3p0StandardImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0StandardImageToVideoAspectRatio::SixteenByNine), + generate_audio: Some(true), + negative_prompt: None, + end_image_url: None, + shot_type: Some(st), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_standard_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v1p6_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v1p6_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..4e3deb7e04 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v1p6_pro_image_to_video_webhook.rs @@ -0,0 +1,120 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_v1p6_pro_image_to_video::{kling_v1p6_pro_image_to_video, KlingV1p6ProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Kling1p6ProArgs<'a, R: IntoUrl> { + pub request: Kling1p6ProRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Kling1p6ProRequest { + pub image_url: String, + pub end_frame_image_url: Option, + pub prompt: String, + pub duration: Kling1p6ProDuration, + pub aspect_ratio: Kling1p6ProAspectRatio, +} + +#[derive(Copy, Clone, Debug)] +pub enum Kling1p6ProDuration { + Default, + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug)] +pub enum Kling1p6ProAspectRatio { + Square, // 1:1 + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +impl FalRequestCostCalculator for Kling1p6ProRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "Your request will cost $0.095 per second." + match self.duration { + Kling1p6ProDuration::Default => 48, // $0.095 * 5 = $0.475 (round up) + Kling1p6ProDuration::FiveSeconds => 48, // $0.095 * 5 = $0.475 (round up) + Kling1p6ProDuration::TenSeconds => 95, // $0.095 * 10 = $0.95 + } + } +} + + +/// Kling 1.6 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v1.6/pro/image-to-video +pub async fn enqueue_kling_v1p6_pro_image_to_video_webhook( + args: Kling1p6ProArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Kling1p6ProDuration::Default => None, // defaults to "5" + Kling1p6ProDuration::FiveSeconds => Some("5".to_string()), // Gross... + Kling1p6ProDuration::TenSeconds => Some("10".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Kling1p6ProAspectRatio::Square => Some("1:1".to_string()), + Kling1p6ProAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Kling1p6ProAspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let request = KlingV1p6ProImageToVideoInput { + image_url: req.image_url, + tail_image_url: req.end_frame_image_url, + prompt: req.prompt, + aspect_ratio, + duration, + // Maybe expose these later + cfg_scale: None, + negative_prompt: None, + }; + + let result = kling_v1p6_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{enqueue_kling_v1p6_pro_image_to_video_webhook, Kling1p6ProArgs, Kling1p6ProRequest, Kling1p6ProAspectRatio, Kling1p6ProDuration}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Kling1p6ProArgs { + request: Kling1p6ProRequest { + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_frame_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + prompt: "shiba in glasses runs to the lake and stands by the shore".to_string(), + duration: Kling1p6ProDuration::Default, + aspect_ratio: Kling1p6ProAspectRatio::WideSixteenNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v1p6_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p1_master_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p1_master_image_to_video_webhook.rs new file mode 100644 index 0000000000..76cdc89b5a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p1_master_image_to_video_webhook.rs @@ -0,0 +1,121 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::image::http_kling_v2p1_master_image_to_video::{kling_v2p1_master_image_to_video, KlingV2p1MasterImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +pub struct Kling2p1MasterArgs<'a, R: IntoUrl> { + pub request: Kling2p1MasterRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Kling2p1MasterRequest { + pub image_url: String, + pub prompt: String, + pub duration: Kling2p1MasterDuration, + pub aspect_ratio: Kling2p1MasterAspectRatio, +} + +#[derive(Copy, Clone, Debug)] +pub enum Kling2p1MasterDuration { + Default, + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug)] +pub enum Kling2p1MasterAspectRatio { + Square, // 1:1 + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +impl FalRequestCostCalculator for Kling2p1MasterRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost $1.40. + // For every additional second you will be charged $0.28." + match self.duration { + Kling2p1MasterDuration::Default => 140, // $1.40 for 5 seconds + Kling2p1MasterDuration::FiveSeconds => 140, // $1.40 for 5 seconds + Kling2p1MasterDuration::TenSeconds => 280, // $1.40 + (0.28 * 5) = $2.80 + } + } +} + + +/// Kling 2.1 Master Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.1/master/image-to-video +pub async fn enqueue_kling_v2p1_master_image_to_video_webhook( + args: Kling2p1MasterArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Kling2p1MasterDuration::Default => None, // defaults to "5" + Kling2p1MasterDuration::FiveSeconds => Some("5".to_string()), // Gross... + Kling2p1MasterDuration::TenSeconds => Some("10".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Kling2p1MasterAspectRatio::Square => Some("1:1".to_string()), + Kling2p1MasterAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Kling2p1MasterAspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let request = KlingV2p1MasterImageToVideoInput { + image_url: req.image_url, + prompt: req.prompt, + aspect_ratio, + duration, + // Maybe expose these later + cfg_scale: None, + negative_prompt: None, + tail_image_url: None, + }; + + let result = kling_v2p1_master_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::{enqueue_kling_v2p1_master_image_to_video_webhook, Kling2p1MasterArgs, Kling2p1MasterRequest, Kling2p1MasterAspectRatio, Kling2p1MasterDuration}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test_kling21_master_video() -> AnyhowResult<()> { + let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png"; + + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Kling2p1MasterArgs { + request: Kling2p1MasterRequest { + image_url: image_url.to_string(), + prompt: "a shot of the mountains, the camera rotates around to show the mountain range, the sun begins to set, and the moon becomes visible".to_string(), + duration: Kling2p1MasterDuration::Default, + aspect_ratio: Kling2p1MasterAspectRatio::WideSixteenNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p1_master_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p1_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p1_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..b8f15beb60 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p1_pro_image_to_video_webhook.rs @@ -0,0 +1,120 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_v2p1_pro_image_to_video::{kling_v2p1_pro_image_to_video, KlingV2p1ProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Kling2p1ProArgs<'a, R: IntoUrl> { + pub request: Kling2p1ProRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Kling2p1ProRequest { + pub image_url: String, + pub end_frame_image_url: Option, + pub prompt: String, + pub duration: Kling2p1ProDuration, + pub aspect_ratio: Kling2p1ProAspectRatio, +} + +#[derive(Copy, Clone, Debug)] +pub enum Kling2p1ProDuration { + Default, + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug)] +pub enum Kling2p1ProAspectRatio { + Square, // 1:1 + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +impl FalRequestCostCalculator for Kling2p1ProRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost $0.45. + // For every additional second you will be charged $0.09." + match self.duration { + Kling2p1ProDuration::Default => 45, // $0.45 + Kling2p1ProDuration::FiveSeconds => 45, // $0.45 + Kling2p1ProDuration::TenSeconds => 90, // $0.45 + (5 * 0.09) = $0.90 + } + } +} + +/// Kling 2.1 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.1/pro/image-to-video +pub async fn enqueue_kling_v2p1_pro_image_to_video_webhook( + args: Kling2p1ProArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Kling2p1ProDuration::Default => None, // Defaults to 5 seconds + Kling2p1ProDuration::FiveSeconds => Some("5".to_string()), // Gross... + Kling2p1ProDuration::TenSeconds => Some("10".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Kling2p1ProAspectRatio::Square => Some("1:1".to_string()), + Kling2p1ProAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Kling2p1ProAspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let request = KlingV2p1ProImageToVideoInput { + image_url: req.image_url, + prompt: req.prompt, + tail_image_url: req.end_frame_image_url, + aspect_ratio, + duration, + // Maybe expose these later + cfg_scale: None, + negative_prompt: None, + }; + + let result = kling_v2p1_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::{enqueue_kling_v2p1_pro_image_to_video_webhook, Kling2p1ProArgs, Kling2p1ProRequest, Kling2p1ProAspectRatio, Kling2p1ProDuration}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Kling2p1ProArgs { + request: Kling2p1ProRequest { + image_url: SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL.to_string(), + end_frame_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + prompt: "a shot of the mountains, the camera pulls back to show a corgi waiting to jump into the lake".to_string(), + duration: Kling2p1ProDuration::Default, + aspect_ratio: Kling2p1ProAspectRatio::WideSixteenNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p1_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p5_turbo_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p5_turbo_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..3abb2c2a49 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p5_turbo_pro_image_to_video_webhook.rs @@ -0,0 +1,114 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_v2p5_turbo_pro_image_to_video::{kling_v2p5_turbo_pro_image_to_video, KlingV2p5TurboProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKlingV2p5TurboProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKlingV2p5TurboProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKlingV2p5TurboProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub tail_image_url: Option, + pub negative_prompt: Option, + + pub duration: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p5TurboProImageToVideoDurationSeconds { + Five, + Ten, +} + +impl FalRequestCostCalculator for EnqueueKlingV2p5TurboProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + match self.duration { + None => 35, // $0.35 + Some(EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Five) => 35, // $0.35 + Some(EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Ten) => 70, // $0.35 + (5 * $0.07) = $0.70 + } + } +} + + +/// Kling 2.5 Turbo Pro Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video +pub async fn enqueue_kling_v2p5_turbo_pro_image_to_video_webhook( + args: EnqueueKlingV2p5TurboProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + // NB: Defaults to 5 seconds + let duration = req.duration + .map(|resolution| match resolution { + EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Five => "5", + EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Ten => "10", + }) + .map(|resolution| resolution.to_string()); + + let request = KlingV2p5TurboProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + // Optionals + duration, + tail_image_url: req.tail_image_url, + negative_prompt: req.negative_prompt, + // Constants + cfg_scale: None, + }; + + let result = kling_v2p5_turbo_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::{enqueue_kling_v2p5_turbo_pro_image_to_video_webhook, EnqueueKlingV2p5TurboProImageToVideoArgs, EnqueueKlingV2p5TurboProImageToVideoRequest, EnqueueKlingV2p5TurboProImageToVideoDurationSeconds}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKlingV2p5TurboProImageToVideoArgs { + request: EnqueueKlingV2p5TurboProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + negative_prompt: None, + tail_image_url: None, + duration: Some(EnqueueKlingV2p5TurboProImageToVideoDurationSeconds::Five), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p5_turbo_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p5_turbo_standard_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p5_turbo_standard_image_to_video_webhook.rs new file mode 100644 index 0000000000..d3511325dc --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p5_turbo_standard_image_to_video_webhook.rs @@ -0,0 +1,110 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_v2p5_turbo_standard_image_to_video::{kling_v2p5_turbo_standard_image_to_video, KlingV2p5TurboStandardImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKlingV2p5TurboStandardImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKlingV2p5TurboStandardImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKlingV2p5TurboStandardImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub negative_prompt: Option, + pub duration: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds { + Five, + Ten, +} + + +impl FalRequestCostCalculator for EnqueueKlingV2p5TurboStandardImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost $0.21. + // For every additional second you will be charged $0.042." + match self.duration { + None => 21, // $0.21 + Some(EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds::Five) => 21, // $0.21 + Some(EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds::Ten) => 42, // $0.21 + (5 * $0.042) = $0.42 + } + } +} + +/// Kling 2.5 Turbo Standard Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/standard/image-to-video +pub async fn enqueue_kling_v2p5_turbo_standard_image_to_video_webhook( + args: EnqueueKlingV2p5TurboStandardImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + // NB: Defaults to 5 seconds. + let duration = req.duration + .map(|resolution| match resolution { + EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds::Five => "5", + EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds::Ten => "10", + }) + .map(|resolution| resolution.to_string()); + + let request = KlingV2p5TurboStandardImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + // Optionals + duration, + negative_prompt: req.negative_prompt, + // Constants + cfg_scale: None, + }; + + let result = kling_v2p5_turbo_standard_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_kling_v2p5_turbo_standard_image_to_video_webhook::{enqueue_kling_v2p5_turbo_standard_image_to_video_webhook, EnqueueKlingV2p5TurboStandardImageToVideoArgs, EnqueueKlingV2p5TurboStandardImageToVideoRequest, EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKlingV2p5TurboStandardImageToVideoArgs { + request: EnqueueKlingV2p5TurboStandardImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + negative_prompt: None, + duration: Some(EnqueueKlingV2p5TurboStandardImageToVideoDurationSeconds::Five), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p5_turbo_standard_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p6_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p6_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..446b6cca6f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_kling_v2p6_pro_image_to_video_webhook.rs @@ -0,0 +1,120 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_kling_v2p6_pro_image_to_video::{kling_v2p6_pro_image_to_video, KlingV2p6ProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKlingV2p6ProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKlingV2p6ProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKlingV2p6ProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + + /// NB: Generate audio defaults to true, and it costs twice as much. + pub generate_audio: Option, + + pub negative_prompt: Option, + + /// NB: Duration defaults to 5 seconds + pub duration: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p6ProImageToVideoDurationSeconds { + Five, + Ten, +} + +impl FalRequestCostCalculator for EnqueueKlingV2p6ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be + // charged $0.07 (audio off) or $0.14 (audio on). + // For example, a 5s video with audio on will cost $0.70" + let generate_audio = self.generate_audio.unwrap_or(true); + let duration = self.duration.unwrap_or(EnqueueKlingV2p6ProImageToVideoDurationSeconds::Five); + + match (generate_audio, duration) { + (false, EnqueueKlingV2p6ProImageToVideoDurationSeconds::Five) => 35, // audio off: $0.07 * 5 = $0.35 + (false, EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten) => 70, // audio off: $0.07 * 10 = $0.70 + (true, EnqueueKlingV2p6ProImageToVideoDurationSeconds::Five) => 70, // audio on: $0.14 * 5 = $0.70 + (true, EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten) => 140, // audio on: $0.14 * 10 = $1.40 + } + } +} + + +/// Kling 2.6 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.6/pro/image-to-video +pub async fn enqueue_kling_v2p6_pro_image_to_video_webhook( + args: EnqueueKlingV2p6ProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueKlingV2p6ProImageToVideoDurationSeconds::Five => "5", + EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten => "10", + }) + .map(|resolution| resolution.to_string()); + + let request = KlingV2p6ProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + // Optionals + duration, + negative_prompt: req.negative_prompt, + generate_audio: req.generate_audio, + }; + + let result = kling_v2p6_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::{enqueue_kling_v2p6_pro_image_to_video_webhook, EnqueueKlingV2p6ProImageToVideoArgs, EnqueueKlingV2p6ProImageToVideoRequest, EnqueueKlingV2p6ProImageToVideoDurationSeconds}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKlingV2p6ProImageToVideoArgs { + request: EnqueueKlingV2p6ProImageToVideoRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + prompt: "the man says, 'these ghosts sure are scary!' and then runs. behind him, a group of ghosts appear from the trees and chase him. the camera tracks the action. finally, the man yells, 'go away ghosts'.".to_string(), + negative_prompt: None, + generate_audio: Some(true), + duration: Some(EnqueueKlingV2p6ProImageToVideoDurationSeconds::Ten), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p6_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_pixverse_v5_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_pixverse_v5_image_to_video_webhook.rs new file mode 100644 index 0000000000..d602c10e97 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_pixverse_v5_image_to_video_webhook.rs @@ -0,0 +1,190 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_pixverse_v5_image_to_video::{pixverse_v5_image_to_video, PixverseV5ImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueuePixverseV5ImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueuePixverseV5ImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueuePixverseV5ImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub negative_prompt: Option, + + // NB: eight-second videos do not work with 1080P + // NB: Defaults to 5 seconds + pub duration: Option, + pub aspect_ratio: Option, + + // NB: Defaults to 720p + pub resolution: Option, + pub style: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5ImageToVideoDurationSeconds { + Five, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5ImageToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5ImageToVideoAspectRatio { + Square, + SixteenByNine, + FourByThree, + NineBySixteen, + ThreeByFour, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5ImageToVideoStyle { + Anime, + Animation3d, + Clay, + Comic, + Cyberpunk, +} + +impl FalRequestCostCalculator for EnqueuePixverseV5ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost + // $0.15 for 360p and 540p, + // $0.2 for 720p and + // $0.4 for 1080p. + // For $1 you can run this model with approximately 2 times." + let duration = self.duration.unwrap_or(EnqueuePixverseV5ImageToVideoDurationSeconds::Five); + let resolution = self.resolution.unwrap_or(EnqueuePixverseV5ImageToVideoResolution::SevenTwentyP); + match (duration, resolution) { + // Five seconds + (EnqueuePixverseV5ImageToVideoDurationSeconds::Five, EnqueuePixverseV5ImageToVideoResolution::ThreeSixtyP) => 15, // $0.15 + (EnqueuePixverseV5ImageToVideoDurationSeconds::Five, EnqueuePixverseV5ImageToVideoResolution::FiveFortyP) => 15, // $0.15 + (EnqueuePixverseV5ImageToVideoDurationSeconds::Five, EnqueuePixverseV5ImageToVideoResolution::SevenTwentyP) => 20, // $0.20 + (EnqueuePixverseV5ImageToVideoDurationSeconds::Five, EnqueuePixverseV5ImageToVideoResolution::TenEightyP) => 40, // $0.40 + // Ten seconds + (EnqueuePixverseV5ImageToVideoDurationSeconds::Eight, EnqueuePixverseV5ImageToVideoResolution::ThreeSixtyP) => 30, // NB: Doubling since unclear + (EnqueuePixverseV5ImageToVideoDurationSeconds::Eight, EnqueuePixverseV5ImageToVideoResolution::FiveFortyP) => 30, // NB: Doubling since unclear + (EnqueuePixverseV5ImageToVideoDurationSeconds::Eight, EnqueuePixverseV5ImageToVideoResolution::SevenTwentyP) => 40, // NB: Doubling since unclear + (EnqueuePixverseV5ImageToVideoDurationSeconds::Eight, EnqueuePixverseV5ImageToVideoResolution::TenEightyP) => 80, // NB: Doubling since unclear + } + } +} + + +/// Pixverse 5 Image-to-Video +/// https://fal.ai/models/fal-ai/pixverse/v5/image-to-video +pub async fn enqueue_pixverse_v5_image_to_video_webhook( + args: EnqueuePixverseV5ImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueuePixverseV5ImageToVideoDurationSeconds::Five => "5", + EnqueuePixverseV5ImageToVideoDurationSeconds::Eight=> "8", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueuePixverseV5ImageToVideoAspectRatio::Square => "1:1", + EnqueuePixverseV5ImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueuePixverseV5ImageToVideoAspectRatio::FourByThree => "4:3", + EnqueuePixverseV5ImageToVideoAspectRatio::NineBySixteen => "9:16", + EnqueuePixverseV5ImageToVideoAspectRatio::ThreeByFour => "3:4", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueuePixverseV5ImageToVideoResolution::ThreeSixtyP => "360p", + EnqueuePixverseV5ImageToVideoResolution::FiveFortyP => "540p", + EnqueuePixverseV5ImageToVideoResolution::SevenTwentyP => "720p", + EnqueuePixverseV5ImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let style = req.style + .map(|style| match style { + EnqueuePixverseV5ImageToVideoStyle::Anime => "anime", + EnqueuePixverseV5ImageToVideoStyle::Animation3d => "3d_animation", + EnqueuePixverseV5ImageToVideoStyle::Clay => "clay", + EnqueuePixverseV5ImageToVideoStyle::Comic => "comic", + EnqueuePixverseV5ImageToVideoStyle::Cyberpunk => "cyberpunk", + }) + .map(|s| s.to_string()); + + let request = PixverseV5ImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + // Optionals + duration, + negative_prompt: req.negative_prompt, + aspect_ratio, + resolution, + style, + // Constant + seed: None, + }; + + let result = pixverse_v5_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_pixverse_v5_image_to_video_webhook::{enqueue_pixverse_v5_image_to_video_webhook, EnqueuePixverseV5ImageToVideoArgs, EnqueuePixverseV5ImageToVideoAspectRatio, EnqueuePixverseV5ImageToVideoDurationSeconds, EnqueuePixverseV5ImageToVideoRequest, EnqueuePixverseV5ImageToVideoResolution, EnqueuePixverseV5ImageToVideoStyle}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueuePixverseV5ImageToVideoArgs { + request: EnqueuePixverseV5ImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + negative_prompt: None, + duration: Some(EnqueuePixverseV5ImageToVideoDurationSeconds::Five), + style: Some(EnqueuePixverseV5ImageToVideoStyle::Cyberpunk), + aspect_ratio: Some(EnqueuePixverseV5ImageToVideoAspectRatio::FourByThree), + resolution: Some(EnqueuePixverseV5ImageToVideoResolution::TenEightyP), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_pixverse_v5_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1_lite_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1_lite_image_to_video_webhook.rs new file mode 100644 index 0000000000..07e66ff1c0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1_lite_image_to_video_webhook.rs @@ -0,0 +1,219 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_seedance_1_lite_image_to_video::{seedance_1_lite_image_to_video, Seedance1LiteImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Seedance1LiteArgs<'a, V: IntoUrl> { + pub request: Seedance1LiteRequest, + pub api_key: &'a FalApiKey, + pub webhook_url: V, +} + +#[derive(Clone, Debug)] +pub struct Seedance1LiteRequest { + pub image_url: String, + pub end_frame_image_url: Option, + pub prompt: String, + pub duration: Seedance1LiteDuration, + pub resolution: Seedance1LiteResolution, + pub aspect_ratio: Option, + pub camera_fixed: bool, + pub seed: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Seedance1LiteDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Seedance1LiteResolution { + FourEightyP, // 480p + SevenTwentyP, // 720p + TenEightyP, // 1080p +} + +/// Possible enum values: 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, auto +#[derive(Copy, Clone, Debug)] +pub enum Seedance1LiteAspectRatio { + Auto, + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + + +impl FalRequestCostCalculator for Seedance1LiteRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "Each 720p 5 second video costs $0.18. + // For other resolutions, 1 million video tokens costs $1.8. + // tokens(video) = (height x width x FPS x duration) / 1024." + + if self.resolution == Seedance1LiteResolution::SevenTwentyP + && self.duration == Seedance1LiteDuration::FiveSeconds + { + return 18; + } + + // TODO: Only correct for some aspect ratios for now. + let (width, height) = match self.resolution { + Seedance1LiteResolution::FourEightyP => (640u32, 480u32), // NB: Only for 4:3 ! + Seedance1LiteResolution::SevenTwentyP => (1280, 720), // NB: Only for 16:9 ! + Seedance1LiteResolution::TenEightyP => (1920, 1080), + }; + + let duration = match self.duration { + Seedance1LiteDuration::FiveSeconds => 5.0, + Seedance1LiteDuration::TenSeconds => 10.0, + }; + + // TODO: Not sure if FPS is right. + // Inferred from https://help.scenario.com/en/articles/seedance-models-the-essentials/ + const FPS : f64 = 30.0; + + let tokens = (height as f64) * (width as f64) * FPS * duration; + let tokens = tokens / 1024.0; + + let cost = tokens * 1.8 / 1_000_000.0; + let cost = cost * 100.0; // Dollars to cents. + let cost = cost.ceil(); // NB: This is probably what Fal does. + + cost as UsdCents + } +} + + +/// Seedance 1.0 Lite Image-to-Video +/// https://fal.ai/models/fal-ai/bytedance/seedance/v1/lite/image-to-video +pub async fn enqueue_seedance_1_lite_image_to_video_webhook( + args: Seedance1LiteArgs<'_, V> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Seedance1LiteDuration::FiveSeconds => Some("5".to_string()), + Seedance1LiteDuration::TenSeconds => Some("10".to_string()), + }; + + let resolution = match req.resolution { + Seedance1LiteResolution::FourEightyP => Some("480p".to_string()), + Seedance1LiteResolution::SevenTwentyP => Some("720p".to_string()), + Seedance1LiteResolution::TenEightyP => Some("1080p".to_string()), + }; + + /// Possible enum values: 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, auto + let aspect_ratio = req.aspect_ratio + .map(|r| match r { + Seedance1LiteAspectRatio::Auto => "auto", + Seedance1LiteAspectRatio::TwentyOneByNine => "21:9", + Seedance1LiteAspectRatio::SixteenByNine => "16:9", + Seedance1LiteAspectRatio::FourByThree => "4:3", + Seedance1LiteAspectRatio::Square => "1:1", + Seedance1LiteAspectRatio::ThreeByFour => "3:4", + Seedance1LiteAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let request = Seedance1LiteImageToVideoInput { + image_url: req.image_url, + end_image_url: req.end_frame_image_url, + prompt: req.prompt, + duration, + resolution, + aspect_ratio, + // TODO: Add these later + camera_fixed: None, + seed: -1, + // Static + enable_safety_checker: Some(false), + }; + + let result = seedance_1_lite_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{enqueue_seedance_1_lite_image_to_video_webhook, Seedance1LiteArgs, Seedance1LiteDuration, Seedance1LiteRequest, Seedance1LiteResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL}; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + + #[test] + fn test_cost() { + let mut req = Seedance1LiteRequest { + image_url: String::new(), + end_frame_image_url: Some(String::new()), + prompt: String::new(), + camera_fixed: false, + duration: Seedance1LiteDuration::FiveSeconds, + resolution: Seedance1LiteResolution::SevenTwentyP, + aspect_ratio: None, + seed: None, + }; + + // NB: Constant value specified by FAL + req.duration = Seedance1LiteDuration::FiveSeconds; + req.resolution = Seedance1LiteResolution::SevenTwentyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 18); + + // NB: Calculations follow... + req.duration = Seedance1LiteDuration::FiveSeconds; + req.resolution = Seedance1LiteResolution::TenEightyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 55); + + req.duration = Seedance1LiteDuration::FiveSeconds; + req.resolution = Seedance1LiteResolution::FourEightyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 9); + + req.duration = Seedance1LiteDuration::TenSeconds; + req.resolution = Seedance1LiteResolution::TenEightyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 110); + } + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Seedance1LiteArgs { + request: Seedance1LiteRequest { + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + end_frame_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()), + prompt: "shiba in glasses runs to the lake and stands by the shore".to_string(), + camera_fixed: false, + duration: Seedance1LiteDuration::FiveSeconds, + resolution: Seedance1LiteResolution::SevenTwentyP, + aspect_ratio: None, + seed: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_seedance_1_lite_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..5c957112c4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1_pro_image_to_video_webhook.rs @@ -0,0 +1,208 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_seedance_1_pro_image_to_video::{seedance_1_pro_image_to_video, Seedance1ProImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Seedance1ProArgs<'a, V: IntoUrl> { + pub request: Seedance1ProRequest, + pub webhook_url: V, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Seedance1ProRequest { + pub image_url: String, + pub prompt: String, + pub camera_fixed: bool, + pub duration: Seedance1ProDuration, + pub resolution: Seedance1ProResolution, + pub seed: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Seedance1ProDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Seedance1ProResolution { + FourEightyP, // 480p + SevenTwentyP, // 720p + TenEightyP, // 1080p +} + + +impl FalRequestCostCalculator for Seedance1ProRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "Each 1080p 5 second video costs roughly $0.62. + // For other resolutions, 1 million video tokens costs $2.5. + // tokens(video) = (height x width x FPS x duration) / 1024." + + if self.resolution == Seedance1ProResolution::TenEightyP + && self.duration == Seedance1ProDuration::FiveSeconds + { + return 62; + } + + // TODO: Only correct for some aspect ratios for now. + let (width, height) = match self.resolution { + Seedance1ProResolution::FourEightyP => (640u32, 480u32), // NB: Only for 4:3 ! + Seedance1ProResolution::SevenTwentyP => (1280, 720), // NB: Only for 16:9 ! + Seedance1ProResolution::TenEightyP => (1920, 1080), + }; + + let duration = match self.duration { + Seedance1ProDuration::ThreeSeconds => 3.0, + Seedance1ProDuration::FourSeconds => 4.0, + Seedance1ProDuration::FiveSeconds => 5.0, + Seedance1ProDuration::SixSeconds => 6.0, + Seedance1ProDuration::SevenSeconds => 7.0, + Seedance1ProDuration::EightSeconds => 8.0, + Seedance1ProDuration::NineSeconds => 9.0, + Seedance1ProDuration::TenSeconds => 10.0, + Seedance1ProDuration::ElevenSeconds => 11.0, + Seedance1ProDuration::TwelveSeconds => 12.0, + }; + + // TODO: Not sure if FPS is right. + // Inferred from https://help.scenario.com/en/articles/seedance-models-the-essentials/ + const FPS : f64 = 30.0; + + let tokens = (height as f64) * (width as f64) * FPS * duration; + let tokens = tokens / 1024.0; + + let cost = tokens * 2.5 / 1_000_000.0; + let cost = cost * 100.0; // Dollars to cents. + let cost = cost.ceil(); // NB: This is probably what Fal does. + + cost as UsdCents + } +} + +/// Seedance 1.0 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/bytedance/seedance/v1/pro/image-to-video +pub async fn enqueue_seedance_1_pro_image_to_video_webhook( + args: Seedance1ProArgs<'_, V> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Seedance1ProDuration::ThreeSeconds => Some("3".to_string()), + Seedance1ProDuration::FourSeconds => Some("4".to_string()), + Seedance1ProDuration::FiveSeconds => Some("5".to_string()), + Seedance1ProDuration::SixSeconds => Some("6".to_string()), + Seedance1ProDuration::SevenSeconds => Some("7".to_string()), + Seedance1ProDuration::EightSeconds => Some("8".to_string()), + Seedance1ProDuration::NineSeconds => Some("9".to_string()), + Seedance1ProDuration::TenSeconds => Some("10".to_string()), + Seedance1ProDuration::ElevenSeconds => Some("11".to_string()), + Seedance1ProDuration::TwelveSeconds => Some("12".to_string()), + }; + + let resolution = match req.resolution { + Seedance1ProResolution::FourEightyP => Some("480p".to_string()), + Seedance1ProResolution::SevenTwentyP => Some("720p".to_string()), + Seedance1ProResolution::TenEightyP => Some("1080p".to_string()), + }; + + let request = Seedance1ProImageToVideoInput { + image_url: req.image_url, + prompt: req.prompt, + duration, + resolution, + // TODO: Add these later + camera_fixed: None, + // Static + enable_safety_checker: Some(false), + }; + + let result = seedance_1_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use crate::requests_old::webhook::video::image::enqueue_seedance_1_pro_image_to_video_webhook::{enqueue_seedance_1_pro_image_to_video_webhook, Seedance1ProArgs, Seedance1ProDuration, Seedance1ProRequest, Seedance1ProResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TALL_MOCHI_WITH_GLASSES_IMAGE_URL; + + #[test] + fn test_cost() { + let mut req = Seedance1ProRequest { + image_url: String::new(), + prompt: String::new(), + camera_fixed: false, + duration: Seedance1ProDuration::FiveSeconds, + resolution: Seedance1ProResolution::TenEightyP, + seed: None, + }; + + // NB: Constant value specified by FAL + req.duration = Seedance1ProDuration::FiveSeconds; + req.resolution = Seedance1ProResolution::TenEightyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 62); + + // NB: Calculations follow... + req.duration = Seedance1ProDuration::FiveSeconds; + req.resolution = Seedance1ProResolution::SevenTwentyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 34); + + req.duration = Seedance1ProDuration::TenSeconds; + req.resolution = Seedance1ProResolution::SevenTwentyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 68); + + req.duration = Seedance1ProDuration::TenSeconds; + req.resolution = Seedance1ProResolution::TenEightyP; + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 152); + } + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Seedance1ProArgs { + request: Seedance1ProRequest { + image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(), + prompt: "shiba in glasses runs to the lake and stands by the shore".to_string(), + camera_fixed: false, + duration: Seedance1ProDuration::FiveSeconds, + resolution: Seedance1ProResolution::SevenTwentyP, + seed: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_seedance_1_pro_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1p5_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1p5_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..4fb05e17c3 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_seedance_1p5_pro_image_to_video_webhook.rs @@ -0,0 +1,358 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::image::http_seedance_1p5_pro_image_to_video::{seedance_1p5_pro_image_to_video, Seedance1p5ProImageToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueSeedance1p5ProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueSeedance1p5ProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueSeedance1p5ProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub end_image_url: Option, + pub resolution: Option, + pub duration: Option, + pub aspect_ratio: Option, + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueSeedance1p5ProImageToVideoDuration { + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueSeedance1p5ProImageToVideoResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueSeedance1p5ProImageToVideoAspectRatio { + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, + Auto, +} + +impl FalRequestCostCalculator for EnqueueSeedance1p5ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "Each 720p 5 second video with audio costs roughly $0.26. + // For other resolutions, 1 million video tokens with audio costs $2.4. + // tokens(video) = (height x width x FPS x duration) / 1024." + + let resolution = self.resolution.unwrap_or(EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP); + let duration = self.duration.unwrap_or(EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds); + + let audio = self.generate_audio.unwrap_or(true); + let dollars_per_million_tokens = if audio { 2.4 } else { 1.2 }; + + if resolution == EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP + && duration == EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds + { + return if audio { 26 } else { 13 }; + } + + // TODO: Only correct for some aspect ratios for now. + let (width, height) = match resolution { + EnqueueSeedance1p5ProImageToVideoResolution::FourEightyP => (640u32, 480u32), // NB: Only for 4:3 ! + EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP => (1280, 720), // NB: Only for 16:9 ! + EnqueueSeedance1p5ProImageToVideoResolution::TenEightyP => (1920, 1080), + }; + + let duration_secs = match duration { + EnqueueSeedance1p5ProImageToVideoDuration::FourSeconds => 4.0, + EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds => 5.0, + EnqueueSeedance1p5ProImageToVideoDuration::SixSeconds => 6.0, + EnqueueSeedance1p5ProImageToVideoDuration::SevenSeconds => 7.0, + EnqueueSeedance1p5ProImageToVideoDuration::EightSeconds => 8.0, + EnqueueSeedance1p5ProImageToVideoDuration::NineSeconds => 9.0, + EnqueueSeedance1p5ProImageToVideoDuration::TenSeconds => 10.0, + EnqueueSeedance1p5ProImageToVideoDuration::ElevenSeconds => 11.0, + EnqueueSeedance1p5ProImageToVideoDuration::TwelveSeconds => 12.0, + }; + + const FPS: f64 = 30.0; + + let tokens = (height as f64) * (width as f64) * FPS * duration_secs; + let tokens = tokens / 1024.0; + + let cost = tokens * dollars_per_million_tokens / 1_000_000.0; + let cost = cost * 100.0; // Dollars to cents. + let cost = cost.ceil(); + + cost as UsdCents + } +} + +/// Seedance 1.5 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/bytedance/seedance/v1.5/pro/image-to-video +pub async fn enqueue_seedance_1p5_pro_image_to_video_webhook( + args: EnqueueSeedance1p5ProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| match d { + EnqueueSeedance1p5ProImageToVideoDuration::FourSeconds => "4", + EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds => "5", + EnqueueSeedance1p5ProImageToVideoDuration::SixSeconds => "6", + EnqueueSeedance1p5ProImageToVideoDuration::SevenSeconds => "7", + EnqueueSeedance1p5ProImageToVideoDuration::EightSeconds => "8", + EnqueueSeedance1p5ProImageToVideoDuration::NineSeconds => "9", + EnqueueSeedance1p5ProImageToVideoDuration::TenSeconds => "10", + EnqueueSeedance1p5ProImageToVideoDuration::ElevenSeconds => "11", + EnqueueSeedance1p5ProImageToVideoDuration::TwelveSeconds => "12", + }) + .map(|d| d.to_string()); + + let resolution = req.resolution + .map(|r| match r { + EnqueueSeedance1p5ProImageToVideoResolution::FourEightyP => "480p", + EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP => "720p", + EnqueueSeedance1p5ProImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueSeedance1p5ProImageToVideoAspectRatio::TwentyOneByNine => "21:9", + EnqueueSeedance1p5ProImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueSeedance1p5ProImageToVideoAspectRatio::FourByThree => "4:3", + EnqueueSeedance1p5ProImageToVideoAspectRatio::Square => "1:1", + EnqueueSeedance1p5ProImageToVideoAspectRatio::ThreeByFour => "3:4", + EnqueueSeedance1p5ProImageToVideoAspectRatio::NineBySixteen => "9:16", + EnqueueSeedance1p5ProImageToVideoAspectRatio::Auto => "auto", + }) + .map(|ar| ar.to_string()); + + let request = Seedance1p5ProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + end_image_url: req.end_image_url, + duration, + resolution, + aspect_ratio, + camera_fixed: None, + seed: None, + enable_safety_checker: Some(false), + generate_audio: Some(req.generate_audio.unwrap_or(true)), + }; + + let result = seedance_1p5_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[test] + fn test_cost() { + let mut req = EnqueueSeedance1p5ProImageToVideoRequest { + prompt: String::new(), + image_url: String::new(), + end_image_url: None, + duration: Some(EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds), + resolution: Some(EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: None, + }; + + // NB: Constant value specified by Fal + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 26); + + // Calculated values + req.duration = Some(EnqueueSeedance1p5ProImageToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 65); + + req.duration = Some(EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds); + req.resolution = Some(EnqueueSeedance1p5ProImageToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 73); + + req.duration = Some(EnqueueSeedance1p5ProImageToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProImageToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 146); + } + + #[test] + fn test_cost_audio_off() { + let mut req = EnqueueSeedance1p5ProImageToVideoRequest { + prompt: String::new(), + image_url: String::new(), + end_image_url: None, + duration: Some(EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds), + resolution: Some(EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + }; + + // 720p 5s without audio = half of 26 + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 13); + + // Calculated values — half of audio-on costs (ceil) + req.duration = Some(EnqueueSeedance1p5ProImageToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 33); + + req.duration = Some(EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds); + req.resolution = Some(EnqueueSeedance1p5ProImageToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 37); + + req.duration = Some(EnqueueSeedance1p5ProImageToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProImageToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 73); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueSeedance1p5ProImageToVideoArgs { + request: EnqueueSeedance1p5ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera".to_string(), + duration: Some(EnqueueSeedance1p5ProImageToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueSeedance1p5ProImageToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueSeedance1p5ProImageToVideoResolution::SevenTwentyP), + end_image_url: None, + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_seedance_1p5_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueSeedance1p5ProImageToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueSeedance1p5ProImageToVideoArgs { + request: EnqueueSeedance1p5ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(EnqueueSeedance1p5ProImageToVideoDuration::FourSeconds), + aspect_ratio: Some(ar), + resolution: None, + end_image_url: None, + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_seedance_1p5_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueSeedance1p5ProImageToVideoDuration::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueSeedance1p5ProImageToVideoArgs { + request: EnqueueSeedance1p5ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueSeedance1p5ProImageToVideoAspectRatio::SixteenByNine), + resolution: None, + end_image_url: None, + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_seedance_1p5_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_resolutions() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for res in EnqueueSeedance1p5ProImageToVideoResolution::iter() { + println!("--- resolution: {:?} ---", res); + let args = EnqueueSeedance1p5ProImageToVideoArgs { + request: EnqueueSeedance1p5ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly comes alive".to_string(), + duration: Some(EnqueueSeedance1p5ProImageToVideoDuration::FourSeconds), + aspect_ratio: Some(EnqueueSeedance1p5ProImageToVideoAspectRatio::SixteenByNine), + resolution: Some(res), + end_image_url: None, + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_seedance_1p5_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_sora_2_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_sora_2_image_to_video_webhook.rs new file mode 100644 index 0000000000..f15cb2d737 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_sora_2_image_to_video_webhook.rs @@ -0,0 +1,216 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::image::http_sora_2_image_to_video::{sora_2_image_to_video, Sora2ImageToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueSora2ImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueSora2ImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueSora2ImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub duration: Option, + pub resolution: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ImageToVideoDurationSeconds { + Four, + Eight, + Twelve, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ImageToVideoResolution { + Auto, + SevenTwentyP, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ImageToVideoAspectRatio { + Auto, + NineBySixteen, + SixteenByNine, +} + +impl FalRequestCostCalculator for EnqueueSora2ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "The pricing is $0.1/s for Sora 2." + let duration = self.duration.unwrap_or(EnqueueSora2ImageToVideoDurationSeconds::Four); + match duration { + EnqueueSora2ImageToVideoDurationSeconds::Four => 40, // $0.10 * 4 + EnqueueSora2ImageToVideoDurationSeconds::Eight => 80, // $0.10 * 8 + EnqueueSora2ImageToVideoDurationSeconds::Twelve => 120 // $0.10 * 12 + } + } +} + +/// Sora 2 Image-to-Video +/// https://fal.ai/models/fal-ai/sora-2/image-to-video +pub async fn enqueue_sora_2_image_to_video_webhook( + args: EnqueueSora2ImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| match d { + EnqueueSora2ImageToVideoDurationSeconds::Four => 4, + EnqueueSora2ImageToVideoDurationSeconds::Eight => 8, + EnqueueSora2ImageToVideoDurationSeconds::Twelve => 12, + }); + + let resolution = req.resolution + .map(|r| match r { + EnqueueSora2ImageToVideoResolution::Auto => "auto", + EnqueueSora2ImageToVideoResolution::SevenTwentyP => "720p", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueSora2ImageToVideoAspectRatio::Auto => "auto", + EnqueueSora2ImageToVideoAspectRatio::NineBySixteen => "9:16", + EnqueueSora2ImageToVideoAspectRatio::SixteenByNine => "16:9", + }) + .map(|ar| ar.to_string()); + + let request = Sora2ImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + duration, + resolution, + aspect_ratio, + delete_video: Some(false), + }; + + let result = sora_2_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueSora2ImageToVideoArgs { + request: EnqueueSora2ImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + duration: Some(EnqueueSora2ImageToVideoDurationSeconds::Twelve), + aspect_ratio: Some(EnqueueSora2ImageToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueSora2ImageToVideoResolution::SevenTwentyP), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_sora_2_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueSora2ImageToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueSora2ImageToVideoArgs { + request: EnqueueSora2ImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(EnqueueSora2ImageToVideoDurationSeconds::Four), + aspect_ratio: Some(ar), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueSora2ImageToVideoDurationSeconds::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueSora2ImageToVideoArgs { + request: EnqueueSora2ImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueSora2ImageToVideoAspectRatio::SixteenByNine), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_resolutions() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for res in EnqueueSora2ImageToVideoResolution::iter() { + println!("--- resolution: {:?} ---", res); + let args = EnqueueSora2ImageToVideoArgs { + request: EnqueueSora2ImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly comes alive".to_string(), + duration: Some(EnqueueSora2ImageToVideoDurationSeconds::Four), + aspect_ratio: Some(EnqueueSora2ImageToVideoAspectRatio::SixteenByNine), + resolution: Some(res), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_sora_2_pro_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_sora_2_pro_image_to_video_webhook.rs new file mode 100644 index 0000000000..2673097434 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_sora_2_pro_image_to_video_webhook.rs @@ -0,0 +1,226 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::image::http_sora_2_pro_image_to_video::{sora_2_pro_image_to_video, Sora2ProImageToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueSora2ProImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueSora2ProImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueSora2ProImageToVideoRequest { + // Request required + pub prompt: String, + pub image_url: String, + + // Optional args + pub resolution: Option, + pub duration: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ProImageToVideoDurationSeconds { + Four, + Eight, + Twelve, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ProImageToVideoResolution { + Auto, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ProImageToVideoAspectRatio { + Auto, + NineBySixteen, + SixteenByNine, +} + +impl FalRequestCostCalculator for EnqueueSora2ProImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.30/s for 720p and $0.50/s for 1080p. + let duration = self.duration.unwrap_or(EnqueueSora2ProImageToVideoDurationSeconds::Four); + let resolution = self.resolution.unwrap_or(EnqueueSora2ProImageToVideoResolution::Auto); + + match (duration, resolution) { + (EnqueueSora2ProImageToVideoDurationSeconds::Four, EnqueueSora2ProImageToVideoResolution::Auto) => 120, + (EnqueueSora2ProImageToVideoDurationSeconds::Four, EnqueueSora2ProImageToVideoResolution::SevenTwentyP) => 120, + (EnqueueSora2ProImageToVideoDurationSeconds::Four, EnqueueSora2ProImageToVideoResolution::TenEightyP) => 200, + (EnqueueSora2ProImageToVideoDurationSeconds::Eight, EnqueueSora2ProImageToVideoResolution::Auto) => 240, + (EnqueueSora2ProImageToVideoDurationSeconds::Eight, EnqueueSora2ProImageToVideoResolution::SevenTwentyP) => 240, + (EnqueueSora2ProImageToVideoDurationSeconds::Eight, EnqueueSora2ProImageToVideoResolution::TenEightyP) => 400, + (EnqueueSora2ProImageToVideoDurationSeconds::Twelve, EnqueueSora2ProImageToVideoResolution::Auto) => 360, + (EnqueueSora2ProImageToVideoDurationSeconds::Twelve, EnqueueSora2ProImageToVideoResolution::SevenTwentyP) => 360, + (EnqueueSora2ProImageToVideoDurationSeconds::Twelve, EnqueueSora2ProImageToVideoResolution::TenEightyP) => 600, + } + } +} + +/// Sora 2 Pro Image-to-Video +/// https://fal.ai/models/fal-ai/sora-2/image-to-video/pro +pub async fn enqueue_sora_2_pro_image_to_video_webhook( + args: EnqueueSora2ProImageToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| match d { + EnqueueSora2ProImageToVideoDurationSeconds::Four => 4, + EnqueueSora2ProImageToVideoDurationSeconds::Eight => 8, + EnqueueSora2ProImageToVideoDurationSeconds::Twelve => 12, + }); + + let resolution = req.resolution + .map(|r| match r { + EnqueueSora2ProImageToVideoResolution::Auto => "auto", + EnqueueSora2ProImageToVideoResolution::SevenTwentyP => "720p", + EnqueueSora2ProImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueSora2ProImageToVideoAspectRatio::Auto => "auto", + EnqueueSora2ProImageToVideoAspectRatio::NineBySixteen => "9:16", + EnqueueSora2ProImageToVideoAspectRatio::SixteenByNine => "16:9", + }) + .map(|ar| ar.to_string()); + + let request = Sora2ProImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + duration, + resolution, + aspect_ratio, + delete_video: Some(false), + }; + + let result = sora_2_pro_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueSora2ProImageToVideoArgs { + request: EnqueueSora2ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton gets off the podium and begins walking to the camera. the camera orbits slightly. The t-rex gets close and then bites.".to_string(), + duration: Some(EnqueueSora2ProImageToVideoDurationSeconds::Twelve), + aspect_ratio: Some(EnqueueSora2ProImageToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueSora2ProImageToVideoResolution::TenEightyP), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_sora_2_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueSora2ProImageToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueSora2ProImageToVideoArgs { + request: EnqueueSora2ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton comes alive and roars at the camera".to_string(), + duration: Some(EnqueueSora2ProImageToVideoDurationSeconds::Four), + aspect_ratio: Some(ar), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueSora2ProImageToVideoDurationSeconds::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueSora2ProImageToVideoArgs { + request: EnqueueSora2ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly turns its head".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueSora2ProImageToVideoAspectRatio::SixteenByNine), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_resolutions() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for res in EnqueueSora2ProImageToVideoResolution::iter() { + println!("--- resolution: {:?} ---", res); + let args = EnqueueSora2ProImageToVideoArgs { + request: EnqueueSora2ProImageToVideoRequest { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the skeleton slowly comes alive".to_string(), + duration: Some(EnqueueSora2ProImageToVideoDurationSeconds::Four), + aspect_ratio: Some(EnqueueSora2ProImageToVideoAspectRatio::SixteenByNine), + resolution: Some(res), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_pro_image_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_2_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_2_image_to_video_webhook.rs new file mode 100644 index 0000000000..40f3762e35 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_2_image_to_video_webhook.rs @@ -0,0 +1,119 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::core_api::webhook_response::WebhookResponse; +use crate::requests_old::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use reqwest::IntoUrl; + +pub struct Veo2Args<'a, R: IntoUrl> { + pub request: Veo2Request, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +/// Args for Veo 2 image-to-video. Note: image-to-video does NOT support +/// aspect_ratio — the output inherits the source image's aspect ratio. +#[derive(Clone, Debug)] +pub struct Veo2Request { + pub image_url: String, + pub prompt: String, + pub duration: Veo2Duration, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Veo2Duration { + Default, // NB: Default is 5 seconds + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, +} + +/// Aspect ratio enum — used by the text-to-video endpoint only. +/// Kept here because it's the canonical location that the text-to-video +/// webhook and the router plan both re-export from. +#[derive(Copy, Clone, Debug)] +pub enum Veo2AspectRatio { + Auto, + AutoPreferPortrait, + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +impl FalRequestCostCalculator for Veo2Request { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost $2.50. + // For every aditional second you will be charged $0.50." + match self.duration { + Veo2Duration::Default => 250, + Veo2Duration::FiveSeconds => 250, + Veo2Duration::SixSeconds => 300, + Veo2Duration::SevenSeconds => 350, + Veo2Duration::EightSeconds => 400, + } + } +} + + +/// Veo 2 Image-to-Video +/// https://fal.ai/models/fal-ai/veo2/image-to-video +pub async fn enqueue_veo_2_image_to_video_webhook( + args: Veo2Args<'_, R> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Veo2Duration::Default => None, // NB: Default is 5 seconds. + Veo2Duration::FiveSeconds => Some("5s".to_string()), + Veo2Duration::SixSeconds => Some("6s".to_string()), + Veo2Duration::SevenSeconds => Some("7s".to_string()), + Veo2Duration::EightSeconds => Some("8s".to_string()), + }; + + let request = Veo2ImageToVideoInput { + image_url: req.image_url, + prompt: req.prompt, + duration, + }; + + let result = veo_2_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Duration, Veo2Request}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test_veo_2_image_to_video() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Veo2Args { + request: Veo2Request { + image_url: MOUNTAIN_TREE_IMAGE_URL.to_string(), + prompt: "a shot of the mountains as the sun sets and reveals the moon and stars".to_string(), + duration: Veo2Duration::Default, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_2_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3_fast_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3_fast_image_to_video_webhook.rs new file mode 100644 index 0000000000..c5365f2325 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3_fast_image_to_video_webhook.rs @@ -0,0 +1,145 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_veo_3_fast_image_to_video::{veo_3_fast_image_to_video, Veo3FastImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Veo3FastArgs<'a, R: IntoUrl> { + pub request: Veo3FastRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Veo3FastRequest { + pub prompt: String, + pub image_url: String, + pub aspect_ratio: Veo3FastAspectRatio, + pub duration: Veo3FastDuration, + pub resolution: Veo3FastResolution, + pub generate_audio: bool, +} + +#[derive(Copy, Clone, Debug)] +pub enum Veo3FastDuration { + Default, // NB: Defaults to 8 seconds. + FourSeconds, + SixSeconds, + EightSeconds, +} + +#[derive(Copy, Clone, Debug)] +pub enum Veo3FastAspectRatio { + Auto, + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +#[derive(Copy, Clone, Debug)] +pub enum Veo3FastResolution { + Default, + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for Veo3FastRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.10 (audio off) or $0.15 (audio on). + // For example, a 5s video with audio on will cost $0.75." + match (self.duration, self.generate_audio) { + (Veo3FastDuration::FourSeconds, false) => 40, + (Veo3FastDuration::SixSeconds, false) => 60, + (Veo3FastDuration::EightSeconds, false) => 80, + (Veo3FastDuration::Default, false) => 80, // NB: Default is 8 seconds + (Veo3FastDuration::FourSeconds, true) => 60, + (Veo3FastDuration::SixSeconds, true) => 90, + (Veo3FastDuration::EightSeconds, true) => 120, + (Veo3FastDuration::Default, true) => 120, // NB: Default is 8 seconds + } + } +} + + +/// Veo 3 Fast Image-to-Video +/// https://fal.ai/models/fal-ai/veo3/fast/image-to-video +pub async fn enqueue_veo_3_fast_image_to_video_webhook( + args: Veo3FastArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Veo3FastDuration::Default => None, // NB: Defaults to 8. + Veo3FastDuration::FourSeconds => Some("4s".to_string()), + Veo3FastDuration::SixSeconds => Some("6s".to_string()), + Veo3FastDuration::EightSeconds => Some("8s".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Veo3FastAspectRatio::Auto => Some("auto".to_string()), + Veo3FastAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo3FastAspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let resolution = match req.resolution { + Veo3FastResolution::Default => None, + Veo3FastResolution::SevenTwentyP => Some("720p".to_string()), + Veo3FastResolution::TenEightyP => Some("1080p".to_string()), + }; + + let request = Veo3FastImageToVideoInput { + image_url: req.image_url, + prompt: req.prompt, + aspect_ratio, + resolution, + duration, + generate_audio: Some(req.generate_audio), + }; + + let result = veo_3_fast_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastRequest, Veo3FastResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_GHOST_TREX_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + let image_url = ERNEST_GHOST_TREX_IMAGE_URL; + + // XXX: Don't commit secrets! + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = Veo3FastArgs { + request: Veo3FastRequest { + image_url: image_url.to_string(), + prompt: "man is standing next to a ghost and t-rex, they begin to chase him as the camera pulls back to show the wider scene".to_string(), + aspect_ratio: Veo3FastAspectRatio::WideSixteenNine, + duration: Veo3FastDuration::EightSeconds, + generate_audio: true, + resolution: Veo3FastResolution::TenEightyP, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3_fast_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs new file mode 100644 index 0000000000..6f6a591dc5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs @@ -0,0 +1,149 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_veo_3_image_to_video::{veo_3_image_to_video, Veo3ImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct Veo3Args<'a, R: IntoUrl> { + pub request: Veo3Request, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Veo3Request { + pub image_url: String, + pub prompt: String, + pub duration: Veo3I2vDuration, + pub aspect_ratio: Veo3I2vAspectRatio, + pub resolution: Veo3I2vResolution, + pub generate_audio: bool, +} + +/// Duration for Veo 3 image-to-video. Default is 8 seconds. +#[derive(Copy, Clone, Debug)] +pub enum Veo3I2vDuration { + Default, // Default is 8 seconds + FourSeconds, + SixSeconds, + EightSeconds, +} + +/// Aspect ratio for Veo 3 image-to-video. +/// Supports Auto (inherit source), 16:9, and 9:16. No Square. +#[derive(Copy, Clone, Debug)] +pub enum Veo3I2vAspectRatio { + Auto, + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +/// Resolution for Veo 3 image-to-video. +#[derive(Copy, Clone, Debug)] +pub enum Veo3I2vResolution { + Default, + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for Veo3Request { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.20 (audio off) or + // $0.40 (audio on). + // For example, a 5s video with audio on will cost $2." + + match (self.duration, self.generate_audio) { + (Veo3I2vDuration::FourSeconds, false) => 80, + (Veo3I2vDuration::SixSeconds, false) => 120, + (Veo3I2vDuration::EightSeconds, false) => 160, + (Veo3I2vDuration::Default, false) => 160, + (Veo3I2vDuration::FourSeconds, true) => 160, + (Veo3I2vDuration::SixSeconds, true) => 240, + (Veo3I2vDuration::EightSeconds, true) => 320, + (Veo3I2vDuration::Default, true) => 320, + } + } +} + + +/// Veo 3 Image-to-Video +/// https://fal.ai/models/fal-ai/veo3/image-to-video +pub async fn enqueue_veo_3_image_to_video_webhook( + args: Veo3Args<'_, R> +) -> Result { + let req = args.request; + + let duration = match req.duration { + Veo3I2vDuration::Default => None, + Veo3I2vDuration::FourSeconds => Some("4s".to_string()), + Veo3I2vDuration::SixSeconds => Some("6s".to_string()), + Veo3I2vDuration::EightSeconds => Some("8s".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Veo3I2vAspectRatio::Auto => Some("auto".to_string()), + Veo3I2vAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo3I2vAspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let resolution = match req.resolution { + Veo3I2vResolution::Default => None, + Veo3I2vResolution::SevenTwentyP => Some("720p".to_string()), + Veo3I2vResolution::TenEightyP => Some("1080p".to_string()), + }; + + let request = Veo3ImageToVideoInput { + image_url: req.image_url, + prompt: req.prompt, + aspect_ratio, + resolution, + duration, + generate_audio: Some(req.generate_audio), + }; + + let result = veo_3_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{ + enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, + Veo3I2vResolution, Veo3Request, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Veo3Args { + request: Veo3Request { + image_url: TREX_SKELETON_IMAGE_URL.to_string(), + prompt: "the t-rex skeleton starts walking towards the camera and roars".to_string(), + duration: Veo3I2vDuration::EightSeconds, + aspect_ratio: Veo3I2vAspectRatio::WideSixteenNine, + resolution: Veo3I2vResolution::TenEightyP, + generate_audio: true, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_veo_3_image_to_video_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook.rs new file mode 100644 index 0000000000..9424eb1b9a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook.rs @@ -0,0 +1,165 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_veo_3p1_fast_first_last_frame_image_to_video::{veo_3p1_fast_first_last_frame_image_to_video, Veo3p1FastFirstLastFrameImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest { + // Request required + pub prompt: String, + + // Starting frame + pub first_frame_url: String, + + // Ending frame + pub last_frame_url: String, + + // Optional args follow + + // NB: Defaults to 8 seconds + pub duration: Option, + + pub aspect_ratio: Option, + + pub resolution: Option, + + // NB: Defaults to true. Costs more to generate audio. + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds { + Four, + Six, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.10 (audio off) or + // $0.15 (audio on). + // For example, a 5s video with audio on will cost $0.75." + let duration = self.duration + .unwrap_or(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight); + + let generate_audio = self.generate_audio.unwrap_or(true); + + match (duration, generate_audio) { + (EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four, false) => 40, + (EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six, false) => 60, + (EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight, false) => 80, + (EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four, true) => 60, + (EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six, true) => 90, + (EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight, true) => 120, + } + } +} + + +/// Veo 3.1 Fast First Frame / Last Frame Image-to-Video +/// https://fal.ai/models/fal-ai/veo3.1/fast/first-last-frame-to-video +pub async fn enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook( + args: EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four => "4s", + EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six => "6s", + EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight=> "8s", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::Auto => "auto", + EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::SevenTwentyP => "720p", + EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let request = Veo3p1FastFirstLastFrameImageToVideoInput { + prompt: req.prompt, + first_frame_url: req.first_frame_url, + last_frame_url: req.last_frame_url, + generate_audio: req.generate_audio, + // Optionals + duration, + aspect_ratio, + resolution, + }; + + let result = veo_3p1_fast_first_last_frame_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::{enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook, EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs, EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio, EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds, EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest, EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{TALL_CORGI_SHIBA_TREASURE_OCEAN_URL, TALL_CORGI_SHIBA_TREASURE_SKY_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs { + request: EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest { + first_frame_url: TALL_CORGI_SHIBA_TREASURE_OCEAN_URL.to_string(), + last_frame_url: TALL_CORGI_SHIBA_TREASURE_SKY_URL.to_string(), + prompt: "There is a tiny ocean island with a corgi and shiba and treasure chest on it. The corgi and shiba are barking at the chest, when suddenly the island launches itself into the air. The camera tracks the island and follows it up high in the sky. The sun beams over the horizon. The dogs are happy and bark. The gold coins gleam in the sun.".to_string(), + duration: Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six), + aspect_ratio: Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::NineBySixteen), + resolution: Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::SevenTwentyP), + generate_audio: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_fast_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_fast_image_to_video_webhook.rs new file mode 100644 index 0000000000..955316f2b0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_fast_image_to_video_webhook.rs @@ -0,0 +1,155 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_veo_3p1_fast_image_to_video::{veo_3p1_fast_image_to_video, Veo3p1FastImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueVeo3p1FastImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueVeo3p1FastImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueVeo3p1FastImageToVideoRequest { + // Request required + pub prompt: String, + + // Starting frame + pub image_url: String, + + // Optional args + pub duration: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastImageToVideoDurationSeconds { + Four, + Six, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + + +impl FalRequestCostCalculator for EnqueueVeo3p1FastImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.10 (audio off) or + // $0.15 (audio on). + // For example, a 5s video with audio on will cost $0.75." + let duration = self.duration + .unwrap_or(EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight); + + let generate_audio = self.generate_audio.unwrap_or(true); + + match (duration, generate_audio) { + (EnqueueVeo3p1FastImageToVideoDurationSeconds::Four, false) => 40, + (EnqueueVeo3p1FastImageToVideoDurationSeconds::Six, false) => 60, + (EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight, false) => 80, + (EnqueueVeo3p1FastImageToVideoDurationSeconds::Four, true) => 60, + (EnqueueVeo3p1FastImageToVideoDurationSeconds::Six, true) => 90, + (EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight, true) => 120, + } + } +} + + +/// Veo 3.1 Fast Image-to-Video +/// https://fal.ai/models/fal-ai/veo3.1/fast/image-to-video +pub async fn enqueue_veo_3p1_fast_image_to_video_webhook( + args: EnqueueVeo3p1FastImageToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueVeo3p1FastImageToVideoDurationSeconds::Four => "4s", + EnqueueVeo3p1FastImageToVideoDurationSeconds::Six => "6s", + EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight=> "8s", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueueVeo3p1FastImageToVideoAspectRatio::Auto => "auto", + EnqueueVeo3p1FastImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueVeo3p1FastImageToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueVeo3p1FastImageToVideoResolution::SevenTwentyP => "720p", + EnqueueVeo3p1FastImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let request = Veo3p1FastImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + generate_audio: req.generate_audio, + // Optionals + duration, + aspect_ratio, + resolution, + }; + + let result = veo_3p1_fast_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::{enqueue_veo_3p1_fast_image_to_video_webhook, EnqueueVeo3p1FastImageToVideoArgs, EnqueueVeo3p1FastImageToVideoAspectRatio, EnqueueVeo3p1FastImageToVideoDurationSeconds, EnqueueVeo3p1FastImageToVideoRequest, EnqueueVeo3p1FastImageToVideoResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TALL_CORGI_SHIBA_TREASURE_OCEAN_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueVeo3p1FastImageToVideoArgs { + request: EnqueueVeo3p1FastImageToVideoRequest { + image_url: TALL_CORGI_SHIBA_TREASURE_OCEAN_URL.to_string(), + prompt: "There is a tiny ocean island with a corgi and shiba and treasure chest on it. The corgi and shiba are barking at the chest, when suddenly the island launches itself into the air. The camera tracks the island and follows it up high in the sky. The sun beams over the horizon. The dogs are happy and bark. The gold coins gleam in the sun.".to_string(), + duration: Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Four), + aspect_ratio: Some(EnqueueVeo3p1FastImageToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueVeo3p1FastImageToVideoResolution::TenEightyP), + generate_audio: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3p1_fast_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_first_last_frame_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_first_last_frame_image_to_video_webhook.rs new file mode 100644 index 0000000000..40467ad7a4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_first_last_frame_image_to_video_webhook.rs @@ -0,0 +1,159 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_veo_3p1_first_last_frame_image_to_video::{veo_3p1_first_last_frame_image_to_video, Veo3p1FirstLastFrameImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueVeo3p1FirstLastFrameImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueVeo3p1FirstLastFrameImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueVeo3p1FirstLastFrameImageToVideoRequest { + // Request required + pub prompt: String, + + // Starting frame + pub first_frame_url: String, + + // Ending frame + pub last_frame_url: String, + + // Optional args + pub duration: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds { + Four, + Six, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FirstLastFrameImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for EnqueueVeo3p1FirstLastFrameImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.20 (audio off) or + // $0.40 (audio on). + // For example, a 5s video with audio on will cost $2." + let duration = self.duration + .unwrap_or(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight); + + let generate_audio = self.generate_audio.unwrap_or(true); + + match (duration, generate_audio) { + (EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four, false) => 80, + (EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six, false) => 120, + (EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight, false) => 160, + (EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four, true) => 160, + (EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six, true) => 240, + (EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight, true) => 320, + } + } +} + + +/// Veo 3.1 First Frame / Last Frame Image-to-Video +/// https://fal.ai/models/fal-ai/veo3.1/first-last-frame-to-video +pub async fn enqueue_veo_3p1_first_last_frame_image_to_video_webhook( + args: EnqueueVeo3p1FirstLastFrameImageToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four => "4s", + EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six => "6s", + EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight=> "8s", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::Auto => "auto", + EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueVeo3p1FirstLastFrameImageToVideoResolution::SevenTwentyP => "720p", + EnqueueVeo3p1FirstLastFrameImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let request = Veo3p1FirstLastFrameImageToVideoInput { + prompt: req.prompt, + first_frame_url: req.first_frame_url, + last_frame_url: req.last_frame_url, + generate_audio: req.generate_audio, + // Optionals + duration, + aspect_ratio, + resolution, + }; + + let result = veo_3p1_first_last_frame_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::{enqueue_veo_3p1_first_last_frame_image_to_video_webhook, EnqueueVeo3p1FirstLastFrameImageToVideoArgs, EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio, EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds, EnqueueVeo3p1FirstLastFrameImageToVideoRequest, EnqueueVeo3p1FirstLastFrameImageToVideoResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::{TALL_CORGI_SHIBA_TREASURE_OCEAN_URL, TALL_CORGI_SHIBA_TREASURE_SKY_URL}; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueVeo3p1FirstLastFrameImageToVideoArgs { + request: EnqueueVeo3p1FirstLastFrameImageToVideoRequest { + first_frame_url: TALL_CORGI_SHIBA_TREASURE_OCEAN_URL.to_string(), + last_frame_url: TALL_CORGI_SHIBA_TREASURE_SKY_URL.to_string(), + prompt: "There is a tiny ocean island with a corgi and shiba and treasure chest on it. The corgi and shiba are barking at the chest, when suddenly the island launches itself into the air. The camera tracks the island and follows it up high in the sky. The sun beams over the horizon. The dogs are happy and bark. The gold coins gleam in the sun.".to_string(), + duration: Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight), + aspect_ratio: Some(EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::NineBySixteen), + resolution: Some(EnqueueVeo3p1FirstLastFrameImageToVideoResolution::TenEightyP), + generate_audio: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3p1_first_last_frame_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_image_to_video_webhook.rs new file mode 100644 index 0000000000..7436396354 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/enqueue_veo_3p1_image_to_video_webhook.rs @@ -0,0 +1,154 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::image::http_veo_3p1_image_to_video::{veo_3p1_image_to_video, Veo3p1ImageToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueVeo3p1ImageToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueVeo3p1ImageToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueVeo3p1ImageToVideoRequest { + // Request required + pub prompt: String, + + // Starting frame + pub image_url: String, + + // Optional args + pub duration: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1ImageToVideoDurationSeconds { + Four, + Six, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1ImageToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1ImageToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for EnqueueVeo3p1ImageToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.20 (audio off) or + // $0.40 (audio on). + // For example, a 5s video with audio on will cost $2." + let duration = self.duration + .unwrap_or(EnqueueVeo3p1ImageToVideoDurationSeconds::Eight); + + let generate_audio = self.generate_audio.unwrap_or(true); + + match (duration, generate_audio) { + (EnqueueVeo3p1ImageToVideoDurationSeconds::Four, false) => 80, + (EnqueueVeo3p1ImageToVideoDurationSeconds::Six, false) => 120, + (EnqueueVeo3p1ImageToVideoDurationSeconds::Eight, false) => 160, + (EnqueueVeo3p1ImageToVideoDurationSeconds::Four, true) => 160, + (EnqueueVeo3p1ImageToVideoDurationSeconds::Six, true) => 240, + (EnqueueVeo3p1ImageToVideoDurationSeconds::Eight, true) => 320, + } + } +} + + +/// Veo 3.1 Image-to-Video +/// https://fal.ai/models/fal-ai/veo3.1/image-to-video +pub async fn enqueue_veo_3p1_image_to_video_webhook( + args: EnqueueVeo3p1ImageToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueVeo3p1ImageToVideoDurationSeconds::Four => "4s", + EnqueueVeo3p1ImageToVideoDurationSeconds::Six => "6s", + EnqueueVeo3p1ImageToVideoDurationSeconds::Eight=> "8s", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueueVeo3p1ImageToVideoAspectRatio::Auto => "auto", + EnqueueVeo3p1ImageToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueVeo3p1ImageToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueVeo3p1ImageToVideoResolution::SevenTwentyP => "720p", + EnqueueVeo3p1ImageToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let request = Veo3p1ImageToVideoInput { + prompt: req.prompt, + image_url: req.image_url, + generate_audio: req.generate_audio, + // Optionals + duration, + aspect_ratio, + resolution, + }; + + let result = veo_3p1_image_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::{enqueue_veo_3p1_image_to_video_webhook, EnqueueVeo3p1ImageToVideoArgs, EnqueueVeo3p1ImageToVideoAspectRatio, EnqueueVeo3p1ImageToVideoDurationSeconds, EnqueueVeo3p1ImageToVideoRequest, EnqueueVeo3p1ImageToVideoResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::TALL_CORGI_SHIBA_TREASURE_OCEAN_URL; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueVeo3p1ImageToVideoArgs { + request: EnqueueVeo3p1ImageToVideoRequest { + image_url: TALL_CORGI_SHIBA_TREASURE_OCEAN_URL.to_string(), + prompt: "There is a tiny ocean island with a corgi and shiba and treasure chest on it. The corgi and shiba are barking at the chest, when suddenly the island launches itself into the air. The camera tracks the island and follows it up high in the sky. The sun beams over the horizon. The dogs are happy and bark. The gold coins gleam in the sun.".to_string(), + duration: Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Eight), + aspect_ratio: Some(EnqueueVeo3p1ImageToVideoAspectRatio::NineBySixteen), + resolution: Some(EnqueueVeo3p1ImageToVideoResolution::TenEightyP), + generate_audio: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3p1_image_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/image/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/mod.rs new file mode 100644 index 0000000000..c25ae05555 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/image/mod.rs @@ -0,0 +1,23 @@ +pub mod enqueue_hailuo_v2p3_fast_pro_image_to_video_webhook; +pub mod enqueue_hailuo_v2p3_pro_image_to_video_webhook; +pub mod enqueue_kling_v1p6_pro_image_to_video_webhook; +pub mod enqueue_kling_v2p1_master_image_to_video_webhook; +pub mod enqueue_kling_v2p1_pro_image_to_video_webhook; +pub mod enqueue_kling_v2p5_turbo_pro_image_to_video_webhook; +pub mod enqueue_kling_v2p5_turbo_standard_image_to_video_webhook; +pub mod enqueue_kling_v2p6_pro_image_to_video_webhook; +pub mod enqueue_kling_3p0_pro_image_to_video_webhook; +pub mod enqueue_kling_3p0_standard_image_to_video_webhook; +pub mod enqueue_pixverse_v5_image_to_video_webhook; +pub mod enqueue_seedance_1_lite_image_to_video_webhook; +pub mod enqueue_seedance_1_pro_image_to_video_webhook; +pub mod enqueue_seedance_1p5_pro_image_to_video_webhook; +pub mod enqueue_sora_2_image_to_video_webhook; +pub mod enqueue_sora_2_pro_image_to_video_webhook; +pub mod enqueue_veo_2_image_to_video_webhook; +pub mod enqueue_veo_3_fast_image_to_video_webhook; +pub mod enqueue_veo_3_image_to_video_webhook; +pub mod enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook; +pub mod enqueue_veo_3p1_fast_image_to_video_webhook; +pub mod enqueue_veo_3p1_first_last_frame_image_to_video_webhook; +pub mod enqueue_veo_3p1_image_to_video_webhook; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/mod.rs new file mode 100644 index 0000000000..70e7921855 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/mod.rs @@ -0,0 +1,2 @@ +pub mod image; +pub mod text; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_hailuo_v2p3_pro_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_hailuo_v2p3_pro_text_to_video_webhook.rs new file mode 100644 index 0000000000..065bf75a58 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_hailuo_v2p3_pro_text_to_video_webhook.rs @@ -0,0 +1,83 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_hailuo_v2p3_pro_text_to_video::{hailuo_v2p3_pro_text_to_video, HailuoV2p3ProTextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueHailuoV2p3ProTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueHailuoV2p3ProTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueHailuoV2p3ProTextToVideoRequest { + // Request required + pub prompt: String, + + // Optional args + pub prompt_optimizer: Option, +} + +impl FalRequestCostCalculator for EnqueueHailuoV2p3ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + /// "Your request will cost $0.49 per video generation." + 49 + } +} + + +/// Hailuo 2.3 Pro Text-to-Video +/// https://fal.ai/models/fal-ai/minimax/hailuo-2.3/pro/text-to-video +pub async fn enqueue_hailuo_v2p3_pro_text_to_video_webhook( + args: EnqueueHailuoV2p3ProTextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + let prompt_optimizer = req.prompt_optimizer.unwrap_or(true); + + let request = HailuoV2p3ProTextToVideoInput { + prompt: req.prompt, + // Optionals + prompt_optimizer: Some(prompt_optimizer), + }; + + let result = hailuo_v2p3_pro_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_hailuo_v2p3_pro_text_to_video_webhook::{enqueue_hailuo_v2p3_pro_text_to_video_webhook, EnqueueHailuoV2p3ProTextToVideoArgs, EnqueueHailuoV2p3ProTextToVideoRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueHailuoV2p3ProTextToVideoArgs { + request: EnqueueHailuoV2p3ProTextToVideoRequest { + prompt: "a gray alien with big eyes dressed in an american flag tank top gives the peace symbol, it then barbecues some hot dogs on the grill".to_string(), + prompt_optimizer: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_hailuo_v2p3_pro_text_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_3p0_pro_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_3p0_pro_text_to_video_webhook.rs new file mode 100644 index 0000000000..86213d38ec --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_3p0_pro_text_to_video_webhook.rs @@ -0,0 +1,311 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_kling_3p0_pro_text_to_video::{kling_3p0_pro_text_to_video, Kling3p0ProTextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKling3p0ProTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKling3p0ProTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKling3p0ProTextToVideoRequest { + pub prompt: String, + + // Optional args + pub generate_audio: Option, + pub negative_prompt: Option, + pub duration: Option, + pub aspect_ratio: Option, + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0ProTextToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl EnqueueKling3p0ProTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + pub fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0ProTextToVideoShotType { + Customize, + Intelligent, +} + +impl FalRequestCostCalculator for EnqueueKling3p0ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 3.0 Pro pricing: + // Audio off: $0.224/second + // Audio on: $0.336/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(EnqueueKling3p0ProTextToVideoDuration::FiveSeconds) + .to_seconds(); + + let rate = if generate_audio { 336u64 } else { 224u64 }; + (rate * duration_secs + 9) / 10 + } +} + +/// Kling 3.0 Pro Text-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v3/pro/text-to-video +pub async fn enqueue_kling_3p0_pro_text_to_video_webhook( + args: EnqueueKling3p0ProTextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| d.to_str().to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect| match aspect { + EnqueueKling3p0ProTextToVideoAspectRatio::Square => "1:1", + EnqueueKling3p0ProTextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueKling3p0ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let shot_type = req.shot_type + .map(|st| match st { + EnqueueKling3p0ProTextToVideoShotType::Customize => "customize", + EnqueueKling3p0ProTextToVideoShotType::Intelligent => "intelligent", + }) + .map(|s| s.to_string()); + + let request = Kling3p0ProTextToVideoInput { + prompt: req.prompt, + generate_audio: req.generate_audio, + duration, + aspect_ratio, + negative_prompt: req.negative_prompt, + shot_type, + cfg_scale: None, + }; + + let result = kling_3p0_pro_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + + #[test] + fn test_cost() { + let mut req = EnqueueKling3p0ProTextToVideoRequest { + prompt: "a spacecraft drifts through an asteroid field, dramatic lighting".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(EnqueueKling3p0ProTextToVideoDuration::FiveSeconds), + aspect_ratio: None, + shot_type: None, + }; + + // Audio off: $0.224/sec + // 5s: (224 * 5 + 9) / 10 = 1129 / 10 = 112 + assert_eq!(req.calculate_cost_in_cents(), 112); + + // 3s: (224 * 3 + 9) / 10 = 681 / 10 = 68 + req.duration = Some(EnqueueKling3p0ProTextToVideoDuration::ThreeSeconds); + assert_eq!(req.calculate_cost_in_cents(), 68); + + // 10s: (224 * 10 + 9) / 10 = 2249 / 10 = 224 + req.duration = Some(EnqueueKling3p0ProTextToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 224); + + // 15s: (224 * 15 + 9) / 10 = 3369 / 10 = 336 + req.duration = Some(EnqueueKling3p0ProTextToVideoDuration::FifteenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 336); + + // Audio on: $0.336/sec + req.generate_audio = Some(true); + + // 5s: (336 * 5 + 9) / 10 = 1689 / 10 = 168 + req.duration = Some(EnqueueKling3p0ProTextToVideoDuration::FiveSeconds); + assert_eq!(req.calculate_cost_in_cents(), 168); + + // 10s: (336 * 10 + 9) / 10 = 3369 / 10 = 336 + req.duration = Some(EnqueueKling3p0ProTextToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 336); + + // 15s: (336 * 15 + 9) / 10 = 5049 / 10 = 504 + req.duration = Some(EnqueueKling3p0ProTextToVideoDuration::FifteenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 504); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKling3p0ProTextToVideoArgs { + request: EnqueueKling3p0ProTextToVideoRequest { + prompt: "a samurai draws a katana in a bamboo forest, cherry blossoms falling in slow motion".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(EnqueueKling3p0ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0ProTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_3p0_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueKling3p0ProTextToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueKling3p0ProTextToVideoArgs { + request: EnqueueKling3p0ProTextToVideoRequest { + prompt: "a lighthouse beam sweeps across a stormy sea at night".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(EnqueueKling3p0ProTextToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueKling3p0ProTextToVideoDuration::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueKling3p0ProTextToVideoArgs { + request: EnqueueKling3p0ProTextToVideoRequest { + prompt: "a hot air balloon rises over misty mountains at dawn".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(EnqueueKling3p0ProTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for st in EnqueueKling3p0ProTextToVideoShotType::iter() { + println!("--- shot type: {:?} ---", st); + let args = EnqueueKling3p0ProTextToVideoArgs { + request: EnqueueKling3p0ProTextToVideoRequest { + prompt: "an eagle soars over a snow-capped mountain range".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(EnqueueKling3p0ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0ProTextToVideoAspectRatio::SixteenByNine), + shot_type: Some(st), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_3p0_standard_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_3p0_standard_text_to_video_webhook.rs new file mode 100644 index 0000000000..7f3dab8c1b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_3p0_standard_text_to_video_webhook.rs @@ -0,0 +1,312 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_kling_3p0_standard_text_to_video::{kling_3p0_standard_text_to_video, Kling3p0StandardTextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKling3p0StandardTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKling3p0StandardTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKling3p0StandardTextToVideoRequest { + pub prompt: String, + + // Optional args + pub generate_audio: Option, + pub negative_prompt: Option, + pub duration: Option, + pub aspect_ratio: Option, + pub shot_type: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0StandardTextToVideoDuration { + ThreeSeconds, + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, + ThirteenSeconds, + FourteenSeconds, + FifteenSeconds, +} + +impl EnqueueKling3p0StandardTextToVideoDuration { + pub fn to_seconds(&self) -> u64 { + match self { + Self::ThreeSeconds => 3, + Self::FourSeconds => 4, + Self::FiveSeconds => 5, + Self::SixSeconds => 6, + Self::SevenSeconds => 7, + Self::EightSeconds => 8, + Self::NineSeconds => 9, + Self::TenSeconds => 10, + Self::ElevenSeconds => 11, + Self::TwelveSeconds => 12, + Self::ThirteenSeconds => 13, + Self::FourteenSeconds => 14, + Self::FifteenSeconds => 15, + } + } + + pub fn to_str(&self) -> &'static str { + match self { + Self::ThreeSeconds => "3", + Self::FourSeconds => "4", + Self::FiveSeconds => "5", + Self::SixSeconds => "6", + Self::SevenSeconds => "7", + Self::EightSeconds => "8", + Self::NineSeconds => "9", + Self::TenSeconds => "10", + Self::ElevenSeconds => "11", + Self::TwelveSeconds => "12", + Self::ThirteenSeconds => "13", + Self::FourteenSeconds => "14", + Self::FifteenSeconds => "15", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0StandardTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueKling3p0StandardTextToVideoShotType { + Customize, + Intelligent, +} + +impl FalRequestCostCalculator for EnqueueKling3p0StandardTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Kling 3.0 Standard pricing: + // Audio off: $0.168/second + // Audio on: $0.252/second + let generate_audio = self.generate_audio.unwrap_or(true); + let duration_secs = self.duration + .unwrap_or(EnqueueKling3p0StandardTextToVideoDuration::FiveSeconds) + .to_seconds(); + + // Rate in tenths-of-cents per second + let rate = if generate_audio { 252u64 } else { 168u64 }; + (rate * duration_secs + 9) / 10 + } +} + +/// Kling 3.0 Standard Text-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v3/standard/text-to-video +pub async fn enqueue_kling_3p0_standard_text_to_video_webhook( + args: EnqueueKling3p0StandardTextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| d.to_str().to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect| match aspect { + EnqueueKling3p0StandardTextToVideoAspectRatio::Square => "1:1", + EnqueueKling3p0StandardTextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueKling3p0StandardTextToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let shot_type = req.shot_type + .map(|st| match st { + EnqueueKling3p0StandardTextToVideoShotType::Customize => "customize", + EnqueueKling3p0StandardTextToVideoShotType::Intelligent => "intelligent", + }) + .map(|s| s.to_string()); + + let request = Kling3p0StandardTextToVideoInput { + prompt: req.prompt, + generate_audio: req.generate_audio, + duration, + aspect_ratio, + negative_prompt: req.negative_prompt, + shot_type, + cfg_scale: None, + }; + + let result = kling_3p0_standard_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + + #[test] + fn test_cost() { + let mut req = EnqueueKling3p0StandardTextToVideoRequest { + prompt: "a cat sitting on a windowsill watching rain".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(EnqueueKling3p0StandardTextToVideoDuration::FiveSeconds), + aspect_ratio: None, + shot_type: None, + }; + + // Audio off: $0.168/sec + // 5s: (168 * 5 + 9) / 10 = 849 / 10 = 84 + assert_eq!(req.calculate_cost_in_cents(), 84); + + // 3s: (168 * 3 + 9) / 10 = 513 / 10 = 51 + req.duration = Some(EnqueueKling3p0StandardTextToVideoDuration::ThreeSeconds); + assert_eq!(req.calculate_cost_in_cents(), 51); + + // 10s: (168 * 10 + 9) / 10 = 1689 / 10 = 168 + req.duration = Some(EnqueueKling3p0StandardTextToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 168); + + // 15s: (168 * 15 + 9) / 10 = 2529 / 10 = 252 + req.duration = Some(EnqueueKling3p0StandardTextToVideoDuration::FifteenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 252); + + // Audio on: $0.252/sec + req.generate_audio = Some(true); + + // 5s: (252 * 5 + 9) / 10 = 1269 / 10 = 126 + req.duration = Some(EnqueueKling3p0StandardTextToVideoDuration::FiveSeconds); + assert_eq!(req.calculate_cost_in_cents(), 126); + + // 10s: (252 * 10 + 9) / 10 = 2529 / 10 = 252 + req.duration = Some(EnqueueKling3p0StandardTextToVideoDuration::TenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 252); + + // 15s: (252 * 15 + 9) / 10 = 3789 / 10 = 378 + req.duration = Some(EnqueueKling3p0StandardTextToVideoDuration::FifteenSeconds); + assert_eq!(req.calculate_cost_in_cents(), 378); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKling3p0StandardTextToVideoArgs { + request: EnqueueKling3p0StandardTextToVideoRequest { + prompt: "a golden retriever puppy chases butterflies through a sunlit meadow, cinematic slow motion".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(EnqueueKling3p0StandardTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_3p0_standard_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueKling3p0StandardTextToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueKling3p0StandardTextToVideoArgs { + request: EnqueueKling3p0StandardTextToVideoRequest { + prompt: "a wave crashes against a rocky shoreline at sunset".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(EnqueueKling3p0StandardTextToVideoDuration::ThreeSeconds), + aspect_ratio: Some(ar), + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_standard_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueKling3p0StandardTextToVideoDuration::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueKling3p0StandardTextToVideoArgs { + request: EnqueueKling3p0StandardTextToVideoRequest { + prompt: "a candle flame flickers in a dark room".to_string(), + generate_audio: Some(false), + negative_prompt: None, + duration: Some(dur), + aspect_ratio: Some(EnqueueKling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_standard_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_shot_types() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for st in EnqueueKling3p0StandardTextToVideoShotType::iter() { + println!("--- shot type: {:?} ---", st); + let args = EnqueueKling3p0StandardTextToVideoArgs { + request: EnqueueKling3p0StandardTextToVideoRequest { + prompt: "a bird takes flight from a tree branch".to_string(), + generate_audio: Some(true), + negative_prompt: None, + duration: Some(EnqueueKling3p0StandardTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueKling3p0StandardTextToVideoAspectRatio::SixteenByNine), + shot_type: Some(st), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_kling_3p0_standard_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_v2p5_turbo_pro_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_v2p5_turbo_pro_text_to_video_webhook.rs new file mode 100644 index 0000000000..800eab8799 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_v2p5_turbo_pro_text_to_video_webhook.rs @@ -0,0 +1,124 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_kling_v2p5_turbo_pro_text_to_video::{kling_v2p5_turbo_pro_text_to_video, KlingV2p5TurboProTextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKlingV2p5TurboProTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKlingV2p5TurboProTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKlingV2p5TurboProTextToVideoRequest { + // Request required + pub prompt: String, + + // Optional args + pub negative_prompt: Option, + pub duration: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p5TurboProTextToVideoDurationSeconds { + Five, + Ten, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p5TurboProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + + +impl FalRequestCostCalculator for EnqueueKlingV2p5TurboProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For 5s video your request will cost $0.35. + // For every additional second you will be charged $0.07." + match self.duration { + None => 35, // $0.35 + Some(EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Five) => 35, // $0.35 + Some(EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Ten) => 70, // $0.35 + (5 * $0.07) = $0.70 + } + } +} + + +/// Kling 2.5 Turbo Pro Text-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/text-to-video +pub async fn enqueue_kling_v2p5_turbo_pro_text_to_video_webhook( + args: EnqueueKlingV2p5TurboProTextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Five => "5", + EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Ten => "10", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect| match aspect { + EnqueueKlingV2p5TurboProTextToVideoAspectRatio::Square => "1:1", + EnqueueKlingV2p5TurboProTextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueKlingV2p5TurboProTextToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let request = KlingV2p5TurboProTextToVideoInput { + prompt: req.prompt, + // Optionals + duration, + aspect_ratio, + negative_prompt: req.negative_prompt, + // Constants + cfg_scale: None, + }; + + let result = kling_v2p5_turbo_pro_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{enqueue_kling_v2p5_turbo_pro_text_to_video_webhook, EnqueueKlingV2p5TurboProTextToVideoArgs, EnqueueKlingV2p5TurboProTextToVideoRequest, EnqueueKlingV2p5TurboProTextToVideoAspectRatio, EnqueueKlingV2p5TurboProTextToVideoDurationSeconds}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKlingV2p5TurboProTextToVideoArgs { + request: EnqueueKlingV2p5TurboProTextToVideoRequest { + prompt: "an owl flies by an abandoned castle at dusk. the camera tracks the flying owl. the castle stones are damp and gritty. there are lots of trees. fireflies dance over the fields".to_string(), + negative_prompt: None, + aspect_ratio: Some(EnqueueKlingV2p5TurboProTextToVideoAspectRatio::SixteenByNine), + duration: Some(EnqueueKlingV2p5TurboProTextToVideoDurationSeconds::Five), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p5_turbo_pro_text_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_v2p6_pro_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_v2p6_pro_text_to_video_webhook.rs new file mode 100644 index 0000000000..a16019adf6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_kling_v2p6_pro_text_to_video_webhook.rs @@ -0,0 +1,131 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_kling_v2p6_pro_text_to_video::{kling_v2p6_pro_text_to_video, KlingV2p6ProTextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueKlingV2p6ProTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueKlingV2p6ProTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueKlingV2p6ProTextToVideoRequest { + // Request required + pub prompt: String, + + // Optional args + pub generate_audio: Option, + pub negative_prompt: Option, + pub duration: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p6ProTextToVideoDurationSeconds { + Five, + Ten, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueKlingV2p6ProTextToVideoAspectRatio { + Square, + SixteenByNine, + NineBySixteen, +} + +impl FalRequestCostCalculator for EnqueueKlingV2p6ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be + // charged $0.07 (audio off) or $0.14 (audio on). + // For example, a 5s video with audio on will cost $0.70" + let generate_audio = self.generate_audio.unwrap_or(true); + let duration = self.duration.unwrap_or(EnqueueKlingV2p6ProTextToVideoDurationSeconds::Five); + + match (generate_audio, duration) { + (false, EnqueueKlingV2p6ProTextToVideoDurationSeconds::Five) => 35, // audio off: $0.07 * 5 = $0.35 + (false, EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten) => 70, // audio off: $0.07 * 10 = $0.70 + (true, EnqueueKlingV2p6ProTextToVideoDurationSeconds::Five) => 70, // audio on: $0.14 * 5 = $0.70 + (true, EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten) => 140, // audio on: $0.14 * 10 = $1.40 + } + } +} + + +/// Kling 2.6 Pro Text-to-Video +/// https://fal.ai/models/fal-ai/kling-video/v2.6/pro/text-to-video +pub async fn enqueue_kling_v2p6_pro_text_to_video_webhook( + args: EnqueueKlingV2p6ProTextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueKlingV2p6ProTextToVideoDurationSeconds::Five => "5", + EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten => "10", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect| match aspect { + EnqueueKlingV2p6ProTextToVideoAspectRatio::Square => "1:1", + EnqueueKlingV2p6ProTextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueKlingV2p6ProTextToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let request = KlingV2p6ProTextToVideoInput { + prompt: req.prompt, + // Optionals + generate_audio: req.generate_audio, + duration, + aspect_ratio, + negative_prompt: req.negative_prompt, + // Constants + cfg_scale: None, + }; + + let result = kling_v2p6_pro_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::{enqueue_kling_v2p6_pro_text_to_video_webhook, EnqueueKlingV2p6ProTextToVideoArgs, EnqueueKlingV2p6ProTextToVideoRequest, EnqueueKlingV2p6ProTextToVideoAspectRatio, EnqueueKlingV2p6ProTextToVideoDurationSeconds}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueKlingV2p6ProTextToVideoArgs { + request: EnqueueKlingV2p6ProTextToVideoRequest { + prompt: "a humanoid lizard creature walks around on the surface of an alien planet. the sky is purple and there are yellow plants. The lizard says, 'You should consider day trading'. Suddenly a giant t-rex comes in from off screen and eats the lizard creature.".to_string(), + negative_prompt: None, + aspect_ratio: Some(EnqueueKlingV2p6ProTextToVideoAspectRatio::SixteenByNine), + duration: Some(EnqueueKlingV2p6ProTextToVideoDurationSeconds::Ten), + generate_audio: Some(true), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_kling_v2p6_pro_text_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_pixverse_v5_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_pixverse_v5_text_to_video_webhook.rs new file mode 100644 index 0000000000..88d9079970 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_pixverse_v5_text_to_video_webhook.rs @@ -0,0 +1,155 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::text::http_pixverse_v5_text_to_video::{pixverse_v5_text_to_video, PixverseV5TextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueuePixverseV5TextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueuePixverseV5TextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueuePixverseV5TextToVideoRequest { + // Request required + pub prompt: String, + + // Optional args + pub negative_prompt: Option, + + // NB: eight-second videos do not work with 1080P + pub duration: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub style: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5TextToVideoDurationSeconds { + Five, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5TextToVideoResolution { + ThreeSixtyP, + FiveFortyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5TextToVideoAspectRatio { + Square, + SixteenByNine, + FourByThree, + NineBySixteen, + ThreeByFour, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueuePixverseV5TextToVideoStyle { + Anime, + Animation3d, + Clay, + Comic, + Cyberpunk, +} + +pub async fn enqueue_pixverse_v5_text_to_video_webhook( + args: EnqueuePixverseV5TextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueuePixverseV5TextToVideoDurationSeconds::Five => "5", + EnqueuePixverseV5TextToVideoDurationSeconds::Eight=> "8", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueuePixverseV5TextToVideoAspectRatio::Square => "1:1", + EnqueuePixverseV5TextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueuePixverseV5TextToVideoAspectRatio::FourByThree => "4:3", + EnqueuePixverseV5TextToVideoAspectRatio::NineBySixteen => "9:16", + EnqueuePixverseV5TextToVideoAspectRatio::ThreeByFour => "3:4", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueuePixverseV5TextToVideoResolution::ThreeSixtyP => "360p", + EnqueuePixverseV5TextToVideoResolution::FiveFortyP => "540p", + EnqueuePixverseV5TextToVideoResolution::SevenTwentyP => "720p", + EnqueuePixverseV5TextToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let style = req.style + .map(|style| match style { + EnqueuePixverseV5TextToVideoStyle::Anime => "anime", + EnqueuePixverseV5TextToVideoStyle::Animation3d => "3d_animation", + EnqueuePixverseV5TextToVideoStyle::Clay => "clay", + EnqueuePixverseV5TextToVideoStyle::Comic => "comic", + EnqueuePixverseV5TextToVideoStyle::Cyberpunk => "cyberpunk", + }) + .map(|s| s.to_string()); + + let request = PixverseV5TextToVideoInput { + prompt: req.prompt, + // Optionals + duration, + negative_prompt: req.negative_prompt, + aspect_ratio, + resolution, + style, + // Constant + seed: None, + }; + + let result = pixverse_v5_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_pixverse_v5_text_to_video_webhook::{enqueue_pixverse_v5_text_to_video_webhook, EnqueuePixverseV5TextToVideoArgs, EnqueuePixverseV5TextToVideoRequest, EnqueuePixverseV5TextToVideoAspectRatio, EnqueuePixverseV5TextToVideoDurationSeconds, EnqueuePixverseV5TextToVideoResolution, EnqueuePixverseV5TextToVideoStyle}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueuePixverseV5TextToVideoArgs { + request: EnqueuePixverseV5TextToVideoRequest { + prompt: "an angry racoon shakes its fist at the garbage truck as it drives away, the camera orbits the racoon. the racoon sighs".to_string(), + negative_prompt: None, + duration: Some(EnqueuePixverseV5TextToVideoDurationSeconds::Eight), + style: Some(EnqueuePixverseV5TextToVideoStyle::Anime), + aspect_ratio: Some(EnqueuePixverseV5TextToVideoAspectRatio::FourByThree), + resolution: Some(EnqueuePixverseV5TextToVideoResolution::SevenTwentyP), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_pixverse_v5_text_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_seedance_1p5_pro_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_seedance_1p5_pro_text_to_video_webhook.rs new file mode 100644 index 0000000000..e8e55ca6db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_seedance_1p5_pro_text_to_video_webhook.rs @@ -0,0 +1,343 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::text::http_seedance_1p5_pro_text_to_video::{seedance_1p5_pro_text_to_video, Seedance1p5ProTextToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueSeedance1p5ProTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueSeedance1p5ProTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueSeedance1p5ProTextToVideoRequest { + // Request required + pub prompt: String, + + // Optional args + pub resolution: Option, + pub duration: Option, + pub aspect_ratio: Option, + pub generate_audio: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueSeedance1p5ProTextToVideoDuration { + FourSeconds, + FiveSeconds, + SixSeconds, + SevenSeconds, + EightSeconds, + NineSeconds, + TenSeconds, + ElevenSeconds, + TwelveSeconds, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueSeedance1p5ProTextToVideoResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, strum::EnumIter)] +pub enum EnqueueSeedance1p5ProTextToVideoAspectRatio { + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, + Auto, +} + +impl FalRequestCostCalculator for EnqueueSeedance1p5ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Fal: + // "Each 720p 5 second video with audio costs roughly $0.26. + // For other resolutions, 1 million video tokens with audio costs $2.4. + // Without audio, the price is 1.2 per million tokens. + // tokens(video) = (height x width x FPS x duration) / 1024." + + let resolution = self.resolution.unwrap_or(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP); + let duration = self.duration.unwrap_or(EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds); + + let audio = self.generate_audio.unwrap_or(true); + let dollars_per_million_tokens = if audio { 2.4 } else { 1.2 }; + + if resolution == EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP + && duration == EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds + { + return if audio { 26 } else { 13 }; + } + + // TODO: Only correct for some aspect ratios for now. + let (width, height) = match resolution { + EnqueueSeedance1p5ProTextToVideoResolution::FourEightyP => (640u32, 480u32), // NB: Only for 4:3 ! + EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP => (1280, 720), // NB: Only for 16:9 ! + EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP => (1920, 1080), + }; + + let duration_secs = match duration { + EnqueueSeedance1p5ProTextToVideoDuration::FourSeconds => 4.0, + EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds => 5.0, + EnqueueSeedance1p5ProTextToVideoDuration::SixSeconds => 6.0, + EnqueueSeedance1p5ProTextToVideoDuration::SevenSeconds => 7.0, + EnqueueSeedance1p5ProTextToVideoDuration::EightSeconds => 8.0, + EnqueueSeedance1p5ProTextToVideoDuration::NineSeconds => 9.0, + EnqueueSeedance1p5ProTextToVideoDuration::TenSeconds => 10.0, + EnqueueSeedance1p5ProTextToVideoDuration::ElevenSeconds => 11.0, + EnqueueSeedance1p5ProTextToVideoDuration::TwelveSeconds => 12.0, + }; + + const FPS: f64 = 30.0; + + let tokens = (height as f64) * (width as f64) * FPS * duration_secs; + let tokens = tokens / 1024.0; + + let cost = tokens * dollars_per_million_tokens / 1_000_000.0; + let cost = cost * 100.0; // Dollars to cents. + let cost = cost.ceil(); + + cost as UsdCents + } +} + +/// Seedance 1.5 Pro Text-to-Video +/// https://fal.ai/models/fal-ai/bytedance/seedance/v1.5/pro/text-to-video +pub async fn enqueue_seedance_1p5_pro_text_to_video_webhook( + args: EnqueueSeedance1p5ProTextToVideoArgs<'_, R> +) -> Result { + + let req = args.request; + + let duration = req.duration + .map(|d| match d { + EnqueueSeedance1p5ProTextToVideoDuration::FourSeconds => "4", + EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds => "5", + EnqueueSeedance1p5ProTextToVideoDuration::SixSeconds => "6", + EnqueueSeedance1p5ProTextToVideoDuration::SevenSeconds => "7", + EnqueueSeedance1p5ProTextToVideoDuration::EightSeconds => "8", + EnqueueSeedance1p5ProTextToVideoDuration::NineSeconds => "9", + EnqueueSeedance1p5ProTextToVideoDuration::TenSeconds => "10", + EnqueueSeedance1p5ProTextToVideoDuration::ElevenSeconds => "11", + EnqueueSeedance1p5ProTextToVideoDuration::TwelveSeconds => "12", + }) + .map(|d| d.to_string()); + + let resolution = req.resolution + .map(|r| match r { + EnqueueSeedance1p5ProTextToVideoResolution::FourEightyP => "480p", + EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP => "720p", + EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP => "1080p", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueSeedance1p5ProTextToVideoAspectRatio::TwentyOneByNine => "21:9", + EnqueueSeedance1p5ProTextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueSeedance1p5ProTextToVideoAspectRatio::FourByThree => "4:3", + EnqueueSeedance1p5ProTextToVideoAspectRatio::Square => "1:1", + EnqueueSeedance1p5ProTextToVideoAspectRatio::ThreeByFour => "3:4", + EnqueueSeedance1p5ProTextToVideoAspectRatio::NineBySixteen => "9:16", + EnqueueSeedance1p5ProTextToVideoAspectRatio::Auto => "auto", + }) + .map(|ar| ar.to_string()); + + let request = Seedance1p5ProTextToVideoInput { + prompt: req.prompt, + duration, + resolution, + aspect_ratio, + camera_fixed: None, + seed: None, + enable_safety_checker: Some(false), + generate_audio: Some(req.generate_audio.unwrap_or(true)), + }; + + let result = seedance_1p5_pro_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + + #[test] + fn test_cost() { + let mut req = EnqueueSeedance1p5ProTextToVideoRequest { + prompt: String::new(), + duration: Some(EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds), + resolution: Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: None, + }; + + // NB: Constant value specified by Fal + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 26); + + // Calculated values + req.duration = Some(EnqueueSeedance1p5ProTextToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 65); + + req.duration = Some(EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds); + req.resolution = Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 73); + + req.duration = Some(EnqueueSeedance1p5ProTextToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 146); + } + + #[test] + fn test_cost_audio_off() { + let mut req = EnqueueSeedance1p5ProTextToVideoRequest { + prompt: String::new(), + duration: Some(EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds), + resolution: Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP), + aspect_ratio: None, + generate_audio: Some(false), + }; + + // 720p 5s without audio = half of 26 + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 13); + + // Calculated values — half of audio-on costs (ceil) + req.duration = Some(EnqueueSeedance1p5ProTextToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 33); + + req.duration = Some(EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds); + req.resolution = Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 37); + + req.duration = Some(EnqueueSeedance1p5ProTextToVideoDuration::TenSeconds); + req.resolution = Some(EnqueueSeedance1p5ProTextToVideoResolution::TenEightyP); + let cost = req.calculate_cost_in_cents(); + assert_eq!(cost, 73); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueSeedance1p5ProTextToVideoArgs { + request: EnqueueSeedance1p5ProTextToVideoRequest { + prompt: "a dinosaur walks through a misty forest at dawn, cinematic lighting".to_string(), + duration: Some(EnqueueSeedance1p5ProTextToVideoDuration::FiveSeconds), + aspect_ratio: Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueSeedance1p5ProTextToVideoResolution::SevenTwentyP), + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_seedance_1p5_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueSeedance1p5ProTextToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueSeedance1p5ProTextToVideoArgs { + request: EnqueueSeedance1p5ProTextToVideoRequest { + prompt: "a corgi runs along a beach at golden hour".to_string(), + duration: Some(EnqueueSeedance1p5ProTextToVideoDuration::FourSeconds), + aspect_ratio: Some(ar), + resolution: None, + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_seedance_1p5_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueSeedance1p5ProTextToVideoDuration::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueSeedance1p5ProTextToVideoArgs { + request: EnqueueSeedance1p5ProTextToVideoRequest { + prompt: "a corgi runs along a beach at golden hour".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::SixteenByNine), + resolution: None, + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_seedance_1p5_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_resolutions() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for res in EnqueueSeedance1p5ProTextToVideoResolution::iter() { + println!("--- resolution: {:?} ---", res); + let args = EnqueueSeedance1p5ProTextToVideoArgs { + request: EnqueueSeedance1p5ProTextToVideoRequest { + prompt: "a bird flying over an ocean at golden hour".to_string(), + duration: Some(EnqueueSeedance1p5ProTextToVideoDuration::FourSeconds), + aspect_ratio: Some(EnqueueSeedance1p5ProTextToVideoAspectRatio::SixteenByNine), + resolution: Some(res), + generate_audio: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_seedance_1p5_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_sora_2_pro_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_sora_2_pro_text_to_video_webhook.rs new file mode 100644 index 0000000000..41983a73b2 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_sora_2_pro_text_to_video_webhook.rs @@ -0,0 +1,209 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::text::http_sora_2_pro_text_to_video::{sora_2_pro_text_to_video, Sora2ProTextToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueSora2ProTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueSora2ProTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueSora2ProTextToVideoRequest { + pub prompt: String, + pub resolution: Option, + pub duration: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ProTextToVideoDurationSeconds { + Four, + Eight, + Twelve, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ProTextToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2ProTextToVideoAspectRatio { + NineBySixteen, + SixteenByNine, +} + + +impl FalRequestCostCalculator for EnqueueSora2ProTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing: $0.30/s for 720p and $0.50/s for 1080p. + let duration = self.duration.unwrap_or(EnqueueSora2ProTextToVideoDurationSeconds::Four); + let resolution = self.resolution.unwrap_or(EnqueueSora2ProTextToVideoResolution::TenEightyP); + + match (duration, resolution) { + (EnqueueSora2ProTextToVideoDurationSeconds::Four, EnqueueSora2ProTextToVideoResolution::SevenTwentyP) => 120, + (EnqueueSora2ProTextToVideoDurationSeconds::Four, EnqueueSora2ProTextToVideoResolution::TenEightyP) => 200, + (EnqueueSora2ProTextToVideoDurationSeconds::Eight, EnqueueSora2ProTextToVideoResolution::SevenTwentyP) => 240, + (EnqueueSora2ProTextToVideoDurationSeconds::Eight, EnqueueSora2ProTextToVideoResolution::TenEightyP) => 400, + (EnqueueSora2ProTextToVideoDurationSeconds::Twelve, EnqueueSora2ProTextToVideoResolution::SevenTwentyP) => 360, + (EnqueueSora2ProTextToVideoDurationSeconds::Twelve, EnqueueSora2ProTextToVideoResolution::TenEightyP) => 600, + } + } +} + +/// Sora 2 Pro Text-to-Video +/// https://fal.ai/models/fal-ai/sora-2/text-to-video/pro +pub async fn enqueue_sora_2_pro_text_to_video_webhook( + args: EnqueueSora2ProTextToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|d| match d { + EnqueueSora2ProTextToVideoDurationSeconds::Four => 4, + EnqueueSora2ProTextToVideoDurationSeconds::Eight => 8, + EnqueueSora2ProTextToVideoDurationSeconds::Twelve => 12, + }); + + let resolution = req.resolution + .map(|r| match r { + EnqueueSora2ProTextToVideoResolution::SevenTwentyP => "720p", + EnqueueSora2ProTextToVideoResolution::TenEightyP => "1080p", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueSora2ProTextToVideoAspectRatio::NineBySixteen => "9:16", + EnqueueSora2ProTextToVideoAspectRatio::SixteenByNine => "16:9", + }) + .map(|ar| ar.to_string()); + + let request = Sora2ProTextToVideoInput { + prompt: req.prompt, + duration, + resolution, + aspect_ratio, + delete_video: Some(false), + }; + + let result = sora_2_pro_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueSora2ProTextToVideoArgs { + request: EnqueueSora2ProTextToVideoRequest { + prompt: "a dinosaur turns to the camera and asks, 'do you have adequate car insurance?' it then stomps off and attacks a brontosaurus".to_string(), + duration: Some(EnqueueSora2ProTextToVideoDurationSeconds::Eight), + aspect_ratio: Some(EnqueueSora2ProTextToVideoAspectRatio::NineBySixteen), + resolution: Some(EnqueueSora2ProTextToVideoResolution::SevenTwentyP), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_sora_2_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueSora2ProTextToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueSora2ProTextToVideoArgs { + request: EnqueueSora2ProTextToVideoRequest { + prompt: "a serene mountain lake at sunset with gentle ripples".to_string(), + duration: Some(EnqueueSora2ProTextToVideoDurationSeconds::Four), + aspect_ratio: Some(ar), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueSora2ProTextToVideoDurationSeconds::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueSora2ProTextToVideoArgs { + request: EnqueueSora2ProTextToVideoRequest { + prompt: "Suspenseful Hollywood movie. Interior, night. Customers are eating at a city diner. Suddenly a t-rex bursts its head through the large windows. The customers run.".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueSora2ProTextToVideoAspectRatio::SixteenByNine), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_resolutions() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for res in EnqueueSora2ProTextToVideoResolution::iter() { + println!("--- resolution: {:?} ---", res); + let args = EnqueueSora2ProTextToVideoArgs { + request: EnqueueSora2ProTextToVideoRequest { + prompt: "a bird flying over an ocean at golden hour".to_string(), + duration: Some(EnqueueSora2ProTextToVideoDurationSeconds::Four), + aspect_ratio: Some(EnqueueSora2ProTextToVideoAspectRatio::SixteenByNine), + resolution: Some(res), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_pro_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_sora_2_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_sora_2_text_to_video_webhook.rs new file mode 100644 index 0000000000..9a01a6d91f --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_sora_2_text_to_video_webhook.rs @@ -0,0 +1,177 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests_old::http::video::text::http_sora_2_text_to_video::{sora_2_text_to_video, Sora2TextToVideoInput}; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueSora2TextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueSora2TextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueSora2TextToVideoRequest { + pub prompt: String, + pub resolution: Option, + pub duration: Option, + pub aspect_ratio: Option, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2TextToVideoDurationSeconds { + Four, + Eight, + Twelve, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2TextToVideoResolution { + SevenTwentyP, +} + +#[derive(Copy, Clone, Debug, strum::EnumIter)] +pub enum EnqueueSora2TextToVideoAspectRatio { + NineBySixteen, + SixteenByNine, +} + + +impl FalRequestCostCalculator for EnqueueSora2TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "The pricing is $0.1/s for Sora 2." + let duration = self.duration.unwrap_or(EnqueueSora2TextToVideoDurationSeconds::Four); + match duration { + EnqueueSora2TextToVideoDurationSeconds::Four => 40, // $0.10 * 4 + EnqueueSora2TextToVideoDurationSeconds::Eight => 80, // $0.10 * 8 + EnqueueSora2TextToVideoDurationSeconds::Twelve => 120 // $0.10 * 12 + } + } +} + +/// Sora 2 Text-to-Video +/// https://fal.ai/models/fal-ai/sora-2/text-to-video +pub async fn enqueue_sora_2_text_to_video_webhook( + args: EnqueueSora2TextToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|d| match d { + EnqueueSora2TextToVideoDurationSeconds::Four => 4, + EnqueueSora2TextToVideoDurationSeconds::Eight => 8, + EnqueueSora2TextToVideoDurationSeconds::Twelve => 12, + }); + + let resolution = req.resolution + .map(|r| match r { + EnqueueSora2TextToVideoResolution::SevenTwentyP => "720p", + }) + .map(|r| r.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|ar| match ar { + EnqueueSora2TextToVideoAspectRatio::NineBySixteen => "9:16", + EnqueueSora2TextToVideoAspectRatio::SixteenByNine => "16:9", + }) + .map(|ar| ar.to_string()); + + let request = Sora2TextToVideoInput { + prompt: req.prompt, + duration, + resolution, + aspect_ratio, + delete_video: Some(false), + }; + + let result = sora_2_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use errors::AnyhowResult; + use std::fs::read_to_string; + use strum::IntoEnumIterator; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueSora2TextToVideoArgs { + request: EnqueueSora2TextToVideoRequest { + prompt: "a dinosaur turns to the camera and asks, 'do you have adequate car insurance?' it then stomps off and attacks a brontosaurus".to_string(), + duration: Some(EnqueueSora2TextToVideoDurationSeconds::Eight), + aspect_ratio: Some(EnqueueSora2TextToVideoAspectRatio::NineBySixteen), + resolution: Some(EnqueueSora2TextToVideoResolution::SevenTwentyP), + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_sora_2_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_aspect_ratios() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for ar in EnqueueSora2TextToVideoAspectRatio::iter() { + println!("--- aspect ratio: {:?} ---", ar); + let args = EnqueueSora2TextToVideoArgs { + request: EnqueueSora2TextToVideoRequest { + prompt: "a snowy mountain cabin at sunrise".to_string(), + duration: Some(EnqueueSora2TextToVideoDurationSeconds::Four), + aspect_ratio: Some(ar), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request per variant (expensive) + async fn test_all_durations() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + for dur in EnqueueSora2TextToVideoDurationSeconds::iter() { + println!("--- duration: {:?} ---", dur); + let args = EnqueueSora2TextToVideoArgs { + request: EnqueueSora2TextToVideoRequest { + prompt: "a corgi walks in front of a giant windmill in a beautiful village at sunrise".to_string(), + duration: Some(dur), + aspect_ratio: Some(EnqueueSora2TextToVideoAspectRatio::SixteenByNine), + resolution: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + let result = enqueue_sora_2_text_to_video_webhook(args).await?; + println!("result: {:?}", result); + } + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs new file mode 100644 index 0000000000..66c9767342 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs @@ -0,0 +1,113 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::core_api::webhook_response::WebhookResponse; +use crate::requests_old::http::video::text::http_veo_2_text_to_video::{ + veo_2_text_to_video, Veo2TextToVideoInput, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{ + FalRequestCostCalculator, UsdCents, +}; +// Re-use the same duration/aspect-ratio enums as image-to-video. +pub use crate::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{ + Veo2AspectRatio, Veo2Duration, +}; +use reqwest::IntoUrl; + +pub struct Veo2TextToVideoArgs<'a, V: IntoUrl> { + pub request: Veo2TextToVideoRequest, + pub webhook_url: V, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Veo2TextToVideoRequest { + pub prompt: String, + pub negative_prompt: Option, + pub duration: Veo2Duration, + pub aspect_ratio: Veo2AspectRatio, +} + +impl FalRequestCostCalculator for Veo2TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Same pricing as image-to-video: + // "For 5s video your request will cost $2.50. + // For every additional second you will be charged $0.50." + match self.duration { + Veo2Duration::Default => 250, + Veo2Duration::FiveSeconds => 250, + Veo2Duration::SixSeconds => 300, + Veo2Duration::SevenSeconds => 350, + Veo2Duration::EightSeconds => 400, + } + } +} + +/// Veo 2 Text-to-Video +/// https://fal.ai/models/fal-ai/veo2 +pub async fn enqueue_veo_2_text_to_video_webhook( + args: Veo2TextToVideoArgs<'_, V>, +) -> Result { + let req = args.request; + + let duration = match req.duration { + Veo2Duration::Default => None, + Veo2Duration::FiveSeconds => Some("5s".to_string()), + Veo2Duration::SixSeconds => Some("6s".to_string()), + Veo2Duration::SevenSeconds => Some("7s".to_string()), + Veo2Duration::EightSeconds => Some("8s".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Veo2AspectRatio::Auto => None, // Let the API default (16:9) + Veo2AspectRatio::AutoPreferPortrait => Some("9:16".to_string()), + Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let request = Veo2TextToVideoInput { + prompt: req.prompt, + aspect_ratio, + duration, + negative_prompt: req.negative_prompt, + }; + + let result = veo_2_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{ + enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2TextToVideoRequest, + Veo2AspectRatio, Veo2Duration, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test_veo_2_text_to_video() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Veo2TextToVideoArgs { + request: Veo2TextToVideoRequest { + prompt: "a drone shot of a coastal sunset with waves crashing on rocks".to_string(), + negative_prompt: None, + duration: Veo2Duration::FiveSeconds, + aspect_ratio: Veo2AspectRatio::WideSixteenNine, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_veo_2_text_to_video_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs new file mode 100644 index 0000000000..a3ed59982b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs @@ -0,0 +1,146 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::core_api::webhook_response::WebhookResponse; +use crate::requests_old::http::video::text::http_veo_3_text_to_video::{ + veo_3_text_to_video, Veo3TextToVideoInput, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{ + FalRequestCostCalculator, UsdCents, +}; +use reqwest::IntoUrl; + +pub struct Veo3TextToVideoArgs<'a, V: IntoUrl> { + pub request: Veo3TextToVideoRequest, + pub webhook_url: V, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct Veo3TextToVideoRequest { + pub prompt: String, + pub negative_prompt: Option, + pub duration: Veo3T2vDuration, + pub aspect_ratio: Veo3T2vAspectRatio, + pub resolution: Veo3T2vResolution, + pub generate_audio: bool, +} + +/// Duration for Veo 3 text-to-video. Default is 8 seconds. +#[derive(Copy, Clone, Debug)] +pub enum Veo3T2vDuration { + Default, // Default is 8 seconds + FourSeconds, + SixSeconds, + EightSeconds, +} + +/// Aspect ratio for Veo 3 text-to-video. +/// Only 16:9 and 9:16 are supported. No Auto, no Square. +#[derive(Copy, Clone, Debug)] +pub enum Veo3T2vAspectRatio { + Default, // Default is 16:9 + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +/// Resolution for Veo 3 text-to-video. +#[derive(Copy, Clone, Debug)] +pub enum Veo3T2vResolution { + Default, + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for Veo3TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // $0.20/sec audio off, $0.40/sec audio on. + match (self.duration, self.generate_audio) { + (Veo3T2vDuration::FourSeconds, false) => 80, + (Veo3T2vDuration::SixSeconds, false) => 120, + (Veo3T2vDuration::EightSeconds, false) => 160, + (Veo3T2vDuration::Default, false) => 160, + (Veo3T2vDuration::FourSeconds, true) => 160, + (Veo3T2vDuration::SixSeconds, true) => 240, + (Veo3T2vDuration::EightSeconds, true) => 320, + (Veo3T2vDuration::Default, true) => 320, + } + } +} + +/// Veo 3 Text-to-Video +/// https://fal.ai/models/fal-ai/veo3 +pub async fn enqueue_veo_3_text_to_video_webhook( + args: Veo3TextToVideoArgs<'_, V>, +) -> Result { + let req = args.request; + + let duration = match req.duration { + Veo3T2vDuration::Default => None, + Veo3T2vDuration::FourSeconds => Some("4s".to_string()), + Veo3T2vDuration::SixSeconds => Some("6s".to_string()), + Veo3T2vDuration::EightSeconds => Some("8s".to_string()), + }; + + let aspect_ratio = match req.aspect_ratio { + Veo3T2vAspectRatio::Default => None, + Veo3T2vAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo3T2vAspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let resolution = match req.resolution { + Veo3T2vResolution::Default => None, + Veo3T2vResolution::SevenTwentyP => Some("720p".to_string()), + Veo3T2vResolution::TenEightyP => Some("1080p".to_string()), + }; + + let request = Veo3TextToVideoInput { + prompt: req.prompt, + aspect_ratio, + resolution, + duration, + generate_audio: Some(req.generate_audio), + negative_prompt: req.negative_prompt, + }; + + let result = veo_3_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{ + enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3TextToVideoRequest, + Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test_veo_3_text_to_video() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Veo3TextToVideoArgs { + request: Veo3TextToVideoRequest { + prompt: "a drone shot of a coastal sunset with waves crashing on rocks".to_string(), + negative_prompt: None, + duration: Veo3T2vDuration::EightSeconds, + aspect_ratio: Veo3T2vAspectRatio::WideSixteenNine, + resolution: Veo3T2vResolution::TenEightyP, + generate_audio: true, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_veo_3_text_to_video_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3p1_fast_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3p1_fast_text_to_video_webhook.rs new file mode 100644 index 0000000000..1e1b7c5620 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3p1_fast_text_to_video_webhook.rs @@ -0,0 +1,157 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_veo_3p1_fast_text_to_video::{veo_3p1_fast_text_to_video, Veo3p1FastTextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueVeo3p1FastTextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueVeo3p1FastTextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueVeo3p1FastTextToVideoRequest { + pub prompt: String, + pub duration: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub generate_audio: Option, + pub enhance_prompt: Option, + pub negative_prompt: Option, + pub seed: Option, + pub auto_fix: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastTextToVideoDurationSeconds { + Four, + Six, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastTextToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1FastTextToVideoResolution { + SevenTwentyP, + TenEightyP, +} + +impl FalRequestCostCalculator for EnqueueVeo3p1FastTextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.10 (audio off) or + // $0.15 (audio on). + // For example, a 5s video with audio on will cost $0.75." + let duration = self.duration + .unwrap_or(EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight); + + let generate_audio = self.generate_audio.unwrap_or(true); + + match (duration, generate_audio) { + (EnqueueVeo3p1FastTextToVideoDurationSeconds::Four, false) => 40, + (EnqueueVeo3p1FastTextToVideoDurationSeconds::Six, false) => 60, + (EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight, false) => 80, + (EnqueueVeo3p1FastTextToVideoDurationSeconds::Four, true) => 60, + (EnqueueVeo3p1FastTextToVideoDurationSeconds::Six, true) => 90, + (EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight, true) => 120, + } + } +} + + +/// Veo 3.1 Fast Text-to-Video +/// https://fal.ai/models/fal-ai/veo3.1/fast +pub async fn enqueue_veo_3p1_fast_text_to_video_webhook( + args: EnqueueVeo3p1FastTextToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueVeo3p1FastTextToVideoDurationSeconds::Four => "4s", + EnqueueVeo3p1FastTextToVideoDurationSeconds::Six => "6s", + EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight=> "8s", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueueVeo3p1FastTextToVideoAspectRatio::Auto => "auto", + EnqueueVeo3p1FastTextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueVeo3p1FastTextToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueVeo3p1FastTextToVideoResolution::SevenTwentyP => "720p", + EnqueueVeo3p1FastTextToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let request = Veo3p1FastTextToVideoInput { + prompt: req.prompt, + // Optionals + duration, + aspect_ratio, + resolution, + negative_prompt: req.negative_prompt, + generate_audio: req.generate_audio, + enhance_prompt: req.enhance_prompt, + seed: req.seed, + auto_fix: req.auto_fix, + }; + + let result = veo_3p1_fast_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::{enqueue_veo_3p1_fast_text_to_video_webhook, EnqueueVeo3p1FastTextToVideoArgs, EnqueueVeo3p1FastTextToVideoRequest, EnqueueVeo3p1FastTextToVideoAspectRatio, EnqueueVeo3p1FastTextToVideoDurationSeconds, EnqueueVeo3p1FastTextToVideoResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueVeo3p1FastTextToVideoArgs { + request: EnqueueVeo3p1FastTextToVideoRequest { + prompt: "An alien space ship hovers over new york city. it looks ominous, ready to attack. suddenly, it drops a bunch of ping pong balls on the city".to_string(), + duration: Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight), + aspect_ratio: Some(EnqueueVeo3p1FastTextToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueVeo3p1FastTextToVideoResolution::TenEightyP), + generate_audio: Some(true), + negative_prompt: None, + enhance_prompt: Some(true), + auto_fix: Some(true), + seed: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3p1_fast_text_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3p1_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3p1_text_to_video_webhook.rs new file mode 100644 index 0000000000..590cc58f3d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/enqueue_veo_3p1_text_to_video_webhook.rs @@ -0,0 +1,158 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; +use crate::requests_old::http::video::text::http_veo_3p1_text_to_video::{veo_3p1_text_to_video, Veo3p1TextToVideoInput}; +use crate::requests::core_api::webhook_response::WebhookResponse; +use reqwest::IntoUrl; + +pub struct EnqueueVeo3p1TextToVideoArgs<'a, R: IntoUrl> { + pub request: EnqueueVeo3p1TextToVideoRequest, + pub webhook_url: R, + pub api_key: &'a FalApiKey, +} + +#[derive(Clone, Debug)] +pub struct EnqueueVeo3p1TextToVideoRequest { + pub prompt: String, + pub duration: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub generate_audio: Option, + pub enhance_prompt: Option, + pub negative_prompt: Option, + pub seed: Option, + pub auto_fix: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1TextToVideoDurationSeconds { + Four, + Six, + Eight, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1TextToVideoAspectRatio { + Auto, + SixteenByNine, + NineBySixteen, +} + +#[derive(Copy, Clone, Debug)] +pub enum EnqueueVeo3p1TextToVideoResolution { + SevenTwentyP, + TenEightyP, +} + + +impl FalRequestCostCalculator for EnqueueVeo3p1TextToVideoRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // "For every second of video you generated, you will be charged + // $0.20 (audio off) or + // $0.40 (audio on). + // For example, a 5s video with audio on will cost $2." + let duration = self.duration + .unwrap_or(EnqueueVeo3p1TextToVideoDurationSeconds::Eight); + + let generate_audio = self.generate_audio.unwrap_or(true); + + match (duration, generate_audio) { + (EnqueueVeo3p1TextToVideoDurationSeconds::Four, false) => 80, + (EnqueueVeo3p1TextToVideoDurationSeconds::Six, false) => 120, + (EnqueueVeo3p1TextToVideoDurationSeconds::Eight, false) => 160, + (EnqueueVeo3p1TextToVideoDurationSeconds::Four, true) => 160, + (EnqueueVeo3p1TextToVideoDurationSeconds::Six, true) => 240, + (EnqueueVeo3p1TextToVideoDurationSeconds::Eight, true) => 320, + } + } +} + + +/// Veo 3.1 Text-to-Video +/// https://fal.ai/models/fal-ai/veo3.1 +pub async fn enqueue_veo_3p1_text_to_video_webhook( + args: EnqueueVeo3p1TextToVideoArgs<'_, R> +) -> Result { + let req = args.request; + + let duration = req.duration + .map(|resolution| match resolution { + EnqueueVeo3p1TextToVideoDurationSeconds::Four => "4s", + EnqueueVeo3p1TextToVideoDurationSeconds::Six => "6s", + EnqueueVeo3p1TextToVideoDurationSeconds::Eight=> "8s", + }) + .map(|s| s.to_string()); + + let aspect_ratio = req.aspect_ratio + .map(|aspect_ratio| match aspect_ratio { + EnqueueVeo3p1TextToVideoAspectRatio::Auto => "auto", + EnqueueVeo3p1TextToVideoAspectRatio::SixteenByNine => "16:9", + EnqueueVeo3p1TextToVideoAspectRatio::NineBySixteen => "9:16", + }) + .map(|s| s.to_string()); + + let resolution = req.resolution + .map(|resolution| match resolution { + EnqueueVeo3p1TextToVideoResolution::SevenTwentyP => "720p", + EnqueueVeo3p1TextToVideoResolution::TenEightyP => "1080p", + }) + .map(|s| s.to_string()); + + let request = Veo3p1TextToVideoInput { + prompt: req.prompt, + // Optionals + duration, + aspect_ratio, + resolution, + negative_prompt: req.negative_prompt, + generate_audio: req.generate_audio, + enhance_prompt: req.enhance_prompt, + seed: req.seed, + auto_fix: req.auto_fix, + }; + + let result = veo_3p1_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::{enqueue_veo_3p1_text_to_video_webhook, EnqueueVeo3p1TextToVideoArgs, EnqueueVeo3p1TextToVideoRequest, EnqueueVeo3p1TextToVideoAspectRatio, EnqueueVeo3p1TextToVideoDurationSeconds, EnqueueVeo3p1TextToVideoResolution}; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test() -> AnyhowResult<()> { + // XXX: Don't commit secrets! + let secret = read_to_string("/home/bt/Artcraft/credentials/fal_api_key.txt")?; + + let api_key = FalApiKey::from_str(&secret); + + let args = EnqueueVeo3p1TextToVideoArgs { + request: EnqueueVeo3p1TextToVideoRequest { + prompt: "An alien space ship hovers over new york city. it looks ominous, ready to attack. suddenly, it drops a bunch of ping pong balls on the city".to_string(), + duration: Some(EnqueueVeo3p1TextToVideoDurationSeconds::Eight), + aspect_ratio: Some(EnqueueVeo3p1TextToVideoAspectRatio::SixteenByNine), + resolution: Some(EnqueueVeo3p1TextToVideoResolution::TenEightyP), + generate_audio: Some(true), + negative_prompt: None, + enhance_prompt: Some(true), + auto_fix: Some(true), + seed: None, + }, + api_key: &api_key, + webhook_url: "https://example.com/webhook", + }; + + let result = enqueue_veo_3p1_text_to_video_webhook(args).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests_old/webhook/video/text/mod.rs b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/mod.rs new file mode 100644 index 0000000000..8023ecc7a1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests_old/webhook/video/text/mod.rs @@ -0,0 +1,13 @@ +pub mod enqueue_hailuo_v2p3_pro_text_to_video_webhook; +pub mod enqueue_kling_v2p5_turbo_pro_text_to_video_webhook; +pub mod enqueue_kling_v2p6_pro_text_to_video_webhook; +pub mod enqueue_kling_3p0_pro_text_to_video_webhook; +pub mod enqueue_kling_3p0_standard_text_to_video_webhook; +pub mod enqueue_pixverse_v5_text_to_video_webhook; +pub mod enqueue_seedance_1p5_pro_text_to_video_webhook; +pub mod enqueue_sora_2_pro_text_to_video_webhook; +pub mod enqueue_sora_2_text_to_video_webhook; +pub mod enqueue_veo_2_text_to_video_webhook; +pub mod enqueue_veo_3_text_to_video_webhook; +pub mod enqueue_veo_3p1_fast_text_to_video_webhook; +pub mod enqueue_veo_3p1_text_to_video_webhook; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/utils/clone_queue_payload.rs b/crates/api_clients/fal_client/src/utils/clone_queue_payload.rs new file mode 100644 index 0000000000..4d9c3eac49 --- /dev/null +++ b/crates/api_clients/fal_client/src/utils/clone_queue_payload.rs @@ -0,0 +1,8 @@ +use crate::requests::core_api::queue::Queue; +use crate::requests::core_api::queue_response::QueueResponse; +use crate::utils::clone_queue_response::clone_queue_response; +use serde::de::DeserializeOwned; + +pub fn clone_queue_payload(queue: &Queue) -> QueueResponse { + clone_queue_response(&queue.payload) +} diff --git a/crates/api_clients/fal_client/src/utils/clone_queue_response.rs b/crates/api_clients/fal_client/src/utils/clone_queue_response.rs new file mode 100644 index 0000000000..1600b7d063 --- /dev/null +++ b/crates/api_clients/fal_client/src/utils/clone_queue_response.rs @@ -0,0 +1,10 @@ +use crate::requests::core_api::queue_response::QueueResponse; + +pub fn clone_queue_response(response: &QueueResponse) -> QueueResponse { + QueueResponse { + request_id: response.request_id.clone(), + response_url: response.response_url.clone(), + status_url: response.status_url.clone(), + cancel_url: response.cancel_url.clone(), + } +} diff --git a/crates/api_clients/fal_client/src/utils/mod.rs b/crates/api_clients/fal_client/src/utils/mod.rs new file mode 100644 index 0000000000..18c0af1eb4 --- /dev/null +++ b/crates/api_clients/fal_client/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod clone_queue_payload; +pub mod clone_queue_response; \ No newline at end of file diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrate_webhook_contents.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrate_webhook_contents.rs new file mode 100644 index 0000000000..b25a58d492 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrate_webhook_contents.rs @@ -0,0 +1,308 @@ +use serde_json::Value; + +use crate::webhook_payload::raw::webhook_error_type::WebhookErrorType; +use crate::webhook_payload::hydrated::hydrated_webhook_contents::{ErrorData, PayloadErrorData, WebhookSuccessData, HydratedWebhookContents}; +use crate::webhook_payload::raw::raw_webhook_payload::{RawWebhookPayload, RawWebhookStatus}; +use crate::webhook_payload::hydrated::success_case_extractors::extract_contents_from_payload; + +/// Parse the inner payload of a FAL webhook into one of three cases. +/// +/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`. +/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError. +/// 3. Otherwise, return Success with the payload. +pub fn hydrate_webhook_contents(webhook: &RawWebhookPayload) -> HydratedWebhookContents { + match webhook.status { + RawWebhookStatus::Error => { + let (message, error_type) = extract_error_first_detail(&webhook.payload); + + HydratedWebhookContents::Error(ErrorData { + message, + error_type, + }) + } + RawWebhookStatus::Ok => { + // Check for `payload_error` case: status=OK but no payload, has payload_error. + if webhook.payload.is_none() { + if let Some(ref payload_error) = webhook.payload_error { + return HydratedWebhookContents::PayloadError(PayloadErrorData { + payload_error: payload_error.clone(), + }); + } + } + + let value = webhook.payload + .clone() + .unwrap_or(Value::Null); + + let extracted_contents = extract_contents_from_payload(&value); + + HydratedWebhookContents::Success(WebhookSuccessData { + payload: value, + extracted_contents, + }) + } + } +} + +/// Extract the first `msg` and `type` from `payload.detail[]`. +fn extract_error_first_detail(payload: &Option) -> (Option, Option) { + let payload = match payload { + Some(p) => p, + None => return (None, None), + }; + + let detail_array = match payload.get("detail").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return (None, None), + }; + + let first = match detail_array.first() { + Some(item) => item, + None => return (None, None), + }; + + let message = first.get("msg").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let error_type = first + .get("type") + .and_then(|v| v.as_str()) + .map(WebhookErrorType::from_str); + + (message, error_type) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn gpt_image_1p5_content_policy_violation() { + let webhook = load_test_webhook("failure/gpt_image_1p5_fail_content_policy.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::ContentPolicyViolation), + ); + assert_eq!( + data.message.as_deref(), + Some("The content could not be processed because it contained material flagged by a content checker."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn gpt_image_invalid_api_key() { + let webhook = load_test_webhook("failure/gpt_image_fail_invalid_api_key.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::InvalidApiKey), + ); + assert_eq!( + data.message.as_deref(), + Some("Invalid API key"), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn kling_1p6_pro_file_too_large() { + let webhook = load_test_webhook("failure/kling_1p6_pro_file_too_large_error.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::FileTooLarge), + ); + assert_eq!( + data.message.as_deref(), + Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn kling_3p0_pro_content_policy_violation() { + let webhook = load_test_webhook("failure/kling_3p0_pro_fail_content_policy.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::ContentPolicyViolation), + ); + assert_eq!( + data.message.as_deref(), + Some("The content could not be processed because it contained material flagged by a content checker."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn nano_banana_pro_no_media_generated() { + let webhook = load_test_webhook("failure/nano_banana_pro_fail_no_media_generated.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::NoMediaGenerated), + ); + assert!( + data.message.as_deref().unwrap().starts_with("The model did not generate"), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn hunyuan_3d_part_format_not_supported() { + let webhook = load_test_webhook("failure/hunyuan_3d_3p1_part_fail_format_not_supported.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::ValueError), + ); + assert_eq!( + data.message.as_deref(), + Some("Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn triposplat_no_foreground_subject() { + let webhook = load_test_webhook("failure/triposplat_fail_no_foreground_subject.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::InputValueError), + ); + assert_eq!( + data.message.as_deref(), + Some("No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn payload_error_case() { + let webhook = RawWebhookPayload { + request_id: "test-123".to_string(), + gateway_request_id: "test-123".to_string(), + status: RawWebhookStatus::Ok, + error: None, + payload: None, + payload_error: Some("encoding error occurred".to_string()), + }; + + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::PayloadError(data) => { + assert_eq!(data.payload_error, "encoding error occurred"); + } + other => panic!("Expected HydratedWebhookContents::PayloadError, got {:?}", other), + } + } + + #[test] + fn success_case() { + let webhook = RawWebhookPayload { + request_id: "test-456".to_string(), + gateway_request_id: "test-456".to_string(), + status: RawWebhookStatus::Ok, + error: None, + payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})), + payload_error: None, + }; + + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Success(data) => { + assert!(data.payload.get("images").is_some()); + } + other => panic!("Expected HydratedWebhookContents::Success, got {:?}", other), + } + } + + #[test] + fn seedream_partner_validation_failed() { + let webhook = load_test_webhook("failure/seedream_partner_validation_failed.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::ContentPolicyViolation), + ); + assert_eq!( + data.message.as_deref(), + Some("The content could not be processed because it contained material flagged by a content checker."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn error_with_no_payload() { + let webhook = RawWebhookPayload { + request_id: "test-789".to_string(), + gateway_request_id: "test-789".to_string(), + status: RawWebhookStatus::Error, + error: Some("Internal server error".to_string()), + payload: None, + payload_error: None, + }; + + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!(data.message, None); + assert_eq!(data.error_type, None); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs new file mode 100644 index 0000000000..8487e68371 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs @@ -0,0 +1,232 @@ +use serde::Deserialize; +use serde_json::Value; + +use crate::webhook_payload::raw::webhook_error_type::WebhookErrorType; + +/// The parsed inner payload of a FAL webhook. +#[derive(Debug)] +pub enum HydratedWebhookContents { + /// The webhook reported success and has a payload. + Success(WebhookSuccessData), + + /// The webhook reported an error (status=ERROR) with optional detail info. + Error(ErrorData), + + /// The webhook reported as "success" but (1) had no payload and (2) had a payload_error. + /// In rare instances of an "OK" response, there may be an error on Fal's end with encoding + /// the payload. If that happens, the "payload_error" field may be set, and this enum variant + /// represents that failure case. + PayloadError(PayloadErrorData), +} + +#[derive(Debug)] +pub struct WebhookSuccessData { + /// The success data is polymorphic, so we're returning a JSON `Value` for now. + /// This will allow for downstream handlers to parse the payload as needed. + /// This is the entire raw success payload. + pub payload: Value, + + /// If there are any extracted sub-payload contents, such as "images" or "video", + /// then they are included here. This may not be fully inclusive of future + /// payload types. + pub extracted_contents: Option, +} + +#[derive(Debug)] +pub struct ExtractedContents { + /// Parsed from `payload.image` (single image result). + pub image: Option, + + /// Parsed from `payload.images` (batch image results). + pub images: Option>, + + /// Parsed from `payload.video`. + pub video: Option, + + /// Parsed from `payload.audio` (e.g. Seed Audio 1.0 speech results). + pub audio: Option, + + /// Parsed from `payload.model_glb`. + pub model_glb: Option, + + /// Parsed from `payload.model_glb_pbr` (e.g. Hunyuan 3D 2.1's PBR-textured + /// GLB variant, sent alongside `model_glb`). + pub model_glb_pbr: Option, + + /// Parsed from `payload.model_urls` (e.g. Hunyuan 3D 3.0's per-format file + /// map, sent alongside `model_glb`). + pub model_urls: Option, + + /// Parsed from `payload.model_mesh`. + /// NB: `triposplat` ply gaussian splat files also arrive via this payload handler. + /// These are decidedly *not* "mesh" files! + pub model_mesh: Option, + + /// Parsed from `payload.model_obj` (e.g. Hunyuan 3D v3.1 Rapid's OBJ output). + pub model_obj: Option, + + /// Parsed from `payload.result_files` (e.g. Hunyuan 3D v3.1 Part's FBX part files). + pub result_files: Option>, + + /// Parsed from `payload.thumbnail`. + pub thumbnail: Option, + + /// Parsed from `payload.rendered_image` (e.g. Tripo 3D's preview image, + /// used as a cover image like `thumbnail`). + pub rendered_image: Option, + + /// Parsed from `payload.preprocessed_image` (e.g. TripoSplat's segmented + /// input image, usable as a cover image for the splat result). + pub preprocessed_image: Option, +} + +/// Data under `payload.image`: +#[derive(Debug, Deserialize)] +pub struct ImageData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub height: Option, + pub width: Option, +} + +/// Data under `payload.images` (a list of these): +#[derive(Debug, Deserialize)] +pub struct ImagesData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub height: Option, + pub width: Option, +} + +/// Data under `payload.video`: +#[derive(Debug, Deserialize)] +pub struct VideoData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, +} + +/// Data under `payload.audio` (e.g. Seed Audio 1.0): +#[derive(Debug, Deserialize)] +pub struct AudioData { + pub url: Option, + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub bitrate: Option, + pub channels: Option, + /// Duration in seconds. + pub duration: Option, + pub sample_rate: Option, +} + +/// Data under `payload.model_glb` (there may be other sibling keys too). +/// Also used for `payload.model_glb_pbr`, which has the same shape. +#[derive(Debug, Deserialize)] +pub struct ModelGlbData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub url: Option, +} + +/// Data under `payload.model_mesh` (there may be other sibling keys too) +/// NB: `triposplat` ply gaussian splat files also arrive via this payload. +/// These are decidedly *not* "mesh" files! +#[derive(Debug, Deserialize)] +pub struct ModelMeshData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub url: Option, +} + +/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0 / 3.1, Tripo 3D): a +/// map of the generation's output files by format. Any slot may be null or +/// absent. The `glb` entry frequently duplicates the top-level model key +/// (same URL), but may point to a different file. Slots may also duplicate +/// each other's URLs (e.g. Tripo 3D's `glb` and `pbr_model`). +#[derive(Debug, Deserialize)] +pub struct ModelUrlsData { + /// Untextured base model (Tripo 3D). + pub base_model: Option, + /// Blender project file (Meshy). + pub blend: Option, + pub fbx: Option, + pub glb: Option, + /// OBJ material file (Hunyuan 3D 3.1). + pub mtl: Option, + pub obj: Option, + /// PBR-textured model (Tripo 3D). + pub pbr_model: Option, + /// STL model (Meshy). + pub stl: Option, + /// PBR texture image for the OBJ (Hunyuan 3D 3.1). + pub texture: Option, + pub usdz: Option, +} + +/// Data under `payload.model_obj` (there may be other sibling keys too). +/// Some endpoints (e.g. Hunyuan 3D v3.1 Rapid text-to-3d) return an OBJ +/// model instead of (or alongside) a GLB. +#[derive(Debug, Deserialize)] +pub struct ModelObjData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub url: Option, +} + +/// An entry under `payload.result_files` (a list of these). +/// Used by endpoints returning multiple output files, e.g. Hunyuan 3D v3.1 +/// Part's per-part FBX files. +#[derive(Debug, Deserialize)] +pub struct ResultFileData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub url: Option, +} + +/// Data under `payload.thumbnail` (there may be other sibling keys too) +/// Frequently seen together with `model_glb`. +/// Also used for `payload.rendered_image` (Tripo 3D), which has the same shape. +#[derive(Debug, Deserialize)] +pub struct ThumbnailData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub url: Option, +} + +/// Data under `payload.preprocessed_image` (there may be other sibling keys too). +/// Seen together with `model_mesh` in TripoSplat results: the segmented input +/// image, which we use as a cover image for the splat. +#[derive(Debug, Deserialize)] +pub struct PreprocessedImageData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub height: Option, + pub url: Option, + pub width: Option, +} + +#[derive(Debug)] +pub struct ErrorData { + /// The first human-readable message from `payload.detail[].msg`, if any. + pub message: Option, + + /// The first machine-readable error type from `payload.detail[].type`, if any. + pub error_type: Option, +} + +#[derive(Debug)] +pub struct PayloadErrorData { + pub payload_error: String, +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/mod.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/mod.rs new file mode 100644 index 0000000000..0a599d4a1e --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/mod.rs @@ -0,0 +1,2 @@ +pub mod hydrated_webhook_contents; +pub mod success_case_extractors; diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_audio_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_audio_payload.rs new file mode 100644 index 0000000000..b965a9a2f1 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_audio_payload.rs @@ -0,0 +1,103 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::AudioData; + +/// Extract and deserialize the `audio` key from a webhook success payload +/// (e.g. Seed Audio 1.0 speech results). +pub(crate) fn extract_audio(obj: &Map) -> Option { + let value = obj.get("audio")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn audio_from_seed_audio_1p0_test_file() { + let webhook = load_test_webhook("success/seed_audio_1p0_audio_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let audio = contents.audio.expect("audio should be Some"); + assert_eq!(audio.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bc36/vOSCcxsbibkZIiXR9BIZn_speech.mp3")); + assert_eq!(audio.content_type.as_deref(), Some("audio/mpeg")); + assert_eq!(audio.file_name.as_deref(), Some("speech.mp3")); + assert_eq!(audio.file_size, Some(53612)); + assert_eq!(audio.duration, Some(6.63995)); + assert_eq!(audio.sample_rate, Some(24000)); + assert!(audio.bitrate.is_none()); + assert!(audio.channels.is_none()); + + // No other content types in this payload. + assert!(contents.image.is_none()); + assert!(contents.images.is_none()); + assert!(contents.video.is_none()); + assert!(contents.model_glb.is_none()); + assert!(contents.thumbnail.is_none()); + } + + #[test] + fn synthetic_audio_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "audio": { + "url": "https://cdn.example.com/speech.mp3", + "content_type": "audio/mpeg", + "file_name": "speech.mp3", + "file_size": 12345, + "bitrate": 128000, + "channels": 2, + "duration": 3.5, + "sample_rate": 44100 + } + }"#).unwrap(); + + let audio = extract_audio(&obj).expect("should extract audio"); + assert_eq!(audio.url.as_deref(), Some("https://cdn.example.com/speech.mp3")); + assert_eq!(audio.content_type.as_deref(), Some("audio/mpeg")); + assert_eq!(audio.file_name.as_deref(), Some("speech.mp3")); + assert_eq!(audio.file_size, Some(12345)); + assert_eq!(audio.bitrate, Some(128000)); + assert_eq!(audio.channels, Some(2)); + assert_eq!(audio.duration, Some(3.5)); + assert_eq!(audio.sample_rate, Some(44100)); + } + + #[test] + fn audio_url_only() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "audio": {"url": "https://cdn.example.com/speech.wav"} + }"#).unwrap(); + + let audio = extract_audio(&obj).expect("should extract audio"); + assert_eq!(audio.url.as_deref(), Some("https://cdn.example.com/speech.wav")); + assert!(audio.content_type.is_none()); + assert!(audio.duration.is_none()); + } + + #[test] + fn missing_audio_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "video": {"url": "https://example.com/video.mp4"} + }"#).unwrap(); + + assert!(extract_audio(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_image_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_image_payload.rs new file mode 100644 index 0000000000..53fe92f408 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_image_payload.rs @@ -0,0 +1,100 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ImageData; + +/// Extract and deserialize the `image` key from a webhook success payload. +pub (crate) fn extract_image(obj: &Map) -> Option { + let value = obj.get("image")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::{RawWebhookPayload, RawWebhookStatus}; + + #[test] + fn payload_without_known_keys_has_none_extracted_contents() { + let webhook = RawWebhookPayload { + request_id: "test-no-keys".to_string(), + gateway_request_id: "test-no-keys".to_string(), + status: RawWebhookStatus::Ok, + error: None, + payload: Some(serde_json::json!({"some_other_key": "value"})), + payload_error: None, + }; + + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + assert!(data.extracted_contents.is_none()); + } + + #[test] + fn null_payload_has_none_extracted_contents() { + let webhook = RawWebhookPayload { + request_id: "test-null".to_string(), + gateway_request_id: "test-null".to_string(), + status: RawWebhookStatus::Ok, + error: None, + payload: None, + payload_error: None, + }; + + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + assert!(data.extracted_contents.is_none()); + } + + #[test] + fn synthetic_image_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "image": { + "url": "https://cdn.example.com/single.png", + "content_type": "image/png", + "file_name": "single.png", + "file_size": 98765, + "width": 512, + "height": 512 + } + }"#).unwrap(); + + let image = extract_image(&obj).expect("should extract image"); + assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/single.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("single.png")); + assert_eq!(image.file_size, Some(98765)); + assert_eq!(image.width, Some(512)); + assert_eq!(image.height, Some(512)); + } + + #[test] + fn synthetic_image_url_only() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "image": {"url": "https://cdn.example.com/img.jpg"} + }"#).unwrap(); + + let image = extract_image(&obj).expect("should extract image"); + assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/img.jpg")); + assert!(image.content_type.is_none()); + assert!(image.file_size.is_none()); + } + + #[test] + fn missing_image_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "images": [{"url": "https://example.com/img.png"}] + }"#).unwrap(); + + assert!(extract_image(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_images_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_images_payload.rs new file mode 100644 index 0000000000..393fdc92b6 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_images_payload.rs @@ -0,0 +1,96 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ImagesData; + +/// Extract and deserialize the `images` key from a webhook success payload. +pub (crate) fn extract_images(obj: &Map) -> Option> { + let value = obj.get("images")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn images_payload_from_test_file() { + let webhook = load_test_webhook("success/images_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let images = contents.images.expect("images should be Some"); + assert_eq!(images.len(), 2); + + assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/01234567/name1.png")); + assert_eq!(images[0].content_type.as_deref(), Some("image/png")); + assert_eq!(images[0].file_name.as_deref(), Some("name1.png")); + assert_eq!(images[0].file_size, Some(11500002)); + + assert_eq!(images[1].url.as_deref(), Some("https://v3b.fal.media/files/b/01234567/name2.png")); + assert_eq!(images[1].file_size, Some(13499999)); + + assert!(contents.image.is_none()); + assert!(contents.video.is_none()); + assert!(contents.model_glb.is_none()); + assert!(contents.model_mesh.is_none()); + } + + #[test] + fn synthetic_images_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "images": [ + {"url": "https://example.com/a.png", "content_type": "image/png", "width": 1024, "height": 768}, + {"url": "https://example.com/b.jpg", "content_type": "image/jpeg", "file_size": 54321} + ] + }"#).unwrap(); + + let images = extract_images(&obj).expect("should extract images"); + assert_eq!(images.len(), 2); + + assert_eq!(images[0].url.as_deref(), Some("https://example.com/a.png")); + assert_eq!(images[0].width, Some(1024)); + assert_eq!(images[0].height, Some(768)); + assert!(images[0].file_size.is_none()); + + assert_eq!(images[1].url.as_deref(), Some("https://example.com/b.jpg")); + assert_eq!(images[1].content_type.as_deref(), Some("image/jpeg")); + assert_eq!(images[1].file_size, Some(54321)); + assert!(images[1].width.is_none()); + } + + #[test] + fn empty_images_array_returns_empty_vec() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "images": [] + }"#).unwrap(); + + let images = extract_images(&obj).expect("should extract empty images"); + assert!(images.is_empty()); + } + + #[test] + fn missing_images_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "video": {"url": "https://example.com/v.mp4"} + }"#).unwrap(); + + assert!(extract_images(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_payload.rs new file mode 100644 index 0000000000..2eeaf3e3ef --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_payload.rs @@ -0,0 +1,91 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData; + +/// Extract and deserialize the `model_glb` key from a webhook success payload. +pub (crate) fn extract_model_glb(obj: &Map) -> Option { + let value = obj.get("model_glb")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn model_glb_from_test_file() { + let webhook = load_test_webhook("success/model_glb_urls_and_thumbnail_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("model.glb")); + assert_eq!(glb.file_size, Some(66284788)); + + // This payload also has a thumbnail. + assert!(contents.thumbnail.is_some()); + + // No image/images/video/mesh in this payload. + assert!(contents.image.is_none()); + assert!(contents.images.is_none()); + assert!(contents.video.is_none()); + assert!(contents.model_mesh.is_none()); + } + + #[test] + fn synthetic_model_glb_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb": { + "url": "https://cdn.example.com/model.glb", + "content_type": "model/gltf-binary", + "file_name": "output.glb", + "file_size": 5432100 + } + }"#).unwrap(); + + let glb = extract_model_glb(&obj).expect("should extract model_glb"); + assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("output.glb")); + assert_eq!(glb.file_size, Some(5432100)); + } + + #[test] + fn model_glb_url_only() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb": {"url": "https://cdn.example.com/m.glb"} + }"#).unwrap(); + + let glb = extract_model_glb(&obj).expect("should extract model_glb"); + assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/m.glb")); + assert!(glb.content_type.is_none()); + } + + #[test] + fn missing_model_glb_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "video": {"url": "https://example.com/v.mp4"} + }"#).unwrap(); + + assert!(extract_model_glb(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs new file mode 100644 index 0000000000..83a484703c --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs @@ -0,0 +1,87 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData; + +/// Extract and deserialize the `model_glb_pbr` key from a webhook success +/// payload. Sent alongside `model_glb` by e.g. Hunyuan 3D 2.1; same shape. +pub(crate) fn extract_model_glb_pbr(obj: &Map) -> Option { + let value = obj.get("model_glb_pbr")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn model_glbs_from_hunyuan_3d_2p1_test_file() { + let webhook = load_test_webhook("success/hunyuan_3d_2p1_model_glbs_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb")); + assert_eq!(glb.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(glb.file_name.as_deref(), Some("demo_textured.glb")); + assert_eq!(glb.file_size, Some(1574216)); + + let glb_pbr = contents.model_glb_pbr.expect("model_glb_pbr should be Some"); + assert_eq!(glb_pbr.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb")); + assert_eq!(glb_pbr.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(glb_pbr.file_name.as_deref(), Some("demo_textured_pbr.glb")); + assert_eq!(glb_pbr.file_size, Some(7512760)); + + // The payload also carries a `model_mesh` zip archive of the whole + // generation. It parses out, but the webhook handler prefers the GLBs. + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip")); + assert_eq!(mesh.file_name.as_deref(), Some("3d_model.zip")); + + assert!(contents.thumbnail.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn synthetic_model_glb_pbr_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb_pbr": { + "url": "https://cdn.example.com/model_pbr.glb", + "content_type": "model/gltf-binary", + "file_name": "model_pbr.glb", + "file_size": 7654321 + } + }"#).unwrap(); + + let glb_pbr = extract_model_glb_pbr(&obj).expect("should extract model_glb_pbr"); + assert_eq!(glb_pbr.url.as_deref(), Some("https://cdn.example.com/model_pbr.glb")); + assert_eq!(glb_pbr.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb_pbr.file_name.as_deref(), Some("model_pbr.glb")); + assert_eq!(glb_pbr.file_size, Some(7654321)); + } + + #[test] + fn missing_model_glb_pbr_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb": {"url": "https://example.com/model.glb"} + }"#).unwrap(); + + assert!(extract_model_glb_pbr(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs new file mode 100644 index 0000000000..d8e1f5c213 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs @@ -0,0 +1,116 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelMeshData; + +/// Extract and deserialize the `model_mesh` key from a webhook success payload. +pub (crate) fn extract_model_mesh(obj: &Map) -> Option { + let value = obj.get("model_mesh")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn model_mesh_from_test_file() { + let webhook = load_test_webhook("success/model_mesh_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb2d/XCpH7fk-tZVXr1XMUr7DY_mesh-1775972273-765284.glb")); + assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(mesh.file_name.as_deref(), Some("mesh-1775972273-765284.glb")); + assert_eq!(mesh.file_size, Some(3886928)); + + // No other content types in this payload. + assert!(contents.image.is_none()); + assert!(contents.images.is_none()); + assert!(contents.video.is_none()); + assert!(contents.model_glb.is_none()); + assert!(contents.thumbnail.is_none()); + } + + #[test] + fn triposplat_ply_splat_from_test_file() { + // TripoSplat sends its PLY gaussian splat under `model_mesh`. + let webhook = load_test_webhook("success/triposplat_splat_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply")); + assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(mesh.file_name.as_deref(), Some("output.ply")); + assert_eq!(mesh.file_size, Some(17826208)); + + // The segmented input image rides along as `preprocessed_image`. + assert!(contents.preprocessed_image.is_some()); + + assert!(contents.model_glb.is_none()); + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.thumbnail.is_none()); + } + + #[test] + fn synthetic_model_mesh_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_mesh": { + "url": "https://cdn.example.com/mesh.obj", + "content_type": "model/obj", + "file_name": "output.obj", + "file_size": 1234567 + } + }"#).unwrap(); + + let mesh = extract_model_mesh(&obj).expect("should extract model_mesh"); + assert_eq!(mesh.url.as_deref(), Some("https://cdn.example.com/mesh.obj")); + assert_eq!(mesh.content_type.as_deref(), Some("model/obj")); + assert_eq!(mesh.file_name.as_deref(), Some("output.obj")); + assert_eq!(mesh.file_size, Some(1234567)); + } + + #[test] + fn model_mesh_url_only() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_mesh": {"url": "https://cdn.example.com/m.obj"} + }"#).unwrap(); + + let mesh = extract_model_mesh(&obj).expect("should extract model_mesh"); + assert_eq!(mesh.url.as_deref(), Some("https://cdn.example.com/m.obj")); + assert!(mesh.content_type.is_none()); + } + + #[test] + fn missing_model_mesh_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "image": {"url": "https://example.com/img.png"} + }"#).unwrap(); + + assert!(extract_model_mesh(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_obj_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_obj_payload.rs new file mode 100644 index 0000000000..c60ba55281 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_obj_payload.rs @@ -0,0 +1,52 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelObjData; + +/// Extract and deserialize the `model_obj` key from a webhook success payload. +pub (crate) fn extract_model_obj(obj: &Map) -> Option { + let value = obj.get("model_obj")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_model_obj_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_obj": { + "url": "https://cdn.example.com/model.obj", + "content_type": "model/obj", + "file_name": "model.obj", + "file_size": 654321 + } + }"#).unwrap(); + + let model_obj = extract_model_obj(&obj).expect("should extract model_obj"); + assert_eq!(model_obj.url.as_deref(), Some("https://cdn.example.com/model.obj")); + assert_eq!(model_obj.content_type.as_deref(), Some("model/obj")); + assert_eq!(model_obj.file_name.as_deref(), Some("model.obj")); + assert_eq!(model_obj.file_size, Some(654321)); + } + + #[test] + fn model_obj_url_only() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_obj": {"url": "https://cdn.example.com/m.obj"} + }"#).unwrap(); + + let model_obj = extract_model_obj(&obj).expect("should extract model_obj"); + assert_eq!(model_obj.url.as_deref(), Some("https://cdn.example.com/m.obj")); + assert!(model_obj.content_type.is_none()); + } + + #[test] + fn missing_model_obj_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_mesh": {"url": "https://example.com/mesh.glb"} + }"#).unwrap(); + + assert!(extract_model_obj(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs new file mode 100644 index 0000000000..cb5c63b41b --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs @@ -0,0 +1,327 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelUrlsData; + +/// Extract and deserialize the `model_urls` key from a webhook success +/// payload (e.g. Hunyuan 3D 3.0's per-format file map). +pub(crate) fn extract_model_urls(obj: &Map) -> Option { + let value = obj.get("model_urls")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn model_urls_from_hunyuan_3d_3p0_test_file() { + let webhook = load_test_webhook("success/hunyuan_3d_3p0_model_urls_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("model.glb")); + assert_eq!(glb.file_size, Some(51001160)); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // The `glb` slot duplicates `model_glb` in this payload (same URL). + let urls_glb = model_urls.glb.expect("model_urls.glb should be Some"); + assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb")); + + let urls_obj = model_urls.obj.expect("model_urls.obj should be Some"); + assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj")); + assert_eq!(urls_obj.content_type.as_deref(), Some("text/plain")); + assert_eq!(urls_obj.file_name.as_deref(), Some("model.obj")); + assert_eq!(urls_obj.file_size, Some(41107268)); + + assert!(model_urls.fbx.is_none()); + assert!(model_urls.mtl.is_none()); + assert!(model_urls.texture.is_none()); + assert!(model_urls.usdz.is_none()); + + let thumbnail = contents.thumbnail.expect("thumbnail should be Some"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(136476)); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.model_mesh.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn model_urls_and_thumbnail_from_hunyuan_3d_3p1_test_file() { + let webhook = load_test_webhook("success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("model.glb")); + assert_eq!(glb.file_size, Some(64261364)); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // The `glb` slot duplicates `model_glb` in this payload (same URL). + let urls_glb = model_urls.glb.expect("model_urls.glb should be Some"); + assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb")); + + let urls_obj = model_urls.obj.expect("model_urls.obj should be Some"); + assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj")); + assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj")); + assert_eq!(urls_obj.file_size, Some(34563830)); + + // 3.1 additionally ships the OBJ's material and PBR texture files. + let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some"); + assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl")); + assert_eq!(urls_mtl.content_type.as_deref(), Some("text/plain")); + assert_eq!(urls_mtl.file_name.as_deref(), Some("material.mtl")); + assert_eq!(urls_mtl.file_size, Some(245)); + + let urls_texture = model_urls.texture.expect("model_urls.texture should be Some"); + assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png")); + assert_eq!(urls_texture.content_type.as_deref(), Some("image/png")); + assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_20250901.png")); + assert_eq!(urls_texture.file_size, Some(22794575)); + + assert!(model_urls.fbx.is_none()); + assert!(model_urls.usdz.is_none()); + + // The thumbnail is attached to the GLB as its cover image downstream. + let thumbnail = contents.thumbnail.expect("thumbnail should be Some"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(141254)); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.model_mesh.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn obj_and_thumbnail_from_hunyuan_3d_3p1_rapid_test_file() { + let webhook = load_test_webhook("success/hunyuan_3d_3p1_rapid_obj_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + // NB: Hunyuan 3D 3.1 Rapid puts an OBJ under the `model_glb` key. The + // key name doesn't matter downstream: the webhook handler resolves the + // real file type from content_type / file name, so it uploads as an OBJ + // mesh. + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj")); + assert_eq!(glb.content_type.as_deref(), Some("model/obj")); + assert_eq!(glb.file_name.as_deref(), Some("23dce35fc95b34722f15a7329061d5df.obj")); + assert_eq!(glb.file_size, Some(3258750)); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // The `glb` slot is null here; `obj` duplicates `model_glb` (same URL). + assert!(model_urls.glb.is_none()); + assert!(model_urls.fbx.is_none()); + assert!(model_urls.usdz.is_none()); + + let urls_obj = model_urls.obj.expect("model_urls.obj should be Some"); + assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj")); + assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj")); + assert_eq!(urls_obj.file_size, Some(3258750)); + + let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some"); + assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl")); + assert_eq!(urls_mtl.file_size, Some(229)); + + let urls_texture = model_urls.texture.expect("model_urls.texture should be Some"); + assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png")); + assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_v128.png")); + assert_eq!(urls_texture.file_size, Some(5503289)); + + // The thumbnail is attached to the mesh as its cover image downstream. + let thumbnail = contents.thumbnail.expect("thumbnail should be Some"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(120530)); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.model_mesh.is_none()); + assert!(contents.model_obj.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn model_urls_and_rendered_image_from_tripo3d_test_file() { + let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + // Tripo 3D sends its GLB under `model_mesh` (not `model_glb`); the + // webhook handler picks `model_urls.glb` as the primary GLB instead. + assert!(contents.model_glb.is_none()); + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb")); + assert_eq!(mesh.content_type.as_deref(), Some("model/gltf-binary")); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // `glb` and `pbr_model` duplicate each other (same URL) in this payload; + // the webhook handler dedupes by URL and uploads the file only once. + let urls_glb = model_urls.glb.expect("model_urls.glb should be Some"); + assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb")); + assert_eq!(urls_glb.file_size, Some(43981280)); + + let urls_pbr_model = model_urls.pbr_model.expect("model_urls.pbr_model should be Some"); + assert_eq!(urls_pbr_model.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb")); + + assert!(model_urls.base_model.is_none()); + assert!(model_urls.fbx.is_none()); + assert!(model_urls.mtl.is_none()); + assert!(model_urls.obj.is_none()); + assert!(model_urls.texture.is_none()); + assert!(model_urls.usdz.is_none()); + + // The preview arrives as `rendered_image` (no `thumbnail` key) and is + // attached to the mesh as its cover image downstream. + let rendered_image = contents.rendered_image.expect("rendered_image should be Some"); + assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png")); + assert!(contents.thumbnail.is_none()); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn model_urls_and_thumbnail_from_meshy_test_file() { + let webhook = load_test_webhook("success/meshy_model_urls_thumbnail_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb")); + assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb.file_name.as_deref(), Some("model.glb")); + assert_eq!(glb.file_size, Some(15943768)); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // The `glb` slot duplicates `model_glb` (same URL); the webhook handler + // dedupes by URL so the file uploads only once. + let urls_glb = model_urls.glb.expect("model_urls.glb should be Some"); + assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb")); + + // Alternate formats of the same model; parsed but not uploaded. + let urls_fbx = model_urls.fbx.expect("model_urls.fbx should be Some"); + assert_eq!(urls_fbx.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx")); + assert_eq!(urls_fbx.file_size, Some(13665964)); + + let urls_obj = model_urls.obj.expect("model_urls.obj should be Some"); + assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj")); + + let urls_stl = model_urls.stl.expect("model_urls.stl should be Some"); + assert_eq!(urls_stl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl")); + assert_eq!(urls_stl.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(urls_stl.file_size, Some(1508384)); + + let urls_usdz = model_urls.usdz.expect("model_urls.usdz should be Some"); + assert_eq!(urls_usdz.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz")); + assert_eq!(urls_usdz.content_type.as_deref(), Some("model/vnd.usdz+zip")); + + assert!(model_urls.base_model.is_none()); + assert!(model_urls.blend.is_none()); + assert!(model_urls.mtl.is_none()); + assert!(model_urls.pbr_model.is_none()); + assert!(model_urls.texture.is_none()); + + // The thumbnail is attached to the GLB as its cover image downstream. + let thumbnail = contents.thumbnail.expect("thumbnail should be Some"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(192910)); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.model_mesh.is_none()); + assert!(contents.rendered_image.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn synthetic_model_urls_payload_with_distinct_glb() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_urls": { + "fbx": null, + "glb": { + "url": "https://cdn.example.com/model_alt.glb", + "content_type": "model/gltf-binary", + "file_name": "model_alt.glb", + "file_size": 1234567 + }, + "obj": null, + "usdz": null + } + }"#).unwrap(); + + let model_urls = extract_model_urls(&obj).expect("should extract model_urls"); + let glb = model_urls.glb.expect("glb slot should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model_alt.glb")); + assert_eq!(glb.file_size, Some(1234567)); + assert!(model_urls.fbx.is_none()); + assert!(model_urls.obj.is_none()); + assert!(model_urls.usdz.is_none()); + } + + #[test] + fn missing_model_urls_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb": {"url": "https://example.com/model.glb"} + }"#).unwrap(); + + assert!(extract_model_urls(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs new file mode 100644 index 0000000000..bffba9854d --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs @@ -0,0 +1,84 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::PreprocessedImageData; + +/// Extract and deserialize the `preprocessed_image` key from a webhook success payload. +pub(crate) fn extract_preprocessed_image(obj: &Map) -> Option { + let value = obj.get("preprocessed_image")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn preprocessed_image_from_triposplat_test_file() { + let webhook = load_test_webhook("success/triposplat_splat_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let image = contents.preprocessed_image.expect("preprocessed_image should be Some"); + assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("61fc73dbe84446b08b1fcce2167b71d5.png")); + assert_eq!(image.file_size, Some(952754)); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(1024)); + + // The splat itself arrives under `model_mesh`. + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply")); + assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(mesh.file_name.as_deref(), Some("output.ply")); + assert_eq!(mesh.file_size, Some(17826208)); + } + + #[test] + fn synthetic_preprocessed_image_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "preprocessed_image": { + "url": "https://cdn.example.com/segmented.png", + "content_type": "image/png", + "file_name": "segmented.png", + "file_size": 12345, + "width": 1024, + "height": 1024 + } + }"#).unwrap(); + + let image = extract_preprocessed_image(&obj).expect("should extract preprocessed_image"); + assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/segmented.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("segmented.png")); + assert_eq!(image.file_size, Some(12345)); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(1024)); + } + + #[test] + fn missing_preprocessed_image_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_mesh": {"url": "https://example.com/output.ply"} + }"#).unwrap(); + + assert!(extract_preprocessed_image(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_rendered_image_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_rendered_image_payload.rs new file mode 100644 index 0000000000..a2ab444db6 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_rendered_image_payload.rs @@ -0,0 +1,76 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ThumbnailData; + +/// Extract and deserialize the `rendered_image` key from a webhook success +/// payload (e.g. Tripo 3D's preview image). Same shape as `thumbnail`, and +/// used the same way: as a cover image for the model. +pub(crate) fn extract_rendered_image(obj: &Map) -> Option { + let value = obj.get("rendered_image")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn rendered_image_from_tripo3d_test_file() { + let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let rendered_image = contents.rendered_image.expect("rendered_image should be Some"); + assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png")); + assert_eq!(rendered_image.content_type.as_deref(), Some("image/png")); + assert_eq!(rendered_image.file_name.as_deref(), Some("preview.png")); + assert_eq!(rendered_image.file_size, Some(30970)); + + // Tripo has no `thumbnail` key; the preview only arrives here. + assert!(contents.thumbnail.is_none()); + } + + #[test] + fn synthetic_rendered_image_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "rendered_image": { + "url": "https://cdn.example.com/preview.png", + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 12345 + } + }"#).unwrap(); + + let image = extract_rendered_image(&obj).expect("should extract rendered_image"); + assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/preview.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("preview.png")); + assert_eq!(image.file_size, Some(12345)); + } + + #[test] + fn missing_rendered_image_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "thumbnail": {"url": "https://example.com/preview.png"} + }"#).unwrap(); + + assert!(extract_rendered_image(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_result_files_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_result_files_payload.rs new file mode 100644 index 0000000000..76554773e0 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_result_files_payload.rs @@ -0,0 +1,68 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ResultFileData; + +/// Extract and deserialize the `result_files` key (a list of output files) +/// from a webhook success payload. +/// +/// Returns `None` for a missing/unparseable key AND for an empty list — an +/// empty result set is not actionable, so callers can treat `Some` as +/// "there is at least one file". +pub (crate) fn extract_result_files(obj: &Map) -> Option> { + let value = obj.get("result_files")?; + let files: Vec = serde_json::from_value(value.clone()).ok()?; + if files.is_empty() { + return None; + } + Some(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_result_files_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "result_files": [ + { + "url": "https://cdn.example.com/part_1.fbx", + "content_type": "application/octet-stream", + "file_name": "part_1.fbx", + "file_size": 111111 + }, + { + "url": "https://cdn.example.com/part_2.fbx", + "content_type": "application/octet-stream", + "file_name": "part_2.fbx", + "file_size": 222222 + } + ] + }"#).unwrap(); + + let files = extract_result_files(&obj).expect("should extract result_files"); + assert_eq!(files.len(), 2); + assert_eq!(files[0].url.as_deref(), Some("https://cdn.example.com/part_1.fbx")); + assert_eq!(files[0].file_size, Some(111111)); + assert_eq!(files[1].url.as_deref(), Some("https://cdn.example.com/part_2.fbx")); + assert_eq!(files[1].file_name.as_deref(), Some("part_2.fbx")); + } + + #[test] + fn empty_result_files_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "result_files": [] + }"#).unwrap(); + + assert!(extract_result_files(&obj).is_none()); + } + + #[test] + fn missing_result_files_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb": {"url": "https://example.com/mesh.glb"} + }"#).unwrap(); + + assert!(extract_result_files(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_thumbnail_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_thumbnail_payload.rs new file mode 100644 index 0000000000..6737b47036 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_thumbnail_payload.rs @@ -0,0 +1,86 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ThumbnailData; + +/// Extract and deserialize the `thumbnail` key from a webhook success payload. +pub(crate) fn extract_thumbnail(obj: &Map) -> Option { + let value = obj.get("thumbnail")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn thumbnail_from_model_glb_test_file() { + let webhook = load_test_webhook("success/model_glb_urls_and_thumbnail_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let thumbnail = contents.thumbnail.expect("thumbnail should be Some"); + assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb38/yvwGJWZV9TkWZuZvjxZNj_preview.png")); + assert_eq!(thumbnail.content_type.as_deref(), Some("image/png")); + assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png")); + assert_eq!(thumbnail.file_size, Some(93436)); + } + + #[test] + fn synthetic_thumbnail_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "thumbnail": { + "url": "https://cdn.example.com/thumb.png", + "content_type": "image/png", + "file_name": "thumb.png", + "file_size": 12345 + } + }"#).unwrap(); + + let thumb = extract_thumbnail(&obj).expect("should extract thumbnail"); + assert_eq!(thumb.url.as_deref(), Some("https://cdn.example.com/thumb.png")); + assert_eq!(thumb.content_type.as_deref(), Some("image/png")); + assert_eq!(thumb.file_name.as_deref(), Some("thumb.png")); + assert_eq!(thumb.file_size, Some(12345)); + } + + #[test] + fn missing_thumbnail_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb": {"url": "https://example.com/model.glb"} + }"#).unwrap(); + + assert!(extract_thumbnail(&obj).is_none()); + } + + #[test] + fn model_mesh_payload_has_no_thumbnail() { + let webhook = load_test_webhook("success/model_mesh_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + assert!(contents.thumbnail.is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_video_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_video_payload.rs new file mode 100644 index 0000000000..9c405c023f --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_video_payload.rs @@ -0,0 +1,90 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::VideoData; + +/// Extract and deserialize the `video` key from a webhook success payload. +pub (crate) fn extract_video(obj: &Map) -> Option { + let value = obj.get("video")?; + serde_json::from_value(value.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents; + use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents; + use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + + fn load_test_webhook(filename: &str) -> RawWebhookPayload { + let path = format!("test_data/webhooks/{}", filename); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); + serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)) + } + + #[test] + fn video_payload_from_test_file() { + let webhook = load_test_webhook("success/video_payload_1.json"); + let result = hydrate_webhook_contents(&webhook); + + let HydratedWebhookContents::Success(data) = result else { + panic!("Expected Success, got {:?}", result); + }; + + let contents = data.extracted_contents + .expect("extracted_contents should be Some"); + + let video = contents.video.expect("video should be Some"); + + assert_eq!(video.url.as_deref(), Some("https://v3b.fal.media/files/b/0abcdef0/AB-CDE_123456789abcde_output.mp4")); + assert_eq!(video.content_type.as_deref(), Some("video/mp4")); + assert_eq!(video.file_name.as_deref(), Some("output.mp4")); + assert_eq!(video.file_size, Some(6226845)); + + assert!(contents.image.is_none()); + assert!(contents.images.is_none()); + assert!(contents.model_glb.is_none()); + assert!(contents.model_mesh.is_none()); + } + + #[test] + fn synthetic_video_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "video": { + "url": "https://cdn.example.com/output.mp4", + "content_type": "video/mp4", + "file_name": "clip.mp4", + "file_size": 12345678 + } + }"#).unwrap(); + + let video = extract_video(&obj).expect("should extract video"); + assert_eq!(video.url.as_deref(), Some("https://cdn.example.com/output.mp4")); + assert_eq!(video.content_type.as_deref(), Some("video/mp4")); + assert_eq!(video.file_name.as_deref(), Some("clip.mp4")); + assert_eq!(video.file_size, Some(12345678)); + } + + #[test] + fn synthetic_video_url_only() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "video": {"url": "https://cdn.example.com/v.mp4"} + }"#).unwrap(); + + let video = extract_video(&obj).expect("should extract video"); + assert_eq!(video.url.as_deref(), Some("https://cdn.example.com/v.mp4")); + assert!(video.content_type.is_none()); + assert!(video.file_name.is_none()); + assert!(video.file_size.is_none()); + } + + #[test] + fn missing_video_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "images": [{"url": "https://example.com/img.png"}] + }"#).unwrap(); + + assert!(extract_video(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs new file mode 100644 index 0000000000..06578003bb --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs @@ -0,0 +1,69 @@ +pub mod extract_audio_payload; +pub mod extract_image_payload; +pub mod extract_images_payload; +pub mod extract_video_payload; +pub mod extract_model_glb_payload; +pub mod extract_model_glb_pbr_payload; +pub mod extract_model_mesh_payload; +pub mod extract_model_obj_payload; +pub mod extract_model_urls_payload; +pub mod extract_preprocessed_image_payload; +pub mod extract_rendered_image_payload; +pub mod extract_result_files_payload; +pub mod extract_thumbnail_payload; + +use serde_json::Value; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ExtractedContents; + +/// Try to extract known content keys from a success payload. +/// +/// Checks the payload (as a JSON object) for any of: `image`, `images`, +/// `video`, `audio`, `model_glb`, `model_glb_pbr`, `model_urls`, +/// `model_mesh`, `model_obj`, `result_files`, `thumbnail`, `rendered_image`, +/// `preprocessed_image`. If at least one is found, returns +/// `Some(ExtractedContents)` with copies of the relevant values. Multiple +/// keys can be populated simultaneously. Returns `None` if the payload is +/// not an object or none of the known keys are present. +pub fn extract_contents_from_payload(payload: &Value) -> Option { + let obj = payload.as_object()?; + + let image = extract_image_payload::extract_image(obj); + let images = extract_images_payload::extract_images(obj); + let video = extract_video_payload::extract_video(obj); + let audio = extract_audio_payload::extract_audio(obj); + let model_glb = extract_model_glb_payload::extract_model_glb(obj); + let model_glb_pbr = extract_model_glb_pbr_payload::extract_model_glb_pbr(obj); + let model_urls = extract_model_urls_payload::extract_model_urls(obj); + let model_mesh = extract_model_mesh_payload::extract_model_mesh(obj); + let model_obj = extract_model_obj_payload::extract_model_obj(obj); + let result_files = extract_result_files_payload::extract_result_files(obj); + let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj); + let rendered_image = extract_rendered_image_payload::extract_rendered_image(obj); + let preprocessed_image = extract_preprocessed_image_payload::extract_preprocessed_image(obj); + + if image.is_none() && images.is_none() && video.is_none() && audio.is_none() + && model_glb.is_none() && model_glb_pbr.is_none() && model_urls.is_none() + && model_mesh.is_none() && model_obj.is_none() + && result_files.is_none() && thumbnail.is_none() && rendered_image.is_none() + && preprocessed_image.is_none() + { + return None; + } + + Some(ExtractedContents { + image, + images, + video, + audio, + model_glb, + model_glb_pbr, + model_urls, + model_mesh, + model_obj, + result_files, + thumbnail, + rendered_image, + preprocessed_image, + }) +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/mod.rs b/crates/api_clients/fal_client/src/webhook_payload/mod.rs new file mode 100644 index 0000000000..eaaaac6df5 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/mod.rs @@ -0,0 +1,4 @@ +pub mod raw; +pub mod hydrated; +pub mod hydrate_webhook_contents; +pub mod parse_raw_webhook_payload; diff --git a/crates/api_clients/fal_client/src/webhook_payload/parse_raw_webhook_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/parse_raw_webhook_payload.rs new file mode 100644 index 0000000000..1b54efb626 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/parse_raw_webhook_payload.rs @@ -0,0 +1,5 @@ +use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload; + +pub fn parse_raw_webhook_payload(json: &str) -> Result { + serde_json::from_str(json) +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/raw/mod.rs b/crates/api_clients/fal_client/src/webhook_payload/raw/mod.rs new file mode 100644 index 0000000000..f141a2c3be --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/raw/mod.rs @@ -0,0 +1,2 @@ +pub mod webhook_error_type; +pub mod raw_webhook_payload; diff --git a/crates/api_clients/fal_client/src/webhook_payload/raw/raw_webhook_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/raw/raw_webhook_payload.rs new file mode 100644 index 0000000000..fedf703ab0 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/raw/raw_webhook_payload.rs @@ -0,0 +1,43 @@ +use serde::Deserialize; +use serde_json::Value; + +/// The value FAL sends to webhook endpoints. +#[derive(Deserialize, Debug, Clone)] +pub struct RawWebhookPayload { + /// Value originally used in the queue API + pub request_id: String, + + /// Mostly the same as `request_id`, but if the request failed and was retried, + /// then gateway_request_id will have the value of the last tried request, + /// while request_id will be the value used in the queue API. + pub gateway_request_id: String, + + /// Status of the request: OK or ERROR. + pub status: RawWebhookStatus, + + /// When an error happens, the status will be ERROR. + /// The error property will contain a message and payload will contain the error details. + /// eg. ""Invalid status code: 422" + pub error: Option, + + /// Payload of the webhook (typically present) + /// THIS FIELD IS HIGHLY VARIABLE JSON. + /// This is present for both *success* and *failure* cases, + /// though the payload differs. Sometimes it is absent. + pub payload: Option, + + /// In rare instances, if there was an error encoding the payload, + /// this may be set. This can seemingly be set on "OK" responses. + pub payload_error: Option, +} + +#[derive(Deserialize, Debug, Clone, Copy)] +pub enum RawWebhookStatus { + /// Success case + #[serde(alias = "OK")] + Ok, + + /// Failure case + #[serde(alias = "ERROR")] + Error +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/raw/webhook_error_type.rs b/crates/api_clients/fal_client/src/webhook_payload/raw/webhook_error_type.rs new file mode 100644 index 0000000000..26d3a194f2 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/raw/webhook_error_type.rs @@ -0,0 +1,114 @@ +use serde::Deserialize; + +/// Machine-readable error type from FAL webhook error payloads. +/// +/// These are the `type` field values from the `detail` array in FAL error responses. +/// See: https://fal.ai/docs/documentation/model-apis/errors +#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookErrorType { + FileTooLarge, + InternalServerError, + GenerationTimeout, + DownstreamServiceError, + DownstreamServiceUnavailable, + ContentPolicyViolation, + NoMediaGenerated, + ImageTooSmall, + ImageTooLarge, + ImageLoadError, + FileDownloadError, + FaceDetectionError, + /// Generic input validation error. The `msg` field carries the specifics + /// (e.g. TripoSplat's "No foreground subject could be detected..."). + InputValueError, + /// Generic validation error. The `msg` field carries the specifics (e.g. + /// Hunyuan 3D Part's "Part generation only supports FBX format..."). + ValueError, + GreaterThan, + GreaterThanEqual, + LessThan, + LessThanEqual, + MultipleOf, + SequenceTooShort, + SequenceTooLong, + OneOf, + FeatureNotSupported, + InvalidArchive, + ArchiveFileCountBelowMinimum, + ArchiveFileCountExceedsMaximum, + InvalidApiKey, + + /// Catch-all for unrecognized error types from FAL. + #[serde(untagged)] + Unknown(String), +} + +impl WebhookErrorType { + pub fn from_str(value: &str) -> Self { + serde_json::from_value(serde_json::Value::String(value.to_string())) + .unwrap_or_else(|_| Self::Unknown(value.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_known_types() { + let cases = vec![ + ("\"file_too_large\"", WebhookErrorType::FileTooLarge), + ("\"internal_server_error\"", WebhookErrorType::InternalServerError), + ("\"generation_timeout\"", WebhookErrorType::GenerationTimeout), + ("\"downstream_service_error\"", WebhookErrorType::DownstreamServiceError), + ("\"downstream_service_unavailable\"", WebhookErrorType::DownstreamServiceUnavailable), + ("\"content_policy_violation\"", WebhookErrorType::ContentPolicyViolation), + ("\"no_media_generated\"", WebhookErrorType::NoMediaGenerated), + ("\"image_too_small\"", WebhookErrorType::ImageTooSmall), + ("\"image_too_large\"", WebhookErrorType::ImageTooLarge), + ("\"image_load_error\"", WebhookErrorType::ImageLoadError), + ("\"file_download_error\"", WebhookErrorType::FileDownloadError), + ("\"face_detection_error\"", WebhookErrorType::FaceDetectionError), + ("\"input_value_error\"", WebhookErrorType::InputValueError), + ("\"value_error\"", WebhookErrorType::ValueError), + ("\"greater_than\"", WebhookErrorType::GreaterThan), + ("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual), + ("\"less_than\"", WebhookErrorType::LessThan), + ("\"less_than_equal\"", WebhookErrorType::LessThanEqual), + ("\"multiple_of\"", WebhookErrorType::MultipleOf), + ("\"sequence_too_short\"", WebhookErrorType::SequenceTooShort), + ("\"sequence_too_long\"", WebhookErrorType::SequenceTooLong), + ("\"one_of\"", WebhookErrorType::OneOf), + ("\"feature_not_supported\"", WebhookErrorType::FeatureNotSupported), + ("\"invalid_archive\"", WebhookErrorType::InvalidArchive), + ("\"archive_file_count_below_minimum\"", WebhookErrorType::ArchiveFileCountBelowMinimum), + ("\"archive_file_count_exceeds_maximum\"", WebhookErrorType::ArchiveFileCountExceedsMaximum), + ("\"invalid_api_key\"", WebhookErrorType::InvalidApiKey), + ]; + + for (json, expected) in cases { + let deserialized: WebhookErrorType = serde_json::from_str(json).unwrap(); + assert_eq!(deserialized, expected, "failed for {}", json); + } + } + + #[test] + fn deserialize_unknown_type() { + let deserialized: WebhookErrorType = serde_json::from_str("\"some_new_error_type\"").unwrap(); + assert_eq!(deserialized, WebhookErrorType::Unknown("some_new_error_type".to_string())); + } + + #[test] + fn from_str_known() { + assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation); + assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError); + assert_eq!(WebhookErrorType::from_str("input_value_error"), WebhookErrorType::InputValueError); + assert_eq!(WebhookErrorType::from_str("value_error"), WebhookErrorType::ValueError); + } + + #[test] + fn from_str_unknown() { + assert_eq!(WebhookErrorType::from_str("brand_new_type"), WebhookErrorType::Unknown("brand_new_type".to_string())); + } +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/gpt_image_1p5_fail_content_policy.json b/crates/api_clients/fal_client/test_data/webhooks/failure/gpt_image_1p5_fail_content_policy.json new file mode 100644 index 0000000000..2b2dd02c69 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/gpt_image_1p5_fail_content_policy.json @@ -0,0 +1,29 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019d4756-130b-7141-a16c-8f411b785844", + "payload": { + "detail": [ + { + "input": { + "background": "auto", + "image_size": "1536x1024", + "input_fidelity": null, + "num_images": 1, + "openai_api_key": null, + "output_format": "png", + "prompt": "[redacted nsfw query]", + "quality": "medium" + }, + "loc": [ + "body", + "prompt" + ], + "msg": "The content could not be processed because it contained material flagged by a content checker.", + "type": "content_policy_violation", + "url": "https://docs.fal.ai/errors#content_policy_violation" + } + ] + }, + "request_id": "019d4756-130b-7141-a16c-8f411b785844", + "status": "ERROR" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/gpt_image_fail_invalid_api_key.json b/crates/api_clients/fal_client/test_data/webhooks/failure/gpt_image_fail_invalid_api_key.json new file mode 100644 index 0000000000..3d56fe9c27 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/gpt_image_fail_invalid_api_key.json @@ -0,0 +1,29 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22", + "payload": { + "detail": [ + { + "input": { + "background": "auto", + "image_size": "1536x1024", + "input_fidelity": null, + "num_images": 1, + "openai_api_key": "REDACTED", + "output_format": "png", + "prompt": "[redacted]", + "quality": "high" + }, + "loc": [ + "body", + "openai_api_key" + ], + "msg": "Invalid API key", + "type": "invalid_api_key", + "url": "https://docs.fal.ai/errors#invalid_api_key" + } + ] + }, + "request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22", + "status": "ERROR" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/hunyuan_3d_3p1_part_fail_format_not_supported.json b/crates/api_clients/fal_client/test_data/webhooks/failure/hunyuan_3d_3p1_part_fail_format_not_supported.json new file mode 100644 index 0000000000..96e5bb9c37 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/hunyuan_3d_3p1_part_fail_format_not_supported.json @@ -0,0 +1,18 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843", + "payload": { + "detail": [ + { + "loc": [ + "body", + "input_file_url" + ], + "msg": "Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first.", + "type": "value_error" + } + ] + }, + "request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843", + "status": "ERROR" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/kling_1p6_pro_file_too_large_error.json b/crates/api_clients/fal_client/test_data/webhooks/failure/kling_1p6_pro_file_too_large_error.json new file mode 100644 index 0000000000..bdb0818dfe --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/kling_1p6_pro_file_too_large_error.json @@ -0,0 +1,23 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019d477e-c123-7120-81c7-30d6b58a6720", + "payload": { + "detail": [ + { + "ctx": { + "max_size": 10485760 + }, + "input": "[redacted]", + "loc": [ + "body", + "image_url" + ], + "msg": "File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file.", + "type": "file_too_large", + "url": "https://docs.fal.ai/errors#file_too_large" + } + ] + }, + "request_id": "019d477e-c123-7120-81c7-30d6b58a6720", + "status": "ERROR" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/kling_3p0_pro_fail_content_policy.json b/crates/api_clients/fal_client/test_data/webhooks/failure/kling_3p0_pro_fail_content_policy.json new file mode 100644 index 0000000000..8f8c619a3e --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/kling_3p0_pro_fail_content_policy.json @@ -0,0 +1,29 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019d4761-035d-7b50-8c42-1521efdd4f34", + "payload": { + "detail": [ + { + "input": { + "aspect_ratio": "16:9", + "cfg_scale": 0.5, + "duration": "3", + "generate_audio": false, + "multi_prompt": null, + "negative_prompt": "blur, distort, and low quality", + "prompt": "[redacted prompt]", + "shot_type": "customize", + "voice_ids": null + }, + "loc": [ + "body" + ], + "msg": "The content could not be processed because it contained material flagged by a content checker.", + "type": "content_policy_violation", + "url": "https://docs.fal.ai/errors#content_policy_violation" + } + ] + }, + "request_id": "019d4761-035d-7b50-8c42-1521efdd4f34", + "status": "ERROR" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/nano_banana_pro_fail_no_media_generated.json b/crates/api_clients/fal_client/test_data/webhooks/failure/nano_banana_pro_fail_no_media_generated.json new file mode 100644 index 0000000000..f9404fed3d --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/nano_banana_pro_fail_no_media_generated.json @@ -0,0 +1,30 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019d475a-291a-7681-81dc-b27d2489beae", + "payload": { + "detail": [ + { + "input": { + "aspect_ratio": "16:9", + "enable_web_search": false, + "limit_generations": false, + "num_images": 1, + "output_format": "png", + "prompt": "[nsfw prompt redacted]", + "resolution": "1K", + "safety_tolerance": "4", + "seed": null, + "sync_mode": false + }, + "loc": [ + "body" + ], + "msg": "The model did not generate the expected output for this prompt. This may occur for several reasons, including unsafe content, a prompt that is incompatible with the selected media type (e.g., prompting an image model to generate audio), references to missing attachments, or other cases where the input cannot be processed as the requested output type. Please review your inputs and try again. If the issue persists, contact support.", + "type": "no_media_generated", + "url": "https://docs.fal.ai/errors#no_media_generated" + } + ] + }, + "request_id": "019d475a-291a-7681-81dc-b27d2489beae", + "status": "ERROR" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/seedream_partner_validation_failed.json b/crates/api_clients/fal_client/test_data/webhooks/failure/seedream_partner_validation_failed.json new file mode 100644 index 0000000000..8d441a09f4 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/seedream_partner_validation_failed.json @@ -0,0 +1,35 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019d8079-baa1-75c0-a2da-7347ee642909", + "payload": { + "detail": [ + { + "ctx": { + "extra_info": { + "reason": "partner_validation_failed" + } + }, + "input": { + "enable_safety_checker": false, + "image_size": "landscape_16_9", + "image_urls": [ + "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/w/k/5/k/8/wk5k8p47fwsw4hvgyj6gsb7nczcqeh58/snapshot_wk5k8p47fwsw4hvgyj6gsb7nczcqeh58.png" + ], + "max_images": 1, + "num_images": 4, + "prompt": "Add a giant monster in the distance", + "sync_mode": false + }, + "loc": [ + "body", + "image" + ], + "msg": "The content could not be processed because it contained material flagged by a content checker.", + "type": "content_policy_violation", + "url": "https://docs.fal.ai/errors#content_policy_violation" + } + ] + }, + "request_id": "019d8079-baa1-75c0-a2da-7347ee642909", + "status": "ERROR" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/failure/triposplat_fail_no_foreground_subject.json b/crates/api_clients/fal_client/test_data/webhooks/failure/triposplat_fail_no_foreground_subject.json new file mode 100644 index 0000000000..2153e77d24 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/failure/triposplat_fail_no_foreground_subject.json @@ -0,0 +1,19 @@ +{ + "error": "Unexpected status code: 422", + "gateway_request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b", + "payload": { + "detail": [ + { + "input": "https://cdn-2.fakeyou.com/media/f/d/6/f/6/fd6f6czv21gkrdqhptz6nj072wh7w06y/artcraft_fd6f6czv21gkrdqhptz6nj072wh7w06y.png", + "loc": [ + "body" + ], + "msg": "No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background.", + "type": "input_value_error", + "url": "https://docs.fal.ai/errors#input_value_error" + } + ] + }, + "request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b", + "status": "ERROR" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_2p1_model_glbs_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_2p1_model_glbs_payload_1.json new file mode 100644 index 0000000000..d19408225b --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_2p1_model_glbs_payload_1.json @@ -0,0 +1,27 @@ +{ + "error": null, + "gateway_request_id": "019f4d49-6859-77b3-aaac-3b91b248481a", + "payload": { + "model_glb": { + "content_type": "application/octet-stream", + "file_name": "demo_textured.glb", + "file_size": 1574216, + "url": "https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb" + }, + "model_glb_pbr": { + "content_type": "application/octet-stream", + "file_name": "demo_textured_pbr.glb", + "file_size": 7512760, + "url": "https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb" + }, + "model_mesh": { + "content_type": "application/octet-stream", + "file_name": "3d_model.zip", + "file_size": 16332900, + "url": "https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip" + }, + "seed": 283904 + }, + "request_id": "019f4d49-6859-77b3-aaac-3b91b248481a", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p0_model_urls_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p0_model_urls_payload_1.json new file mode 100644 index 0000000000..e99b3212ff --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p0_model_urls_payload_1.json @@ -0,0 +1,37 @@ +{ + "error": null, + "gateway_request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16", + "payload": { + "model_glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 51001160, + "url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb" + }, + "model_urls": { + "fbx": null, + "glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 51001160, + "url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb" + }, + "obj": { + "content_type": "text/plain", + "file_name": "model.obj", + "file_size": 41107268, + "url": "https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj" + }, + "usdz": null + }, + "seed": null, + "thumbnail": { + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 136476, + "url": "https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png" + } + }, + "request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json new file mode 100644 index 0000000000..ff83998550 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json @@ -0,0 +1,49 @@ +{ + "error": null, + "gateway_request_id": "019f4d79-35fd-7462-85be-359c8232f56e", + "payload": { + "model_glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 64261364, + "url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb" + }, + "model_urls": { + "fbx": null, + "glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 64261364, + "url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb" + }, + "mtl": { + "content_type": "text/plain", + "file_name": "material.mtl", + "file_size": 245, + "url": "https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl" + }, + "obj": { + "content_type": "model/obj", + "file_name": "c3a871e997ecd5f889bf671630de23b4.obj", + "file_size": 34563830, + "url": "https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj" + }, + "texture": { + "content_type": "image/png", + "file_name": "texture_pbr_20250901.png", + "file_size": 22794575, + "url": "https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png" + }, + "usdz": null + }, + "seed": null, + "thumbnail": { + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 141254, + "url": "https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png" + } + }, + "request_id": "019f4d79-35fd-7462-85be-359c8232f56e", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_rapid_obj_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_rapid_obj_payload_1.json new file mode 100644 index 0000000000..83e3105683 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_rapid_obj_payload_1.json @@ -0,0 +1,55 @@ +{ + "error": null, + "gateway_request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8", + "payload": { + "material_mtl": { + "content_type": "text/plain", + "file_name": "material.mtl", + "file_size": 229, + "url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl" + }, + "model_glb": { + "content_type": "model/obj", + "file_name": "23dce35fc95b34722f15a7329061d5df.obj", + "file_size": 3258750, + "url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj" + }, + "model_urls": { + "fbx": null, + "glb": null, + "mtl": { + "content_type": "text/plain", + "file_name": "material.mtl", + "file_size": 229, + "url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl" + }, + "obj": { + "content_type": "model/obj", + "file_name": "23dce35fc95b34722f15a7329061d5df.obj", + "file_size": 3258750, + "url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj" + }, + "texture": { + "content_type": "image/png", + "file_name": "texture_pbr_v128.png", + "file_size": 5503289, + "url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png" + }, + "usdz": null + }, + "texture": { + "content_type": "image/png", + "file_name": "texture_pbr_v128.png", + "file_size": 5503289, + "url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png" + }, + "thumbnail": { + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 120530, + "url": "https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png" + } + }, + "request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/images_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/images_payload_1.json new file mode 100644 index 0000000000..3d4fdea8f7 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/images_payload_1.json @@ -0,0 +1,26 @@ +{ + "error": null, + "gateway_request_id": "11111111-2222-3333-4444-555555555555", + "payload": { + "images": [ + { + "content_type": "image/png", + "file_name": "name1.png", + "file_size": 11500002, + "height": null, + "url": "https://v3b.fal.media/files/b/01234567/name1.png", + "width": null + }, + { + "content_type": "image/png", + "file_name": "name2.png", + "file_size": 13499999, + "height": null, + "url": "https://v3b.fal.media/files/b/01234567/name2.png", + "width": null + } + ] + }, + "request_id": "11111111-2222-3333-4444-555555555555", + "status": "OK" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/meshy_model_urls_thumbnail_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/meshy_model_urls_thumbnail_payload_1.json new file mode 100644 index 0000000000..e04c303c90 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/meshy_model_urls_thumbnail_payload_1.json @@ -0,0 +1,88 @@ +{ + "error": null, + "gateway_request_id": "019f4db7-e55c-78d1-9719-0303ca50a961", + "payload": { + "animation_fbx": null, + "animation_glb": null, + "basic_animations": null, + "model_glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 15943768, + "url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb" + }, + "model_urls": { + "blend": null, + "fbx": { + "content_type": "application/octet-stream", + "file_name": "model.fbx", + "file_size": 13665964, + "url": "https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx" + }, + "glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 15943768, + "url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb" + }, + "obj": { + "content_type": "text/plain", + "file_name": "model.obj", + "file_size": 3083671, + "url": "https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj" + }, + "stl": { + "content_type": "application/octet-stream", + "file_name": "model.stl", + "file_size": 1508384, + "url": "https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl" + }, + "usdz": { + "content_type": "model/vnd.usdz+zip", + "file_name": "model.usdz", + "file_size": 16861118, + "url": "https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz" + } + }, + "rig_task_id": null, + "rigged_character_fbx": null, + "rigged_character_glb": null, + "seed": 274321742, + "texture_urls": [ + { + "base_color": { + "content_type": "image/png", + "file_name": "texture_0.png", + "file_size": 7539838, + "url": "https://v3b.fal.media/files/b/0aa1bbe3/EXWiAuAYPobYiJPnW66mQ_texture_0.png" + }, + "metallic": { + "content_type": "image/png", + "file_name": "texture_0_metallic.png", + "file_size": 858941, + "url": "https://v3b.fal.media/files/b/0aa1bbee/RTZIZRCDQTvN7jdl4EL_C_texture_0_metallic.png" + }, + "normal": { + "content_type": "image/png", + "file_name": "texture_0_normal.png", + "file_size": 8236038, + "url": "https://v3b.fal.media/files/b/0aa1bbe3/yNk-S5Dhw2xc9fb3fIjwD_texture_0_normal.png" + }, + "roughness": { + "content_type": "image/png", + "file_name": "texture_0_roughness.png", + "file_size": 1184908, + "url": "https://v3b.fal.media/files/b/0aa1bbe3/nnT25g7wgul2oDTRlI_jX_texture_0_roughness.png" + } + } + ], + "thumbnail": { + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 192910, + "url": "https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png" + } + }, + "request_id": "019f4db7-e55c-78d1-9719-0303ca50a961", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/model_glb_urls_and_thumbnail_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/model_glb_urls_and_thumbnail_1.json new file mode 100644 index 0000000000..f3e0287c27 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/model_glb_urls_and_thumbnail_1.json @@ -0,0 +1,37 @@ +{ + "error": null, + "gateway_request_id": "019d8031-ede2-7932-a18f-9b2865908c0e", + "payload": { + "model_glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 66284788, + "url": "https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb" + }, + "model_urls": { + "fbx": null, + "glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 66284788, + "url": "https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb" + }, + "obj": { + "content_type": "text/plain", + "file_name": "model.obj", + "file_size": 55806533, + "url": "https://v3b.fal.media/files/b/0a95eb38/vfrPPtdTXNEYh_ExY-adN_model.obj" + }, + "usdz": null + }, + "seed": null, + "thumbnail": { + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 93436, + "url": "https://v3b.fal.media/files/b/0a95eb38/yvwGJWZV9TkWZuZvjxZNj_preview.png" + } + }, + "request_id": "019d8031-ede2-7932-a18f-9b2865908c0e", + "status": "OK" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/model_mesh_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/model_mesh_payload_1.json new file mode 100644 index 0000000000..5cb7f66a82 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/model_mesh_payload_1.json @@ -0,0 +1,15 @@ +{ + "error": null, + "gateway_request_id": "019d8032-38cb-7161-8dd8-0b4adf10fa72", + "payload": { + "model_mesh": { + "content_type": "application/octet-stream", + "file_name": "mesh-1775972273-765284.glb", + "file_size": 3886928, + "url": "https://v3b.fal.media/files/b/0a95eb2d/XCpH7fk-tZVXr1XMUr7DY_mesh-1775972273-765284.glb" + }, + "seed": 765284 + }, + "request_id": "019d8032-38cb-7161-8dd8-0b4adf10fa72", + "status": "OK" +} \ No newline at end of file diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/seed_audio_1p0_audio_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/seed_audio_1p0_audio_payload_1.json new file mode 100644 index 0000000000..0b84c749ba --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/seed_audio_1p0_audio_payload_1.json @@ -0,0 +1,18 @@ +{ + "error": null, + "gateway_request_id": "019f4dc7-e98b-7f90-94c5-a8b131ff2660", + "payload": { + "audio": { + "bitrate": null, + "channels": null, + "content_type": "audio/mpeg", + "duration": 6.63995, + "file_name": "speech.mp3", + "file_size": 53612, + "sample_rate": 24000, + "url": "https://v3b.fal.media/files/b/0aa1bc36/vOSCcxsbibkZIiXR9BIZn_speech.mp3" + } + }, + "request_id": "019f4dc7-e98b-7f90-94c5-a8b131ff2660", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/tripo3d_model_urls_rendered_image_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/tripo3d_model_urls_rendered_image_payload_1.json new file mode 100644 index 0000000000..5e13d19ed5 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/tripo3d_model_urls_rendered_image_payload_1.json @@ -0,0 +1,36 @@ +{ + "error": null, + "gateway_request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c", + "payload": { + "model_mesh": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 43981280, + "url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb" + }, + "model_urls": { + "base_model": null, + "fbx": null, + "glb": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 43981280, + "url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb" + }, + "pbr_model": { + "content_type": "model/gltf-binary", + "file_name": "model.glb", + "file_size": 43981280, + "url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb" + } + }, + "rendered_image": { + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 30970, + "url": "https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png" + } + }, + "request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/triposplat_splat_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/triposplat_splat_payload_1.json new file mode 100644 index 0000000000..1f1fe1d659 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/triposplat_splat_payload_1.json @@ -0,0 +1,28 @@ +{ + "error": null, + "gateway_request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844", + "payload": { + "model_mesh": { + "content_type": "application/octet-stream", + "file_name": "output.ply", + "file_size": 17826208, + "url": "https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply" + }, + "num_gaussians": 262144, + "preprocessed_image": { + "content_type": "image/png", + "file_name": "61fc73dbe84446b08b1fcce2167b71d5.png", + "file_size": 952754, + "height": 1024, + "url": "https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png", + "width": 1024 + }, + "seed": 1637204116, + "timings": { + "export": 0.794984320000367, + "inference": 4.911052849000043 + } + }, + "request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844", + "status": "OK" +} diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/video_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/video_payload_1.json new file mode 100644 index 0000000000..413f59db96 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/video_payload_1.json @@ -0,0 +1,14 @@ +{ + "error": null, + "gateway_request_id": "01234560-1230-1230-1230-122233344455", + "payload": { + "video": { + "content_type": "video/mp4", + "file_name": "output.mp4", + "file_size": 6226845, + "url": "https://v3b.fal.media/files/b/0abcdef0/AB-CDE_123456789abcde_output.mp4" + } + }, + "request_id": "01234560-1230-1230-1230-122233344455", + "status": "OK" +} \ No newline at end of file diff --git a/crates/api_clients/gmicloud_client/Cargo.toml b/crates/api_clients/gmicloud_client/Cargo.toml new file mode 100644 index 0000000000..4e8b4130ff --- /dev/null +++ b/crates/api_clients/gmicloud_client/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "gmicloud_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "gmicloud_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true + +# External +log.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +test_data = { path = "../../testing/test_data" } +test_utils = { path = "../../testing/test_utils" } +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/gmicloud_client/src/creds/gmicloud_api_key.rs b/crates/api_clients/gmicloud_client/src/creds/gmicloud_api_key.rs new file mode 100644 index 0000000000..d1c30426b0 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/creds/gmicloud_api_key.rs @@ -0,0 +1,17 @@ +/// A GmiCloud API key used for Bearer token authentication. +#[derive(Clone)] +pub struct GmiCloudApiKey(pub String); + +impl GmiCloudApiKey { + pub fn new(api_key: String) -> Self { + Self(api_key.trim().to_string()) + } + + pub fn from_str(api_key: &str) -> Self { + Self(api_key.trim().to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/api_clients/gmicloud_client/src/creds/mod.rs b/crates/api_clients/gmicloud_client/src/creds/mod.rs new file mode 100644 index 0000000000..471a13fe7c --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/creds/mod.rs @@ -0,0 +1 @@ +pub mod gmicloud_api_key; diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_client_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_client_error.rs new file mode 100644 index 0000000000..e2acccad3f --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_client_error.rs @@ -0,0 +1,23 @@ +use std::fmt; + +/// Errors that occur client-side before/during request construction. +#[derive(Debug)] +pub enum GmiCloudClientError { + /// The API key was not provided or is empty. + NoApiKeyPresent, + + /// A reqwest client-level error. + ReqwestError(reqwest::Error), +} + +impl fmt::Display for GmiCloudClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for GmiCloudClientError { + fn from(err: reqwest::Error) -> Self { + Self::ReqwestError(err) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_error.rs new file mode 100644 index 0000000000..eef5221f8d --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_error.rs @@ -0,0 +1,37 @@ +use std::fmt; + +use crate::error::gmicloud_client_error::GmiCloudClientError; +use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError; +use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError; + +/// Top-level error type for the GmiCloud client. +#[derive(Debug)] +pub enum GmiCloudError { + Client(GmiCloudClientError), + ApiSpecific(GmiCloudSpecificApiError), + ApiGeneric(GmiCloudGenericApiError), +} + +impl fmt::Display for GmiCloudError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for GmiCloudError { + fn from(err: GmiCloudClientError) -> Self { + Self::Client(err) + } +} + +impl From for GmiCloudError { + fn from(err: GmiCloudSpecificApiError) -> Self { + Self::ApiSpecific(err) + } +} + +impl From for GmiCloudError { + fn from(err: GmiCloudGenericApiError) -> Self { + Self::ApiGeneric(err) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_generic_api_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_generic_api_error.rs new file mode 100644 index 0000000000..cc9d7e326a --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_generic_api_error.rs @@ -0,0 +1,26 @@ +use std::fmt; + +/// Server-side errors with unknown or generic causes. +#[derive(Debug)] +pub enum GmiCloudGenericApiError { + /// Failed to parse the JSON response body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// Non-200 response that couldn't be classified. + UncategorizedBadResponseWithStatusAndBody { status_code: u16, body: String }, + + /// A reqwest transport error. + ReqwestError(reqwest::Error), +} + +impl fmt::Display for GmiCloudGenericApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for GmiCloudGenericApiError { + fn from(err: reqwest::Error) -> Self { + Self::ReqwestError(err) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_specific_api_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_specific_api_error.rs new file mode 100644 index 0000000000..27eac68a7e --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_specific_api_error.rs @@ -0,0 +1,23 @@ +use std::fmt; + +/// Well-known server-side errors with specific causes. +#[derive(Debug)] +pub enum GmiCloudSpecificApiError { + /// The API key is invalid or expired. + Unauthorized, + + /// The request was rejected due to content policy. + ContentPolicyViolation(String), + + /// The input image was rejected because it may contain a real person. + ContentContainsRealPerson(String), + + /// The account has insufficient credits or a billing issue. + BillingError { status_code: u16, body: String }, +} + +impl fmt::Display for GmiCloudSpecificApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/mod.rs b/crates/api_clients/gmicloud_client/src/error/mod.rs new file mode 100644 index 0000000000..a3317b572f --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/mod.rs @@ -0,0 +1,4 @@ +pub mod gmicloud_client_error; +pub mod gmicloud_error; +pub mod gmicloud_generic_api_error; +pub mod gmicloud_specific_api_error; diff --git a/crates/api_clients/gmicloud_client/src/lib.rs b/crates/api_clients/gmicloud_client/src/lib.rs new file mode 100644 index 0000000000..f062f8d0bf --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/lib.rs @@ -0,0 +1,7 @@ +pub mod creds; +pub mod error; +pub mod requests; +pub mod traits; + +#[cfg(test)] +mod test_utils; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/mod.rs new file mode 100644 index 0000000000..b04b953edb --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/mod.rs @@ -0,0 +1 @@ +pub mod video; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/mod.rs new file mode 100644 index 0000000000..c3032250aa --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/mod.rs @@ -0,0 +1,2 @@ +pub mod seedance_2_0_260128; +pub mod seedance_2_0_fast_260128; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/api.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/api.rs new file mode 100644 index 0000000000..9ac0b5ead9 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/api.rs @@ -0,0 +1,401 @@ +use crate::creds::gmicloud_api_key::GmiCloudApiKey; +use crate::error::gmicloud_error::GmiCloudError; +use crate::requests::api::video::seedance_2_0_260128::raw_request::Seedance20Payload; +use crate::requests::common::create_request::{ + create_gmicloud_request, create_gmicloud_request_with_context, + GmiCloudCreateRequest, GmiCloudCreateResponse, +}; +use crate::requests::context::request_context::RequestContext; + +const MODEL_ID: &str = "seedance-2-0-260128"; + +/// User-facing request for the Seedance 2.0 model via GmiCloud. +#[derive(Clone, Debug)] +pub struct Seedance20Request { + /// Text prompt describing the video to generate. Required. + pub prompt: String, + + /// Video duration in seconds (4–15). Default: 5. + pub duration: Option, + + /// Output resolution. + pub resolution: Option, + + /// Aspect ratio of the output video. + pub ratio: Option, + + /// Random seed for reproducibility (0–4294967295). + pub seed: Option, + + /// Whether to embed a watermark. Default: false. + pub watermark: Option, + + /// Whether to synthesize audio. Default: true. + pub generate_audio: Option, + + /// Whether to enable web search grounding. Default: false. + pub web_search: Option, + + /// First frame image URL for image-to-video generation. + pub first_frame: Option, + + /// Last frame image URL for image-to-video generation. + pub last_frame: Option, + + /// Reference image URLs. + pub reference_images: Option>, + + /// Reference video URLs. + pub reference_videos: Option>, + + /// Reference audio file URLs. + pub reference_audios: Option>, + + /// Pre-uploaded asset IDs. + pub reference_asset_ids: Option>, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Seedance20Resolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Seedance20Ratio { + /// 16:9 (landscape) + Landscape16x9, + /// 4:3 + Standard4x3, + /// 1:1 (square) + Square, + /// 3:4 + Portrait3x4, + /// 9:16 (portrait) + Portrait9x16, + /// 21:9 (ultra-wide) + UltraWide21x9, + /// Adaptive (inferred from input) + Adaptive, +} + +impl Seedance20Request { + pub fn to_raw_payload(&self) -> Seedance20Payload { + Seedance20Payload { + prompt: self.prompt.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| match r { + Seedance20Resolution::FourEightyP => "480p", + Seedance20Resolution::SevenTwentyP => "720p", + Seedance20Resolution::TenEightyP => "1080p", + }.to_string()), + ratio: self.ratio.map(|r| match r { + Seedance20Ratio::Landscape16x9 => "16:9", + Seedance20Ratio::Standard4x3 => "4:3", + Seedance20Ratio::Square => "1:1", + Seedance20Ratio::Portrait3x4 => "3:4", + Seedance20Ratio::Portrait9x16 => "9:16", + Seedance20Ratio::UltraWide21x9 => "21:9", + Seedance20Ratio::Adaptive => "adaptive", + }.to_string()), + seed: self.seed, + watermark: self.watermark, + generate_audio: self.generate_audio, + web_search: self.web_search, + first_frame: self.first_frame.clone(), + last_frame: self.last_frame.clone(), + reference_images: self.reference_images.clone(), + reference_videos: self.reference_videos.clone(), + reference_audios: self.reference_audios.clone(), + reference_asset_ids: self.reference_asset_ids.clone(), + } + } + + pub async fn send_request( + &self, + api_key: &GmiCloudApiKey, + ) -> Result { + let context = RequestContext { + api_key, + maybe_timeout: None, + }; + self.send_request_with_context(&context).await + } + + pub async fn send_request_with_context( + &self, + context: &RequestContext<'_>, + ) -> Result { + let body = GmiCloudCreateRequest { + model: MODEL_ID.to_string(), + payload: self.to_raw_payload(), + }; + create_gmicloud_request_with_context(context, &body).await + } + + pub fn model_id() -> &'static str { + MODEL_ID + } + + /// The effective duration in seconds for cost calculation. + pub fn effective_duration_seconds(&self) -> u8 { + self.duration.unwrap_or(5) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_request() -> Seedance20Request { + Seedance20Request { + prompt: "a dog running through a field".to_string(), + duration: None, + resolution: None, + ratio: None, + seed: None, + watermark: None, + generate_audio: None, + web_search: None, + first_frame: None, + last_frame: None, + reference_images: None, + reference_videos: None, + reference_audios: None, + reference_asset_ids: None, + } + } + + mod raw_payload_tests { + use super::*; + + #[test] + fn minimal_request_serializes_only_prompt() { + let request = minimal_request(); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["prompt"], "a dog running through a field"); + // All optional fields should be absent + assert!(json.get("duration").is_none()); + assert!(json.get("resolution").is_none()); + assert!(json.get("ratio").is_none()); + assert!(json.get("seed").is_none()); + assert!(json.get("watermark").is_none()); + assert!(json.get("generate_audio").is_none()); + assert!(json.get("web_search").is_none()); + assert!(json.get("first_frame").is_none()); + assert!(json.get("last_frame").is_none()); + assert!(json.get("reference_images").is_none()); + assert!(json.get("reference_videos").is_none()); + assert!(json.get("reference_audios").is_none()); + assert!(json.get("reference_asset_ids").is_none()); + } + + #[test] + fn full_request_serializes_all_fields() { + let request = Seedance20Request { + prompt: "a cat sitting on a windowsill".to_string(), + duration: Some(10), + resolution: Some(Seedance20Resolution::TenEightyP), + ratio: Some(Seedance20Ratio::Landscape16x9), + seed: Some(42), + watermark: Some(false), + generate_audio: Some(true), + web_search: Some(true), + first_frame: Some("https://example.com/first.png".to_string()), + last_frame: Some("https://example.com/last.png".to_string()), + reference_images: Some(vec!["https://example.com/ref1.png".to_string()]), + reference_videos: Some(vec!["https://example.com/ref1.mp4".to_string()]), + reference_audios: Some(vec!["https://example.com/ref1.wav".to_string()]), + reference_asset_ids: Some(vec!["asset_123".to_string()]), + }; + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["prompt"], "a cat sitting on a windowsill"); + assert_eq!(json["duration"], 10); + assert_eq!(json["resolution"], "1080p"); + assert_eq!(json["ratio"], "16:9"); + assert_eq!(json["seed"], 42); + assert_eq!(json["watermark"], false); + assert_eq!(json["generate_audio"], true); + assert_eq!(json["web_search"], true); + assert_eq!(json["first_frame"], "https://example.com/first.png"); + assert_eq!(json["last_frame"], "https://example.com/last.png"); + assert_eq!(json["reference_images"][0], "https://example.com/ref1.png"); + assert_eq!(json["reference_videos"][0], "https://example.com/ref1.mp4"); + assert_eq!(json["reference_audios"][0], "https://example.com/ref1.wav"); + assert_eq!(json["reference_asset_ids"][0], "asset_123"); + } + + #[test] + fn all_ratios_serialize() { + let cases = [ + (Seedance20Ratio::Landscape16x9, "16:9"), + (Seedance20Ratio::Standard4x3, "4:3"), + (Seedance20Ratio::Square, "1:1"), + (Seedance20Ratio::Portrait3x4, "3:4"), + (Seedance20Ratio::Portrait9x16, "9:16"), + (Seedance20Ratio::UltraWide21x9, "21:9"), + (Seedance20Ratio::Adaptive, "adaptive"), + ]; + for (ratio, expected) in cases { + let mut request = minimal_request(); + request.ratio = Some(ratio); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["ratio"], expected, "{ratio:?}"); + } + } + + #[test] + fn all_resolutions_serialize() { + let cases = [ + (Seedance20Resolution::FourEightyP, "480p"), + (Seedance20Resolution::SevenTwentyP, "720p"), + (Seedance20Resolution::TenEightyP, "1080p"), + ]; + for (resolution, expected) in cases { + let mut request = minimal_request(); + request.resolution = Some(resolution); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["resolution"], expected, "{resolution:?}"); + } + } + + #[test] + fn duration_is_integer() { + let mut request = minimal_request(); + request.duration = Some(7); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["duration"], 7); + assert!(json["duration"].is_number()); + } + + #[test] + fn create_request_body_shape() { + let mut request = minimal_request(); + request.prompt = "test".to_string(); + request.duration = Some(5); + request.ratio = Some(Seedance20Ratio::Square); + let body = GmiCloudCreateRequest { + model: Seedance20Request::model_id().to_string(), + payload: request.to_raw_payload(), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["model"], "seedance-2-0-260128"); + assert_eq!(json["payload"]["prompt"], "test"); + assert_eq!(json["payload"]["duration"], 5); + assert_eq!(json["payload"]["ratio"], "1:1"); + } + + #[test] + fn image_to_video_with_first_frame() { + let mut request = minimal_request(); + request.first_frame = Some("https://example.com/cat.png".to_string()); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["first_frame"], "https://example.com/cat.png"); + assert!(json.get("last_frame").is_none()); + } + + #[test] + fn keyframe_with_first_and_last() { + let mut request = minimal_request(); + request.first_frame = Some("https://example.com/start.png".to_string()); + request.last_frame = Some("https://example.com/end.png".to_string()); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["first_frame"], "https://example.com/start.png"); + assert_eq!(json["last_frame"], "https://example.com/end.png"); + } + } + + mod effective_duration_tests { + use super::*; + + #[test] + fn defaults_to_five() { + assert_eq!(minimal_request().effective_duration_seconds(), 5); + } + + #[test] + fn respects_explicit_value() { + let mut request = minimal_request(); + request.duration = Some(12); + assert_eq!(request.effective_duration_seconds(), 12); + } + } + + mod live_api_tests { + use super::*; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "a golden retriever puppy playing in autumn leaves".to_string(); + request.duration = Some(5); + request.ratio = Some(Seedance20Ratio::Landscape16x9); + let result = request.send_request(&api_key).await.unwrap(); + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + + // Balance was $49.75 before running this. + // [480p] Request ID: 4a0efe0d-990f-480d-9792-5bb71dd26b85 + // Balance was $49.51 after running this. + // 24 cents = 2.4 cents per second + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_480p() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string(); + request.duration = Some(10); + request.resolution = Some(Seedance20Resolution::FourEightyP); + request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string()); + let result = request.send_request(&api_key).await.unwrap(); + println!("[480p] Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + + // Balance was $49.51 before running this. + // [720p] Request ID: 40f63632-dd9d-4725-ab73-5bb764e773be + // Balance was $48.99 after running this. + // 52 cents = 5.2 cents per second + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_720p() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string(); + request.duration = Some(10); + request.resolution = Some(Seedance20Resolution::SevenTwentyP); + request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string()); + let result = request.send_request(&api_key).await.unwrap(); + println!("[720p] Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + + // Balance was $48.99 before running this. + // Balance is $47.83 after running this + // 116 cents = 11.6 cents per second + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_1080p() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string(); + request.duration = Some(10); + request.resolution = Some(Seedance20Resolution::TenEightyP); + request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string()); + let result = request.send_request(&api_key).await.unwrap(); + println!("[1080p] Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/cost.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/cost.rs new file mode 100644 index 0000000000..dee90b4c6a --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/cost.rs @@ -0,0 +1,162 @@ +use crate::requests::api::video::seedance_2_0_260128::api::{ + Seedance20Request, Seedance20Resolution, +}; +use crate::traits::gmicloud_request_cost_calculator_trait::{ + GmiCloudRequestCostCalculator, UsdCents, +}; + +impl GmiCloudRequestCostCalculator for Seedance20Request { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_seconds = self.effective_duration_seconds() as u64; + + // Cost per second in US cents, by resolution. + // Observed from GmiCloud billing: + // 480p: $0.024/s = 2.4 ¢/s + // 720p: $0.052/s = 5.2 ¢/s (default) + // 1080p: $0.116/s = 11.6 ¢/s + let cents_per_second: f64 = match self.resolution { + Some(Seedance20Resolution::FourEightyP) => 2.4, + Some(Seedance20Resolution::SevenTwentyP) | None => 5.2, + Some(Seedance20Resolution::TenEightyP) => 11.6, + }; + + (cents_per_second * duration_seconds as f64).ceil() as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::seedance_2_0_260128::api::Seedance20Ratio; + + fn make_request(duration: Option, resolution: Option) -> Seedance20Request { + Seedance20Request { + prompt: "test".to_string(), + duration, + resolution, + ratio: None, + seed: None, + watermark: None, + generate_audio: None, + web_search: None, + first_frame: None, + last_frame: None, + reference_images: None, + reference_videos: None, + reference_audios: None, + reference_asset_ids: None, + } + } + + mod default_resolution_tests { + use super::*; + + #[test] + fn cost_default_5s() { + // 720p: 5.2 * 5 = 26 ¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 26); + } + + #[test] + fn cost_default_10s() { + // 5.2 * 10 = 52 ¢ + assert_eq!(make_request(Some(10), None).calculate_cost_in_cents(), 52); + } + + #[test] + fn cost_default_15s() { + // 5.2 * 15 = 78 ¢ + assert_eq!(make_request(Some(15), None).calculate_cost_in_cents(), 78); + } + } + + mod resolution_480p_tests { + use super::*; + + #[test] + fn cost_480p_5s() { + // 2.4 * 5 = 12 ¢ + assert_eq!(make_request(Some(5), Some(Seedance20Resolution::FourEightyP)).calculate_cost_in_cents(), 12); + } + + #[test] + fn cost_480p_10s() { + // 2.4 * 10 = 24 ¢ + assert_eq!(make_request(Some(10), Some(Seedance20Resolution::FourEightyP)).calculate_cost_in_cents(), 24); + } + + #[test] + fn cost_480p_15s() { + // 2.4 * 15 = 36 ¢ + assert_eq!(make_request(Some(15), Some(Seedance20Resolution::FourEightyP)).calculate_cost_in_cents(), 36); + } + } + + mod resolution_720p_tests { + use super::*; + + #[test] + fn cost_720p_5s() { + // 5.2 * 5 = 26 ¢ + assert_eq!(make_request(Some(5), Some(Seedance20Resolution::SevenTwentyP)).calculate_cost_in_cents(), 26); + } + + #[test] + fn cost_720p_10s() { + // 5.2 * 10 = 52 ¢ + assert_eq!(make_request(Some(10), Some(Seedance20Resolution::SevenTwentyP)).calculate_cost_in_cents(), 52); + } + + #[test] + fn cost_720p_15s() { + // 5.2 * 15 = 78 ¢ + assert_eq!(make_request(Some(15), Some(Seedance20Resolution::SevenTwentyP)).calculate_cost_in_cents(), 78); + } + } + + mod resolution_1080p_tests { + use super::*; + + #[test] + fn cost_1080p_5s() { + // 11.6 * 5 = 58 ¢ + assert_eq!(make_request(Some(5), Some(Seedance20Resolution::TenEightyP)).calculate_cost_in_cents(), 58); + } + + #[test] + fn cost_1080p_10s() { + // 11.6 * 10 = 116 ¢ + assert_eq!(make_request(Some(10), Some(Seedance20Resolution::TenEightyP)).calculate_cost_in_cents(), 116); + } + + #[test] + fn cost_1080p_15s() { + // 11.6 * 15 = 174 ¢ + assert_eq!(make_request(Some(15), Some(Seedance20Resolution::TenEightyP)).calculate_cost_in_cents(), 174); + } + } + + mod ratio_independence_tests { + use super::*; + + #[test] + fn cost_is_independent_of_ratio() { + let ratios = [ + Seedance20Ratio::Landscape16x9, + Seedance20Ratio::Portrait9x16, + Seedance20Ratio::Square, + Seedance20Ratio::Standard4x3, + Seedance20Ratio::Portrait3x4, + Seedance20Ratio::UltraWide21x9, + Seedance20Ratio::Adaptive, + ]; + let base = make_request(Some(10), Some(Seedance20Resolution::SevenTwentyP)) + .calculate_cost_in_cents(); + for ratio in ratios { + let mut request = make_request(Some(10), Some(Seedance20Resolution::SevenTwentyP)); + request.ratio = Some(ratio); + assert_eq!(request.calculate_cost_in_cents(), base, "{ratio:?}"); + } + } + } +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/raw_request.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/raw_request.rs new file mode 100644 index 0000000000..31f2091c3b --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/raw_request.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// The payload sent inside `GmiCloudCreateRequest.payload` for seedance-2-0-260128. +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance20Payload { + pub prompt: String, + + /// Duration in seconds (4–15). Default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution: "480p", "720p", "1080p". Default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio: "16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive". + #[serde(skip_serializing_if = "Option::is_none")] + pub ratio: Option, + + /// Random seed for reproducibility (0–4294967295). + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to embed a watermark. Default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub watermark: Option, + + /// Whether to synthesize audio. Default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Whether to enable web search grounding. Default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub web_search: Option, + + /// First frame image URL for image-to-video generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_frame: Option, + + /// Last frame image URL for image-to-video generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_frame: Option, + + /// Reference image URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_images: Option>, + + /// Reference video URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_videos: Option>, + + /// Reference audio file URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_audios: Option>, + + /// Pre-uploaded asset IDs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_asset_ids: Option>, +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/api.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/api.rs new file mode 100644 index 0000000000..3d836e9fcf --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/api.rs @@ -0,0 +1,306 @@ +use crate::creds::gmicloud_api_key::GmiCloudApiKey; +use crate::error::gmicloud_error::GmiCloudError; +use crate::requests::api::video::seedance_2_0_fast_260128::raw_request::Seedance20FastPayload; +use crate::requests::common::create_request::{ + create_gmicloud_request, create_gmicloud_request_with_context, + GmiCloudCreateRequest, GmiCloudCreateResponse, +}; +use crate::requests::context::request_context::RequestContext; + +const MODEL_ID: &str = "seedance-2-0-fast-260128"; + +/// User-facing request for the Seedance 2.0 Fast model via GmiCloud. +#[derive(Clone, Debug)] +pub struct Seedance20FastRequest { + /// Text prompt describing the video to generate. Required. + pub prompt: String, + + /// Video duration in seconds (4–15). Default: 5. + pub duration: Option, + + /// Output resolution. + pub resolution: Option, + + /// Aspect ratio of the output video. + pub ratio: Option, + + /// Random seed for reproducibility (0–4294967295). + pub seed: Option, + + /// Whether to embed a watermark. Default: false. + pub watermark: Option, + + /// Whether to synthesize audio. Default: true. + pub generate_audio: Option, + + /// Whether to enable web search grounding. Default: false. + pub web_search: Option, + + /// First frame image URL for image-to-video generation. + pub first_frame: Option, + + /// Last frame image URL for image-to-video generation. + pub last_frame: Option, + + /// Reference image URLs. + pub reference_images: Option>, + + /// Reference video URLs. + pub reference_videos: Option>, + + /// Reference audio file URLs. + pub reference_audios: Option>, + + /// Pre-uploaded asset IDs. + pub reference_asset_ids: Option>, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Seedance20FastResolution { + FourEightyP, + SevenTwentyP, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Seedance20FastRatio { + /// 16:9 (landscape) + Landscape16x9, + /// 4:3 + Standard4x3, + /// 1:1 (square) + Square, + /// 3:4 + Portrait3x4, + /// 9:16 (portrait) + Portrait9x16, + /// 21:9 (ultra-wide) + UltraWide21x9, + /// Adaptive (inferred from input) + Adaptive, +} + +impl Seedance20FastRequest { + pub fn to_raw_payload(&self) -> Seedance20FastPayload { + Seedance20FastPayload { + prompt: self.prompt.clone(), + duration: self.duration, + resolution: self.resolution.map(|r| match r { + Seedance20FastResolution::FourEightyP => "480p", + Seedance20FastResolution::SevenTwentyP => "720p", + }.to_string()), + ratio: self.ratio.map(|r| match r { + Seedance20FastRatio::Landscape16x9 => "16:9", + Seedance20FastRatio::Standard4x3 => "4:3", + Seedance20FastRatio::Square => "1:1", + Seedance20FastRatio::Portrait3x4 => "3:4", + Seedance20FastRatio::Portrait9x16 => "9:16", + Seedance20FastRatio::UltraWide21x9 => "21:9", + Seedance20FastRatio::Adaptive => "adaptive", + }.to_string()), + seed: self.seed, + watermark: self.watermark, + generate_audio: self.generate_audio, + web_search: self.web_search, + first_frame: self.first_frame.clone(), + last_frame: self.last_frame.clone(), + reference_images: self.reference_images.clone(), + reference_videos: self.reference_videos.clone(), + reference_audios: self.reference_audios.clone(), + reference_asset_ids: self.reference_asset_ids.clone(), + } + } + + pub async fn send_request( + &self, + api_key: &GmiCloudApiKey, + ) -> Result { + let context = RequestContext { + api_key, + maybe_timeout: None, + }; + self.send_request_with_context(&context).await + } + + pub async fn send_request_with_context( + &self, + context: &RequestContext<'_>, + ) -> Result { + let body = GmiCloudCreateRequest { + model: MODEL_ID.to_string(), + payload: self.to_raw_payload(), + }; + create_gmicloud_request_with_context(context, &body).await + } + + pub fn model_id() -> &'static str { + MODEL_ID + } + + /// The effective duration in seconds for cost calculation. + pub fn effective_duration_seconds(&self) -> u8 { + self.duration.unwrap_or(5) + } +} + +fn minimal_request() -> Seedance20FastRequest { + Seedance20FastRequest { + prompt: "test".to_string(), + duration: None, + resolution: None, + ratio: None, + seed: None, + watermark: None, + generate_audio: None, + web_search: None, + first_frame: None, + last_frame: None, + reference_images: None, + reference_videos: None, + reference_audios: None, + reference_asset_ids: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod raw_payload_tests { + use super::*; + + #[test] + fn minimal_request_serializes_only_prompt() { + let request = minimal_request(); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["prompt"], "test"); + assert!(json.get("duration").is_none()); + assert!(json.get("resolution").is_none()); + assert!(json.get("ratio").is_none()); + } + + #[test] + fn full_request_serializes_all_fields() { + let request = Seedance20FastRequest { + prompt: "a cat sitting on a windowsill".to_string(), + duration: Some(10), + resolution: Some(Seedance20FastResolution::SevenTwentyP), + ratio: Some(Seedance20FastRatio::Portrait9x16), + seed: Some(123), + watermark: Some(true), + generate_audio: Some(false), + web_search: Some(true), + first_frame: Some("https://example.com/first.png".to_string()), + last_frame: Some("https://example.com/last.png".to_string()), + reference_images: Some(vec!["https://example.com/ref.png".to_string()]), + reference_videos: Some(vec!["https://example.com/ref.mp4".to_string()]), + reference_audios: Some(vec!["https://example.com/ref.wav".to_string()]), + reference_asset_ids: Some(vec!["asset_456".to_string()]), + }; + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["prompt"], "a cat sitting on a windowsill"); + assert_eq!(json["duration"], 10); + assert_eq!(json["resolution"], "720p"); + assert_eq!(json["ratio"], "9:16"); + assert_eq!(json["seed"], 123); + assert_eq!(json["watermark"], true); + assert_eq!(json["generate_audio"], false); + assert_eq!(json["web_search"], true); + assert_eq!(json["first_frame"], "https://example.com/first.png"); + assert_eq!(json["last_frame"], "https://example.com/last.png"); + } + + #[test] + fn all_ratios_serialize() { + let cases = [ + (Seedance20FastRatio::Landscape16x9, "16:9"), + (Seedance20FastRatio::Standard4x3, "4:3"), + (Seedance20FastRatio::Square, "1:1"), + (Seedance20FastRatio::Portrait3x4, "3:4"), + (Seedance20FastRatio::Portrait9x16, "9:16"), + (Seedance20FastRatio::UltraWide21x9, "21:9"), + (Seedance20FastRatio::Adaptive, "adaptive"), + ]; + for (ratio, expected) in cases { + let mut request = minimal_request(); + request.ratio = Some(ratio); + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["ratio"], expected, "{ratio:?}"); + } + } + + #[test] + fn create_request_body_shape() { + let mut request = minimal_request(); + request.duration = Some(5); + request.ratio = Some(Seedance20FastRatio::Square); + let body = GmiCloudCreateRequest { + model: Seedance20FastRequest::model_id().to_string(), + payload: request.to_raw_payload(), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["model"], "seedance-2-0-fast-260128"); + assert_eq!(json["payload"]["prompt"], "test"); + assert_eq!(json["payload"]["duration"], 5); + assert_eq!(json["payload"]["ratio"], "1:1"); + } + } + + mod live_api_tests { + use super::*; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "a golden retriever puppy playing in autumn leaves".to_string(); + request.duration = Some(5); + request.ratio = Some(Seedance20FastRatio::Landscape16x9); + let result = request.send_request(&api_key).await.unwrap(); + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + + // $47.83 before run + // [fast 480p] Request ID: 1b654a85-d99e-4380-b615-c7a8985ed15b + // $47.73 after run + // $0.10 = $0.01 per second + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_480p() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string(); + request.duration = Some(10); + request.resolution = Some(Seedance20FastResolution::FourEightyP); + request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string()); + let result = request.send_request(&api_key).await.unwrap(); + println!("[fast 480p] Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + + // $47.73 before run + // [fast 720p] Request ID: 2fbbe377-7c92-482e-b1eb-92fd296b0a68 + // $47.52 after run + // $0.31 = $0.031 per second + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_720p() { + let api_key = crate::test_utils::load_api_key(); + let mut request = minimal_request(); + request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string(); + request.duration = Some(10); + request.resolution = Some(Seedance20FastResolution::SevenTwentyP); + request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string()); + let result = request.send_request(&api_key).await.unwrap(); + println!("[fast 720p] Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/cost.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/cost.rs new file mode 100644 index 0000000000..74517199c1 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/cost.rs @@ -0,0 +1,185 @@ +use crate::requests::api::video::seedance_2_0_fast_260128::api::{ + Seedance20FastRequest, Seedance20FastResolution, +}; +use crate::traits::gmicloud_request_cost_calculator_trait::{ + GmiCloudRequestCostCalculator, UsdCents, +}; + +impl GmiCloudRequestCostCalculator for Seedance20FastRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_seconds = self.effective_duration_seconds() as u64; + + // Cost per second in US cents, by resolution. + // Observed from GmiCloud billing: + // 480p: $0.01/s = 1.0 ¢/s + // 720p: $0.031/s = 3.1 ¢/s (default) + let cents_per_second: f64 = match self.resolution { + Some(Seedance20FastResolution::FourEightyP) => 1.0, + Some(Seedance20FastResolution::SevenTwentyP) | None => 3.1, + }; + + (cents_per_second * duration_seconds as f64).ceil() as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRatio; + + fn make_request(duration: Option, resolution: Option) -> Seedance20FastRequest { + Seedance20FastRequest { + prompt: "test".to_string(), + duration, + resolution, + ratio: None, + seed: None, + watermark: None, + generate_audio: None, + web_search: None, + first_frame: None, + last_frame: None, + reference_images: None, + reference_videos: None, + reference_audios: None, + reference_asset_ids: None, + } + } + + mod default_resolution_tests { + use super::*; + + #[test] + fn cost_default_5s() { + // 720p: 3.1 * 5 = 15.5 → ceil = 16¢ + assert_eq!(make_request(None, None).calculate_cost_in_cents(), 16); + } + + #[test] + fn cost_default_10s() { + // 3.1 * 10 = 31¢ + assert_eq!(make_request(Some(10), None).calculate_cost_in_cents(), 31); + } + + #[test] + fn cost_default_15s() { + // 3.1 * 15 = 46.5 → ceil = 47¢ + assert_eq!(make_request(Some(15), None).calculate_cost_in_cents(), 47); + } + } + + mod resolution_480p_tests { + use super::*; + + #[test] + fn cost_480p_5s() { + // 1.0 * 5 = 5¢ + assert_eq!(make_request(Some(5), Some(Seedance20FastResolution::FourEightyP)).calculate_cost_in_cents(), 5); + } + + #[test] + fn cost_480p_10s() { + // 1.0 * 10 = 10¢ + assert_eq!(make_request(Some(10), Some(Seedance20FastResolution::FourEightyP)).calculate_cost_in_cents(), 10); + } + + #[test] + fn cost_480p_15s() { + // 1.0 * 15 = 15¢ + assert_eq!(make_request(Some(15), Some(Seedance20FastResolution::FourEightyP)).calculate_cost_in_cents(), 15); + } + } + + mod resolution_720p_tests { + use super::*; + + #[test] + fn cost_720p_5s() { + // 3.1 * 5 = 15.5 → ceil = 16¢ + assert_eq!(make_request(Some(5), Some(Seedance20FastResolution::SevenTwentyP)).calculate_cost_in_cents(), 16); + } + + #[test] + fn cost_720p_10s() { + // 3.1 * 10 = 31¢ + assert_eq!(make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP)).calculate_cost_in_cents(), 31); + } + + #[test] + fn cost_720p_15s() { + // 3.1 * 15 = 46.5 → ceil = 47¢ + assert_eq!(make_request(Some(15), Some(Seedance20FastResolution::SevenTwentyP)).calculate_cost_in_cents(), 47); + } + } + + mod ratio_independence_tests { + use super::*; + + #[test] + fn cost_is_independent_of_ratio() { + let ratios = [ + Seedance20FastRatio::Landscape16x9, + Seedance20FastRatio::Portrait9x16, + Seedance20FastRatio::Square, + Seedance20FastRatio::Standard4x3, + Seedance20FastRatio::Portrait3x4, + Seedance20FastRatio::UltraWide21x9, + Seedance20FastRatio::Adaptive, + ]; + let base = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP)) + .calculate_cost_in_cents(); + for ratio in ratios { + let mut request = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP)); + request.ratio = Some(ratio); + assert_eq!(request.calculate_cost_in_cents(), base, "{ratio:?}"); + } + } + } + + mod comparison_tests { + use super::*; + + #[test] + fn fast_is_cheaper_than_standard_at_720p() { + use crate::requests::api::video::seedance_2_0_260128::api::{ + Seedance20Request, Seedance20Resolution, + }; + use crate::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator; + + let standard = Seedance20Request { + prompt: "test".to_string(), + duration: Some(10), + resolution: Some(Seedance20Resolution::SevenTwentyP), + ratio: None, + seed: None, + watermark: None, + generate_audio: None, + web_search: None, + first_frame: None, + last_frame: None, + reference_images: None, + reference_videos: None, + reference_audios: None, + reference_asset_ids: None, + }; + + let fast = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP)); + + assert!( + fast.calculate_cost_in_cents() < standard.calculate_cost_in_cents(), + "Fast 720p ({}¢) should be cheaper than Standard 720p ({}¢)", + fast.calculate_cost_in_cents(), + standard.calculate_cost_in_cents(), + ); + } + + #[test] + fn higher_resolution_costs_more() { + let cost_480 = make_request(Some(10), Some(Seedance20FastResolution::FourEightyP)) + .calculate_cost_in_cents(); + let cost_720 = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP)) + .calculate_cost_in_cents(); + assert!(cost_480 < cost_720, "480p ({}¢) should be cheaper than 720p ({}¢)", cost_480, cost_720); + } + } +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/raw_request.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/raw_request.rs new file mode 100644 index 0000000000..c6ebf342eb --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/raw_request.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// The payload sent inside `GmiCloudCreateRequest.payload` for seedance-2-0-fast-260128. +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance20FastPayload { + pub prompt: String, + + /// Duration in seconds (4–15). Default: 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution: "480p", "720p", "1080p". Default: "720p". + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Aspect ratio: "16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive". + #[serde(skip_serializing_if = "Option::is_none")] + pub ratio: Option, + + /// Random seed for reproducibility (0–4294967295). + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// Whether to embed a watermark. Default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub watermark: Option, + + /// Whether to synthesize audio. Default: true. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + + /// Whether to enable web search grounding. Default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub web_search: Option, + + /// First frame image URL for image-to-video generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_frame: Option, + + /// Last frame image URL for image-to-video generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_frame: Option, + + /// Reference image URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_images: Option>, + + /// Reference video URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_videos: Option>, + + /// Reference audio file URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_audios: Option>, + + /// Pre-uploaded asset IDs. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_asset_ids: Option>, +} diff --git a/crates/api_clients/gmicloud_client/src/requests/common/create_request.rs b/crates/api_clients/gmicloud_client/src/requests/common/create_request.rs new file mode 100644 index 0000000000..bdbe89125f --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/common/create_request.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use log::warn; +use serde::{Deserialize, Serialize}; + +use crate::creds::gmicloud_api_key::GmiCloudApiKey; +use crate::error::gmicloud_error::GmiCloudError; +use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError; +use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError; +use crate::requests::context::request_context::RequestContext; + +const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey"; + +/// Default request timeout. Video requests return quickly (async queue), +/// but image requests block until completion and can take 60+ seconds. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180); + +/// The top-level request body sent to `POST /requests`. +#[derive(Debug, Serialize)] +pub struct GmiCloudCreateRequest { + pub model: String, + pub payload: P, +} + +/// The response from `POST /requests`. +#[derive(Debug, Deserialize)] +pub struct GmiCloudCreateResponse { + pub request_id: String, + pub model: String, + pub status: String, +} + +/// Submit a request to GmiCloud using just an API key (default timeout). +pub async fn create_gmicloud_request( + api_key: &GmiCloudApiKey, + body: &GmiCloudCreateRequest

, +) -> Result { + let context = RequestContext { + api_key, + maybe_timeout: None, + }; + create_gmicloud_request_with_context(&context, body).await +} + +/// Submit a request to GmiCloud with explicit context (custom timeout, etc.). +pub async fn create_gmicloud_request_with_context( + context: &RequestContext<'_>, + body: &GmiCloudCreateRequest

, +) -> Result { + let url = format!("{}/requests", BASE_URL); + let timeout = context.maybe_timeout.unwrap_or(DEFAULT_TIMEOUT); + + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(GmiCloudGenericApiError::from)?; + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", context.api_key.as_str())) + .json(body) + .send() + .await + .map_err(GmiCloudGenericApiError::from)?; + + let status = response.status(); + let body_text = response.text().await + .map_err(GmiCloudGenericApiError::from)?; + + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(GmiCloudSpecificApiError::Unauthorized.into()); + } + + if !status.is_success() { + warn!("GmiCloud API error: status={}, body={}", status, body_text); + + // Detect specific error codes embedded in the response body. + if let Some(specific) = classify_error_body(&body_text) { + return Err(specific.into()); + } + + return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status.as_u16(), + body: body_text, + }.into()); + } + + let parsed: GmiCloudCreateResponse = serde_json::from_str(&body_text) + .map_err(|err| GmiCloudGenericApiError::SerdeResponseParseErrorWithBody(err, body_text))?; + + Ok(parsed) +} + +/// Attempt to classify an error response body into a specific error type. +/// GmiCloud sometimes wraps inner API errors as a JSON string within `{"error": "..."}`. +fn classify_error_body(body: &str) -> Option { + if body.contains("InputImageSensitiveContentDetected") { + let message = extract_inner_message(body) + .unwrap_or_else(|| "Input image may contain a real person".to_string()); + return Some(GmiCloudSpecificApiError::ContentContainsRealPerson(message)); + } + None +} + +/// Extract the inner `message` field from a doubly-encoded GmiCloud error response. +/// Expected shape: `{"error":"API request failed with status 400: {\"error\":{\"message\":\"...\"}}"}` +fn extract_inner_message(body: &str) -> Option { + let outer: serde_json::Value = serde_json::from_str(body).ok()?; + let error_str = outer.get("error")?.as_str()?; + + // The inner JSON is embedded after "status NNN: " + let inner_json_start = error_str.find('{')?; + let inner_str = &error_str[inner_json_start..]; + let inner: serde_json::Value = serde_json::from_str(inner_str).ok()?; + + inner.get("error")?.get("message")?.as_str().map(|s| s.to_string()) +} diff --git a/crates/api_clients/gmicloud_client/src/requests/common/mod.rs b/crates/api_clients/gmicloud_client/src/requests/common/mod.rs new file mode 100644 index 0000000000..4d39c7e431 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/common/mod.rs @@ -0,0 +1 @@ +pub mod create_request; diff --git a/crates/api_clients/gmicloud_client/src/requests/context/mod.rs b/crates/api_clients/gmicloud_client/src/requests/context/mod.rs new file mode 100644 index 0000000000..c5c6f6fabd --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/context/mod.rs @@ -0,0 +1 @@ +pub mod request_context; diff --git a/crates/api_clients/gmicloud_client/src/requests/context/request_context.rs b/crates/api_clients/gmicloud_client/src/requests/context/request_context.rs new file mode 100644 index 0000000000..a3af82918f --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/context/request_context.rs @@ -0,0 +1,8 @@ +use std::time::Duration; + +use crate::creds::gmicloud_api_key::GmiCloudApiKey; + +pub struct RequestContext<'a> { + pub api_key: &'a GmiCloudApiKey, + pub maybe_timeout: Option, +} diff --git a/crates/api_clients/gmicloud_client/src/requests/mod.rs b/crates/api_clients/gmicloud_client/src/requests/mod.rs new file mode 100644 index 0000000000..b698f1a58d --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/mod.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod common; +pub mod context; +pub mod poll_request_queue; diff --git a/crates/api_clients/gmicloud_client/src/requests/poll_request_queue/mod.rs b/crates/api_clients/gmicloud_client/src/requests/poll_request_queue/mod.rs new file mode 100644 index 0000000000..ee008f248d --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/poll_request_queue/mod.rs @@ -0,0 +1 @@ +pub mod poll_request_queue; diff --git a/crates/api_clients/gmicloud_client/src/requests/poll_request_queue/poll_request_queue.rs b/crates/api_clients/gmicloud_client/src/requests/poll_request_queue/poll_request_queue.rs new file mode 100644 index 0000000000..551b5d78ee --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/poll_request_queue/poll_request_queue.rs @@ -0,0 +1,281 @@ +use log::warn; +use serde::Deserialize; + +use crate::creds::gmicloud_api_key::GmiCloudApiKey; +use crate::error::gmicloud_error::GmiCloudError; +use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError; +use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError; + +const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey"; + +/// The response from `GET /requests/{request_id}`. +#[derive(Debug, Deserialize)] +pub struct GmiCloudPollResponse { + pub request_id: String, + pub org_id: Option, + pub user_id: Option, + pub model: String, + pub status: String, + pub is_public: Option, + pub payload: Option, + pub outcome: Option, + pub created_at: Option, + pub updated_at: Option, + pub queued_at: Option, +} + +/// The outcome of a completed request. +#[derive(Debug, Deserialize, PartialEq)] +pub struct GmiCloudOutcome { + pub video_url: Option, + pub thumbnail_image_url: Option, +} + +impl GmiCloudPollResponse { + /// Whether the request has completed successfully. + pub fn is_success(&self) -> bool { + self.status == "success" + } + + /// Whether the request is still in progress (dispatched or processing). + pub fn is_in_progress(&self) -> bool { + self.status == "dispatched" || self.status == "processing" + } + + /// Whether the request has failed. + pub fn is_failed(&self) -> bool { + self.status == "failed" + } + + /// Get the video URL if the request completed successfully. + pub fn video_url(&self) -> Option<&str> { + self.outcome.as_ref()?.video_url.as_deref() + } + + /// Get the thumbnail URL if the request completed successfully. + pub fn thumbnail_url(&self) -> Option<&str> { + self.outcome.as_ref()?.thumbnail_image_url.as_deref() + } +} + +/// Poll the status of a GmiCloud request. +pub async fn poll_gmicloud_request( + api_key: &GmiCloudApiKey, + request_id: &str, +) -> Result { + let url = format!("{}/requests/{}", BASE_URL, request_id); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key.as_str())) + .send() + .await + .map_err(GmiCloudGenericApiError::from)?; + + let status = response.status(); + let body_text = response.text().await + .map_err(GmiCloudGenericApiError::from)?; + + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(GmiCloudSpecificApiError::Unauthorized.into()); + } + + if !status.is_success() { + warn!("GmiCloud poll error: status={}, body={}", status, body_text); + return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status.as_u16(), + body: body_text, + }.into()); + } + + let parsed: GmiCloudPollResponse = serde_json::from_str(&body_text) + .map_err(|err| GmiCloudGenericApiError::SerdeResponseParseErrorWithBody(err, body_text))?; + + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod deserialization_tests { + use super::*; + + #[test] + fn deserialize_success_response() { + let json = r#"{ + "request_id": "40f63632-dd9d-4725-ab73-5bb764e773be", + "org_id": "3e7939ae-467e-4c5d-ae35-fd6097cee454", + "user_id": "07e393c5-f2f1-45fe-8838-cc4046837693", + "model": "seedance-2-0-260128", + "status": "success", + "is_public": false, + "payload": { + "duration": 10, + "first_frame": "https://example.com/image.jpg", + "prompt": "a dog running", + "resolution": "720p" + }, + "outcome": { + "thumbnail_image_url": "https://example.com/thumb.jpg", + "video_url": "https://example.com/video.mp4" + }, + "created_at": 1778928208, + "updated_at": 1778928397, + "queued_at": 1778928208 + }"#; + + let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.request_id, "40f63632-dd9d-4725-ab73-5bb764e773be"); + assert_eq!(response.model, "seedance-2-0-260128"); + assert!(response.is_success()); + assert!(!response.is_in_progress()); + assert!(!response.is_failed()); + assert_eq!(response.video_url(), Some("https://example.com/video.mp4")); + assert_eq!(response.thumbnail_url(), Some("https://example.com/thumb.jpg")); + assert_eq!(response.created_at, Some(1778928208)); + assert_eq!(response.updated_at, Some(1778928397)); + } + + #[test] + fn deserialize_processing_response() { + let json = r#"{ + "request_id": "6fb2b474-fb3a-480f-a142-f200537c6de4", + "org_id": "63729242-1870-4ff4-80b0-d485980ae31c", + "model": "seedance-2-0-260128", + "status": "processing", + "is_public": false, + "payload": { + "prompt": "a dog running", + "duration": 5 + }, + "outcome": null, + "created_at": 1753468514, + "updated_at": 1753468514, + "queued_at": 1753468514 + }"#; + + let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap(); + assert!(!response.is_success()); + assert!(response.is_in_progress()); + assert!(!response.is_failed()); + assert_eq!(response.video_url(), None); + assert_eq!(response.outcome, None); + } + + #[test] + fn deserialize_dispatched_response() { + let json = r#"{ + "request_id": "abc", + "model": "seedance-2-0-fast-260128", + "status": "dispatched", + "outcome": null + }"#; + + let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap(); + assert!(response.is_in_progress()); + assert!(!response.is_success()); + } + + #[test] + fn deserialize_failed_response() { + let json = r#"{ + "request_id": "abc", + "model": "seedance-2-0-260128", + "status": "failed", + "outcome": null + }"#; + + let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap(); + assert!(response.is_failed()); + assert!(!response.is_success()); + assert!(!response.is_in_progress()); + } + } + + mod live_api_tests { + use super::*; + + // seedance-2-0-260128, 10s 720p, image-to-video + #[tokio::test] + #[ignore] // requires real API key + async fn poll_seedance_20_720p() { + let api_key = crate::test_utils::load_api_key(); + let response = poll_gmicloud_request(&api_key, "40f63632-dd9d-4725-ab73-5bb764e773be") + .await.unwrap(); + + assert_eq!(response.request_id, "40f63632-dd9d-4725-ab73-5bb764e773be"); + assert_eq!(response.model, "seedance-2-0-260128"); + assert!(response.is_success()); + + let payload = response.payload.as_ref().unwrap(); + assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water"); + assert_eq!(payload["duration"], 10); + assert_eq!(payload["resolution"], "720p"); + assert!(payload["first_frame"].as_str().unwrap().contains("svcpp441gyb6mjtt8413kf5zxw1ehy4t")); + + assert!(response.video_url().unwrap().ends_with(".mp4")); + } + + // seedance-2-0-260128, 10s 480p, image-to-video + #[tokio::test] + #[ignore] // requires real API key + async fn poll_seedance_20_480p() { + let api_key = crate::test_utils::load_api_key(); + let response = poll_gmicloud_request(&api_key, "4a0efe0d-990f-480d-9792-5bb71dd26b85") + .await.unwrap(); + + assert_eq!(response.request_id, "4a0efe0d-990f-480d-9792-5bb71dd26b85"); + assert_eq!(response.model, "seedance-2-0-260128"); + assert!(response.is_success()); + + let payload = response.payload.as_ref().unwrap(); + assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water"); + assert_eq!(payload["duration"], 10); + assert_eq!(payload["resolution"], "480p"); + + assert!(response.video_url().unwrap().ends_with(".mp4")); + } + + // seedance-2-0-fast-260128, 10s 720p, image-to-video + #[tokio::test] + #[ignore] // requires real API key + async fn poll_seedance_20_fast_720p() { + let api_key = crate::test_utils::load_api_key(); + let response = poll_gmicloud_request(&api_key, "2fbbe377-7c92-482e-b1eb-92fd296b0a68") + .await.unwrap(); + + assert_eq!(response.request_id, "2fbbe377-7c92-482e-b1eb-92fd296b0a68"); + assert_eq!(response.model, "seedance-2-0-fast-260128"); + assert!(response.is_success()); + + let payload = response.payload.as_ref().unwrap(); + assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water"); + assert_eq!(payload["duration"], 10); + assert_eq!(payload["resolution"], "720p"); + + assert!(response.video_url().unwrap().ends_with(".mp4")); + } + + // seedance-2-0-fast-260128, 10s 480p, image-to-video + #[tokio::test] + #[ignore] // requires real API key + async fn poll_seedance_20_fast_480p() { + let api_key = crate::test_utils::load_api_key(); + let response = poll_gmicloud_request(&api_key, "1b654a85-d99e-4380-b615-c7a8985ed15b") + .await.unwrap(); + + assert_eq!(response.request_id, "1b654a85-d99e-4380-b615-c7a8985ed15b"); + assert_eq!(response.model, "seedance-2-0-fast-260128"); + assert!(response.is_success()); + + let payload = response.payload.as_ref().unwrap(); + assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water"); + assert_eq!(payload["duration"], 10); + assert_eq!(payload["resolution"], "480p"); + + assert!(response.video_url().unwrap().ends_with(".mp4")); + } + } +} diff --git a/crates/api_clients/gmicloud_client/src/test_utils/mod.rs b/crates/api_clients/gmicloud_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..8749608c6c --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/test_utils/mod.rs @@ -0,0 +1,10 @@ +use crate::creds::gmicloud_api_key::GmiCloudApiKey; +use std::fs::read_to_string; + +const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/gmicloud_api_key.txt"; + +pub fn load_api_key() -> GmiCloudApiKey { + let secret = read_to_string(API_KEY_PATH) + .expect("Failed to read GmiCloud API key file"); + GmiCloudApiKey::from_str(&secret) +} diff --git a/crates/api_clients/gmicloud_client/src/traits/gmicloud_request_cost_calculator_trait.rs b/crates/api_clients/gmicloud_client/src/traits/gmicloud_request_cost_calculator_trait.rs new file mode 100644 index 0000000000..ad5b8cd1ad --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/traits/gmicloud_request_cost_calculator_trait.rs @@ -0,0 +1,7 @@ +/// Cost in US cents (1¢ = 1 unit). +pub type UsdCents = u64; + +/// Implemented on request types to estimate the cost of a GmiCloud API call. +pub trait GmiCloudRequestCostCalculator { + fn calculate_cost_in_cents(&self) -> UsdCents; +} diff --git a/crates/api_clients/gmicloud_client/src/traits/mod.rs b/crates/api_clients/gmicloud_client/src/traits/mod.rs new file mode 100644 index 0000000000..b6a09dfdfa --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/traits/mod.rs @@ -0,0 +1 @@ +pub mod gmicloud_request_cost_calculator_trait; diff --git a/crates/api_clients/grok_api_client/Cargo.toml b/crates/api_clients/grok_api_client/Cargo.toml new file mode 100644 index 0000000000..0335e6dbfa --- /dev/null +++ b/crates/api_clients/grok_api_client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "grok_api_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "grok_api_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true + +# External +log.workspace = true +# `multipart` is enabled on top of the workspace feature set so we can use +# `reqwest::multipart` for POST /v1/files uploads. +reqwest = { workspace = true, features = ["multipart"] } +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +anyhow.workspace = true +env_logger.workspace = true +test_data.workspace = true +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/grok_api_client/src/api/mod.rs b/crates/api_clients/grok_api_client/src/api/mod.rs new file mode 100644 index 0000000000..1b24f16c4d --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod requests; +pub mod traits; +pub mod types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/delete_file.rs b/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/delete_file.rs new file mode 100644 index 0000000000..ac4aa039ff --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/delete_file.rs @@ -0,0 +1,115 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::files::delete_file::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`delete_file`]. Borrows the API key separately from +/// the request body so callers can log/save [`DeleteFileRequest`] without +/// leaking the credential. +#[derive(Clone, Debug)] +pub struct DeleteFileArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: DeleteFileRequest, +} + +/// The material part of a delete-file request. Derives [`Serialize`]. +#[derive(Clone, Debug, Serialize)] +pub struct DeleteFileRequest { + pub file_id: String, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct DeleteFileSuccess { + pub file_id: Option, + /// Should be `true` after a successful delete. + pub deleted: bool, +} + +// ── Implementation ── + +/// DELETE https://api.x.ai/v1/files/{file_id} — delete a previously-uploaded +/// file. After deletion the `file_id` becomes invalid and cannot be +/// referenced in further requests. +/// +/// Docs: +pub async fn delete_file(args: DeleteFileArgs<'_>) -> Result { + let req = args.request; + let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, req.file_id); + + info!("Grok delete_file: file_id={}", req.file_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.delete(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok delete_file response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: DeleteFileResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(DeleteFileSuccess { + file_id: parsed.id, + deleted: parsed.deleted.unwrap_or(false), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_body_deserializes_success() { + // serde drops the `object` field on parse; only `id` and `deleted` are + // load-bearing for our DTO. + let json = r#"{ "id": "file_abc", "object": "file", "deleted": true }"#; + let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id.as_deref(), Some("file_abc")); + assert_eq!(parsed.deleted, Some(true)); + } + + #[test] + fn response_body_deserializes_minimal() { + // xAI may omit some fields; only `deleted` is load-bearing. + let json = r#"{ "deleted": true }"#; + let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.deleted, Some(true)); + assert!(parsed.id.is_none()); + } + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = DeleteFileArgs { + api_key: &key, + request: DeleteFileRequest { file_id: "file_abc".to_string() }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak")); + assert!(json.contains("\"file_id\":\"file_abc\"")); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/mod.rs new file mode 100644 index 0000000000..d909d0ba4d --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/mod.rs @@ -0,0 +1,2 @@ +pub mod delete_file; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/request_types.rs new file mode 100644 index 0000000000..b88ed7eff9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/delete_file/request_types.rs @@ -0,0 +1,12 @@ +use serde_derive::Deserialize; + +/// Response shape for DELETE /v1/files/{file_id}. +/// +/// Docs: +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct DeleteFileResponseBody { + pub id: Option, + + /// xAI returns `true` on a successful delete. + pub deleted: Option, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/download_file/download_file.rs b/crates/api_clients/grok_api_client/src/api/requests/files/download_file/download_file.rs new file mode 100644 index 0000000000..5b25990eed --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/download_file/download_file.rs @@ -0,0 +1,144 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`download_file`]. Borrows the API key separately +/// from the request body so callers can log/save [`DownloadFileRequest`] +/// without leaking the credential. +#[derive(Clone, Debug)] +pub struct DownloadFileArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: DownloadFileRequest, +} + +/// The material part of a download-file request. Derives [`Serialize`]. +#[derive(Clone, Debug, Serialize)] +pub struct DownloadFileRequest { + pub file_id: String, + + /// Optional `?format=` query — `"original"` returns the bytes as uploaded; + /// `"text"` returns a textual transcription where applicable. Omit for the + /// xAI default. + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DownloadFormat { + Original, + Text, +} + +impl DownloadFormat { + pub fn as_str(&self) -> &'static str { + match self { + Self::Original => "original", + Self::Text => "text", + } + } +} + +// Serialize as the wire string ("original" or "text"). +impl serde::Serialize for DownloadFormat { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct DownloadFileSuccess { + /// Raw file content. The xAI response Content-Type is `application/octet-stream`. + pub bytes: Vec, +} + +// ── Implementation ── + +/// GET https://api.x.ai/v1/files/{file_id}/content — download the raw bytes +/// of a previously-uploaded file. +/// +/// Docs: +pub async fn download_file(args: DownloadFileArgs<'_>) -> Result { + let req = args.request; + let mut url = format!("{}/v1/files/{}/content", XAI_API_BASE_URL, req.file_id); + if let Some(fmt) = req.format { + url.push_str("?format="); + url.push_str(fmt.as_str()); + } + + info!("Grok download_file: file_id={}", req.file_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.get(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + + // On error, read as text so the classifier can quote it back. + if !status.is_success() { + let body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + classify_grok_http_error(status, Some(&body))?; + // classify_grok_http_error always returns Err on non-success. + unreachable!(); + } + + let bytes = response.bytes() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok download_file response: status={}, bytes={}", status, bytes.len()); + + Ok(DownloadFileSuccess { bytes: bytes.to_vec() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn download_format_serializes() { + assert_eq!(DownloadFormat::Original.as_str(), "original"); + assert_eq!(DownloadFormat::Text.as_str(), "text"); + } + + #[test] + fn download_format_serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&DownloadFormat::Original).unwrap(), "\"original\""); + assert_eq!(serde_json::to_string(&DownloadFormat::Text).unwrap(), "\"text\""); + } + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = DownloadFileArgs { + api_key: &key, + request: DownloadFileRequest { + file_id: "file_abc".to_string(), + format: Some(DownloadFormat::Original), + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak")); + assert!(json.contains("\"file_id\":\"file_abc\"")); + assert!(json.contains("\"format\":\"original\"")); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/download_file/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/files/download_file/mod.rs new file mode 100644 index 0000000000..db2beffde9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/download_file/mod.rs @@ -0,0 +1 @@ +pub mod download_file; diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/get_file_metadata.rs b/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/get_file_metadata.rs new file mode 100644 index 0000000000..eedaa8e005 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/get_file_metadata.rs @@ -0,0 +1,141 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::files::get_file_metadata::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`get_file_metadata`]. Borrows the API key separately +/// from the request body so callers can log/save [`GetFileMetadataRequest`] +/// without leaking the credential. +#[derive(Clone, Debug)] +pub struct GetFileMetadataArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: GetFileMetadataRequest, +} + +/// The material part of a get-file-metadata request. Derives [`Serialize`]. +#[derive(Clone, Debug, Serialize)] +pub struct GetFileMetadataRequest { + pub file_id: String, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct FileMetadata { + pub file_id: String, + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} + +// ── Implementation ── + +/// GET https://api.x.ai/v1/files/{file_id} — fetch metadata for an uploaded +/// file (no content; use `download_file` for that). +/// +/// Docs: +pub async fn get_file_metadata(args: GetFileMetadataArgs<'_>) -> Result { + let req = args.request; + let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, req.file_id); + + info!("Grok get_file_metadata: file_id={}", req.file_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.get(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok get_file_metadata response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: FileMetadataResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(FileMetadata { + file_id: parsed.id, + bytes: parsed.bytes, + created_at: parsed.created_at, + expires_at: parsed.expires_at, + filename: parsed.filename, + purpose: parsed.purpose, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + use crate::error::grok_specific_api_error::GrokSpecificApiError; + + #[test] + fn response_body_deserializes() { + let json = r#"{ + "id": "file_abc123", + "object": "file", + "bytes": 4096, + "created_at": 1716489600, + "expires_at": 1719081600, + "filename": "photo.png", + "purpose": "vision" + }"#; + let parsed: FileMetadataResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id, "file_abc123"); + assert_eq!(parsed.expires_at, Some(1719081600)); + } + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = GetFileMetadataArgs { + api_key: &key, + request: GetFileMetadataRequest { file_id: "file_abc".to_string() }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak")); + assert!(json.contains("\"file_id\":\"file_abc\"")); + } + + #[tokio::test] + #[ignore] // manually test — requires real API key + valid file_id + async fn live_test_unknown_file_returns_not_found() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = get_file_metadata(GetFileMetadataArgs { + api_key: &api_key, + request: GetFileMetadataRequest { + file_id: "file_doesnotexist000000".to_string(), + }, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })), + "expected NotFound, got: {:?}", err); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/mod.rs new file mode 100644 index 0000000000..28a25301bc --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/mod.rs @@ -0,0 +1,2 @@ +pub mod get_file_metadata; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/request_types.rs new file mode 100644 index 0000000000..2a59ffd0f2 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/get_file_metadata/request_types.rs @@ -0,0 +1,15 @@ +use serde_derive::Deserialize; + +/// Response shape for GET /v1/files/{file_id}. Same envelope as the upload +/// response (xAI returns identical file objects from both endpoints). +/// +/// Docs: +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct FileMetadataResponseBody { + pub id: String, + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/files/mod.rs new file mode 100644 index 0000000000..9c1d3f5ff3 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/mod.rs @@ -0,0 +1,4 @@ +pub mod delete_file; +pub mod download_file; +pub mod get_file_metadata; +pub mod upload_file; diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/mod.rs new file mode 100644 index 0000000000..ffc5d71d95 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/mod.rs @@ -0,0 +1,2 @@ +pub mod upload_file; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/request_types.rs new file mode 100644 index 0000000000..4bbebb2862 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/request_types.rs @@ -0,0 +1,14 @@ +use serde_derive::Deserialize; + +/// Response shape for POST /v1/files. Matches OpenAI-style file objects. +/// +/// Docs: +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct UploadFileResponseBody { + pub id: String, + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/upload_file.rs b/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/upload_file.rs new file mode 100644 index 0000000000..b842b2f12c --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/files/upload_file/upload_file.rs @@ -0,0 +1,312 @@ +use std::fmt; + +use log::info; +use reqwest::multipart::{Form, Part}; +use serde_derive::Serialize; + +use crate::api::requests::files::upload_file::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024; + +// ── Public args ── + +/// Top-level argument to [`upload_file`]. Borrows the API key separately from +/// the request body so callers can log/save [`UploadFileRequest`] without +/// leaking the credential. +#[derive(Clone, Debug)] +pub struct UploadFileArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: UploadFileRequest, +} + +/// The material part of a file-upload request. +/// +/// Derives [`Serialize`] for log/audit, BUT `file_bytes` is skipped during +/// serialization (a megabyte-scale binary blob shouldn't end up in a JSON +/// log). [`Debug`] is implemented manually to print the byte length instead +/// of the raw contents, so accidental `{:?}` formatting can't dump the file. +#[derive(Clone, Serialize)] +pub struct UploadFileRequest { + /// Raw bytes of the file. xAI caps uploads at 50 MB. Skipped from both + /// `Serialize` and `Debug` output — log/save the rest of the request and + /// recover the bytes from disk or cache out-of-band. + #[serde(skip)] + pub file_bytes: Vec, + + /// Original filename. xAI extracts this from the multipart + /// `Content-Disposition: filename=...` header and surfaces it back in the + /// response. + pub filename: String, + + /// Optional MIME type. If `None`, reqwest uses `application/octet-stream`. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_content_type: Option, + + /// Time-to-live in seconds (3600 – 2_592_000, i.e. 1 hour to 30 days). If + /// `None`, the file persists indefinitely until explicitly deleted. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_after_seconds: Option, + + /// OpenAI-compatibility label. xAI accepts but doesn't enforce this — set + /// it if porting code that already uses OpenAI's Files API. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option, +} + +impl fmt::Debug for UploadFileRequest { + // Print byte-length, not raw bytes — derived Debug on Vec would dump + // the entire file into the log line. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UploadFileRequest") + .field("file_bytes_len", &self.file_bytes.len()) + .field("filename", &self.filename) + .field("maybe_content_type", &self.maybe_content_type) + .field("expires_after_seconds", &self.expires_after_seconds) + .field("purpose", &self.purpose) + .finish() + } +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct UploadFileSuccess { + /// The xAI file identifier. Use this in the `FileId(_)` variant of + /// [`crate::api::requests::images::image_edit::image_edit::ImageSource`], + /// [`crate::api::requests::videos::video_generation::video_generation::VideoImageSource`], + /// [`crate::api::requests::videos::video_edit::video_edit::VideoSource`], or + /// [`crate::api::requests::videos::video_extension::video_extension::VideoExtensionSource`]. + pub file_id: String, + + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/files — upload a file and receive a `file_id` +/// you can reference in subsequent image/video edit, generation, or +/// extension calls. +/// +/// xAI documentation: +/// - +pub async fn upload_file(args: UploadFileArgs<'_>) -> Result { + let req = args.request; + + if req.file_bytes.is_empty() { + return Err(GrokClientError::InvalidRequest( + "upload_file: file_bytes is empty".to_string(), + ).into()); + } + if req.file_bytes.len() as u64 > MAX_FILE_BYTES { + return Err(GrokClientError::InvalidRequest( + format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", req.file_bytes.len()), + ).into()); + } + + let url = format!("{}/v1/files", XAI_API_BASE_URL); + + info!( + "Grok upload_file: filename={}, bytes={}, expires_after={:?}, purpose={:?}", + req.filename, req.file_bytes.len(), req.expires_after_seconds, req.purpose + ); + + let mut file_part = Part::bytes(req.file_bytes).file_name(req.filename.clone()); + if let Some(ct) = req.maybe_content_type.as_deref() { + file_part = file_part.mime_str(ct) + .map_err(GrokClientError::ReqwestClientError)?; + } + + let mut form = Form::new().part("file", file_part); + if let Some(secs) = req.expires_after_seconds { + form = form.text("expires_after", secs.to_string()); + } + if let Some(purpose) = req.purpose { + form = form.text("purpose", purpose); + } + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.post(&url) + .header("Authorization", bearer) + .multipart(form) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok upload_file response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: UploadFileResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(UploadFileSuccess { + file_id: parsed.id, + bytes: parsed.bytes, + created_at: parsed.created_at, + expires_at: parsed.expires_at, + filename: parsed.filename, + purpose: parsed.purpose, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + use crate::error::grok_specific_api_error::GrokSpecificApiError; + + // ── Client-side validation ── + + #[tokio::test] + async fn empty_bytes_returns_bad_request() { + let api_key = GrokApiKey::new("dummy".to_string()); + let result = upload_file(UploadFileArgs { + api_key: &api_key, + request: UploadFileRequest { + file_bytes: vec![], + filename: "x.png".to_string(), + maybe_content_type: None, + expires_after_seconds: None, + purpose: None, + }, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_)))); + } + + #[tokio::test] + async fn over_size_limit_returns_bad_request() { + let api_key = GrokApiKey::new("dummy".to_string()); + let oversized: Vec = vec![0u8; (MAX_FILE_BYTES + 1) as usize]; + let result = upload_file(UploadFileArgs { + api_key: &api_key, + request: UploadFileRequest { + file_bytes: oversized, + filename: "x.png".to_string(), + maybe_content_type: None, + expires_after_seconds: None, + purpose: None, + }, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_)))); + } + + // ── Public Request shape — must not leak api_key OR file_bytes ── + + #[test] + fn request_serializes_without_api_key_and_without_file_bytes() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = UploadFileArgs { + api_key: &key, + request: UploadFileRequest { + file_bytes: vec![0x89, 0x50, 0x4E, 0x47], // "PNG" header — recognizable + filename: "photo.png".to_string(), + maybe_content_type: Some("image/png".to_string()), + expires_after_seconds: Some(3600), + purpose: None, + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak"), "api_key leaked in: {}", json); + assert!(!json.contains("PNG"), "file_bytes leaked in: {}", json); + assert!(!json.contains("file_bytes"), "file_bytes field present in: {}", json); + assert!(json.contains("\"filename\":\"photo.png\"")); + assert!(json.contains("\"maybe_content_type\":\"image/png\"")); + assert!(json.contains("\"expires_after_seconds\":3600")); + assert!(!json.contains("\"purpose\"")); + } + + #[test] + fn debug_does_not_dump_file_bytes() { + let req = UploadFileRequest { + file_bytes: vec![0u8; 1_000_000], + filename: "huge.bin".to_string(), + maybe_content_type: None, + expires_after_seconds: None, + purpose: None, + }; + let debug = format!("{:?}", req); + assert!(debug.contains("file_bytes_len: 1000000"), "expected length, got: {}", debug); + assert!(!debug.contains(", 0, 0, 0, 0"), "raw bytes leaked into Debug: {}", debug); + } + + // ── Response parsing ── + + #[test] + fn response_body_deserializes() { + let json = r#"{ + "id": "file_abc123", + "object": "file", + "bytes": 1024, + "created_at": 1716489600, + "expires_at": null, + "filename": "photo.png", + "purpose": "vision" + }"#; + let parsed: UploadFileResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id, "file_abc123"); + assert_eq!(parsed.bytes, Some(1024)); + assert_eq!(parsed.filename.as_deref(), Some("photo.png")); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn live_test_upload_small_png() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + // 1x1 transparent PNG + let png_bytes: Vec = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, + ]; + + let api_key = get_test_api_key()?; + let result = upload_file(UploadFileArgs { + api_key: &api_key, + request: UploadFileRequest { + file_bytes: png_bytes, + filename: "test_1x1.png".to_string(), + maybe_content_type: Some("image/png".to_string()), + expires_after_seconds: Some(3600), + purpose: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Uploaded file_id={}, bytes={:?}", result.file_id, result.bytes); + assert!(!result.file_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs new file mode 100644 index 0000000000..44198ff2f6 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs @@ -0,0 +1,299 @@ +use crate::api::requests::images::image_edit::image_edit::ImageEditRequest; +use crate::api::requests::images::image_generation::cost::output_mills_per_image; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::image_types::image_model::ImageModel; +use crate::api::types::image_types::image_resolution::ImageResolution; + +// `image_edit` bills BOTH input (per source image fed in) AND output (per +// generated image). Per : +// +// grok-imagine-image input $0.002/img → 2 mills/img +// grok-imagine-image-quality input $0.010/img → 10 mills/img +// +// Output rates match `image_generation` (shared helper `output_mills_per_image`). + +impl GrokRequestCostCalculator for ImageEditRequest { + fn calculate_cost_in_mills(&self) -> UsdMills { + let model = self.model.clone().unwrap_or(ImageModel::GrokImagineImageQuality); + let resolution = self.resolution.unwrap_or(ImageResolution::OneK); + + let input_mills_per_img = input_mills_per_image(&model); + let output_mills_per_img = output_mills_per_image(&model, resolution); + + let n = self.number_images.unwrap_or(1) as u64; + let source_count = self.source_images.len() as u64; + + (input_mills_per_img * source_count) + (output_mills_per_img * n) + } +} + +/// Input rate in mills per source image, by model. +fn input_mills_per_image(model: &ImageModel) -> UsdMills { + match model { + ImageModel::GrokImagineImage => 2, + ImageModel::GrokImagineImageQuality => 10, + // Custom: conservatively assume the more expensive `-quality` tier. + ImageModel::Custom(_) => 10, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::images::image_edit::image_edit::ImageSource; + use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio; + + fn make_request( + model: Option, + resolution: Option, + number_images: Option, + source_count: usize, + ) -> ImageEditRequest { + ImageEditRequest { + prompt: "test".to_string(), + source_images: (0..source_count) + .map(|i| ImageSource::Url(format!("https://example.com/{i}.png"))) + .collect(), + model, + number_images, + aspect_ratio: None, + resolution, + response_format: None, + user: None, + } + } + + // ── Per-rate components ── + + mod per_rate_components { + use super::*; + + #[test] + fn standard_input_is_2_mills_per_source() { + assert_eq!(input_mills_per_image(&ImageModel::GrokImagineImage), 2); + } + + #[test] + fn quality_input_is_10_mills_per_source() { + assert_eq!(input_mills_per_image(&ImageModel::GrokImagineImageQuality), 10); + } + + #[test] + fn custom_input_defaults_to_quality_rate() { + assert_eq!(input_mills_per_image(&ImageModel::Custom("anything".to_string())), 10); + } + } + + // ── Single source + single output, every (model, resolution) pair ── + + mod single_source_single_output { + use super::*; + + #[test] + fn standard_1k() { + // input 2 + output 20 = 22 mills, 3¢ (ceil) + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 22); + assert_eq!(req.calculate_cost_in_cents(), 3); + } + + #[test] + fn standard_2k() { + // input 2 + output 20 = 22 mills, 3¢ + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::TwoK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 22); + assert_eq!(req.calculate_cost_in_cents(), 3); + } + + #[test] + fn quality_1k() { + // input 10 + output 50 = 60 mills, 6¢ + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 60); + assert_eq!(req.calculate_cost_in_cents(), 6); + } + + #[test] + fn quality_2k() { + // input 10 + output 70 = 80 mills, 8¢ + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 80); + assert_eq!(req.calculate_cost_in_cents(), 8); + } + } + + // ── Multi-source edit (up to 3 per xAI's docs) ── + + mod multi_source { + use super::*; + + #[test] + fn quality_1k_three_sources_one_output() { + // input 10 × 3 = 30, output 50, total 80 mills, 8¢ + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 3); + assert_eq!(req.calculate_cost_in_mills(), 80); + assert_eq!(req.calculate_cost_in_cents(), 8); + } + + #[test] + fn standard_1k_three_sources_two_outputs() { + // input 2 × 3 = 6, output 20 × 2 = 40, total 46 mills, 5¢ (ceil) + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(2), 3); + assert_eq!(req.calculate_cost_in_mills(), 46); + assert_eq!(req.calculate_cost_in_cents(), 5); + } + } + + // ── Batch output scaling (n > 1) ── + + mod batch_scaling { + use super::*; + + #[test] + fn batch_scales_only_output_not_input() { + let single = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1), 1) + .calculate_cost_in_mills(); // 10 + 70 = 80 + let quad = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(4), 1) + .calculate_cost_in_mills(); // 10 + 70 × 4 = 290 + assert_eq!(single, 80); + assert_eq!(quad, 290); + // Input doesn't scale with n. + assert_ne!(quad, single * 4); + } + + #[test] + fn quality_1k_scaling_1_through_8() { + for n in 1u32..=8 { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(n), 1); + assert_eq!(req.calculate_cost_in_mills(), 10 + 50 * n as u64, "n={n}"); + } + } + } + + // ── Defaults ── + + mod defaults { + use super::*; + + #[test] + fn fully_default_with_one_source() { + // model=quality, res=1k, n=1, sources=1 → 10 + 50 = 60 mills + let req = make_request(None, None, None, 1); + assert_eq!(req.calculate_cost_in_mills(), 60); + assert_eq!(req.calculate_cost_in_cents(), 6); + } + } + + // ── Independence from non-pricing fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1); + let base_cost = base.calculate_cost_in_mills(); + for ar in [ + ImageAspectRatio::Square, + ImageAspectRatio::Landscape16x9, + ImageAspectRatio::Portrait9x16, + ImageAspectRatio::Auto, + ] { + base.aspect_ratio = Some(ar); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}"); + } + } + + #[test] + fn cost_is_independent_of_source_url_content() { + let mut req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 0); + req.source_images = vec![ + ImageSource::Url("https://example.com/a.png".to_string()), + ImageSource::FileId("file_abc".to_string()), + ]; + // 10 × 2 + 50 = 70 mills + assert_eq!(req.calculate_cost_in_mills(), 70); + } + } + + // ── Exhaustive matrix ── + + mod exhaustive_matrix { + use super::*; + + // (model, resolution, n, sources, expected_mills) + const CASES: &[(ImageModelKind, ImageResolution, u32, usize, u64)] = &[ + // grok-imagine-image (standard) — input 2/img, output 20/img regardless of resolution + (ImageModelKind::Standard, ImageResolution::OneK, 1, 1, 22), + (ImageModelKind::Standard, ImageResolution::OneK, 1, 2, 24), + (ImageModelKind::Standard, ImageResolution::OneK, 1, 3, 26), + (ImageModelKind::Standard, ImageResolution::OneK, 2, 1, 42), + (ImageModelKind::Standard, ImageResolution::OneK, 4, 1, 82), + (ImageModelKind::Standard, ImageResolution::TwoK, 1, 1, 22), + (ImageModelKind::Standard, ImageResolution::TwoK, 2, 2, 44), + (ImageModelKind::Standard, ImageResolution::TwoK, 3, 3, 66), + // grok-imagine-image-quality — input 10/img, output 50/img (1k) or 70/img (2k) + (ImageModelKind::Quality, ImageResolution::OneK, 1, 1, 60), + (ImageModelKind::Quality, ImageResolution::OneK, 1, 2, 70), + (ImageModelKind::Quality, ImageResolution::OneK, 1, 3, 80), + (ImageModelKind::Quality, ImageResolution::OneK, 2, 1, 110), + (ImageModelKind::Quality, ImageResolution::OneK, 4, 1, 210), + (ImageModelKind::Quality, ImageResolution::TwoK, 1, 1, 80), + (ImageModelKind::Quality, ImageResolution::TwoK, 2, 2, 160), + (ImageModelKind::Quality, ImageResolution::TwoK, 3, 3, 240), + (ImageModelKind::Quality, ImageResolution::TwoK, 4, 3, 310), + ]; + + #[derive(Copy, Clone, Debug)] + enum ImageModelKind { Standard, Quality } + + fn to_model(kind: ImageModelKind) -> ImageModel { + match kind { + ImageModelKind::Standard => ImageModel::GrokImagineImage, + ImageModelKind::Quality => ImageModel::GrokImagineImageQuality, + } + } + + #[test] + fn all_matrix_cases() { + for &(kind, res, n, sources, expected) in CASES { + let req = make_request(Some(to_model(kind)), Some(res), Some(n), sources); + assert_eq!(req.calculate_cost_in_mills(), expected, + "model={:?} res={:?} n={} sources={}", kind, res, n, sources); + } + } + } + + // ── Monotonicity ── + + mod monotonicity { + use super::*; + + #[test] + fn more_sources_cost_more() { + let one = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1) + .calculate_cost_in_mills(); + let three = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 3) + .calculate_cost_in_mills(); + assert!(one < three); + } + + #[test] + fn more_outputs_cost_more() { + let one = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1) + .calculate_cost_in_mills(); + let four = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(4), 1) + .calculate_cost_in_mills(); + assert!(one < four); + } + + #[test] + fn quality_costs_more_than_standard() { + for res in [ImageResolution::OneK, ImageResolution::TwoK] { + let std = make_request(Some(ImageModel::GrokImagineImage), Some(res), Some(2), 2).calculate_cost_in_mills(); + let qua = make_request(Some(ImageModel::GrokImagineImageQuality), Some(res), Some(2), 2).calculate_cost_in_mills(); + assert!(std < qua, "standard ({std}) should be < quality ({qua}) at {res:?}"); + } + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/image_edit.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/image_edit.rs new file mode 100644 index 0000000000..c15b4d83f3 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/image_edit.rs @@ -0,0 +1,372 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::images::image_edit::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio; +use crate::api::types::image_types::image_model::ImageModel; +use crate::api::types::image_types::image_resolution::ImageResolution; +use crate::api::types::image_types::image_response_format::ImageResponseFormat; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; + +// ── Public args ── + +/// Top-level argument to [`image_edit`]. Borrows the API key separately from +/// the request body so callers can log/save [`ImageEditRequest`] without +/// leaking the credential. +#[derive(Clone, Debug)] +pub struct ImageEditArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: ImageEditRequest, +} + +/// The material part of an image-edit request. Derives [`Serialize`] so it +/// can be persisted to a log or audit store independently of the API key. +#[derive(Clone, Debug, Serialize)] +pub struct ImageEditRequest { + /// Edit instruction. Required. + pub prompt: String, + + /// One or more source images. xAI supports up to 3. + /// The output aspect ratio follows the first input image unless overridden. + pub source_images: Vec, + + /// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Number of edited images to render. Server default is 1 when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub number_images: Option, + + /// Aspect ratio. See [`ImageAspectRatio`]. When `None`, xAI defaults to + /// matching the first input image. + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Output resolution tier. See [`ImageResolution`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// `Url` (default) or `B64Json`. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// Source image — pick by URL or by `file_id` (from a prior upload). +#[derive(Clone, Debug, Serialize)] +pub enum ImageSource { + /// Either a public HTTPS URL or a `data:` URI containing base64-encoded + /// image bytes. + Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::api::requests::files::upload_file::upload_file::upload_file`]. The file + /// must still exist at request time (it has not been deleted and any + /// `expires_after` has not lapsed). + /// + /// Docs: + /// - + /// - + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct ImageEditSuccess { + pub images: Vec, +} + +#[derive(Debug, Clone)] +pub struct EditedImage { + pub url: Option, + pub b64_json: Option, + pub revised_prompt: Option, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/images/edits — edit one or more source images +/// according to a text prompt. +/// +/// Docs: +/// - +/// - +pub async fn image_edit(args: ImageEditArgs<'_>) -> Result { + let req = args.request; + + if req.source_images.is_empty() { + return Err(GrokClientError::InvalidRequest( + "image_edit requires at least one source image".to_string(), + ).into()); + } + + let url = format!("{}/v1/images/edits", XAI_API_BASE_URL); + let model = req.model.unwrap_or(ImageModel::GrokImagineImageQuality); + + info!( + "Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}", + model.as_str(), req.source_images.len(), + req.aspect_ratio.map(|a| a.as_str()), + req.resolution.map(|r| r.as_str()), + ); + + // Single source → `image`. Multiple sources → `images`. + let (image, images) = if req.source_images.len() == 1 { + (Some(to_image_ref(&req.source_images[0])), None) + } else { + (None, Some(req.source_images.iter().map(to_image_ref).collect())) + }; + + let request_body = ImageEditRequestBody { + prompt: req.prompt, + image, + images, + model: Some(model.as_str().to_string()), + n: req.number_images, + aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()), + resolution: req.resolution.map(|r| r.as_str().to_string()), + response_format: req.response_format.map(|f| f.as_str().to_string()), + user: req.user, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.post(&url) + .header("Authorization", bearer) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok image_edit response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: ImageEditResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(ImageEditSuccess { + images: parsed.data.into_iter().map(|d| EditedImage { + url: d.url, + b64_json: d.b64_json, + revised_prompt: d.revised_prompt, + }).collect(), + }) +} + +fn to_image_ref(source: &ImageSource) -> ImageRef { + match source { + ImageSource::Url(u) => ImageRef { url: Some(u.clone()), file_id: None }, + ImageSource::FileId(id) => ImageRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Wire-format shape tests (against the internal ImageEditRequestBody) ── + + #[test] + fn wire_body_single_source_uses_image_field() { + let body = ImageEditRequestBody { + prompt: "make it sepia".to_string(), + image: Some(ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }), + images: None, + model: Some("grok-imagine-image-quality".to_string()), + n: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"image\":{")); + assert!(!json.contains("\"images\"")); + assert!(json.contains("\"url\":\"https://example.com/a.png\"")); + } + + #[test] + fn wire_body_multi_source_uses_images_array() { + let body = ImageEditRequestBody { + prompt: "combine these".to_string(), + image: None, + images: Some(vec![ + ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }, + ImageRef { url: None, file_id: Some("file_xyz".to_string()) }, + ]), + model: None, + n: None, + aspect_ratio: Some("1:1".to_string()), + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"images\":[")); + assert!(!json.contains("\"image\":{")); + assert!(json.contains("\"file_id\":\"file_xyz\"")); + assert!(json.contains("\"aspect_ratio\":\"1:1\"")); + } + + // ── Public-API shape tests: ImageEditRequest must serialize cleanly ── + + #[test] + fn request_serializes_with_typed_enums() { + let req = ImageEditRequest { + prompt: "edit me".to_string(), + source_images: vec![ImageSource::Url("https://example.com/a.png".to_string())], + model: Some(ImageModel::GrokImagineImageQuality), + number_images: Some(2), + aspect_ratio: Some(ImageAspectRatio::Landscape16x9), + resolution: Some(ImageResolution::TwoK), + response_format: Some(ImageResponseFormat::B64Json), + user: Some("user_xyz".to_string()), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"prompt\":\"edit me\"")); + assert!(json.contains("\"model\":\"grok-imagine-image-quality\"")); + assert!(json.contains("\"aspect_ratio\":\"16:9\"")); + assert!(json.contains("\"resolution\":\"2k\"")); + assert!(json.contains("\"response_format\":\"b64_json\"")); + assert!(json.contains("\"number_images\":2")); + } + + #[test] + fn request_omits_none_fields() { + let req = ImageEditRequest { + prompt: "p".to_string(), + source_images: vec![ImageSource::FileId("file_abc".to_string())], + model: None, + number_images: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("\"model\"")); + assert!(!json.contains("\"aspect_ratio\"")); + assert!(!json.contains("\"resolution\"")); + assert!(!json.contains("\"user\"")); + assert!(!json.contains("\"number_images\"")); + } + + #[test] + fn request_does_not_serialize_api_key() { + let key = GrokApiKey::new("secret_value_must_not_leak".to_string()); + let args = ImageEditArgs { + api_key: &key, + request: ImageEditRequest { + prompt: "p".to_string(), + source_images: vec![ImageSource::Url("u".to_string())], + model: None, + number_images: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_value_must_not_leak"), + "serialized request must not contain the API key. got: {}", json); + } + + #[tokio::test] + async fn empty_sources_returns_bad_request() { + let api_key = GrokApiKey::new("dummy".to_string()); + let result = image_edit(ImageEditArgs { + api_key: &api_key, + request: ImageEditRequest { + prompt: "edit".to_string(), + source_images: vec![], + model: None, + number_images: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_)))); + } + + #[test] + fn response_body_deserializes_url() { + let json = r#"{ "data": [ { "url": "https://imagine.x.ai/edit.png" } ] }"#; + let parsed: ImageEditResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/edit.png")); + } + + #[test] + fn image_ref_url_only() { + let r = ImageRef { url: Some("u".to_string()), file_id: None }; + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("\"url\":\"u\"")); + assert!(!json.contains("\"file_id\"")); + } + + #[test] + fn image_ref_file_id_only() { + let r = ImageRef { url: None, file_id: Some("f".to_string()) }; + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("\"file_id\":\"f\"")); + assert!(!json.contains("\"url\"")); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_image_edit_single() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = image_edit(ImageEditArgs { + api_key: &api_key, + request: ImageEditRequest { + prompt: "Render this as a pencil sketch with detailed shading".to_string(), + source_images: vec![ImageSource::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())], + model: None, + number_images: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Edited {} image(s)", result.images.len()); + for (i, img) in result.images.iter().enumerate() { + println!(" [{}] url={:?}", i, img.url); + } + assert!(!result.images.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs new file mode 100644 index 0000000000..b01b824a27 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs @@ -0,0 +1,3 @@ +pub mod cost; +pub mod image_edit; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/request_types.rs new file mode 100644 index 0000000000..d7fa4c26ae --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/request_types.rs @@ -0,0 +1,57 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct ImageEditRequestBody { + pub prompt: String, + + /// Exactly one of `image` or `images` is sent (single vs. multi-image edit). + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// xAI image reference — exactly one of `url` or `file_id` is set. +#[derive(Serialize, Debug, Clone)] +pub(crate) struct ImageRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageEditResponseBody { + pub data: Vec, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageEditResponseDatum { + pub url: Option, + pub b64_json: Option, + pub revised_prompt: Option, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs new file mode 100644 index 0000000000..f83f624155 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs @@ -0,0 +1,249 @@ +use crate::api::requests::images::image_generation::image_generation::ImageGenerationRequest; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::image_types::image_model::ImageModel; +use crate::api::types::image_types::image_resolution::ImageResolution; + +// xAI published rates as of these docs: +// +// grok-imagine-image 1K $0.02/img, 2K $0.02/img (input $0.002/img) +// grok-imagine-image-quality 1K $0.05/img, 2K $0.07/img (input $0.010/img) +// +// In mills (1¢ = 10 mills): +// +// grok-imagine-image: 1K 20 mills/img, 2K 20 mills/img +// grok-imagine-image-quality: 1K 50 mills/img, 2K 70 mills/img +// +// `image_generation` has no INPUT cost (it's pure text → image) — only output. + +impl GrokRequestCostCalculator for ImageGenerationRequest { + fn calculate_cost_in_mills(&self) -> UsdMills { + let model = self.model.clone().unwrap_or(ImageModel::GrokImagineImageQuality); + let resolution = self.resolution.unwrap_or(ImageResolution::OneK); + + let mills_per_image = output_mills_per_image(&model, resolution); + + // Server default for `n` is 1. + let n = self.number_images.unwrap_or(1) as u64; + + mills_per_image * n + } +} + +/// Output rate in mills per generated image. Used by both +/// `image_generation` and `image_edit` (same output side). +pub(crate) fn output_mills_per_image(model: &ImageModel, resolution: ImageResolution) -> UsdMills { + match (model, resolution) { + (ImageModel::GrokImagineImage, ImageResolution::OneK) => 20, + (ImageModel::GrokImagineImage, ImageResolution::TwoK) => 20, + (ImageModel::GrokImagineImageQuality, ImageResolution::OneK) => 50, + (ImageModel::GrokImagineImageQuality, ImageResolution::TwoK) => 70, + // Custom: conservatively assume the more expensive `-quality` tier. + (ImageModel::Custom(_), ImageResolution::OneK) => 50, + (ImageModel::Custom(_), ImageResolution::TwoK) => 70, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio; + use crate::api::types::image_types::image_response_format::ImageResponseFormat; + + fn make_request( + model: Option, + resolution: Option, + number_images: Option, + ) -> ImageGenerationRequest { + ImageGenerationRequest { + prompt: "test".to_string(), + model, + number_images, + aspect_ratio: None, + resolution, + response_format: None, + user: None, + } + } + + // ── Per-image rate at each (model, resolution) combo ── + + mod per_image_rates { + use super::*; + + #[test] + fn standard_1k_is_20_mills() { + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 20); + // 20 mills = 2¢ (ceiling) + assert_eq!(req.calculate_cost_in_cents(), 2); + } + + #[test] + fn standard_2k_is_20_mills() { + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::TwoK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 20); + assert_eq!(req.calculate_cost_in_cents(), 2); + } + + #[test] + fn quality_1k_is_50_mills() { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 50); + assert_eq!(req.calculate_cost_in_cents(), 5); + } + + #[test] + fn quality_2k_is_70_mills() { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 70); + assert_eq!(req.calculate_cost_in_cents(), 7); + } + } + + // ── Defaults ── + + mod defaults { + use super::*; + + #[test] + fn default_model_is_quality() { + let r1 = make_request(None, Some(ImageResolution::OneK), Some(1)); + let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn default_resolution_is_one_k() { + let r1 = make_request(Some(ImageModel::GrokImagineImageQuality), None, Some(1)); + let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn default_n_is_one() { + let r1 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), None); + let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn fully_default_request_costs_50_mills() { + // All defaults = grok-imagine-image-quality, 1k, n=1 → 50 mills + let req = make_request(None, None, None); + assert_eq!(req.calculate_cost_in_mills(), 50); + assert_eq!(req.calculate_cost_in_cents(), 5); + } + } + + // ── Batch scaling ── + + mod batch_scaling { + use super::*; + + #[test] + fn n_scales_linearly_standard_1k() { + for n in 1u32..=10 { + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(n)); + assert_eq!(req.calculate_cost_in_mills(), 20 * n as u64, "n={n}"); + } + } + + #[test] + fn n_scales_linearly_quality_2k() { + for n in 1u32..=10 { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(n)); + assert_eq!(req.calculate_cost_in_mills(), 70 * n as u64, "n={n}"); + } + } + + #[test] + fn batch_of_four_quality_2k() { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(4)); + // 70 mills × 4 = 280 mills = 28¢ + assert_eq!(req.calculate_cost_in_mills(), 280); + assert_eq!(req.calculate_cost_in_cents(), 28); + } + } + + // ── Custom model (forward-compat) ── + + mod custom_model { + use super::*; + + #[test] + fn custom_model_uses_quality_rate() { + let custom = ImageModel::Custom("grok-imagine-image-future".to_string()); + let r_custom_1k = make_request(Some(custom.clone()), Some(ImageResolution::OneK), Some(1)); + let r_quality_1k = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r_custom_1k.calculate_cost_in_mills(), r_quality_1k.calculate_cost_in_mills()); + + let r_custom_2k = make_request(Some(custom), Some(ImageResolution::TwoK), Some(1)); + let r_quality_2k = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1)); + assert_eq!(r_custom_2k.calculate_cost_in_mills(), r_quality_2k.calculate_cost_in_mills()); + } + } + + // ── Cost doesn't depend on irrelevant fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + let base_cost = base.calculate_cost_in_mills(); + for ar in [ + ImageAspectRatio::Square, + ImageAspectRatio::Landscape16x9, + ImageAspectRatio::Portrait9x16, + ImageAspectRatio::Landscape20x9, + ImageAspectRatio::Auto, + ] { + base.aspect_ratio = Some(ar); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}"); + } + } + + #[test] + fn cost_is_independent_of_response_format() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + let base_cost = base.calculate_cost_in_mills(); + base.response_format = Some(ImageResponseFormat::Url); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + base.response_format = Some(ImageResponseFormat::B64Json); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + + #[test] + fn cost_is_independent_of_user_field() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + let base_cost = base.calculate_cost_in_mills(); + base.user = Some("any-user-id".to_string()); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + } + + // ── Monotonicity sanity checks ── + + mod monotonicity { + use super::*; + + #[test] + fn quality_is_at_least_as_expensive_as_standard() { + for res in [ImageResolution::OneK, ImageResolution::TwoK] { + let std = make_request(Some(ImageModel::GrokImagineImage), Some(res), Some(1)).calculate_cost_in_mills(); + let q = make_request(Some(ImageModel::GrokImagineImageQuality), Some(res), Some(1)).calculate_cost_in_mills(); + assert!(std <= q, "standard ({std}) should be <= quality ({q}) at {res:?}"); + } + } + + #[test] + fn two_k_is_at_least_as_expensive_as_one_k() { + for model in [ImageModel::GrokImagineImage, ImageModel::GrokImagineImageQuality] { + let lo = make_request(Some(model.clone()), Some(ImageResolution::OneK), Some(1)).calculate_cost_in_mills(); + let hi = make_request(Some(model.clone()), Some(ImageResolution::TwoK), Some(1)).calculate_cost_in_mills(); + assert!(lo <= hi, "1k ({lo}) should be <= 2k ({hi}) for {:?}", model.as_str()); + } + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/image_generation.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/image_generation.rs new file mode 100644 index 0000000000..beb802cf77 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/image_generation.rs @@ -0,0 +1,293 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::images::image_generation::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio; +use crate::api::types::image_types::image_model::ImageModel; +use crate::api::types::image_types::image_resolution::ImageResolution; +use crate::api::types::image_types::image_response_format::ImageResponseFormat; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`image_generation`]. Borrows the API key separately +/// from the request body so callers can log/save [`ImageGenerationRequest`] +/// without leaking the credential. +#[derive(Clone, Debug)] +pub struct ImageGenerationArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: ImageGenerationRequest, +} + +/// The material part of an image-generation request. Derives [`Serialize`] so +/// it can be persisted to a log or audit store independently of the API key. +#[derive(Clone, Debug, Serialize)] +pub struct ImageGenerationRequest { + /// Text prompt describing the image. Required. + pub prompt: String, + + /// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] + /// when `None`. Use [`ImageModel::Custom`] for identifiers not yet listed + /// in the enum. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Number of images to render in this request. xAI's docs don't state a + /// hard maximum; server default is 1 when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub number_images: Option, + + /// Aspect ratio. See [`ImageAspectRatio`] for the closed set of accepted + /// values. Server default when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Output resolution tier. See [`ImageResolution`]. Server default when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Url (default) or b64 inline. See [`ImageResponseFormat`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + + /// Optional opaque user identifier for usage attribution. + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct ImageGenerationSuccess { + /// One entry per generated image. Order matches the order xAI returned. + pub images: Vec, +} + +#[derive(Debug, Clone)] +pub struct GeneratedImage { + /// URL to the rendered image. Present when `response_format` was `"url"` + /// (the default). xAI URLs are time-limited. + pub url: Option, + + /// Base64-encoded image bytes. Present when `response_format` was `"b64_json"`. + pub b64_json: Option, + + /// xAI's revised version of the input prompt, if it edited it. + pub revised_prompt: Option, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/images/generations — generate one or more images +/// from a text prompt using xAI's Imagine API. +/// +/// Docs: +pub async fn image_generation(args: ImageGenerationArgs<'_>) -> Result { + let req = args.request; + + let url = format!("{}/v1/images/generations", XAI_API_BASE_URL); + let model = req.model.unwrap_or(ImageModel::GrokImagineImageQuality); + + info!( + "Grok image_generation: model={}, number_images={:?}, aspect_ratio={:?}, resolution={:?}", + model.as_str(), req.number_images, + req.aspect_ratio.map(|a| a.as_str()), + req.resolution.map(|r| r.as_str()), + ); + + let request_body = ImageGenerationRequestBody { + prompt: req.prompt, + model: Some(model.as_str().to_string()), + n: req.number_images, + aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()), + resolution: req.resolution.map(|r| r.as_str().to_string()), + response_format: req.response_format.map(|f| f.as_str().to_string()), + user: req.user, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.post(&url) + .header("Authorization", bearer) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok image_generation response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: ImageGenerationResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(ImageGenerationSuccess { + images: parsed.data.into_iter().map(|d| GeneratedImage { + url: d.url, + b64_json: d.b64_json, + revised_prompt: d.revised_prompt, + }).collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Wire-format shape tests (internal ImageGenerationRequestBody) ── + + #[test] + fn wire_body_serializes_minimal() { + let body = ImageGenerationRequestBody { + prompt: "a cat".to_string(), + model: Some("grok-imagine-image-quality".to_string()), + n: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"a cat\"")); + assert!(json.contains("\"model\":\"grok-imagine-image-quality\"")); + assert!(!json.contains("\"n\"")); + assert!(!json.contains("\"aspect_ratio\"")); + assert!(!json.contains("\"user\"")); + } + + #[test] + fn wire_body_serializes_full() { + let body = ImageGenerationRequestBody { + prompt: "a cat".to_string(), + model: Some("grok-imagine-image-quality".to_string()), + n: Some(2), + aspect_ratio: Some("16:9".to_string()), + resolution: Some("2k".to_string()), + response_format: Some("b64_json".to_string()), + user: Some("user_abc".to_string()), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"n\":2")); + assert!(json.contains("\"aspect_ratio\":\"16:9\"")); + assert!(json.contains("\"resolution\":\"2k\"")); + assert!(json.contains("\"response_format\":\"b64_json\"")); + assert!(json.contains("\"user\":\"user_abc\"")); + } + + // ── Public-API shape tests: ImageGenerationRequest must serialize cleanly ── + + #[test] + fn request_serializes_with_typed_enums() { + let req = ImageGenerationRequest { + prompt: "a serene lake".to_string(), + model: Some(ImageModel::GrokImagineImageQuality), + number_images: Some(3), + aspect_ratio: Some(ImageAspectRatio::Landscape16x9), + resolution: Some(ImageResolution::OneK), + response_format: Some(ImageResponseFormat::Url), + user: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"prompt\":\"a serene lake\"")); + assert!(json.contains("\"model\":\"grok-imagine-image-quality\"")); + assert!(json.contains("\"number_images\":3")); + assert!(json.contains("\"aspect_ratio\":\"16:9\"")); + assert!(json.contains("\"resolution\":\"1k\"")); + assert!(json.contains("\"response_format\":\"url\"")); + assert!(!json.contains("\"user\"")); + } + + #[test] + fn request_does_not_serialize_api_key() { + let key = GrokApiKey::new("secret_value_must_not_leak".to_string()); + let args = ImageGenerationArgs { + api_key: &key, + request: ImageGenerationRequest { + prompt: "p".to_string(), + model: None, + number_images: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_value_must_not_leak"), + "serialized request must not contain the API key. got: {}", json); + } + + #[test] + fn response_body_deserializes_url() { + let json = r#"{ + "data": [ + { "url": "https://imagine.x.ai/abc.png", "revised_prompt": "a cat" } + ] + }"#; + let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.data.len(), 1); + assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/abc.png")); + assert!(parsed.data[0].b64_json.is_none()); + assert_eq!(parsed.data[0].revised_prompt.as_deref(), Some("a cat")); + } + + #[test] + fn response_body_deserializes_b64_json() { + let json = r#"{ + "data": [ + { "b64_json": "iVBORw0KGgo=" } + ] + }"#; + let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.data[0].b64_json.as_deref(), Some("iVBORw0KGgo=")); + assert!(parsed.data[0].url.is_none()); + } + + // ── Live API tests (ignored — incur cost) ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_image_generation_simple() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = image_generation(ImageGenerationArgs { + api_key: &api_key, + request: ImageGenerationRequest { + prompt: "A serene mountain lake at sunrise, photorealistic".to_string(), + model: None, + number_images: None, + aspect_ratio: Some(ImageAspectRatio::Landscape16x9), + resolution: Some(ImageResolution::OneK), + response_format: None, + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Generated {} image(s)", result.images.len()); + for (i, img) in result.images.iter().enumerate() { + println!(" [{}] url={:?} revised_prompt={:?}", i, img.url, img.revised_prompt); + } + assert!(!result.images.is_empty()); + assert!(result.images[0].url.is_some(), "expected url-format response by default"); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs new file mode 100644 index 0000000000..01ec9a1cf0 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs @@ -0,0 +1,3 @@ +pub mod cost; +pub mod image_generation; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/request_types.rs new file mode 100644 index 0000000000..7d966720bf --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/request_types.rs @@ -0,0 +1,43 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct ImageGenerationRequestBody { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageGenerationResponseBody { + pub data: Vec, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageGenerationResponseDatum { + pub url: Option, + pub b64_json: Option, + + /// xAI may revise the prompt (their docs call this "revised_prompt" in + /// OpenAI-compat responses). Optional, included if present. + pub revised_prompt: Option, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/images/mod.rs new file mode 100644 index 0000000000..404881202b --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/mod.rs @@ -0,0 +1,2 @@ +pub mod image_edit; +pub mod image_generation; diff --git a/crates/api_clients/grok_api_client/src/api/requests/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/mod.rs new file mode 100644 index 0000000000..578c5b56d5 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/mod.rs @@ -0,0 +1,5 @@ +pub mod files; +pub mod images; +pub mod videos; + +pub(crate) mod xai_host; diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/mod.rs new file mode 100644 index 0000000000..23ffa79131 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/mod.rs @@ -0,0 +1,4 @@ +pub mod video_edit; +pub mod video_extension; +pub mod video_generation; +pub mod video_status; diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs new file mode 100644 index 0000000000..6980d42c8f --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs @@ -0,0 +1,177 @@ +use crate::api::requests::videos::video_edit::video_edit::VideoEditRequest; +use crate::api::requests::videos::video_generation::cost::{ + output_mills_per_second, + DEFAULT_VIDEO_DURATION_SECONDS, + INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO, +}; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::video_types::video_resolution::VideoResolution; + +/// Resolution we assume for both input and output. Video edits inherit +/// source resolution capped at 720p, so 720p is also the worst case +/// (output rate is higher at 720p than 480p). +const ASSUMED_RESOLUTION: VideoResolution = VideoResolution::SevenTwentyP; + +// `video_edit` cost depends entirely on the source video's duration: +// +// Input: 10 mills/sec × source_duration (resolution-independent) +// Output: 70 mills/sec × source_duration (720p, the assumed cap) +// Total: 80 mills/sec × source_duration +// +// xAI documents that video edits re-render the ENTIRE source video — output +// duration mirrors source duration, output resolution mirrors source +// resolution (capped at 720p, which is also our max-supported variant). +// +// Source duration isn't in the request body, but callers can populate the +// `source_video_duration_seconds_hint` field on `VideoEditRequest` for an +// accurate estimate. Without it we fall back to xAI's 8-second generation +// default so callers don't get a misleadingly-free quote. + +impl GrokRequestCostCalculator for VideoEditRequest { + /// Uses [`VideoEditRequest::source_video_duration_seconds_hint`] when set + /// (the caller-supplied source duration drives both the input AND output + /// billing); otherwise falls back to [`DEFAULT_VIDEO_DURATION_SECONDS`] + /// (8s, matching xAI's `/v1/videos/generations` default). + /// + /// Resolution is always assumed to be 720p — the request body can't + /// carry the source's actual resolution and 720p is both xAI's cap and + /// the worst case for cost. + /// + /// Formula: `secs × (10 input + 70 output@720p) = 80 × secs` mills. + /// + /// At the 8-second default: 8 × 80 = **640 mills (64¢)**. + fn calculate_cost_in_mills(&self) -> UsdMills { + let secs = self + .source_video_duration_seconds_hint + .unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS) as u64; + let per_second = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO + output_mills_per_second(ASSUMED_RESOLUTION); + per_second * secs + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::videos::video_edit::video_edit::{VideoEditRequest, VideoSource}; + use crate::api::types::video_types::video_model::VideoModel; + + fn make_request(source_video: VideoSource, model: Option) -> VideoEditRequest { + VideoEditRequest { + prompt: "test edit".to_string(), + source_video, + source_video_duration_seconds_hint: None, + model, + user: None, + } + } + + // Default base-trait estimate assumes 8s source @ 720p, so: + // 8 × (10 input + 70 output) = 640 mills = 64¢ + const EXPECTED_BASE_ESTIMATE_MILLS: u64 = 640; + const EXPECTED_BASE_ESTIMATE_CENTS: u64 = 64; + + #[test] + fn base_estimate_is_conservative_eight_seconds_seven_twenty_p() { + let cases = [ + VideoSource::Url("https://example.com/v.mp4".to_string()), + VideoSource::FileId("file_abc".to_string()), + ]; + for source in cases { + let req = make_request(source, None); + assert_eq!(req.calculate_cost_in_mills(), EXPECTED_BASE_ESTIMATE_MILLS); + assert_eq!(req.calculate_cost_in_cents(), EXPECTED_BASE_ESTIMATE_CENTS); + } + } + + #[test] + fn base_estimate_is_independent_of_model() { + let r_default = make_request(VideoSource::Url("u".to_string()), None).calculate_cost_in_mills(); + let r_known = make_request(VideoSource::Url("u".to_string()), Some(VideoModel::GrokImagineVideo)).calculate_cost_in_mills(); + let r_custom = make_request(VideoSource::Url("u".to_string()), + Some(VideoModel::Custom("future".to_string()))).calculate_cost_in_mills(); + assert_eq!(r_default, EXPECTED_BASE_ESTIMATE_MILLS); + assert_eq!(r_known, EXPECTED_BASE_ESTIMATE_MILLS); + assert_eq!(r_custom, EXPECTED_BASE_ESTIMATE_MILLS); + } + + #[test] + fn base_estimate_is_independent_of_prompt_or_user() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.prompt = "an enormously long prompt with many tokens".repeat(100); + req.user = Some("user-id".to_string()); + assert_eq!(req.calculate_cost_in_mills(), EXPECTED_BASE_ESTIMATE_MILLS); + } + + #[test] + fn base_estimate_is_nonzero() { + // Sanity guard against accidentally regressing to the old return-0 footgun. + let req = make_request(VideoSource::Url("u".to_string()), None); + assert!(req.calculate_cost_in_mills() > 0); + assert!(req.calculate_cost_in_cents() > 0); + } + + // ── source_video_duration_seconds_hint hint ── + + mod duration_hint { + use super::*; + + #[test] + fn hint_overrides_default_duration() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds_hint = Some(5); + // 5 × 80 = 400 mills (vs the 640 mills default) + assert_eq!(req.calculate_cost_in_mills(), 400); + assert_eq!(req.calculate_cost_in_cents(), 40); + } + + #[test] + fn one_second_hint() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds_hint = Some(1); + assert_eq!(req.calculate_cost_in_mills(), 80); // 1 × 80 + assert_eq!(req.calculate_cost_in_cents(), 8); + } + + #[test] + fn fifteen_second_hint() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds_hint = Some(15); + assert_eq!(req.calculate_cost_in_mills(), 1200); // 15 × 80 + assert_eq!(req.calculate_cost_in_cents(), 120); + } + + #[test] + fn unset_hint_falls_back_to_default() { + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.source_video_duration_seconds_hint, None); + // Default = 8s @ 720p = 640 mills. + assert_eq!(req.calculate_cost_in_mills(), 640); + } + + #[test] + fn zero_second_hint_costs_zero() { + // Edge case: 0-second source is meaningless but shouldn't panic. + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds_hint = Some(0); + assert_eq!(req.calculate_cost_in_mills(), 0); + } + + #[test] + fn hint_scales_linearly() { + for secs in 1u32..=20 { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds_hint = Some(secs); + assert_eq!(req.calculate_cost_in_mills(), 80 * secs as u64, "secs={secs}"); + } + } + + #[test] + fn hint_is_independent_of_source_kind() { + let mut url_req = make_request(VideoSource::Url("u".to_string()), None); + let mut file_req = make_request(VideoSource::FileId("f".to_string()), None); + url_req.source_video_duration_seconds_hint = Some(7); + file_req.source_video_duration_seconds_hint = Some(7); + assert_eq!(url_req.calculate_cost_in_mills(), file_req.calculate_cost_in_mills()); + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs new file mode 100644 index 0000000000..0aa9ef93f5 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs @@ -0,0 +1,3 @@ +pub mod cost; +pub mod video_edit; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/request_types.rs new file mode 100644 index 0000000000..52539dd040 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/request_types.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct VideoEditRequestBody { + pub prompt: String, + + pub video: VideoSourceRef, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoSourceRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoEditResponseBody { + pub request_id: String, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs new file mode 100644 index 0000000000..1fc7a3e372 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs @@ -0,0 +1,257 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::videos::video_edit::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::api::types::video_types::video_model::VideoModel; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`video_edit`]. Borrows the API key separately from +/// the request body so callers can log/save [`VideoEditRequest`] without +/// leaking the credential. +#[derive(Clone, Debug)] +pub struct VideoEditArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: VideoEditRequest, +} + +/// The material part of a video-edit request. Derives [`Serialize`] so it +/// can be persisted to a log or audit store independently of the API key. +#[derive(Clone, Debug, Serialize)] +pub struct VideoEditRequest { + /// Edit instruction. Required. + pub prompt: String, + + /// Source video to modify (xAI reads this as the input). + pub source_video: VideoSource, + + /// **Cost-estimation hint — NOT sent to xAI, has zero effect on the + /// actual HTTP request.** Duration in whole seconds of the video at + /// [`source_video`](Self::source_video). When set, the + /// [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`] + /// impl uses it for an accurate cost estimate; when `None`, the impl + /// falls back to a conservative default (xAI's 8-second generation + /// default). Populate this field if you already know the source's + /// runtime (e.g. from ffprobe or an upstream generate call) so internal + /// cost tracking stays correct. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_video_duration_seconds_hint: Option, + + /// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`. + /// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// Source video to edit. Pick by public URL or by a previously-uploaded +/// xAI file_id. +#[derive(Clone, Debug, Serialize)] +pub enum VideoSource { + /// Public HTTPS URL pointing to the source video. xAI fetches the bytes + /// on its end. + Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::api::requests::files::upload_file::upload_file::upload_file`]. + /// + /// Docs: + /// - + /// - + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoEditSuccess { + /// Use this with `video_status` to poll for completion. + pub request_id: String, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/videos/edits — modify an existing video based on +/// a text prompt. Asynchronous; poll `video_status` for completion. +/// +/// xAI states the `duration`, `aspectRatio`, and `resolution` parameters are +/// ignored for video edits — the output mirrors the source video. +/// +/// Docs: +pub async fn video_edit(args: VideoEditArgs<'_>) -> Result { + let req = args.request; + + let url = format!("{}/v1/videos/edits", XAI_API_BASE_URL); + let model = req.model.unwrap_or(VideoModel::GrokImagineVideo); + + info!("Grok video_edit: model={}", model.as_str()); + + let request_body = VideoEditRequestBody { + prompt: req.prompt, + video: to_video_source_ref(&req.source_video), + model: Some(model.as_str().to_string()), + user: req.user, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.post(&url) + .header("Authorization", bearer) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_edit response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoEditResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(VideoEditSuccess { request_id: parsed.request_id }) +} + +fn to_video_source_ref(source: &VideoSource) -> VideoSourceRef { + match source { + VideoSource::Url(u) => VideoSourceRef { url: Some(u.clone()), file_id: None }, + VideoSource::FileId(id) => VideoSourceRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Wire-format shape tests ── + + #[test] + fn wire_body_serializes_url_source() { + let body = VideoEditRequestBody { + prompt: "make it stormy".to_string(), + video: VideoSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None }, + model: Some("grok-imagine-video".to_string()), + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"make it stormy\"")); + assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}")); + assert!(json.contains("\"model\":\"grok-imagine-video\"")); + assert!(!json.contains("\"file_id\"")); + } + + #[test] + fn wire_body_serializes_file_id_source() { + let body = VideoEditRequestBody { + prompt: "p".to_string(), + video: VideoSourceRef { url: None, file_id: Some("file_v".to_string()) }, + model: None, + user: Some("u".to_string()), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}")); + assert!(json.contains("\"user\":\"u\"")); + } + + // ── Public Request shape ── + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = VideoEditArgs { + api_key: &key, + request: VideoEditRequest { + prompt: "p".to_string(), + source_video: VideoSource::FileId("file_abc".to_string()), + source_video_duration_seconds_hint: None, + model: None, + user: None, + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak")); + assert!(json.contains("\"source_video\":{\"FileId\":\"file_abc\"}")); + // Hint omitted when None. + assert!(!json.contains("source_video_duration_seconds_hint")); + } + + #[test] + fn source_duration_hint_serializes_when_set() { + let req = VideoEditRequest { + prompt: "p".to_string(), + source_video: VideoSource::Url("u".to_string()), + source_video_duration_seconds_hint: Some(12), + model: None, + user: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"source_video_duration_seconds_hint\":12")); + } + + #[test] + fn source_duration_hint_is_not_in_wire_body() { + // The internal wire body (what actually gets POSTed to xAI) doesn't + // carry the hint — it's intentionally absent from VideoEditRequestBody. + let body = VideoEditRequestBody { + prompt: "p".to_string(), + video: VideoSourceRef { url: Some("u".to_string()), file_id: None }, + model: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(!json.contains("source_video_duration_seconds_hint")); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{ "request_id": "0199c33d-3afa-7000-b400-deadbeefcafe" }"#; + let parsed: VideoEditResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.request_id, "0199c33d-3afa-7000-b400-deadbeefcafe"); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_edit() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_edit(VideoEditArgs { + api_key: &api_key, + request: VideoEditRequest { + prompt: "Change the lighting to midnight. Starry sky with the mily way overhead. Add some shooting stars. Everything is glowing under the starlight.".to_string(), + source_video: VideoSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()), + source_video_duration_seconds_hint: None, + model: None, + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Edit request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs new file mode 100644 index 0000000000..9315b96877 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs @@ -0,0 +1,209 @@ +use crate::api::requests::videos::video_extension::video_extension::VideoExtensionRequest; +use crate::api::requests::videos::video_generation::cost::{ + output_mills_per_second, + INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO, +}; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::video_types::video_resolution::VideoResolution; + +// `video_extension` charges: +// +// Output: 70 mills/sec × extension_duration (assumed 720p, since output +// inherits the source's resolution capped at 720p) +// Input: 10 mills/sec × source_duration (only billed when caller +// supplies a duration hint) +// +// `extension_duration` is the `duration` field on the request (xAI default +// is 6 seconds; range 1–10). `source_duration` isn't in the wire body, but +// callers can populate `source_video_duration_seconds_hint` on the public +// Request to drive the input-side billing. + +/// xAI default duration for video_extension when `duration` is omitted. +const DEFAULT_EXTENSION_DURATION_SECONDS: u32 = 6; + +/// Assumed output resolution. xAI caps extension output at 720p, so this +/// is the worst case for cost. +const ASSUMED_RESOLUTION: VideoResolution = VideoResolution::SevenTwentyP; + +impl GrokRequestCostCalculator for VideoExtensionRequest { + /// Output portion (always billed): extension duration × 70 mills/sec at + /// the assumed 720p output. + /// + /// Input portion (billed only when + /// [`VideoExtensionRequest::source_video_duration_seconds_hint`] is set): + /// 10 mills/sec × hinted source duration. + /// + /// Formula: + /// - hint unset: `extension_duration × 70` + /// - hint set: `extension_duration × 70 + source_duration × 10` + fn calculate_cost_in_mills(&self) -> UsdMills { + let extension_duration = self.duration.unwrap_or(DEFAULT_EXTENSION_DURATION_SECONDS) as u64; + let output_mills = output_mills_per_second(ASSUMED_RESOLUTION) * extension_duration; + let input_mills = self + .source_video_duration_seconds_hint + .map(|secs| INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO * secs as u64) + .unwrap_or(0); + output_mills + input_mills + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::videos::video_extension::video_extension::{ + VideoExtensionRequest, VideoExtensionSource, + }; + use crate::api::types::video_types::video_model::VideoModel; + + fn make_request( + duration: Option, + source: VideoExtensionSource, + model: Option, + ) -> VideoExtensionRequest { + VideoExtensionRequest { + prompt: "test".to_string(), + source_video: source, + source_video_duration_seconds_hint: None, + model, + duration, + } + } + + fn url_source() -> VideoExtensionSource { + VideoExtensionSource::Url("https://example.com/v.mp4".to_string()) + } + + // ── Output portion (the part we CAN compute) ── + + mod output_portion { + use super::*; + + #[test] + fn default_duration_is_6_seconds() { + // 70 × 6 = 420 mills = 42¢ + let req = make_request(None, url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 420); + assert_eq!(req.calculate_cost_in_cents(), 42); + } + + #[test] + fn one_second_extension() { + // 70 × 1 = 70 mills = 7¢ + let req = make_request(Some(1), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 70); + assert_eq!(req.calculate_cost_in_cents(), 7); + } + + #[test] + fn five_second_extension() { + // 70 × 5 = 350 mills = 35¢ + let req = make_request(Some(5), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 350); + assert_eq!(req.calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_second_extension_is_max() { + // 70 × 10 = 700 mills = 70¢ + let req = make_request(Some(10), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 700); + assert_eq!(req.calculate_cost_in_cents(), 70); + } + } + + // ── Scaling ── + + mod scaling { + use super::*; + + #[test] + fn duration_scales_linearly() { + for d in 1u32..=10 { + let req = make_request(Some(d), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 70 * d as u64, "d={d}"); + } + } + } + + // ── Independence from non-pricing fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_source_kind() { + let url = make_request(Some(5), VideoExtensionSource::Url("u".to_string()), None); + let file = make_request(Some(5), VideoExtensionSource::FileId("f".to_string()), None); + assert_eq!(url.calculate_cost_in_mills(), file.calculate_cost_in_mills()); + } + + #[test] + fn cost_is_independent_of_model_variant() { + let mut base = make_request(Some(5), url_source(), None); + let base_cost = base.calculate_cost_in_mills(); + base.model = Some(VideoModel::GrokImagineVideo); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + base.model = Some(VideoModel::Custom("future".to_string())); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + + #[test] + fn cost_is_independent_of_prompt_length() { + let mut base = make_request(Some(5), url_source(), None); + let base_cost = base.calculate_cost_in_mills(); + base.prompt = "a much longer prompt with many more words to influence pricing... or not".to_string(); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + } + + + // ── source_video_duration_seconds_hint hint ── + + mod duration_hint { + use super::*; + + #[test] + fn unset_hint_returns_output_only() { + // ext=5s @ 720p, no hint → just output portion: 70 × 5 = 350 + let req = make_request(Some(5), url_source(), None); + assert_eq!(req.source_video_duration_seconds_hint, None); + assert_eq!(req.calculate_cost_in_mills(), 350); + } + + #[test] + fn set_hint_adds_input_portion() { + // ext=5s @ 720p, source=10s → output 350 + input 100 = 450 + let mut req = make_request(Some(5), url_source(), None); + req.source_video_duration_seconds_hint = Some(10); + assert_eq!(req.calculate_cost_in_mills(), 450); + assert_eq!(req.calculate_cost_in_cents(), 45); + } + + #[test] + fn hint_zero_is_treated_as_zero_input() { + // ext=5s @ 720p, source=0 → output 350 + 0 = 350 + let mut req = make_request(Some(5), url_source(), None); + req.source_video_duration_seconds_hint = Some(0); + assert_eq!(req.calculate_cost_in_mills(), 350); + } + + #[test] + fn default_extension_with_hint() { + // ext default=6s @ 720p (output 420), source=5s (input 50) → 470 + let mut req = make_request(None, url_source(), None); + req.source_video_duration_seconds_hint = Some(5); + assert_eq!(req.calculate_cost_in_mills(), 470); + assert_eq!(req.calculate_cost_in_cents(), 47); + } + + #[test] + fn hint_scales_linearly() { + // Fix ext=5s (output 350); vary hint from 0..30. + for source_secs in 0u32..=30 { + let mut req = make_request(Some(5), url_source(), None); + req.source_video_duration_seconds_hint = Some(source_secs); + assert_eq!(req.calculate_cost_in_mills(), 350 + 10 * source_secs as u64, "source_secs={source_secs}"); + } + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs new file mode 100644 index 0000000000..3769e45251 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs @@ -0,0 +1,3 @@ +pub mod cost; +pub mod video_extension; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/request_types.rs new file mode 100644 index 0000000000..d43b42e2ce --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/request_types.rs @@ -0,0 +1,34 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct VideoExtensionRequestBody { + pub prompt: String, + + pub video: VideoExtensionSourceRef, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Length of the *extension only*, not the total output. xAI default is 6 + /// seconds; range 1–10. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoExtensionSourceRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoExtensionResponseBody { + pub request_id: String, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs new file mode 100644 index 0000000000..8db1d0e4de --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs @@ -0,0 +1,258 @@ +use log::info; +use serde_derive::Serialize; + +use crate::api::requests::videos::video_extension::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::api::types::video_types::video_model::VideoModel; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`video_extension`]. Borrows the API key separately +/// from the request body so callers can log/save [`VideoExtensionRequest`] +/// without leaking the credential. +#[derive(Clone, Debug)] +pub struct VideoExtensionArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: VideoExtensionRequest, +} + +/// The material part of a video-extension request. Derives [`Serialize`] so +/// it can be persisted to a log or audit store independently of the API key. +#[derive(Clone, Debug, Serialize)] +pub struct VideoExtensionRequest { + /// Prompt describing what should happen in the extension. + pub prompt: String, + + /// Source video to extend. + pub source_video: VideoExtensionSource, + + /// **Cost-estimation hint — NOT sent to xAI, has zero effect on the + /// actual HTTP request.** Duration in whole seconds of the video at + /// [`source_video`](Self::source_video). When set, the + /// [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`] + /// impl includes the input-side billing (10 mills/sec × source duration) + /// in the estimate; when `None`, the impl returns the output portion + /// only (extension duration × output rate). Populate this field if you + /// already know the source's runtime (e.g. from ffprobe or an upstream + /// generate call) so internal cost tracking reflects the true total. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_video_duration_seconds_hint: Option, + + /// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`. + /// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Length of the *extension only*, not the total output. xAI default is 6 + /// seconds; range 1–10. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +/// Source video to extend. Pick by public URL or by a previously-uploaded +/// xAI file_id. +#[derive(Clone, Debug, Serialize)] +pub enum VideoExtensionSource { + /// Public HTTPS URL pointing to the source video. + Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::api::requests::files::upload_file::upload_file::upload_file`]. + /// + /// Docs: + /// - + /// - + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoExtensionSuccess { + /// Use this with `video_status` to poll for completion. + pub request_id: String, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/videos/extensions — extend an existing video with +/// additional generated content. +/// +/// Asynchronous; poll `video_status` for completion. +/// +/// Docs: +pub async fn video_extension(args: VideoExtensionArgs<'_>) -> Result { + let req = args.request; + + let url = format!("{}/v1/videos/extensions", XAI_API_BASE_URL); + let model = req.model.unwrap_or(VideoModel::GrokImagineVideo); + + info!("Grok video_extension: model={}, duration={:?}", model.as_str(), req.duration); + + let request_body = VideoExtensionRequestBody { + prompt: req.prompt, + video: to_extension_source_ref(&req.source_video), + model: Some(model.as_str().to_string()), + duration: req.duration, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.post(&url) + .header("Authorization", bearer) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_extension response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoExtensionResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(VideoExtensionSuccess { request_id: parsed.request_id }) +} + +fn to_extension_source_ref(source: &VideoExtensionSource) -> VideoExtensionSourceRef { + match source { + VideoExtensionSource::Url(u) => VideoExtensionSourceRef { url: Some(u.clone()), file_id: None }, + VideoExtensionSource::FileId(id) => VideoExtensionSourceRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Wire-format shape tests ── + + #[test] + fn wire_body_serializes_minimal() { + let body = VideoExtensionRequestBody { + prompt: "keep walking".to_string(), + video: VideoExtensionSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None }, + model: Some("grok-imagine-video".to_string()), + duration: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"keep walking\"")); + assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}")); + assert!(json.contains("\"model\":\"grok-imagine-video\"")); + assert!(!json.contains("\"duration\"")); + } + + #[test] + fn wire_body_serializes_with_duration() { + let body = VideoExtensionRequestBody { + prompt: "p".to_string(), + video: VideoExtensionSourceRef { url: None, file_id: Some("file_v".to_string()) }, + model: None, + duration: Some(8), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"duration\":8")); + assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}")); + } + + // ── Public Request shape ── + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = VideoExtensionArgs { + api_key: &key, + request: VideoExtensionRequest { + prompt: "p".to_string(), + source_video: VideoExtensionSource::Url("u".to_string()), + source_video_duration_seconds_hint: None, + model: None, + duration: Some(5), + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak")); + assert!(json.contains("\"duration\":5")); + assert!(json.contains("\"source_video\":{\"Url\":\"u\"}")); + // Hint omitted when None. + assert!(!json.contains("source_video_duration_seconds_hint")); + } + + #[test] + fn source_duration_hint_serializes_when_set() { + let req = VideoExtensionRequest { + prompt: "p".to_string(), + source_video: VideoExtensionSource::Url("u".to_string()), + source_video_duration_seconds_hint: Some(7), + model: None, + duration: Some(5), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"source_video_duration_seconds_hint\":7")); + } + + #[test] + fn source_duration_hint_is_not_in_wire_body() { + // The internal wire body (what actually gets POSTed to xAI) doesn't + // carry the hint — it's intentionally absent from VideoExtensionRequestBody. + let body = VideoExtensionRequestBody { + prompt: "p".to_string(), + video: VideoExtensionSourceRef { url: Some("u".to_string()), file_id: None }, + model: None, + duration: Some(5), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(!json.contains("source_video_duration_seconds_hint")); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{ "request_id": "ext-abc-123" }"#; + let parsed: VideoExtensionResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.request_id, "ext-abc-123"); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_extension() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_extension(VideoExtensionArgs { + api_key: &api_key, + request: VideoExtensionRequest { + prompt: "Continue the walk down the same street".to_string(), + source_video: VideoExtensionSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()), + source_video_duration_seconds_hint: None, + model: None, + duration: Some(5), + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Extension request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs new file mode 100644 index 0000000000..925c546bc1 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs @@ -0,0 +1,518 @@ +use crate::api::requests::videos::video_generation::video_generation::VideoGenerationRequest; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::video_types::video_model::{VideoModel, VideoModelPricingTier}; +use crate::api::types::video_types::video_resolution::VideoResolution; + +// xAI pricing per and +// : +// +// grok-imagine-video (v1) +// Output: 480p $0.05/sec, 720p $0.07/sec +// Input: $0.01/sec (source video) + $0.002/img (source images) +// +// grok-imagine-video-1.5 (v1.5) +// Output: 480p $0.08/sec, 720p $0.14/sec, 1080p $0.25/sec +// Input: $0.01/img (source images) +// +// In mills (1¢ = 10 mills): +// +// v1 output: 480p 50 mills/sec, 720p 70 mills/sec +// v1 input: 10 mills/sec (video), 2 mills/img +// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec, 1080p 250 mills/sec +// v1.5 input: 10 mills/img +// +// video_generation has NO input video — only optional input images. xAI +// defaults: `duration` = 8 seconds, `resolution` = "480p" (the server side; +// our local default below is 720p — see DEFAULT comments in the impl). + +/// xAI default duration for video_generation when `duration` is omitted. +pub(crate) const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 8; + +/// Per-source-image input rate (v1 tier), in mills. +pub(crate) const INPUT_MILLS_PER_IMAGE: UsdMills = 2; + +/// Per-source-image input rate (v1.5 tier), in mills. +const INPUT_MILLS_PER_IMAGE_V1P5: UsdMills = 10; + +/// Per-source-second input rate (when a source video is supplied) for video +/// endpoints, in mills. Not used by `video_generation` (which has no source +/// video) but referenced by `video_edit` and `video_extension`. +pub(crate) const INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO: UsdMills = 10; + +impl GrokRequestCostCalculator for VideoGenerationRequest { + fn calculate_cost_in_mills(&self) -> UsdMills { + let duration = self.duration.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS) as u64; + let resolution = self.resolution.unwrap_or(VideoResolution::SevenTwentyP); + let tier = self.model + .as_ref() + .map(VideoModel::pricing_tier) + .unwrap_or(VideoModelPricingTier::V1); + + let output_mills = output_mills_per_second_for_tier(resolution, tier) * duration; + + // Source image count = `image` (image-to-video) + len(`reference_images`) + // (reference-to-video). These are mutually exclusive at call time but the + // cost math doesn't need to enforce that — it just counts. + let source_image_count: u64 = + (self.image.is_some() as u64) + + (self.reference_images.as_ref().map(|v| v.len() as u64).unwrap_or(0)); + + let input_mills = input_mills_per_image(tier) * source_image_count; + + output_mills + input_mills + } +} + +/// Output rate in mills per second of generated video at v1 rates, by +/// resolution. Kept for cross-module use by `video_edit` and +/// `video_extension`, which both assume v1 pricing and do not currently take +/// a model into account in their cost calculators. +pub(crate) fn output_mills_per_second(resolution: VideoResolution) -> UsdMills { + output_mills_per_second_for_tier(resolution, VideoModelPricingTier::V1) +} + +/// Output rate in mills per second of generated video, by resolution AND +/// pricing tier. Used by `video_generation`. +fn output_mills_per_second_for_tier( + resolution: VideoResolution, + tier: VideoModelPricingTier, +) -> UsdMills { + match (tier, resolution) { + (VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50, + (VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70, + // v1 doesn't produce 1080p output (it downsizes to 720p); price at 720p. + (VideoModelPricingTier::V1, VideoResolution::TenEightyP) => 70, + (VideoModelPricingTier::V1p5, VideoResolution::FourEightyP) => 80, + (VideoModelPricingTier::V1p5, VideoResolution::SevenTwentyP) => 140, + (VideoModelPricingTier::V1p5, VideoResolution::TenEightyP) => 250, + } +} + +fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills { + match tier { + VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE, + VideoModelPricingTier::V1p5 => INPUT_MILLS_PER_IMAGE_V1P5, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::videos::video_generation::video_generation::VideoImageSource; + use crate::api::types::video_types::video_aspect_ratio::VideoAspectRatio; + use crate::api::types::video_types::video_model::VideoModel; + + fn make_request( + duration: Option, + resolution: Option, + image: Option, + reference_images: Option>, + ) -> VideoGenerationRequest { + VideoGenerationRequest { + prompt: "test".to_string(), + model: None, + image, + reference_images, + aspect_ratio: None, + duration, + resolution, + user: None, + } + } + + fn url_source() -> VideoImageSource { + VideoImageSource::Url("https://example.com/img.png".to_string()) + } + + // ── Output rate sanity ── + + mod output_rate { + use super::*; + + #[test] + fn rate_480p_is_50_mills_per_sec() { + assert_eq!(output_mills_per_second(VideoResolution::FourEightyP), 50); + } + + #[test] + fn rate_720p_is_70_mills_per_sec() { + assert_eq!(output_mills_per_second(VideoResolution::SevenTwentyP), 70); + } + } + + // ── Pure text-to-video (no input images) ── + + mod text_to_video { + use super::*; + + #[test] + fn fully_default_is_8s_720p() { + // Default duration 8s, default resolution 720p (per our defaults in cost.rs). + // 70 × 8 = 560 mills = 56¢ + let req = make_request(None, None, None, None); + assert_eq!(req.calculate_cost_in_mills(), 560); + assert_eq!(req.calculate_cost_in_cents(), 56); + } + + #[test] + fn five_seconds_480p() { + // 50 × 5 = 250 mills = 25¢ + let req = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 250); + assert_eq!(req.calculate_cost_in_cents(), 25); + } + + #[test] + fn five_seconds_720p() { + // 70 × 5 = 350 mills = 35¢ + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 350); + assert_eq!(req.calculate_cost_in_cents(), 35); + } + + #[test] + fn fifteen_seconds_720p_is_max_duration() { + // 70 × 15 = 1050 mills = $1.05 + let req = make_request(Some(15), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 1050); + assert_eq!(req.calculate_cost_in_cents(), 105); + } + } + + // ── Image-to-video (single source image) ── + + mod image_to_video { + use super::*; + + #[test] + fn five_seconds_480p_one_source_image() { + // 50 × 5 + 2 × 1 = 252 mills = 26¢ (ceil) + let req = make_request(Some(5), Some(VideoResolution::FourEightyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 252); + assert_eq!(req.calculate_cost_in_cents(), 26); + } + + #[test] + fn five_seconds_720p_one_source_image() { + // 70 × 5 + 2 = 352 mills = 36¢ (ceil) + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 352); + assert_eq!(req.calculate_cost_in_cents(), 36); + } + } + + // ── Reference-to-video (multiple source images) ── + + mod reference_to_video { + use super::*; + + #[test] + fn five_seconds_720p_two_reference_images() { + // 70 × 5 + 2 × 2 = 354 mills = 36¢ (ceil) + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, Some(vec![url_source(), url_source()])); + assert_eq!(req.calculate_cost_in_mills(), 354); + assert_eq!(req.calculate_cost_in_cents(), 36); + } + + #[test] + fn five_seconds_720p_three_reference_images() { + // 70 × 5 + 2 × 3 = 356 mills + let req = make_request( + Some(5), Some(VideoResolution::SevenTwentyP), + None, Some(vec![url_source(), url_source(), url_source()]), + ); + assert_eq!(req.calculate_cost_in_mills(), 356); + } + + #[test] + fn input_image_count_scales_linearly() { + let base = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + for n in 1usize..=5 { + let imgs: Vec<_> = (0..n).map(|_| url_source()).collect(); + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, Some(imgs)); + assert_eq!(req.calculate_cost_in_mills(), base + INPUT_MILLS_PER_IMAGE * n as u64, "n={n}"); + } + } + } + + // ── Defaults ── + + mod defaults { + use super::*; + + #[test] + fn default_duration_is_8_seconds() { + let r1 = make_request(None, Some(VideoResolution::FourEightyP), None, None); + let r2 = make_request(Some(8), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn default_resolution_is_720p() { + let r1 = make_request(Some(5), None, None, None); + let r2 = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + } + + // ── Independence from non-pricing fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + let base_cost = base.calculate_cost_in_mills(); + for ar in [ + VideoAspectRatio::Square, + VideoAspectRatio::Landscape16x9, + VideoAspectRatio::Portrait9x16, + VideoAspectRatio::Portrait2x3, + ] { + base.aspect_ratio = Some(ar); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}"); + } + } + + #[test] + fn cost_within_v1_tier_is_independent_of_model_variant() { + // Within the v1 pricing tier (default + unrecognized Custom strings), + // model variant must not affect cost. + let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + let base_cost = base.calculate_cost_in_mills(); + for m in [ + VideoModel::GrokImagineVideo, + VideoModel::Custom("grok-imagine-video-future".to_string()), + ] { + base.model = Some(m.clone()); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{:?}", m.as_str()); + } + } + } + + // ── grok-imagine-video-1.5-preview pricing ── + // + // v1.5 rates per the model's docs page: + // 480p: 80 mills/sec, 720p: 140 mills/sec, 10 mills per source image. + + mod v1p5_preview { + use super::*; + + fn make_v1p5_request( + duration: Option, + resolution: Option, + image: Option, + reference_images: Option>, + ) -> VideoGenerationRequest { + let mut req = make_request(duration, resolution, image, reference_images); + req.model = Some(VideoModel::GrokImagineVideo1p5); + req + } + + #[test] + fn five_seconds_480p_text_only() { + // 80 × 5 = 400 mills = 40¢ + let req = make_v1p5_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 400); + assert_eq!(req.calculate_cost_in_cents(), 40); + } + + #[test] + fn five_seconds_720p_text_only() { + // 140 × 5 = 700 mills = 70¢ + let req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 700); + assert_eq!(req.calculate_cost_in_cents(), 70); + } + + #[test] + fn five_seconds_1080p_text_only() { + // 250 × 5 = 1250 mills = $1.25 + let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 1250); + assert_eq!(req.calculate_cost_in_cents(), 125); + } + + #[test] + fn five_seconds_1080p_one_source_image() { + // 250 × 5 + 10 = 1260 mills = $1.26 + let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 1260); + assert_eq!(req.calculate_cost_in_cents(), 126); + } + + #[test] + fn five_seconds_720p_one_source_image() { + // 140 × 5 + 10 × 1 = 710 mills = 71¢ + let req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 710); + assert_eq!(req.calculate_cost_in_cents(), 71); + } + + #[test] + fn five_seconds_480p_three_reference_images() { + // 80 × 5 + 10 × 3 = 430 mills = 43¢ + let req = make_v1p5_request( + Some(5), Some(VideoResolution::FourEightyP), + None, Some(vec![url_source(), url_source(), url_source()]), + ); + assert_eq!(req.calculate_cost_in_mills(), 430); + assert_eq!(req.calculate_cost_in_cents(), 43); + } + + #[test] + fn dated_alias_via_custom_is_billed_as_v1p5() { + // xAI accepts the dated alias as a synonym; cost must match the + // canonical 1.5 variant exactly. + let canonical = { + let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + req.model = Some(VideoModel::GrokImagineVideo1p5); + req.calculate_cost_in_mills() + }; + let via_alias = { + let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + req.model = Some(VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string())); + req.calculate_cost_in_mills() + }; + assert_eq!(canonical, via_alias); + } + + #[test] + fn custom_with_canonical_1p5_name_is_billed_as_v1p5() { + let via_custom = { + let mut req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + req.model = Some(VideoModel::Custom("grok-imagine-video-1.5-preview".to_string())); + req.calculate_cost_in_mills() + }; + let via_enum = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None) + .calculate_cost_in_mills(); + assert_eq!(via_custom, via_enum); + } + + #[test] + fn v1p5_costs_more_than_v1_at_same_settings() { + let v1 = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + let v1p5 = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}"); + } + + // (duration, resolution, has_image, ref_count, expected_mills) + const V1P5_CASES: &[(u32, VideoResolution, bool, usize, u64)] = &[ + // 480p text-only + ( 1, VideoResolution::FourEightyP, false, 0, 80), + ( 8, VideoResolution::FourEightyP, false, 0, 640), + (15, VideoResolution::FourEightyP, false, 0, 1200), + // 720p text-only + ( 1, VideoResolution::SevenTwentyP, false, 0, 140), + ( 8, VideoResolution::SevenTwentyP, false, 0, 1120), + (15, VideoResolution::SevenTwentyP, false, 0, 2100), + // 480p with image-to-video + ( 5, VideoResolution::FourEightyP, true, 0, 410), + (10, VideoResolution::FourEightyP, true, 0, 810), + // 720p with image-to-video + ( 5, VideoResolution::SevenTwentyP, true, 0, 710), + // 720p with reference images + ( 5, VideoResolution::SevenTwentyP, false, 2, 720), + ( 5, VideoResolution::SevenTwentyP, false, 3, 730), + // 1080p (250 mills/sec) + ( 1, VideoResolution::TenEightyP, false, 0, 250), + ( 5, VideoResolution::TenEightyP, false, 0, 1250), + (15, VideoResolution::TenEightyP, false, 0, 3750), + ( 5, VideoResolution::TenEightyP, true, 0, 1260), + ( 5, VideoResolution::TenEightyP, false, 2, 1270), + ]; + + #[test] + fn v1p5_matrix() { + for &(duration, res, has_image, ref_count, expected) in V1P5_CASES { + let image = if has_image { Some(url_source()) } else { None }; + let refs = if ref_count > 0 { + Some((0..ref_count).map(|_| url_source()).collect()) + } else { None }; + let req = make_v1p5_request(Some(duration), Some(res), image, refs); + assert_eq!( + req.calculate_cost_in_mills(), expected, + "duration={duration} res={res:?} has_image={has_image} ref_count={ref_count}", + ); + } + } + } + + // ── Exhaustive matrix ── + + mod exhaustive_matrix { + use super::*; + + // (duration_s, resolution, image_to_video_source, reference_image_count, expected_mills) + const CASES: &[(u32, VideoResolution, bool, usize, u64)] = &[ + // 480p, no input images + ( 1, VideoResolution::FourEightyP, false, 0, 50), + ( 5, VideoResolution::FourEightyP, false, 0, 250), + ( 8, VideoResolution::FourEightyP, false, 0, 400), + (15, VideoResolution::FourEightyP, false, 0, 750), + // 720p, no input images + ( 1, VideoResolution::SevenTwentyP, false, 0, 70), + ( 5, VideoResolution::SevenTwentyP, false, 0, 350), + ( 8, VideoResolution::SevenTwentyP, false, 0, 560), + (15, VideoResolution::SevenTwentyP, false, 0, 1050), + // 480p with image-to-video source (1 input image) + ( 5, VideoResolution::FourEightyP, true, 0, 252), + (10, VideoResolution::FourEightyP, true, 0, 502), + // 720p with image-to-video source (1 input image) + ( 5, VideoResolution::SevenTwentyP, true, 0, 352), + (10, VideoResolution::SevenTwentyP, true, 0, 702), + // 480p with reference_images (no image-to-video) + ( 5, VideoResolution::FourEightyP, false, 1, 252), + ( 5, VideoResolution::FourEightyP, false, 2, 254), + ( 5, VideoResolution::FourEightyP, false, 3, 256), + // 720p with reference_images + ( 5, VideoResolution::SevenTwentyP, false, 1, 352), + ( 5, VideoResolution::SevenTwentyP, false, 2, 354), + ( 5, VideoResolution::SevenTwentyP, false, 3, 356), + ]; + + #[test] + fn all_matrix_cases() { + for &(duration, res, has_image, ref_count, expected) in CASES { + let image = if has_image { Some(url_source()) } else { None }; + let refs = if ref_count > 0 { + Some((0..ref_count).map(|_| url_source()).collect()) + } else { None }; + let req = make_request(Some(duration), Some(res), image, refs); + assert_eq!( + req.calculate_cost_in_mills(), expected, + "duration={duration} res={res:?} has_image={has_image} ref_count={ref_count}", + ); + } + } + } + + // ── Monotonicity ── + + mod monotonicity { + use super::*; + + #[test] + fn longer_duration_costs_more() { + let short = make_request(Some(1), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills(); + let long = make_request(Some(15), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills(); + assert!(short < long); + } + + #[test] + fn higher_resolution_costs_more() { + let p480 = make_request(Some(8), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills(); + let p720 = make_request(Some(8), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + assert!(p480 < p720); + } + + #[test] + fn more_reference_images_cost_more() { + let zero = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + let three = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, + Some(vec![url_source(), url_source(), url_source()])).calculate_cost_in_mills(); + assert!(zero < three); + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs new file mode 100644 index 0000000000..9509b24208 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs @@ -0,0 +1,3 @@ +pub mod cost; +pub mod video_generation; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/request_types.rs new file mode 100644 index 0000000000..97d3559fd0 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/request_types.rs @@ -0,0 +1,49 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct VideoGenerationRequestBody { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Image-to-video: single source image. Mutually exclusive with + /// `reference_images`. + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + + /// Reference-to-video: array of reference images. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_images: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// xAI image/video reference — exactly one of `url` or `file_id` is set. +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoImageRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoGenerationResponseBody { + pub request_id: String, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/video_generation.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/video_generation.rs new file mode 100644 index 0000000000..1e7607225f --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/video_generation.rs @@ -0,0 +1,516 @@ +use log::{info, warn}; +use serde_derive::Serialize; + +use crate::api::requests::videos::video_generation::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::api::types::video_types::video_aspect_ratio::VideoAspectRatio; +use crate::api::types::video_types::video_model::VideoModel; +use crate::api::types::video_types::video_resolution::VideoResolution; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; + +// ── Public args ── + +/// Top-level argument to [`video_generation`]. Borrows the API key separately +/// from the request body so callers can log/save [`VideoGenerationRequest`] +/// without leaking the credential. +#[derive(Clone, Debug)] +pub struct VideoGenerationArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: VideoGenerationRequest, +} + +/// The material part of a video-generation request. Derives [`Serialize`] so +/// it can be persisted to a log or audit store independently of the API key. +#[derive(Clone, Debug, Serialize)] +pub struct VideoGenerationRequest { + /// Text prompt. Required. + pub prompt: String, + + /// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`. + /// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Image-to-video: a single source image. Mutually exclusive with + /// `reference_images` — supplying both returns a `BadRequest` before the + /// HTTP call. + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + + /// Reference-to-video: zero or more reference images. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_images: Option>, + + /// Aspect ratio. See [`VideoAspectRatio`] for the closed set of accepted + /// values (7 ratios — note the video endpoint accepts fewer than image + /// endpoints). Server default is `16:9` when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Duration in seconds (1–15). xAI default is 8. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Output resolution tier. See [`VideoResolution`] (`480p` or `720p`). + /// Server default is `480p` when `None`. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// Source image for `image` (image-to-video) or `reference_images` +/// (reference-to-video). +#[derive(Clone, Debug, Serialize)] +pub enum VideoImageSource { + /// Either a public HTTPS URL or a `data:` URI containing base64-encoded + /// image bytes. + Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::api::requests::files::upload_file::upload_file::upload_file`]. The file + /// must still exist at request time. + /// + /// Docs: + /// - + /// - + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoGenerationSuccess { + /// Use this with `video_status::video_status` to poll for completion. + pub request_id: String, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/videos/generations — start a video generation +/// job. The video is rendered asynchronously; poll +/// `video_status::video_status(request_id)` until it returns `done` or +/// `failed`. +/// +/// Docs: +/// - +/// - +/// - +pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result { + let req = args.request; + + if req.image.is_some() && req.reference_images.as_ref().is_some_and(|v| !v.is_empty()) { + return Err(GrokClientError::InvalidRequest( + "video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(), + ).into()); + } + + let url = format!("{}/v1/videos/generations", XAI_API_BASE_URL); + let model = req.model.unwrap_or(VideoModel::GrokImagineVideo); + + info!( + "Grok video_generation: model={}, has_image={}, ref_imgs={}, aspect_ratio={:?}, duration={:?}, resolution={:?}", + model.as_str(), + req.image.is_some(), + req.reference_images.as_ref().map(|v| v.len()).unwrap_or(0), + req.aspect_ratio.map(|a| a.as_str()), + req.duration, + req.resolution.map(|r| r.as_str()), + ); + + let request_body = VideoGenerationRequestBody { + prompt: req.prompt, + model: Some(model.as_str().to_string()), + image: req.image.as_ref().map(to_video_image_ref), + reference_images: req.reference_images.map(|v| v.iter().map(to_video_image_ref).collect()), + aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()), + duration: req.duration, + resolution: req.resolution.map(|r| r.as_str().to_string()), + user: req.user, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + // DEBUG: log exactly what we send (request body has no secrets — the API key + // travels in the Authorization header, not the body). Helps confirm the wire + // shape when diagnosing 4xx responses. + match serde_json::to_string(&request_body) { + Ok(body_json) => info!("Grok video_generation request: POST {url} body={body_json}"), + Err(err) => warn!("Grok video_generation: failed to serialize request body for logging: {err}"), + } + + let response = client.post(&url) + .header("Authorization", bearer) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + // Capture headers BEFORE `.text()` consumes the response. Response headers + // reveal WAF/CDN blocks (e.g. `server: cloudflare`, `cf-ray`, `cf-mitigated`) + // vs. genuine API errors, and any rate-limit / retry hints. + let response_headers = format!("{:#?}", response.headers()); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_generation response: status={}", status); + + if !status.is_success() { + // The response body now travels in the returned error (`raw_http_body`), + // so log the HEADERS here — those aren't captured in the error type and + // reveal CDN/WAF context (e.g. `server: cloudflare`, `cf-ray`) vs. a + // genuine API rejection. + warn!( + "Grok video_generation FAILED: status={status} ({} body bytes)\n--- response headers ---\n{response_headers}", + response_body.len(), + ); + } + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(VideoGenerationSuccess { request_id: parsed.request_id }) +} + +fn to_video_image_ref(source: &VideoImageSource) -> VideoImageRef { + match source { + VideoImageSource::Url(u) => VideoImageRef { url: Some(u.clone()), file_id: None }, + VideoImageSource::FileId(id) => VideoImageRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use test_data::web::image_urls::{SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL}; + + // ── Wire-format shape tests ── + + #[test] + fn wire_body_serializes_text_only() { + let body = VideoGenerationRequestBody { + prompt: "a cat dancing".to_string(), + model: Some("grok-imagine-video".to_string()), + image: None, + reference_images: None, + aspect_ratio: Some("16:9".to_string()), + duration: Some(5), + resolution: Some("720p".to_string()), + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"a cat dancing\"")); + assert!(json.contains("\"duration\":5")); + assert!(json.contains("\"resolution\":\"720p\"")); + assert!(!json.contains("\"image\"")); + assert!(!json.contains("\"reference_images\"")); + } + + #[test] + fn wire_body_serializes_image_to_video() { + let body = VideoGenerationRequestBody { + prompt: "animate this".to_string(), + model: None, + image: Some(VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }), + reference_images: None, + aspect_ratio: None, + duration: None, + resolution: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"image\":{")); + assert!(json.contains("\"url\":\"https://example.com/a.png\"")); + } + + #[test] + fn wire_body_serializes_reference_to_video() { + let body = VideoGenerationRequestBody { + prompt: " walking".to_string(), + model: None, + image: None, + reference_images: Some(vec![ + VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }, + VideoImageRef { url: None, file_id: Some("file_xyz".to_string()) }, + ]), + aspect_ratio: None, + duration: None, + resolution: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"reference_images\":[")); + assert!(json.contains("\"file_id\":\"file_xyz\"")); + } + + // ── Public Request shape ── + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = VideoGenerationArgs { + api_key: &key, + request: VideoGenerationRequest { + prompt: "p".to_string(), + model: Some(VideoModel::GrokImagineVideo), + image: Some(VideoImageSource::Url("u".to_string())), + reference_images: None, + aspect_ratio: Some(VideoAspectRatio::Landscape16x9), + duration: Some(8), + resolution: Some(VideoResolution::SevenTwentyP), + user: None, + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak"), + "serialized request must not contain the API key. got: {}", json); + assert!(json.contains("\"prompt\":\"p\"")); + assert!(json.contains("\"model\":\"grok-imagine-video\"")); + assert!(json.contains("\"image\":{\"Url\":\"u\"}")); + assert!(json.contains("\"aspect_ratio\":\"16:9\"")); + assert!(json.contains("\"resolution\":\"720p\"")); + } + + #[tokio::test] + async fn image_plus_reference_images_returns_bad_request() { + let api_key = GrokApiKey::new("dummy".to_string()); + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "x".to_string(), + model: None, + image: Some(VideoImageSource::Url("u".to_string())), + reference_images: Some(vec![VideoImageSource::Url("v".to_string())]), + aspect_ratio: None, + duration: None, + resolution: None, + user: None, + }, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_)))); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{ "request_id": "d97415a1-5796-b7ec-379f-4e6819e08fdf" }"#; + let parsed: VideoGenerationResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.request_id, "d97415a1-5796-b7ec-379f-4e6819e08fdf"); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_generation_text_only() -> AnyhowResult<()> { + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "A glowing crystal rocket launching from Mars".to_string(), + model: None, + image: None, + reference_images: None, + aspect_ratio: Some(VideoAspectRatio::Landscape16x9), + duration: Some(5), + resolution: Some(VideoResolution::FourEightyP), + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + + Ok(()) + } + + /// First-frame-to-video: a single source image becomes the opening frame + /// and the prompt drives the animation. Uses `image` (NOT + /// `reference_images`) — that's the xAI distinction between + /// image-to-video and reference-to-video. + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_generation_first_frame_to_video() -> AnyhowResult<()> { + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "The camera slowly pushes in toward the building as the sun sinks below the horizon. Soft golden light, gentle breeze rustling the trees.".to_string(), + model: None, + image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())), + reference_images: None, + aspect_ratio: Some(VideoAspectRatio::Landscape16x9), + duration: Some(5), + resolution: Some(VideoResolution::SevenTwentyP), + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("First-frame video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + /// Reference-to-video: multiple source images influence the generated + /// video. xAI accepts up to 3. The prompt references them by index using + /// ``, ``, … placeholders per xAI's reference-to-video + /// docs. + /// + /// Different aspect ratio than the first-frame test (portrait 9:16 + /// instead of landscape 16:9) so the two live tests don't accidentally + /// share a code path. + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_generation_reference_images() -> AnyhowResult<()> { + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "The dogs from in the scene from . Make them play together.".to_string(), + model: None, + image: None, + reference_images: Some(vec![ + VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()), + VideoImageSource::Url(SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL.to_string()), + ]), + aspect_ratio: Some(VideoAspectRatio::Portrait9x16), + duration: Some(5), + resolution: Some(VideoResolution::SevenTwentyP), + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Reference-to-video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── grok-imagine-video-1.5-preview live API tests ── + // + // All three exercise the v1.5 preview model at 480p / 5s. They're + // `#[ignore]` because they hit xAI's billed endpoint — run them with + // `cargo test -p grok_api_client -- --ignored grok_imagine_1p5`. xAI's + // model docs page advertises `text, image → video` for this model, so + // text-to-video SHOULD work in principle even though earlier ad-hoc curl + // experiments returned a "Text-to-video is not supported" 400 — these + // tests are the source of truth for what xAI actually accepts today. + + mod grok_imagine_1p5_tests { + use super::*; + + const TEST_DURATION_SECONDS: u32 = 5; + const TEST_RESOLUTION: VideoResolution = VideoResolution::FourEightyP; + + /// Text-to-video at 480p / 5s. The only image-related fields are + /// omitted, so this is a pure text-only request. + #[tokio::test] + #[ignore] // costs money — run manually + async fn live_test_grok_imagine_1p5_text_to_video() -> AnyhowResult<()> { + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "Timelapse of a flower blooming in a sunlit garden.".to_string(), + model: Some(VideoModel::GrokImagineVideo1p5), + image: None, + reference_images: None, + aspect_ratio: Some(VideoAspectRatio::Landscape16x9), + duration: Some(TEST_DURATION_SECONDS), + resolution: Some(TEST_RESOLUTION), + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("v1.5 text-to-video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + /// Image-to-video (keyframe) at 480p / 5s. The source image becomes the + /// opening frame and the prompt drives the animation. + #[tokio::test] + #[ignore] // costs money — run manually + async fn live_test_grok_imagine_1p5_image_to_video_keyframe() -> AnyhowResult<()> { + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "The camera slowly pushes in toward the building as the sun sinks below the horizon. Soft golden light, gentle breeze rustling the trees.".to_string(), + model: Some(VideoModel::GrokImagineVideo1p5), + image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())), + reference_images: None, + aspect_ratio: Some(VideoAspectRatio::Landscape16x9), + duration: Some(TEST_DURATION_SECONDS), + resolution: Some(TEST_RESOLUTION), + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("v1.5 image-to-video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + /// Reference-images-to-video at 480p / 5s. Two reference images + /// influence the generated video; the prompt references them by index. + /// + /// xAI's v1.5 preview has been observed to reject `reference_images` + /// outright at runtime (despite the docs implying support). This test + /// is the canonical way to detect when xAI changes that behavior. + #[tokio::test] + #[ignore] // costs money — run manually + async fn live_test_grok_imagine_1p5_reference_images_to_video() -> AnyhowResult<()> { + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key: &api_key, + request: VideoGenerationRequest { + prompt: "The dogs from in the scene from . Make them play together.".to_string(), + model: Some(VideoModel::GrokImagineVideo1p5), + image: None, + reference_images: Some(vec![ + VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()), + VideoImageSource::Url(SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL.to_string()), + ]), + aspect_ratio: Some(VideoAspectRatio::Landscape16x9), + duration: Some(TEST_DURATION_SECONDS), + resolution: Some(TEST_RESOLUTION), + user: None, + }, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("v1.5 reference-to-video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/mod.rs new file mode 100644 index 0000000000..3ce4f7995e --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/mod.rs @@ -0,0 +1,2 @@ +pub mod video_status; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/request_types.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/request_types.rs new file mode 100644 index 0000000000..6c59eeefc9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/request_types.rs @@ -0,0 +1,34 @@ +use serde_derive::Deserialize; + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusResponseBody { + pub status: String, + + pub progress: Option, + + pub model: Option, + + pub video: Option, + + pub error: Option, + + pub usage: Option, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusVideo { + pub url: Option, + pub duration: Option, + pub respect_moderation: Option, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusError { + pub code: String, + pub message: String, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusUsage { + pub cost_in_usd_ticks: Option, +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs new file mode 100644 index 0000000000..5e37599c1a --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs @@ -0,0 +1,562 @@ +use log::info; +use serde_derive::Serialize; +use serde_json::Value as JsonValue; + +use crate::api::requests::videos::video_status::request_types::*; +use crate::api::requests::xai_host::XAI_API_BASE_URL; +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::{body_indicates_moderation, classify_grok_http_error}; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; + +// ── Public args ── + +/// Top-level argument to [`video_status`]. Borrows the API key separately from +/// the request body so callers can log/save [`VideoStatusRequest`] without +/// leaking the credential. +#[derive(Clone, Debug)] +pub struct VideoStatusArgs<'a> { + pub api_key: &'a GrokApiKey, + pub request: VideoStatusRequest, +} + +/// The material part of a video-status poll. Derives [`Serialize`] so it +/// can be persisted to a log or audit store independently of the API key. +#[derive(Clone, Debug, Serialize)] +pub struct VideoStatusRequest { + /// The `request_id` returned by a prior `video_generation`, + /// `video_edit`, or `video_extension` call. + pub request_id: String, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoStatusResponse { + pub status: VideoStatus, +} + +/// The lifecycle state of a video job. `Failed` is a *status*, not an error — +/// xAI accepted the job and (usually) billed for it, but didn't produce a +/// usable video. +#[derive(Debug, Clone)] +pub enum VideoStatus { + /// Still working. `progress` is 0–100 when xAI provides it. + Pending { + progress: Option, + }, + /// Finished successfully — `video.url` is typically populated. + Complete { + model: Option, + video: Option, + cost_in_usd_ticks: Option, + }, + /// xAI couldn't render this. `reason` is our best classification; the + /// remaining fields surface whatever xAI told us so the caller can log, + /// mark the row failed, and (where applicable) refund / surface a + /// user-facing message. + Failed { + reason: FailureReason, + code: Option, + error: Option, + cost_in_usd_ticks: Option, + full_error_json_payload: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FailureReason { + /// xAI's safety filter rejected the prompt or the generated video. + ContentModerated, + /// Anything else: invalid argument we can't recover from, internal error, + /// expired job, etc. + Unknown, +} + +#[derive(Debug, Clone)] +pub struct VideoOutputInfo { + /// Temporary xAI-hosted URL, or the caller-supplied upload URL once xAI has + /// finished uploading. + pub url: Option, + pub duration: Option, + pub respect_moderation: Option, +} + +// ── Implementation ── + +/// GET https://api.x.ai/v1/videos/{request_id} — poll a video generation / +/// edit / extension job for its current status. +/// +/// Returns `Ok(VideoStatusResponse)` for any well-formed xAI response, +/// including jobs that failed or were moderated — those come back as +/// `VideoStatus::Failed`. Only infrastructure problems (auth, network, 404 +/// for unknown request_id, 5xx) bubble up as `Err(GrokError)`. +/// +/// Docs: +pub async fn video_status(args: VideoStatusArgs<'_>) -> Result { + let req = args.request; + let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, req.request_id); + + info!("Grok video_status: request_id={}", req.request_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.get(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let http_status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_status response: http_status={}", http_status); + + if http_status.is_success() { + let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + return classify_status_field(&parsed, &response_body); + } + + // Non-2xx. xAI returns 4xx for some failure modes that are really "the job + // failed" rather than "something is wrong with your request" — most + // notably content moderation. Recognize those and return them as a Failed + // status so callers don't have to thread an error path. + if body_indicates_moderation(&response_body) { + return Ok(build_moderated_failed_response(&response_body)); + } + + // Anything else (401, 402, 404, 429, plain 400/403, 5xx) is a real error. + Err(unwrap_classify_err(classify_grok_http_error(http_status, Some(&response_body)))) +} + +fn classify_status_field( + parsed: &VideoStatusResponseBody, + raw_body: &str, +) -> Result { + let cost = parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks); + + match parsed.status.as_str() { + "pending" | "queued" | "in_progress" | "processing" => Ok(VideoStatusResponse { + status: VideoStatus::Pending { progress: parsed.progress }, + }), + "done" | "completed" | "succeeded" => { + let video = parsed.video.as_ref().map(|v| VideoOutputInfo { + url: v.url.clone(), + duration: v.duration, + respect_moderation: v.respect_moderation, + }); + Ok(VideoStatusResponse { + status: VideoStatus::Complete { + model: parsed.model.clone(), + video, + cost_in_usd_ticks: cost, + }, + }) + } + "failed" => { + let code = parsed.error.as_ref().map(|e| e.code.clone()); + let error_msg = parsed.error.as_ref().map(|e| e.message.clone()); + let reason = if text_indicates_moderation(error_msg.as_deref()) + || body_indicates_moderation(raw_body) + { + FailureReason::ContentModerated + } else { + FailureReason::Unknown + }; + Ok(VideoStatusResponse { + status: VideoStatus::Failed { + reason, + code, + error: error_msg, + cost_in_usd_ticks: cost, + full_error_json_payload: Some(raw_body.to_string()), + }, + }) + } + "expired" => Ok(VideoStatusResponse { + status: VideoStatus::Failed { + reason: FailureReason::Unknown, + code: Some("expired".to_string()), + error: Some("video job expired".to_string()), + cost_in_usd_ticks: cost, + full_error_json_payload: Some(raw_body.to_string()), + }, + }), + other => Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: reqwest::StatusCode::OK, + body: format!("unknown video status: {:?}", other), + }.into()), + } +} + +fn build_moderated_failed_response(raw_body: &str) -> VideoStatusResponse { + let (code, error_msg, cost) = parse_error_body(raw_body); + VideoStatusResponse { + status: VideoStatus::Failed { + reason: FailureReason::ContentModerated, + code, + error: error_msg, + cost_in_usd_ticks: cost, + full_error_json_payload: Some(raw_body.to_string()), + }, + } +} + +/// Best-effort extraction of `{code, error, usage.cost_in_usd_ticks}` from +/// the two error-body shapes xAI uses: +/// +/// 1. Status-poll 4xx: +/// `{"code":"...", "error":"", "usage":{"cost_in_usd_ticks":...}}` +/// 2. OpenAI-compatible: +/// `{"error":{"code":"...", "message":"..."}}` +fn parse_error_body(body: &str) -> (Option, Option, Option) { + let parsed: JsonValue = match serde_json::from_str(body) { + Ok(v) => v, + Err(_) => return (None, None, None), + }; + + let top_level_code = parsed.get("code") + .and_then(JsonValue::as_str) + .map(str::to_string); + + let (error_msg, nested_code) = match parsed.get("error") { + Some(JsonValue::String(s)) => (Some(s.clone()), None), + Some(obj @ JsonValue::Object(_)) => { + let msg = obj.get("message").and_then(JsonValue::as_str).map(str::to_string); + let nested = obj.get("code").and_then(JsonValue::as_str).map(str::to_string); + (msg, nested) + } + _ => (None, None), + }; + + let code = top_level_code.or(nested_code); + + let cost = parsed.get("usage") + .and_then(|u| u.get("cost_in_usd_ticks")) + .and_then(JsonValue::as_u64); + + (code, error_msg, cost) +} + +fn text_indicates_moderation(maybe_text: Option<&str>) -> bool { + maybe_text.map(body_indicates_moderation).unwrap_or(false) +} + +/// `classify_grok_http_error` always returns `Err` when the status is non-2xx, +/// so this is infallible in our call path. Spelled out for clarity at the call +/// site rather than `.unwrap_err()` on the inline expression. +fn unwrap_classify_err(result: Result<(), GrokError>) -> GrokError { + match result { + Ok(()) => GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: reqwest::StatusCode::OK, + body: "classify_grok_http_error returned Ok for a non-2xx response".to_string(), + }.into(), + Err(err) => err, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + use crate::error::grok_specific_api_error::GrokSpecificApiError; + + // ── Status-field dispatch (HTTP 200) ── + + #[test] + fn pending_response_classifies_as_pending() { + let json = r#"{ "status": "pending", "progress": 12, "model": "grok-imagine-video" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + match result.status { + VideoStatus::Pending { progress } => assert_eq!(progress, Some(12)), + other => panic!("expected Pending, got: {:?}", other), + } + } + + #[test] + fn pending_response_with_v1p5_model_does_not_break_dispatch() { + // `model` doesn't surface on Pending; it flows through on Complete only. + let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + assert!(matches!(result.status, VideoStatus::Pending { .. })); + } + + #[test] + fn done_response_classifies_as_complete_with_video() { + let json = r#"{ + "status": "done", + "progress": 100, + "model": "grok-imagine-video", + "video": { "url": "https://imagine.x.ai/v.mp4", "duration": 5, "respect_moderation": true }, + "usage": { "cost_in_usd_ticks": 42 } + }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + match result.status { + VideoStatus::Complete { model, video, cost_in_usd_ticks } => { + assert_eq!(model.as_deref(), Some("grok-imagine-video")); + let video = video.unwrap(); + assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4")); + assert_eq!(video.duration, Some(5)); + assert_eq!(video.respect_moderation, Some(true)); + assert_eq!(cost_in_usd_ticks, Some(42)); + } + other => panic!("expected Complete, got: {:?}", other), + } + } + + #[test] + fn failed_response_returns_ok_failed_unknown() { + let json = r#"{ + "status": "failed", + "progress": 50, + "error": { "code": "invalid_argument", "message": "duration too long" }, + "usage": { "cost_in_usd_ticks": 7 } + }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + match result.status { + VideoStatus::Failed { reason, code, error, cost_in_usd_ticks, full_error_json_payload } => { + assert_eq!(reason, FailureReason::Unknown); + assert_eq!(code.as_deref(), Some("invalid_argument")); + assert_eq!(error.as_deref(), Some("duration too long")); + assert_eq!(cost_in_usd_ticks, Some(7)); + assert_eq!(full_error_json_payload.as_deref(), Some(json)); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + #[test] + fn failed_response_with_moderation_message_is_classified_as_content_moderated() { + let json = r#"{ + "status": "failed", + "error": { "code": "permission_denied", "message": "Video rejected by safety moderation" }, + "usage": { "cost_in_usd_ticks": 11 } + }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + match result.status { + VideoStatus::Failed { reason, code, cost_in_usd_ticks, .. } => { + assert_eq!(reason, FailureReason::ContentModerated); + assert_eq!(code.as_deref(), Some("permission_denied")); + assert_eq!(cost_in_usd_ticks, Some(11)); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + #[test] + fn failed_response_without_error_object_yields_none_code_and_error() { + let json = r#"{ "status": "failed", "progress": 0 }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + match result.status { + VideoStatus::Failed { reason, code, error, .. } => { + assert_eq!(reason, FailureReason::Unknown); + assert!(code.is_none()); + assert!(error.is_none()); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + #[test] + fn expired_response_classifies_as_failed_unknown() { + let json = r#"{ "status": "expired", "usage": { "cost_in_usd_ticks": 3 } }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed, json).unwrap(); + match result.status { + VideoStatus::Failed { reason, code, cost_in_usd_ticks, .. } => { + assert_eq!(reason, FailureReason::Unknown); + assert_eq!(code.as_deref(), Some("expired")); + assert_eq!(cost_in_usd_ticks, Some(3)); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + #[test] + fn unknown_status_field_returns_generic_error() { + let json = r#"{ "status": "magnificent" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let err = classify_status_field(&parsed, json).unwrap_err(); + assert!(matches!(err, GrokError::ApiGeneric(_))); + } + + // ── Error-body dispatch (HTTP 4xx) ── + + #[test] + fn moderation_4xx_body_becomes_failed_content_moderated() { + // The real-world body that previously surfaced as + // `Err(GrokSpecificApiError::PromptModerated { .. })`. It should now be + // an Ok(Failed { ContentModerated }) with all fields populated from + // the JSON. + let body = r#"{"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation.","usage":{"cost_in_usd_ticks":11300000000}}"#; + let response = build_moderated_failed_response(body); + match response.status { + VideoStatus::Failed { reason, code, error, cost_in_usd_ticks, full_error_json_payload } => { + assert_eq!(reason, FailureReason::ContentModerated); + assert_eq!(code.as_deref(), Some("Client specified an invalid argument")); + assert_eq!(error.as_deref(), Some("Generated video rejected by content moderation.")); + assert_eq!(cost_in_usd_ticks, Some(11_300_000_000)); + assert_eq!(full_error_json_payload.as_deref(), Some(body)); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + #[test] + fn moderation_openai_shape_body_extracts_nested_code_and_message() { + let body = r#"{"error":{"code":"content_policy","message":"Content moderation blocked this prompt"}}"#; + let response = build_moderated_failed_response(body); + match response.status { + VideoStatus::Failed { reason, code, error, .. } => { + assert_eq!(reason, FailureReason::ContentModerated); + assert_eq!(code.as_deref(), Some("content_policy")); + assert_eq!(error.as_deref(), Some("Content moderation blocked this prompt")); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + #[test] + fn moderation_unparseable_body_still_yields_failed_with_payload() { + let body = "totally not json but mentions moderation"; + let response = build_moderated_failed_response(body); + match response.status { + VideoStatus::Failed { reason, code, error, full_error_json_payload, .. } => { + assert_eq!(reason, FailureReason::ContentModerated); + assert!(code.is_none()); + assert!(error.is_none()); + assert_eq!(full_error_json_payload.as_deref(), Some(body)); + } + other => panic!("expected Failed, got: {:?}", other), + } + } + + // ── Public Request shape ── + + #[test] + fn request_serializes_without_api_key() { + let key = GrokApiKey::new("secret_must_not_leak".to_string()); + let args = VideoStatusArgs { + api_key: &key, + request: VideoStatusRequest { + request_id: "req_abc".to_string(), + }, + }; + let json = serde_json::to_string(&args.request).unwrap(); + assert!(!json.contains("secret_must_not_leak")); + assert!(json.contains("\"request_id\":\"req_abc\"")); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key + an existing request_id + async fn live_test_video_status_unknown_id_is_not_found() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let api_key = get_test_api_key()?; + // Random UUID — should yield 404. + let result = video_status(VideoStatusArgs { + api_key: &api_key, + request: VideoStatusRequest { + request_id: "00000000-0000-0000-0000-000000000000".to_string(), + }, + }).await; + + println!("Result: {:?}", result.as_ref().map(|r| &r.status)); + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })), + "expected NotFound, got: {:?}", err); + Ok(()) + } + + /// Polls a known request_id and prints the result. Doesn't assert a + /// specific status — depending on when the test runs, the job may be + /// pending, complete, or failed. The point is to exercise the + /// happy-path against the real xAI API. + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn live_test_video_status_known_id() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_status(VideoStatusArgs { + api_key: &api_key, + request: VideoStatusRequest { + request_id: "ce681bd0-133d-9cf3-975c-422d292d4e8e".to_string(), + }, + }).await; + + match &result { + Ok(r) => println!("status: {:?}", r.status), + Err(e) => println!("status error: {:?}", e), + } + Ok(()) + } + + /// Polls another known request_id. Same lax assertions as the test above + /// — status varies with time. + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn live_test_video_status_known_id_2() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_status(VideoStatusArgs { + api_key: &api_key, + request: VideoStatusRequest { + request_id: "7b2fa3dc-8e63-9100-b117-faffb3178773".to_string(), + }, + }).await; + + match &result { + Ok(r) => println!("status: {:?}", r.status), + Err(e) => println!("status error: {:?}", e), + } + Ok(()) + } + + /// Polls yet another known request_id. + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn live_test_video_status_known_id_3() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let api_key = get_test_api_key()?; + let result = video_status(VideoStatusArgs { + api_key: &api_key, + request: VideoStatusRequest { + request_id: "4d4a6185-209e-95f3-942b-510042637839".to_string(), + }, + }).await; + + match &result { + Ok(r) => println!("status: {:?}", r.status), + Err(e) => println!("status error: {:?}", e), + } + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/xai_host.rs b/crates/api_clients/grok_api_client/src/api/requests/xai_host.rs new file mode 100644 index 0000000000..3aeefe4244 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/xai_host.rs @@ -0,0 +1,2 @@ +/// Base URL for the xAI Imagine API. Every request is `{XAI_API_BASE_URL}/v1/...`. +pub(crate) const XAI_API_BASE_URL: &str = "https://api.x.ai"; diff --git a/crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs b/crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs new file mode 100644 index 0000000000..a5f2ee32aa --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs @@ -0,0 +1,36 @@ +//! Cost-estimation trait for xAI (Grok) API requests. +//! +//! Implementations live next to each Request type in +//! `api/requests///cost.rs`. Implemented on the public +//! `*Request` type (NOT the `*Args` wrapper), since the request body is what +//! drives the bill. +//! +//! All math is performed in mills (tenths of a US cent), which is the +//! natural unit for xAI's published rates — an image edit at the cheap tier +//! is $0.002/img = 2 mills, well below cent resolution. + +/// Cost in US cents (1¢ = 1 unit). +pub type UsdCents = u64; + +/// Cost in mills (1¢ = 10 mills). Use this when sub-cent precision matters +/// — many xAI rates are below 1 cent. +pub type UsdMills = u64; + +/// Implemented on `*Request` types to estimate the cost of a Grok API call +/// without actually making it. +/// +/// The estimate is derived from fields present in the request body. Some +/// endpoints (notably `video_edit` and the input-cost portion of +/// `video_extension`) depend on the source video's duration, which is NOT +/// part of the request. Those impls document their limitations. +pub trait GrokRequestCostCalculator { + /// Estimated cost rounded up to the nearest whole cent. + fn calculate_cost_in_cents(&self) -> UsdCents { + // Default impl: derive from mills via ceiling division. + self.calculate_cost_in_mills().div_ceil(10) + } + + /// Estimated cost in mills (tenths of a cent). This is the canonical + /// method — implementations should override this one. + fn calculate_cost_in_mills(&self) -> UsdMills; +} diff --git a/crates/api_clients/grok_api_client/src/api/traits/mod.rs b/crates/api_clients/grok_api_client/src/api/traits/mod.rs new file mode 100644 index 0000000000..db0afc653c --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/traits/mod.rs @@ -0,0 +1 @@ +pub mod grok_request_cost_calculator_trait; diff --git a/crates/api_clients/grok_api_client/src/api/types/image_types/image_aspect_ratio.rs b/crates/api_clients/grok_api_client/src/api/types/image_types/image_aspect_ratio.rs new file mode 100644 index 0000000000..b8129d9270 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/image_types/image_aspect_ratio.rs @@ -0,0 +1,128 @@ +/// Aspect ratios accepted by the xAI image endpoints. +/// +/// The wire format is `":"` — e.g. `Square` serialises as +/// `"1:1"`, `Cinematic20x9` as `"20:9"`. `Auto` defers to xAI to pick. +/// +/// xAI documents this closed set; see +/// . +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ImageAspectRatio { + /// 1:1 square. + Square, + + /// 3:4 portrait (slight tall). + Portrait3x4, + + /// 4:3 landscape (slight wide). + Landscape4x3, + + /// 9:16 portrait (phone/vertical-video). + Portrait9x16, + + /// 16:9 landscape (widescreen / YouTube). + Landscape16x9, + + /// 2:3 portrait (photo). + Portrait2x3, + + /// 3:2 landscape (photo). + Landscape3x2, + + /// 9:19.5 portrait (modern phone screens). + Portrait9x19_5, + + /// 19.5:9 landscape (modern phone screens, rotated). + Landscape19_5x9, + + /// 9:20 portrait (taller phone). + Portrait9x20, + + /// 20:9 landscape (taller phone, rotated). + Landscape20x9, + + /// 1:2 ultra-portrait. + Portrait1x2, + + /// 2:1 ultra-landscape. + Landscape2x1, + + /// Let xAI pick the aspect ratio (typically matches the source image for edits). + Auto, +} + +impl ImageAspectRatio { + /// Wire representation — the exact string xAI expects in the + /// `"aspect_ratio"` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::Square => "1:1", + Self::Portrait3x4 => "3:4", + Self::Landscape4x3 => "4:3", + Self::Portrait9x16 => "9:16", + Self::Landscape16x9 => "16:9", + Self::Portrait2x3 => "2:3", + Self::Landscape3x2 => "3:2", + Self::Portrait9x19_5 => "9:19.5", + Self::Landscape19_5x9 => "19.5:9", + Self::Portrait9x20 => "9:20", + Self::Landscape20x9 => "20:9", + Self::Portrait1x2 => "1:2", + Self::Landscape2x1 => "2:1", + Self::Auto => "auto", + } + } +} + +// Serialize as the wire string ("1:1", "16:9", "auto", …) rather than the +// default external-tag enum form. Lets request structs round-trip cleanly +// through a log/audit pipeline. +impl serde::Serialize for ImageAspectRatio { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_variant_maps_to_a_distinct_string() { + let all = [ + ImageAspectRatio::Square, + ImageAspectRatio::Portrait3x4, + ImageAspectRatio::Landscape4x3, + ImageAspectRatio::Portrait9x16, + ImageAspectRatio::Landscape16x9, + ImageAspectRatio::Portrait2x3, + ImageAspectRatio::Landscape3x2, + ImageAspectRatio::Portrait9x19_5, + ImageAspectRatio::Landscape19_5x9, + ImageAspectRatio::Portrait9x20, + ImageAspectRatio::Landscape20x9, + ImageAspectRatio::Portrait1x2, + ImageAspectRatio::Landscape2x1, + ImageAspectRatio::Auto, + ]; + let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect(); + strs.sort(); + strs.dedup(); + assert_eq!(strs.len(), 14, "expected 14 distinct wire values, got {:?}", strs); + } + + #[test] + fn matches_docs_strings() { + assert_eq!(ImageAspectRatio::Square.as_str(), "1:1"); + assert_eq!(ImageAspectRatio::Landscape16x9.as_str(), "16:9"); + assert_eq!(ImageAspectRatio::Portrait9x19_5.as_str(), "9:19.5"); + assert_eq!(ImageAspectRatio::Landscape19_5x9.as_str(), "19.5:9"); + assert_eq!(ImageAspectRatio::Auto.as_str(), "auto"); + } + + #[test] + fn serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&ImageAspectRatio::Square).unwrap(), "\"1:1\""); + assert_eq!(serde_json::to_string(&ImageAspectRatio::Portrait9x19_5).unwrap(), "\"9:19.5\""); + assert_eq!(serde_json::to_string(&ImageAspectRatio::Auto).unwrap(), "\"auto\""); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/types/image_types/image_model.rs b/crates/api_clients/grok_api_client/src/api/types/image_types/image_model.rs new file mode 100644 index 0000000000..6db84c74aa --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/image_types/image_model.rs @@ -0,0 +1,96 @@ +/// Models accepted by the xAI image endpoints (`/v1/images/generations` and +/// `/v1/images/edits`). Both endpoints accept all variants here. +/// +/// xAI may add or deprecate models faster than this crate ships releases, so +/// the [`ImageModel::Custom`] escape hatch lets callers pass an arbitrary +/// identifier without waiting for a code change. +/// +/// Docs: +/// - Models list: +/// - +/// - +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ImageModel { + /// `grok-imagine-image` — the standard image model. $0.02 / image as of + /// the docs at time of writing; lower cost and faster than the `-quality` + /// variant, at the expense of fidelity. + GrokImagineImage, + + /// `grok-imagine-image-quality` — the higher-quality image model. $0.05 / + /// image as of the docs at time of writing. xAI recommends this as the + /// default for new requests. + GrokImagineImageQuality, + + /// Escape hatch for model identifiers not yet enumerated here. + /// + /// Useful for forward compat and for legacy/deprecated identifiers (e.g. + /// `grok-imagine-image-pro`, deprecated as of 2026-05-15) that still + /// function until xAI removes them. + Custom(String), +} + +impl ImageModel { + /// Wire representation — the exact string xAI expects in the `"model"` field. + pub fn as_str(&self) -> &str { + match self { + Self::GrokImagineImage => "grok-imagine-image", + Self::GrokImagineImageQuality => "grok-imagine-image-quality", + Self::Custom(s) => s.as_str(), + } + } +} + +// Serialize as the wire string ("grok-imagine-image", "grok-imagine-image-quality", +// or the Custom inner string) rather than the default external-tag enum format. +// Lets `ImageGenerationRequest` / `ImageEditRequest` round-trip through any +// log/audit pipeline as readable JSON. +impl serde::Serialize for ImageModel { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_model_serializes() { + assert_eq!(ImageModel::GrokImagineImage.as_str(), "grok-imagine-image"); + } + + #[test] + fn quality_model_serializes() { + assert_eq!(ImageModel::GrokImagineImageQuality.as_str(), "grok-imagine-image-quality"); + } + + #[test] + fn custom_model_passes_through() { + let m = ImageModel::Custom("grok-imagine-image-future".to_string()); + assert_eq!(m.as_str(), "grok-imagine-image-future"); + } + + #[test] + fn deprecated_pro_model_still_usable_via_custom() { + let m = ImageModel::Custom("grok-imagine-image-pro".to_string()); + assert_eq!(m.as_str(), "grok-imagine-image-pro"); + } + + #[test] + fn standard_and_quality_are_distinct_strings() { + assert_ne!( + ImageModel::GrokImagineImage.as_str(), + ImageModel::GrokImagineImageQuality.as_str() + ); + } + + #[test] + fn serializes_as_plain_wire_string() { + let m = ImageModel::GrokImagineImageQuality; + assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image-quality\""); + let m = ImageModel::GrokImagineImage; + assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image\""); + let m = ImageModel::Custom("grok-imagine-image-pro".to_string()); + assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image-pro\""); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/types/image_types/image_resolution.rs b/crates/api_clients/grok_api_client/src/api/types/image_types/image_resolution.rs new file mode 100644 index 0000000000..16d237e8f5 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/image_types/image_resolution.rs @@ -0,0 +1,51 @@ +/// Output resolution for xAI image endpoints. +/// +/// xAI's actual pixel counts depend on the chosen `aspect_ratio` — the `1k` +/// and `2k` labels denote the *target* size class (roughly 1024-px and +/// 2048-px on the longest edge). When omitted, xAI falls back to its server +/// default (currently 1k as of these docs). +/// +/// Docs: +/// - +/// - +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ImageResolution { + /// `"1k"` — ~1024-px class (faster, cheaper). + OneK, + /// `"2k"` — ~2048-px class (higher fidelity, slower, more expensive). + TwoK, +} + +impl ImageResolution { + /// Wire representation — the exact string xAI expects in the `"resolution"` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::OneK => "1k", + Self::TwoK => "2k", + } + } +} + +// Serialize as the wire string ("1k", "2k"). +impl serde::Serialize for ImageResolution { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_docs_strings() { + assert_eq!(ImageResolution::OneK.as_str(), "1k"); + assert_eq!(ImageResolution::TwoK.as_str(), "2k"); + } + + #[test] + fn serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&ImageResolution::OneK).unwrap(), "\"1k\""); + assert_eq!(serde_json::to_string(&ImageResolution::TwoK).unwrap(), "\"2k\""); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/types/image_types/image_response_format.rs b/crates/api_clients/grok_api_client/src/api/types/image_types/image_response_format.rs new file mode 100644 index 0000000000..c4a9158325 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/image_types/image_response_format.rs @@ -0,0 +1,48 @@ +/// Wire format for returned images. +/// +/// - `Url` (default) — xAI returns a temporary CDN URL in `data[].url`. +/// - `B64Json` — xAI returns the raw image bytes inlined as base64 in +/// `data[].b64_json`. Use this when you can't / don't want to make a +/// follow-up HTTP request to fetch the image. +/// +/// Docs: +/// - +/// - +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ImageResponseFormat { + Url, + B64Json, +} + +impl ImageResponseFormat { + pub fn as_str(&self) -> &'static str { + match self { + Self::Url => "url", + Self::B64Json => "b64_json", + } + } +} + +// Serialize as the wire string ("url" or "b64_json"). +impl serde::Serialize for ImageResponseFormat { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_docs_strings() { + assert_eq!(ImageResponseFormat::Url.as_str(), "url"); + assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json"); + } + + #[test] + fn serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&ImageResponseFormat::Url).unwrap(), "\"url\""); + assert_eq!(serde_json::to_string(&ImageResponseFormat::B64Json).unwrap(), "\"b64_json\""); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/types/image_types/mod.rs b/crates/api_clients/grok_api_client/src/api/types/image_types/mod.rs new file mode 100644 index 0000000000..93ae060304 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/image_types/mod.rs @@ -0,0 +1,10 @@ +//! Shared image-endpoint enums (model, aspect ratio, resolution, response format). +//! +//! These are reused by both [`crate::api::requests::image_generation`] and +//! [`crate::api::requests::image_edit`] so callers can pass type-safe values +//! instead of memorising the allowed strings. + +pub mod image_aspect_ratio; +pub mod image_model; +pub mod image_resolution; +pub mod image_response_format; diff --git a/crates/api_clients/grok_api_client/src/api/types/mod.rs b/crates/api_clients/grok_api_client/src/api/types/mod.rs new file mode 100644 index 0000000000..e0eae8aa57 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/mod.rs @@ -0,0 +1,2 @@ +pub mod image_types; +pub mod video_types; diff --git a/crates/api_clients/grok_api_client/src/api/types/video_types/mod.rs b/crates/api_clients/grok_api_client/src/api/types/video_types/mod.rs new file mode 100644 index 0000000000..685e687381 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/video_types/mod.rs @@ -0,0 +1,14 @@ +//! Shared video-endpoint enums (model, aspect ratio, resolution). +//! +//! `VideoModel` is reused by every video endpoint +//! ([`crate::api::requests::videos::video_generation`], +//! [`crate::api::requests::videos::video_edit`], +//! [`crate::api::requests::videos::video_extension`]). +//! +//! `VideoAspectRatio` and `VideoResolution` are accepted only by +//! [`crate::api::requests::videos::video_generation`] — the edit and +//! extension endpoints inherit dimensions from the source video. + +pub mod video_aspect_ratio; +pub mod video_model; +pub mod video_resolution; diff --git a/crates/api_clients/grok_api_client/src/api/types/video_types/video_aspect_ratio.rs b/crates/api_clients/grok_api_client/src/api/types/video_types/video_aspect_ratio.rs new file mode 100644 index 0000000000..490a7056b1 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/video_types/video_aspect_ratio.rs @@ -0,0 +1,101 @@ +/// Aspect ratios accepted by `/v1/videos/generations`. +/// +/// The wire format is `":"` — e.g. [`VideoAspectRatio::Square`] +/// serialises as `"1:1"`, [`VideoAspectRatio::Landscape16x9`] as `"16:9"`. +/// +/// Note: the video endpoint accepts FEWER aspect ratios than image endpoints +/// — there's no `auto`, no ultra-portrait `9:19.5` or `9:20`, no `1:2`/`2:1`. +/// xAI documents this closed set of 7. +/// +/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`) +/// don't accept this field — they inherit the source video's dimensions. +/// +/// Docs: +/// - +/// - +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum VideoAspectRatio { + /// 1:1 square. + Square, + + /// 16:9 landscape (widescreen / YouTube). xAI default. + Landscape16x9, + + /// 9:16 portrait (phone / vertical video). + Portrait9x16, + + /// 4:3 landscape (classic TV). + Landscape4x3, + + /// 3:4 portrait. + Portrait3x4, + + /// 3:2 landscape (photo). + Landscape3x2, + + /// 2:3 portrait (photo). + Portrait2x3, +} + +impl VideoAspectRatio { + /// Wire representation — the exact string xAI expects in the + /// `"aspect_ratio"` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::Square => "1:1", + Self::Landscape16x9 => "16:9", + Self::Portrait9x16 => "9:16", + Self::Landscape4x3 => "4:3", + Self::Portrait3x4 => "3:4", + Self::Landscape3x2 => "3:2", + Self::Portrait2x3 => "2:3", + } + } +} + +// Serialize as the wire string ("1:1", "16:9", …) rather than the default +// external-tag enum form. +impl serde::Serialize for VideoAspectRatio { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_variant_maps_to_a_distinct_string() { + let all = [ + VideoAspectRatio::Square, + VideoAspectRatio::Landscape16x9, + VideoAspectRatio::Portrait9x16, + VideoAspectRatio::Landscape4x3, + VideoAspectRatio::Portrait3x4, + VideoAspectRatio::Landscape3x2, + VideoAspectRatio::Portrait2x3, + ]; + let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect(); + strs.sort(); + strs.dedup(); + assert_eq!(strs.len(), 7, "expected 7 distinct wire values, got {:?}", strs); + } + + #[test] + fn matches_docs_strings() { + assert_eq!(VideoAspectRatio::Square.as_str(), "1:1"); + assert_eq!(VideoAspectRatio::Landscape16x9.as_str(), "16:9"); + assert_eq!(VideoAspectRatio::Portrait9x16.as_str(), "9:16"); + assert_eq!(VideoAspectRatio::Landscape4x3.as_str(), "4:3"); + assert_eq!(VideoAspectRatio::Portrait3x4.as_str(), "3:4"); + assert_eq!(VideoAspectRatio::Landscape3x2.as_str(), "3:2"); + assert_eq!(VideoAspectRatio::Portrait2x3.as_str(), "2:3"); + } + + #[test] + fn serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&VideoAspectRatio::Landscape16x9).unwrap(), "\"16:9\""); + assert_eq!(serde_json::to_string(&VideoAspectRatio::Square).unwrap(), "\"1:1\""); + } +} diff --git a/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs b/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs new file mode 100644 index 0000000000..bf09fe63eb --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs @@ -0,0 +1,165 @@ +/// Models accepted by the xAI video endpoints (`/v1/videos/generations`, +/// `/v1/videos/edits`, `/v1/videos/extensions`). All three endpoints accept +/// the same model identifier. +/// +/// xAI may add or deprecate models faster than this crate ships releases, so +/// the [`VideoModel::Custom`] escape hatch lets callers pass an arbitrary +/// identifier without waiting for a code change. +/// +/// Docs: +/// - Models list: +/// - +/// - +/// - +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VideoModel { + /// `grok-imagine-video` — the original Imagine video model. Used by + /// generation, editing, and extension endpoints. + GrokImagineVideo, + + /// `grok-imagine-video-1.5` — the GA 1.5 model (formerly + /// `grok-imagine-video-1.5-preview`). xAI still accepts the old `-preview` + /// name and the dated alias `grok-imagine-video-1.5-2026-05-30` as synonyms + /// server-side; this variant serializes to the new canonical name. + GrokImagineVideo1p5, + + /// Escape hatch for model identifiers not yet enumerated here. + Custom(String), +} + +impl VideoModel { + /// Wire representation — the exact string xAI expects in the `"model"` field. + pub fn as_str(&self) -> &str { + match self { + Self::GrokImagineVideo => "grok-imagine-video", + Self::GrokImagineVideo1p5 => "grok-imagine-video-1.5", + Self::Custom(s) => s.as_str(), + } + } + + /// Pricing tier this model bills under. Includes recognition of the dated + /// alias `grok-imagine-video-1.5-2026-05-30` passed via [`Self::Custom`], + /// which xAI treats as a synonym for `grok-imagine-video-1.5-preview` and + /// therefore charges at the 1.5 rates. + pub fn pricing_tier(&self) -> VideoModelPricingTier { + match self { + Self::GrokImagineVideo => VideoModelPricingTier::V1, + Self::GrokImagineVideo1p5 => VideoModelPricingTier::V1p5, + Self::Custom(s) => match s.as_str() { + "grok-imagine-video-1.5" + | "grok-imagine-video-1.5-preview" + | "grok-imagine-video-1.5-2026-05-30" => { + VideoModelPricingTier::V1p5 + } + _ => VideoModelPricingTier::V1, + }, + } + } +} + +/// Pricing tier identifier used by the per-request cost calculators. xAI +/// publishes different per-second and per-image rates for each tier — see +/// `video_generation/cost.rs`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VideoModelPricingTier { + /// `grok-imagine-video` and any unrecognized identifier that defaults + /// to v1 rates. + V1, + /// `grok-imagine-video-1.5` (and its old `-preview` / dated aliases). + V1p5, +} + +// Serialize as the wire string ("grok-imagine-video" or the Custom inner +// string) rather than the default external-tag enum format. Lets the public +// VideoGeneration/Edit/ExtensionRequest types round-trip through any +// log/audit pipeline as readable JSON. +impl serde::Serialize for VideoModel { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_model_serializes() { + assert_eq!(VideoModel::GrokImagineVideo.as_str(), "grok-imagine-video"); + } + + #[test] + fn one_point_five_preview_serializes_canonical_name() { + assert_eq!( + VideoModel::GrokImagineVideo1p5.as_str(), + "grok-imagine-video-1.5", + ); + assert_eq!( + serde_json::to_string(&VideoModel::GrokImagineVideo1p5).unwrap(), + "\"grok-imagine-video-1.5\"", + ); + } + + #[test] + fn custom_model_passes_through() { + let m = VideoModel::Custom("grok-imagine-video-future".to_string()); + assert_eq!(m.as_str(), "grok-imagine-video-future"); + } + + #[test] + fn serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&VideoModel::GrokImagineVideo).unwrap(), "\"grok-imagine-video\""); + let m = VideoModel::Custom("grok-imagine-video-v2".to_string()); + assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-video-v2\""); + } + + // ── Pricing tier classification ── + + mod pricing_tier { + use super::*; + + #[test] + fn grok_imagine_video_is_v1() { + assert_eq!(VideoModel::GrokImagineVideo.pricing_tier(), VideoModelPricingTier::V1); + } + + #[test] + fn one_point_five_preview_enum_is_v1p5() { + assert_eq!( + VideoModel::GrokImagineVideo1p5.pricing_tier(), + VideoModelPricingTier::V1p5, + ); + } + + #[test] + fn custom_with_canonical_1p5_name_is_v1p5() { + let m = VideoModel::Custom("grok-imagine-video-1.5".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5); + } + + #[test] + fn custom_with_old_preview_name_is_still_v1p5() { + let m = VideoModel::Custom("grok-imagine-video-1.5-preview".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5); + } + + #[test] + fn custom_with_dated_1p5_alias_is_v1p5() { + // xAI treats this alias as a synonym for the 1.5 preview model. + let m = VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5); + } + + #[test] + fn custom_unknown_defaults_to_v1() { + let m = VideoModel::Custom("grok-imagine-video-future".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1); + } + + #[test] + fn custom_with_v1_name_is_v1() { + let m = VideoModel::Custom("grok-imagine-video".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1); + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/types/video_types/video_resolution.rs b/crates/api_clients/grok_api_client/src/api/types/video_types/video_resolution.rs new file mode 100644 index 0000000000..f7e0d1676d --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/types/video_types/video_resolution.rs @@ -0,0 +1,61 @@ +/// Output resolution for `/v1/videos/generations`. +/// +/// `480p` (server default), `720p`, and `1080p`. The 1.5 model produces +/// genuine 1080p output (the original `grok-imagine-video` capped at 720p). +/// +/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`) +/// don't accept this field — they inherit the source video's resolution +/// (capped at 720p). +/// +/// Docs: +/// - +/// - +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum VideoResolution { + /// `"480p"` — standard definition, faster processing. Server default. + FourEightyP, + + /// `"720p"` — HD quality. + SevenTwentyP, + + /// `"1080p"` — full HD. Supported by `grok-imagine-video-1.5`. + TenEightyP, +} + +impl VideoResolution { + /// Wire representation — the exact string xAI expects in the + /// `"resolution"` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::FourEightyP => "480p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +// Serialize as the wire string ("480p", "720p", "1080p"). +impl serde::Serialize for VideoResolution { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_docs_strings() { + assert_eq!(VideoResolution::FourEightyP.as_str(), "480p"); + assert_eq!(VideoResolution::SevenTwentyP.as_str(), "720p"); + assert_eq!(VideoResolution::TenEightyP.as_str(), "1080p"); + } + + #[test] + fn serializes_as_plain_wire_string() { + assert_eq!(serde_json::to_string(&VideoResolution::FourEightyP).unwrap(), "\"480p\""); + assert_eq!(serde_json::to_string(&VideoResolution::SevenTwentyP).unwrap(), "\"720p\""); + assert_eq!(serde_json::to_string(&VideoResolution::TenEightyP).unwrap(), "\"1080p\""); + } +} diff --git a/crates/api_clients/grok_api_client/src/creds/grok_api_key.rs b/crates/api_clients/grok_api_client/src/creds/grok_api_key.rs new file mode 100644 index 0000000000..0f3399aee3 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/creds/grok_api_key.rs @@ -0,0 +1,21 @@ +/// Holds the API key for authenticating with the Grok (xAI Imagine) API. +/// +/// Sent on every request as `Authorization: Bearer `. +#[derive(Clone)] +pub struct GrokApiKey { + pub(crate) api_key: String, +} + +impl GrokApiKey { + pub fn new(api_key: String) -> Self { + Self { api_key } + } +} + +// Debug is redacted so the key can't end up in a log line via accidental +// `{:?}` on an enclosing args struct. +impl std::fmt::Debug for GrokApiKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "GrokApiKey()") + } +} diff --git a/crates/api_clients/grok_api_client/src/creds/mod.rs b/crates/api_clients/grok_api_client/src/creds/mod.rs new file mode 100644 index 0000000000..a5900cdaad --- /dev/null +++ b/crates/api_clients/grok_api_client/src/creds/mod.rs @@ -0,0 +1 @@ +pub mod grok_api_key; diff --git a/crates/api_clients/grok_api_client/src/error/classify_grok_http_error.rs b/crates/api_clients/grok_api_client/src/error/classify_grok_http_error.rs new file mode 100644 index 0000000000..a37b9d20d7 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/classify_grok_http_error.rs @@ -0,0 +1,200 @@ +use reqwest::StatusCode; + +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; + +/// Convert a non-2xx HTTP response into a `GrokError`. +/// +/// On 2xx returns `Ok(())`; on any error status returns the most-specific +/// `GrokError` variant we can classify from the status code and (optional) +/// response body. +pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), GrokError> { + if status_code.is_success() { + return Ok(()); + } + + // Owned copy of the raw response body, attached to every error variant so + // downstream logging/alerting/pages have the full upstream context. + let raw_body = || maybe_body.map(|b| b.to_string()); + + match status_code { + StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized { raw_http_body: raw_body() }.into()), + StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits { raw_http_body: raw_body() }.into()), + StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound { raw_http_body: raw_body() }.into()), + StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited { raw_http_body: raw_body() }.into()), + _ => {} + } + + // 403 is Forbidden unless the body indicates content moderation. + if status_code == StatusCode::FORBIDDEN { + if let Some(body) = maybe_body { + if body_indicates_moderation(body) { + let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string()); + return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into()); + } + } + return Err(GrokSpecificApiError::Forbidden { raw_http_body: raw_body() }.into()); + } + + // 400 Bad Request is usually a malformed body or moderation rejection. + if status_code == StatusCode::BAD_REQUEST { + if let Some(body) = maybe_body { + if body_indicates_moderation(body) { + let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string()); + return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into()); + } + let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string()); + return Err(GrokSpecificApiError::BadRequest { reason: message, raw_http_body: raw_body() }.into()); + } + return Err(GrokSpecificApiError::BadRequest { reason: String::new(), raw_http_body: None }.into()); + } + + // Everything else falls through to the generic catch-all. + Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: maybe_body.unwrap_or("").to_string(), + }.into()) +} + +/// xAI returns OpenAI-compatible error envelopes: +/// +/// ```json +/// { "error": { "code": "...", "message": "..." } } +/// ``` +/// +/// Extract `error.message` if present. +fn extract_xai_error_message(body: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(body).ok()?; + parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string()) +} + +/// Heuristic: does this response body mention content moderation? xAI's +/// safety filter rejections include words like "moderation", "safety", +/// "content_policy", or "blocked". +pub(crate) fn body_indicates_moderation(body: &str) -> bool { + let lower = body.to_lowercase(); + lower.contains("moderat") // moderation / moderated + || lower.contains("safety") + || lower.contains("content_policy") + || lower.contains("content policy") + || lower.contains("blocked by") + || lower.contains("disallowed") +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Success ── + + #[test] + fn ok_200_returns_ok() { + assert!(classify_grok_http_error(StatusCode::OK, None).is_ok()); + } + + #[test] + fn created_201_returns_ok() { + assert!(classify_grok_http_error(StatusCode::CREATED, Some("{}")).is_ok()); + } + + // ── Direct status mappings ── + + #[test] + fn unauthorized_401() { + let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized { .. }))); + } + + #[test] + fn payment_required_402() { + let err = classify_grok_http_error(StatusCode::PAYMENT_REQUIRED, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits { .. }))); + } + + #[test] + fn not_found_404() { + let err = classify_grok_http_error(StatusCode::NOT_FOUND, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. }))); + } + + #[test] + fn rate_limited_429() { + let err = classify_grok_http_error(StatusCode::TOO_MANY_REQUESTS, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited { .. }))); + } + + // ── 403 Forbidden ── + + #[test] + fn plain_403_is_forbidden() { + let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(r#"{"error":{"message":"Forbidden"}}"#)).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden { .. }))); + } + + #[test] + fn moderation_403_is_prompt_moderated() { + let body = r#"{"error":{"message":"Content moderation blocked this prompt","code":"content_policy"}}"#; + let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(body)).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. }))); + } + + // ── 400 Bad Request ── + + #[test] + fn bad_request_400_extracts_message() { + let body = r#"{"error":{"code":"invalid_argument","message":"unknown field 'foo'"}}"#; + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => { + assert_eq!(msg, "unknown field 'foo'"); + } + other => panic!("expected BadRequest, got: {:?}", other), + } + } + + #[test] + fn bad_request_400_unparseable_body_falls_back_to_raw() { + let body = "definitely not json"; + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => { + assert_eq!(msg, "definitely not json"); + } + other => panic!("expected BadRequest, got: {:?}", other), + } + } + + #[test] + fn bad_request_400_no_body_is_empty_string() { + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, None).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => { + assert!(msg.is_empty()); + } + other => panic!("expected BadRequest, got: {:?}", other), + } + } + + #[test] + fn moderation_400_is_prompt_moderated() { + // Some APIs return moderation as 400 rather than 403. + let body = r#"{"error":{"message":"Safety filter blocked this request"}}"#; + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. }))); + } + + // ── Generic fallback ── + + #[test] + fn server_error_500_is_generic() { + let err = classify_grok_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some("oops")).unwrap_err(); + assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }))); + } + + #[test] + fn bad_gateway_502_is_generic() { + let err = classify_grok_http_error(StatusCode::BAD_GATEWAY, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }))); + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_client_error.rs b/crates/api_clients/grok_api_client/src/error/grok_client_error.rs new file mode 100644 index 0000000000..e662b312fb --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_client_error.rs @@ -0,0 +1,34 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Errors that happen entirely on the client side — before or independent of +/// the HTTP request reaching xAI's servers. +#[derive(Debug)] +pub enum GrokClientError { + /// No API key is present. + NoApiKeyPresent, + + /// An error was encountered building the reqwest client. + ReqwestClientError(reqwest::Error), + + /// The request body could not be serialized to JSON. + RequestSerializationError(serde_json::Error), + + /// The request failed our own validation before being sent to xAI — e.g. + /// mutually-exclusive fields supplied together, a required input missing, or + /// an oversize payload. Detected client-side; no HTTP call was made. + InvalidRequest(String), +} + +impl Error for GrokClientError {} + +impl Display for GrokClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoApiKeyPresent => write!(f, "No API key present."), + Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err), + Self::RequestSerializationError(err) => write!(f, "Failed to serialize request body to JSON: {}", err), + Self::InvalidRequest(msg) => write!(f, "Invalid request (rejected client-side before sending): {}", msg), + } + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_error.rs b/crates/api_clients/grok_api_client/src/error/grok_error.rs new file mode 100644 index 0000000000..d33e238fe0 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_error.rs @@ -0,0 +1,44 @@ +use std::error::Error; + +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; + +/// Top-level error for the Grok API client. Sum of every failure mode any +/// endpoint can produce. +#[derive(Debug)] +pub enum GrokError { + Client(GrokClientError), + ApiSpecific(GrokSpecificApiError), + ApiGeneric(GrokGenericApiError), +} + +impl Error for GrokError {} + +impl std::fmt::Display for GrokError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "GrokClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "GrokSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "GrokGenericApiError: {:?}", e), + } + } +} + +impl From for GrokError { + fn from(error: GrokClientError) -> Self { + Self::Client(error) + } +} + +impl From for GrokError { + fn from(error: GrokSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for GrokError { + fn from(error: GrokGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_generic_api_error.rs b/crates/api_clients/grok_api_client/src/error/grok_generic_api_error.rs new file mode 100644 index 0000000000..4bc1689295 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_generic_api_error.rs @@ -0,0 +1,37 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use reqwest::StatusCode; + +/// Grab-bag for server-side responses that don't map to a `GrokSpecificApiError`. +#[derive(Debug)] +pub enum GrokGenericApiError { + /// Response body didn't deserialize against the expected schema. Includes + /// the original body so we can diagnose drift. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// An uncategorized bad HTTP response with status code and body. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// An uncaught error from the HTTP client (e.g. network/IO). + ReqwestError(reqwest::Error), +} + +impl Error for GrokGenericApiError {} + +impl Display for GrokGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::SerdeResponseParseErrorWithBody(err, body) => { + write!(f, "Failed to parse Grok API response body: {:?}. Body: {}", err, body) + } + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => { + write!(f, "Uncategorized Grok API response: status code {}, body: {}", status_code, body) + } + Self::ReqwestError(err) => write!(f, "Reqwest error during Grok API call: {}", err), + } + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_specific_api_error.rs b/crates/api_clients/grok_api_client/src/error/grok_specific_api_error.rs new file mode 100644 index 0000000000..ba0adc02a6 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_specific_api_error.rs @@ -0,0 +1,100 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Well-known, classifiable error responses from the xAI Imagine API. +/// +/// Distinguishes things like "your key is bad" from generic 5xx so callers can +/// react appropriately (retry vs. surface to user vs. alert). +/// +/// Every variant carries the raw HTTP response body (`raw_http_body`) when one +/// was available, so downstream logging, alerting, and pages have the full +/// upstream context — not just our classification. It's `None` for errors we +/// raise client-side before making the HTTP call (e.g. pre-flight validation). +#[derive(Debug)] +pub enum GrokSpecificApiError { + /// 401 Unauthorized — API key is missing, invalid, or revoked. + Unauthorized { + raw_http_body: Option, + }, + + /// 402 Payment Required / insufficient credits to fulfill the request. + InsufficientCredits { + raw_http_body: Option, + }, + + /// 403 Forbidden — the API key is valid but lacks permission for this + /// resource/model (also covers team-level "out of credits / over spending + /// limit" responses, which xAI returns as 403 `permission-denied`). + Forbidden { + raw_http_body: Option, + }, + + /// 404 Not Found — typically returned for an unknown `request_id` on the + /// video status endpoint. + NotFound { + raw_http_body: Option, + }, + + /// 429 Too Many Requests — rate limit exceeded. + RateLimited { + raw_http_body: Option, + }, + + /// The prompt was rejected by xAI's content moderation. + PromptModerated { + /// The reason we parsed out of the response (e.g. the `error.message`). + reason: String, + /// The raw HTTP response body. + raw_http_body: Option, + }, + + /// 400 Bad Request — the request body shape was invalid (e.g. missing + /// required field, unsupported model, invalid aspect ratio). + BadRequest { + /// The reason we parsed out of the response (or the raw body when it + /// wasn't parseable). For client-side pre-flight failures, the message + /// we generated. + reason: String, + /// The raw HTTP response body. `None` for client-side pre-flight failures. + raw_http_body: Option, + }, +} + +impl Error for GrokSpecificApiError {} + +impl Display for GrokSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unauthorized { raw_http_body } => { + write!(f, "Grok API: Unauthorized (invalid or missing API key){}", fmt_raw_body(raw_http_body)) + } + Self::InsufficientCredits { raw_http_body } => { + write!(f, "Grok API: Insufficient credits{}", fmt_raw_body(raw_http_body)) + } + Self::Forbidden { raw_http_body } => { + write!(f, "Grok API: Forbidden{}", fmt_raw_body(raw_http_body)) + } + Self::NotFound { raw_http_body } => { + write!(f, "Grok API: Not found{}", fmt_raw_body(raw_http_body)) + } + Self::RateLimited { raw_http_body } => { + write!(f, "Grok API: Rate limited{}", fmt_raw_body(raw_http_body)) + } + Self::PromptModerated { reason, raw_http_body } => { + write!(f, "Grok API: Prompt moderated: {}{}", reason, fmt_raw_body(raw_http_body)) + } + Self::BadRequest { reason, raw_http_body } => { + write!(f, "Grok API: Bad request: {}{}", reason, fmt_raw_body(raw_http_body)) + } + } + } +} + +/// Append the raw HTTP body to a `Display` message when one is present, so +/// `{}`-formatted logs/pages carry the upstream context. Empty when absent. +fn fmt_raw_body(raw_http_body: &Option) -> String { + match raw_http_body { + Some(body) if !body.is_empty() => format!(" [raw http body: {}]", body), + _ => String::new(), + } +} diff --git a/crates/api_clients/grok_api_client/src/error/mod.rs b/crates/api_clients/grok_api_client/src/error/mod.rs new file mode 100644 index 0000000000..e4da34d14d --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod classify_grok_http_error; +pub mod grok_client_error; +pub mod grok_error; +pub mod grok_generic_api_error; +pub mod grok_specific_api_error; diff --git a/crates/api_clients/grok_api_client/src/lib.rs b/crates/api_clients/grok_api_client/src/lib.rs new file mode 100644 index 0000000000..ed30825f99 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +pub(crate) mod test_utils; + +// Public API +pub mod api; +pub mod creds; +pub mod error; diff --git a/crates/api_clients/grok_api_client/src/test_utils/get_test_api_key.rs b/crates/api_clients/grok_api_client/src/test_utils/get_test_api_key.rs new file mode 100644 index 0000000000..ca9efea7e9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/test_utils/get_test_api_key.rs @@ -0,0 +1,19 @@ +use std::fs::read_to_string; + +use errors::AnyhowResult; + +use crate::creds::grok_api_key::GrokApiKey; + +const GROK_API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/grok_api_key.txt"; + +/// Load a Grok (xAI) API key from the local credentials file. Whitespace is +/// trimmed because base64-style key files often pick up a trailing newline. +#[cfg(test)] +pub fn get_test_api_key() -> AnyhowResult { + let raw = read_to_string(GROK_API_KEY_PATH)?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + anyhow::bail!("Grok API key file is empty: {}", GROK_API_KEY_PATH); + } + Ok(GrokApiKey::new(trimmed)) +} diff --git a/crates/api_clients/grok_api_client/src/test_utils/mod.rs b/crates/api_clients/grok_api_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..25f5e48d47 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/test_utils/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +pub(crate) mod get_test_api_key; + +#[cfg(test)] +pub(crate) mod setup_test_logging; diff --git a/crates/api_clients/grok_api_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/grok_api_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..53194d939b --- /dev/null +++ b/crates/api_clients/grok_api_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,15 @@ +use std::sync::Once; + +static INIT: Once = Once::new(); + +/// Initialize env_logger so live-API tests print request/response details. +/// Idempotent — safe to call from every test. +#[cfg(test)] +pub fn setup_test_logging() { + INIT.call_once(|| { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init(); + }); +} diff --git a/crates/api_clients/grok_consumer_client/Cargo.toml b/crates/api_clients/grok_consumer_client/Cargo.toml new file mode 100644 index 0000000000..5b3947bfe7 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "grok_consumer_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "grok_consumer_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +cloudflare_errors.workspace = true +cloudflare_mitigation.workspace = true +cookie_store.workspace = true +errors.workspace = true + +# External +anyhow.workspace = true +base64.workspace = true +bstr.workspace = true +byteorder.workspace = true +chrono.workspace = true +futures.workspace = true +log.workspace = true +once_cell.workspace = true +regex.workspace = true +scraper = "0.24.0" +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio = { version = "1", features = ["full"] } +url.workspace = true +uuid.workspace = true +wreq-util.workspace = true +wreq.workspace = true + +[dev-dependencies] +filesys.workspace = true +test_data.workspace = true +test_utils.workspace = true +log4rs = "1.4.0" +env_logger.workspace = true +env.workspace = true +chrono.workspace = true + diff --git a/crates/api_clients/grok_consumer_client/src/client/browser_user_agents.rs b/crates/api_clients/grok_consumer_client/src/client/browser_user_agents.rs new file mode 100644 index 0000000000..94f3923b48 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/client/browser_user_agents.rs @@ -0,0 +1,10 @@ + +/// Firefox 139 (2025) on macOS, which matches wreq's Emulation::Firefox139 +pub const FIREFOX_139_MAC_USER_AGENT : &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0"; + +pub const FIREFOX_143_MAC_USER_AGENT : &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0"; + +pub const FIREFOX_144_MAC_USER_AGENT_UNSUPPORTED_TLS_FINGERPRINTING : &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0"; diff --git a/crates/api_clients/grok_consumer_client/src/client/mod.rs b/crates/api_clients/grok_consumer_client/src/client/mod.rs new file mode 100644 index 0000000000..82c176fa31 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/client/mod.rs @@ -0,0 +1 @@ +pub mod browser_user_agents; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/credentials/_metadata/crypto_signature/xStatsigIDGenerator.py b/crates/api_clients/grok_consumer_client/src/credentials/_metadata/crypto_signature/xStatsigIDGenerator.py new file mode 100644 index 0000000000..1346c58a53 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/credentials/_metadata/crypto_signature/xStatsigIDGenerator.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +# From https://github.com/kbtit25/grok2api/blob/73c917b15438e12699531ccf9411d3a7d1110cbf/xStatsigIDGenerator.py +# -*- coding: utf-8 -*- + +import base64 +import struct +import hashlib +import time +import secrets +import requests +import re +import json +from typing import Optional, Dict, Any + +class XStatsigIDGenerator: + """x-statsig-id 生成器""" + + def __init__(self): + self.base_timestamp = int(time.time()) # 使用当前系统时间 + self.grok_url = "https://grok.com" + + def get_grok_meta_content(self) -> bytes: + """ + 从 grok.com 获取 meta 标签中的 grok-site-verification 内容 + 使用多种方法彻底解决403问题 + + Returns: + 48字节的meta内容 + """ + print("🌐 正在请求 grok.com...") + + # 定义多种绕过策略 + strategies = [ + #self._try_curl_with_proxy, + self._try_curl_with_different_ua, + #self._try_requests_with_session, + #self._try_curl_cffi_advanced, + #self._try_alternative_endpoints, + #self._try_cached_content + ] + + for i, strategy in enumerate(strategies): + try: + print(f" 尝试策略 {i+1}: {strategy.__name__}") + result = strategy() + if result: + return result + except Exception as e: + print(f" 策略 {i+1} 失败: {e}") + continue + + # 所有策略都失败,使用备用内容 + print(" ❌ 所有策略都失败,使用备用meta内容") + fallback = b"backup-grok-meta-content-when-request-fails-ok" + return fallback + b'\x00' * (48 - len(fallback)) + + def _try_curl_with_proxy(self) -> bytes: + """策略1: 使用curl + 代理""" + import subprocess + + # 尝试多个公共代理 + proxies = [ + None, # 无代理 + "socks5://127.0.0.1:1080", # 本地代理 + "http://127.0.0.1:8080", # 本地HTTP代理 + ] + + for proxy in proxies: + try: + curl_command = [ + 'curl', '-s', '-L', '--max-time', '15', + '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + '--header', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + '--header', 'Accept-Language: en-US,en;q=0.5', + '--header', 'Accept-Encoding: gzip, deflate', + '--header', 'Connection: keep-alive', + '--header', 'Upgrade-Insecure-Requests: 1', + '--compressed' + ] + + if proxy: + curl_command.extend(['--proxy', proxy]) + + curl_command.append(self.grok_url) + + result = subprocess.run(curl_command, capture_output=True, text=True, timeout=15) + + if result.returncode == 0 and len(result.stdout) > 1000: + print(f" ✅ curl成功 (代理: {proxy or '无'})") + return self._extract_meta_from_html(result.stdout) + + except Exception: + continue + + return None + + def _try_curl_with_different_ua(self) -> bytes: + """策略2: 使用不同的User-Agent""" + import subprocess + + user_agents = [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0', + #'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + #'Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/109.0 Firefox/119.0', + #'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + #'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15' + ] + + for ua in user_agents: + try: + curl_command = [ + 'curl-impersonate-ff', '-s', '-L', '--max-time', '10', + '--user-agent', ua, + '--header', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + '--header', 'Accept-Language: en-US,en;q=0.9', + '--header', 'Cache-Control: no-cache', + '--header', 'Pragma: no-cache', + '--compressed', + self.grok_url + ] + + result = subprocess.run(curl_command, capture_output=True, text=True, timeout=10) + + if result.returncode == 0 and len(result.stdout) > 1000: + print(f" ✅ 不同UA成功") + return self._extract_meta_from_html(result.stdout) + + except Exception: + continue + + return None + + def _try_requests_with_session(self) -> bytes: + """策略3: 使用requests session模拟真实浏览器行为""" + import requests + from requests.adapters import HTTPAdapter + from urllib3.util.retry import Retry + + session = requests.Session() + + # 配置重试策略 + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + + # 模拟真实浏览器行为 + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'DNT': '1', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Cache-Control': 'max-age=0' + } + + try: + # 先访问主页建立session + session.get('https://x.com', headers=headers, timeout=5) + + # 再访问grok + response = session.get(self.grok_url, headers=headers, timeout=10) + + if response.status_code == 200 and len(response.text) > 1000: + print(f" ✅ session请求成功") + return self._extract_meta_from_html(response.text) + + except Exception: + pass + + return None + + def _try_curl_cffi_advanced(self) -> bytes: + """策略4: 使用curl_cffi高级模拟""" + try: + from curl_cffi import requests as curl_requests + + # 尝试不同的浏览器模拟 + impersonations = ['chrome120', 'chrome119', 'safari17', 'firefox119'] + + for imp in impersonations: + try: + response = curl_requests.get( + self.grok_url, + impersonate=imp, + timeout=10, + headers={ + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache' + } + ) + + if response.status_code == 200 and len(response.text) > 1000: + print(f" ✅ curl_cffi成功 ({imp})") + return self._extract_meta_from_html(response.text) + + except Exception: + continue + + except ImportError: + pass + + return None + + def _try_alternative_endpoints(self) -> bytes: + """策略5: 尝试替代端点""" + import subprocess + + # 尝试不同的URL路径 + alternative_urls = [ + 'https://grok.com/', + 'https://grok.com/login', + 'https://grok.com/home', + 'https://x.com/i/grok' + ] + + for url in alternative_urls: + try: + curl_command = [ + 'curl', '-s', '-L', '--max-time', '8', + '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + '--header', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + '--compressed', + url + ] + + result = subprocess.run(curl_command, capture_output=True, text=True, timeout=8) + + if result.returncode == 0 and len(result.stdout) > 500: + print(f" ✅ 替代端点成功: {url}") + meta_result = self._extract_meta_from_html(result.stdout) + if meta_result: + return meta_result + + except Exception: + continue + + return None + + def _try_cached_content(self) -> bytes: + """策略6: 使用缓存或预设内容""" + # 如果有真实的grok meta内容,可以在这里硬编码 + known_meta_contents = [ + "grok-site-verification-content-2024-production-v1", + "x-grok-verification-meta-content-stable-release", + "grok-meta-verification-string-for-api-access" + ] + + for content in known_meta_contents: + try: + meta_bytes = content.encode('utf-8') + if len(meta_bytes) < 48: + meta_bytes = meta_bytes + b'\x00' * (48 - len(meta_bytes)) + elif len(meta_bytes) > 48: + meta_bytes = meta_bytes[:48] + + print(f" ✅ 使用预设内容: {content[:30]}...") + return meta_bytes + + except Exception: + continue + + return None + + def _extract_meta_from_html(self, html_content: str) -> bytes: + """从HTML中提取meta内容""" + patterns = [ + r' 48: + meta_bytes = meta_bytes[:48] + + return meta_bytes + + # 如果没找到特定的meta标签,尝试从HTML中提取其他有用信息 + if 'grok' in html_content.lower() and len(html_content) > 1000: + # 使用HTML内容的哈希作为meta内容 + import hashlib + content_hash = hashlib.sha256(html_content.encode()).hexdigest()[:48] + meta_bytes = content_hash.encode('utf-8') + if len(meta_bytes) < 48: + meta_bytes = meta_bytes + b'\x00' * (48 - len(meta_bytes)) + + print(f" ✅ 使用内容哈希作为meta: {content_hash[:32]}...") + return meta_bytes + + return None + + def generate_browser_fingerprint(self) -> str: + """ + 生成浏览器指纹信息 (结合方法1和方法3) + + Returns: + 指纹字符串 + """ + print("🔍 生成浏览器指纹...") + + # 模拟浏览器指纹信息 + fingerprint_data = { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0", + "language": "en", + "languages": ["en", "en-US"], + "platform": "MacIntel", + "cookieEnabled": True, + "doNotTrack": None, + "screenWidth": 450, + "screenHeight": 654, + "screenColorDepth": 24, + "screenPixelDepth": 24, + "screenAvailWidth": 450, + "screenAvailHeight": 654, + "innerWidth": 450, + "innerHeight": 654, + "outerWidth": 1920, + "outerHeight": 1055, + "timezone": "America/New York", + "timezoneOffset": -400, + "hardwareConcurrency": 14, + "deviceMemory": 16, + "maxTouchPoints": 0 + } + + # 方法3: 生成指纹哈希 + fingerprint_string = json.dumps(fingerprint_data, sort_keys=True, separators=(',', ':')) + fingerprint_hash = hashlib.sha256(fingerprint_string.encode('utf-8')).hexdigest() + + print(f" 指纹数据长度: {len(fingerprint_string)} 字符") + print(f" 指纹哈希: {fingerprint_hash[:32]}...") + + return fingerprint_hash + + def generate_x_statsig_id(self, method: str = "GET", pathname: str = "/") -> str: + """ + 生成完整的 x-statsig-id + + Args: + method: 请求方式 (GET/POST) + pathname: 请求路径 + + Returns: + 生成的 x-statsig-id 字符串 + """ + print("=" * 60) + print("🚀 开始生成 x-statsig-id") + print("=" * 60) + + print(f"📋 生成参数:") + print(f" Method: {method}") + print(f" Pathname: {pathname}") + + # 1. 获取 grok.com 的 meta content + meta_content = self.get_grok_meta_content() + + # 2. 生成浏览器指纹 + fingerprint = self.generate_browser_fingerprint() + + # 3. 生成当前时间戳 + current_timestamp = int(time.time()) + relative_timestamp = current_timestamp - self.base_timestamp + + print(f"⏰ 时间信息:") + print(f" 当前时间戳: {current_timestamp}") + print(f" 基准时间戳: {self.base_timestamp}") + print(f" 相对时间戳: {relative_timestamp}") + + # 4. 生成时间戳字节 (小端序) + timestamp_bytes = struct.pack(' bool: + """ + 验证生成的ID结构是否正确 + + Args: + statsig_id: 要验证的ID + + Returns: + 验证是否通过 + """ + print("\n" + "=" * 60) + print("🔍 验证生成的ID结构") + print("=" * 60) + + try: + # Base64解码 + decoded_bytes = base64.b64decode(statsig_id) + print(f"✅ Base64解码成功,长度: {len(decoded_bytes)} 字节") + + # 提取异或key + xor_key = decoded_bytes[0] + print(f"✅ 异或key: 0x{xor_key:02x} ({xor_key})") + + # 异或解密 + decrypted = bytearray() + for i in range(1, len(decoded_bytes)): + decrypted.append(decoded_bytes[i] ^ xor_key) + + print(f"✅ 解密后长度: {len(decrypted)} 字节") + + # 验证数据结构 + expected_length = 48 + 4 + 16 + 1 # meta + timestamp + sha256 + fixed + if len(decrypted) == expected_length: + print(f"✅ 数据长度正确: {len(decrypted)}/{expected_length}") + else: + print(f"❌ 数据长度错误: {len(decrypted)}/{expected_length}") + return False + + # 检查固定值 + fixed_val = decrypted[-1] + if fixed_val == 3: + print(f"✅ 固定值正确: {fixed_val}") + else: + print(f"❌ 固定值错误: {fixed_val} (期望: 3)") + return False + + # 解析时间戳 + timestamp_bytes = decrypted[48:52] + timestamp = struct.unpack(' float: + def bezier(u: float): + omu = 1.0 - u + b1 = 3.0 * omu * omu * u + b2 = 3.0 * omu * u * u + b3 = u * u * u + x = b1 * x1 + b2 * x2 + b3 + y = b1 * y1 + b2 * y2 + b3 + return x, y + + lo, hi = 0.0, 1.0 + for _ in range(80): + mid = 0.5 * (lo + hi) + if bezier(mid)[0] < t: + lo = mid + else: + hi = mid + u = 0.5 * (lo + hi) + return bezier(u)[1] + + @staticmethod + def xa(svg: str) -> List[List[int]]: + s = (svg) + substr = s[9:] + parts = substr.split("C") + out = [] + for part in parts: + cleaned = sub(r"[^\d]+", " ", part).strip() + if cleaned == "": + nums = [0] + else: + nums = [int(tok) for tok in cleaned.split() if tok != ""] + out.append(nums) + return out + + @staticmethod + def tohex(num: float) -> str: + rounded = round(float(num), 2) + if rounded == 0.0: + return "0" + sign = "-" if copysign(1.0, rounded) < 0 else "" + absval = abs(rounded) + intpart = int(floor(absval)) + frac = absval - intpart + if frac == 0.0: + return sign + format(intpart, "x") + frac_digits = [] + f = frac + for _ in range(20): + f *= 16 + digit = int(floor(f + 1e-12)) + frac_digits.append(format(digit, "x")) + f -= digit + if abs(f) < 1e-12: + break + frac_str = "".join(frac_digits).rstrip("0") + if frac_str == "": + return sign + format(intpart, "x") + return sign + format(intpart, "x") + "." + frac_str + + @staticmethod + def simulateStyle(values: List[int], c: int) -> Dict[str,str]: + duration = 4096 + currentTime = round(c / 10.0) * 10 + t = currentTime / duration + + cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])] + + easedY = Signature.cubicBezierEased(t, cp[0], cp[1], cp[2], cp[3]) + + start = [float(x) for x in values[0:3]] + end = [float(x) for x in values[3:6]] + r = round(start[0] + (end[0] - start[0]) * easedY) + g = round(start[1] + (end[1] - start[1]) * easedY) + b = round(start[2] + (end[2] - start[2]) * easedY) + color = f"rgb({r}, {g}, {b})" + + endAngle = Signature._h(values[6], 60, 360, True) + angle = endAngle * easedY + rad = angle * pi / 180.0 + + def is_effectively_zero(val: float) -> bool: + return abs(val) < 1e-7 + + def is_effectively_integer(val: float) -> bool: + return abs(val - round(val)) < 1e-7 + + cosv = cos(rad) + sinv = sin(rad) + + if is_effectively_zero(cosv): + a = 0 + d = 0 + else: + if is_effectively_integer(cosv): + a = int(round(cosv)) + d = int(round(cosv)) + else: + a = f"{cosv:.6f}" + d = f"{cosv:.6f}" + + if is_effectively_zero(sinv): + bval = 0 + cval = 0 + else: + if is_effectively_integer(sinv): + bval = int(round(sinv)) + cval = int(round(-sinv)) + else: + bval = f"{sinv:.7f}" + cval = f"{(-sinv):.7f}" + + transform = f"matrix({a}, {bval}, {cval}, {d}, 0, 0)" + return {"color": color, "transform": transform} + + @staticmethod + def xs(x_bytes: bytes, svg: str, x_values: list) -> str: + arr = list(x_bytes) + idx = arr[x_values[0]] % 16 + c = ((arr[x_values[1]] % 16) * (arr[x_values[2]] % 16)) * (arr[x_values[3]] % 16) + o = Signature.xa(svg) + vals = o[idx] + k = Signature.simulateStyle(vals, c) + + concat = str(k["color"]) + str(k["transform"]) + matches = findall(r"[\d\.\-]+", concat) + converted = [] + for m in matches: + num = float(m) + hexstr = Signature.tohex(num) + converted.append(hexstr) + joined = "".join(converted) + cleaned = joined.replace(".", "").replace("-", "") + return cleaned + + @staticmethod + def generate_sign(path: str, method: str, verification: str, svg: str, x_values: list, time_n: int = None, random_float: float = None) -> str: + + n = int(time() - 1682924400) if not time_n else time_n + t = pack(' 0: + first = arr[0] + for i in range(1, len(arr)): + arr[i] = arr[i] ^ first + + return b64encode(bytes(arr)).decode('ascii').replace('=', '') + + +def main(): + sig = Signature.generate_sign('/rest/app-chat/conversations/new', 'POST', self.verification_token, self.svg_data, self.numbers) + print('Signature', sig) + pass + +if __name__ == 'main': + main() diff --git a/crates/api_clients/grok_consumer_client/src/credentials/grok_client_secrets.rs b/crates/api_clients/grok_consumer_client/src/credentials/grok_client_secrets.rs new file mode 100644 index 0000000000..704d8125fd --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/credentials/grok_client_secrets.rs @@ -0,0 +1,36 @@ +use crate::datatypes::api::baggage::Baggage; +use crate::datatypes::api::sentry_trace::SentryTrace; +use crate::datatypes::api::svg_path_data::SvgPathData; +use crate::datatypes::api::user_email::UserEmail; +use crate::datatypes::api::user_id::UserId; +use crate::datatypes::api::verification_token::VerificationToken; +use crate::datatypes::api::xsid_numbers::XsidNumbers; + +#[derive(Clone)] +pub struct GrokClientSecrets { + /// Baggage is tag data from the index.html + pub baggage: Baggage, + + /// Baggage is tag data from the index.html + pub sentry_trace: SentryTrace, + + /// Baggage is tag data from the index.html + pub verification_token: VerificationToken, + + /// The SVG path data to use (chosen via verification_token) + /// We selected one of four-ish possible SVG paths (of length >= 200) by + /// the `verification token -> animation index` algo. + pub svg_path_data: SvgPathData, + + /// This doesn't come from the index.html + /// These numbers are separately loaded from the xsid javascript + pub numbers: XsidNumbers, + + /// From index.html + /// Not strictly needed to sign requests, but typically needed to generate URLs. + pub user_id: UserId, + + /// From index.html + /// Not needed, but returned alongside other details. + pub user_email: Option, +} diff --git a/crates/api_clients/grok_consumer_client/src/credentials/grok_cookies.rs b/crates/api_clients/grok_consumer_client/src/credentials/grok_cookies.rs new file mode 100644 index 0000000000..d7ebcad944 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/credentials/grok_cookies.rs @@ -0,0 +1,24 @@ + +#[derive(Clone)] +pub struct GrokCookies { + /// The entire cookies header (for now) + cookies: String, +} + +impl GrokCookies { + pub fn new(cookies: String) -> Self { + Self { cookies } + } + + pub fn as_str(&self) -> &str { + &self.cookies + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookies.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookies.clone() + } +} diff --git a/crates/api_clients/grok_consumer_client/src/credentials/grok_full_credentials.rs b/crates/api_clients/grok_consumer_client/src/credentials/grok_full_credentials.rs new file mode 100644 index 0000000000..0b37a18a2d --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/credentials/grok_full_credentials.rs @@ -0,0 +1,30 @@ +use crate::credentials::grok_client_secrets::GrokClientSecrets; +use crate::credentials::grok_cookies::GrokCookies; +use crate::datatypes::api::user_id::UserId; + +/// Includes the cookies and all the magic other bits needed to call the API. +#[derive(Clone)] +pub struct GrokFullCredentials { + /// Entire cookie payload + pub cookies: GrokCookies, + + /// Secrets from `index.html` and the xsid javascript. + /// Required for video generation. + pub client_secrets: GrokClientSecrets, +} + +impl GrokFullCredentials { + pub fn from_cookies_and_client_secrets( + cookies: GrokCookies, + client_secrets: GrokClientSecrets + ) -> Self { + Self { + cookies, + client_secrets, + } + } + + pub fn get_user_id_ref(&self) -> &UserId { + &self.client_secrets.user_id + } +} diff --git a/crates/api_clients/grok_consumer_client/src/credentials/grok_user_data.rs b/crates/api_clients/grok_consumer_client/src/credentials/grok_user_data.rs new file mode 100644 index 0000000000..8b9f28b563 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/credentials/grok_user_data.rs @@ -0,0 +1,22 @@ +use crate::datatypes::api::user_email::UserEmail; +use crate::datatypes::api::user_id::UserId; + +#[derive(Clone)] +pub struct GrokUserData { + /// From index.html + /// Not strictly needed to sign requests, but typically needed to generate URLs. + pub user_id: UserId, + + /// From index.html + /// Not needed, but returned alongside other details. + pub user_email: Option, +} + +impl GrokUserData { + pub fn from_id_and_email(id: String, email: Option) -> Self { + Self { + user_id: UserId(id), + user_email: email.map(|email| UserEmail(email)), + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/credentials/mod.rs b/crates/api_clients/grok_consumer_client/src/credentials/mod.rs new file mode 100644 index 0000000000..4d7ea21059 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/credentials/mod.rs @@ -0,0 +1,4 @@ +pub mod grok_client_secrets; +pub mod grok_cookies; +pub mod grok_full_credentials; +pub mod grok_user_data; diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/aspect_ratio.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/aspect_ratio.rs new file mode 100644 index 0000000000..614e451591 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/aspect_ratio.rs @@ -0,0 +1,13 @@ +use serde::Serialize; + +#[derive(Serialize, Clone, Copy, Debug)] +pub enum AspectRatio { + #[serde(rename = "2:3")] + TallTwoByThree, + + #[serde(rename = "3:2")] + WideThreeByTwo, + + #[serde(rename = "1:1")] + Square, +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/baggage.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/baggage.rs new file mode 100644 index 0000000000..0264b43a17 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/baggage.rs @@ -0,0 +1,4 @@ + +/// "baggage" data from the tags +#[derive(Debug, Clone)] +pub struct Baggage(pub(crate) String); diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/file_id.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/file_id.rs new file mode 100644 index 0000000000..b262a287ae --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/file_id.rs @@ -0,0 +1,5 @@ + +/// Type for File IDs. +/// File IDs are UUIds. +#[derive(Clone, Debug)] +pub struct FileId(pub String); diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/mod.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/mod.rs new file mode 100644 index 0000000000..4fe3408938 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/mod.rs @@ -0,0 +1,12 @@ +pub mod aspect_ratio; +pub mod baggage; +pub mod file_id; +pub mod video_generation_mode; +pub mod post_id; +pub mod request_id; +pub mod sentry_trace; +pub mod svg_path_data; +pub mod user_email; +pub mod user_id; +pub mod verification_token; +pub mod xsid_numbers; diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/post_id.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/post_id.rs new file mode 100644 index 0000000000..3315dc9bb3 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/post_id.rs @@ -0,0 +1,12 @@ + +/// Type for Post IDs. +/// Post IDs are UUIds. +/// These are often shared with media/image uploads' `FileId`s, but seldom video generation `FileId`s +#[derive(Clone, Debug)] +pub struct PostId(pub String); + +impl PostId { + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/request_id.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/request_id.rs new file mode 100644 index 0000000000..1e06ece469 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/request_id.rs @@ -0,0 +1,13 @@ + +/// Request IDs. +/// Type for image websocket "tasks" +/// These are for image generation websockets. +/// Request IDs are UUIds. +#[derive(Clone, Debug)] +pub struct RequestId(pub String); + +impl RequestId { + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/sentry_trace.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/sentry_trace.rs new file mode 100644 index 0000000000..8d81c7c89d --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/sentry_trace.rs @@ -0,0 +1,40 @@ +use uuid::Uuid; + +/// "sentry trace" data from the tags +#[derive(Debug, Clone)] +pub struct SentryTrace(pub (crate) String); + +impl SentryTrace { + + /// Generate the format of trace sent in the HTTP request headers + pub fn to_http_request_header(&self) -> String { + // Python reference: + // f'{self.sentry_trace}-{str(uuid4()).replace("-", "")[:16]}-0', + // Inner bit: + // str(uuid4()).replace("-", "")[:16] + let sentry_start = &self.0; + let sentry_uuid = Uuid::new_v4().to_string(); + let sentry_inner = sentry_uuid.replace("-", "")[..16].to_string(); + + format!("{sentry_start}-{sentry_inner}-0") + } +} + +#[cfg(test)] +mod tests { + use crate::datatypes::api::sentry_trace::SentryTrace; + + #[test] + fn test_real_trace() { + let short = SentryTrace("d6d7c55e4a489c0dabaed06e5c7b257b".to_string()); + let header = short.to_http_request_header(); + assert_eq!(header.len(), 51); + } + + #[test] + fn test_invalid_trace() { + let short = SentryTrace("".to_string()); + let header = short.to_http_request_header(); + assert_eq!(header.len(), 19); // header is invalid, we're just making sure Rust doesn't panic + } +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/svg_path_data.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/svg_path_data.rs new file mode 100644 index 0000000000..800e5d6358 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/svg_path_data.rs @@ -0,0 +1,4 @@ + +/// Data on an SVG path, eg. +#[derive(Debug, Clone)] +pub struct SvgPathData(pub(crate) String); diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/user_email.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/user_email.rs new file mode 100644 index 0000000000..a6ba535a04 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/user_email.rs @@ -0,0 +1,9 @@ +/// Type for User Emails +#[derive(Clone, Debug)] +pub struct UserEmail(pub String); + +impl UserEmail { + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/user_id.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/user_id.rs new file mode 100644 index 0000000000..c7e522fddc --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/user_id.rs @@ -0,0 +1,12 @@ + +/// Type for User IDs. +/// User IDs are UUIDs. +#[derive(Clone, Debug)] +pub struct UserId(pub String); + +impl UserId { + pub fn to_string(&self) -> String { + self.0.clone() + } +} + diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/verification_token.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/verification_token.rs new file mode 100644 index 0000000000..f7dba1f88f --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/verification_token.rs @@ -0,0 +1,3 @@ + +#[derive(Debug, Clone)] +pub struct VerificationToken(pub(crate) String); diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/video_generation_mode.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/video_generation_mode.rs new file mode 100644 index 0000000000..3036b34114 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/video_generation_mode.rs @@ -0,0 +1,27 @@ + +/// The video generation mode selector. +#[derive(Copy, Clone, Debug)] +pub enum VideoGenerationMode { + /// Supposedly the "NSFW" mode. + Spicy, + + Fun, + + Normal, + + /// This might be the only mode that allows for a custom text prompt + Custom, +} + +impl VideoGenerationMode { + + /// This is the value for `--mode` in video generation chats. + pub fn as_api_mode_arg(&self) -> &'static str { + match self { + Self::Spicy => "extremely-spicy-or-crazy", + Self::Fun => "extremely-crazy", + Self::Normal => "normal", + Self::Custom => "custom", + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/api/xsid_numbers.rs b/crates/api_clients/grok_consumer_client/src/datatypes/api/xsid_numbers.rs new file mode 100644 index 0000000000..484b010066 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/api/xsid_numbers.rs @@ -0,0 +1,6 @@ + +/// Magic numbers pulled from Javascript +#[derive(Clone, Debug)] +pub struct XsidNumbers { + pub numbers: Vec, +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/file_upload_spec.rs b/crates/api_clients/grok_consumer_client/src/datatypes/file_upload_spec.rs new file mode 100644 index 0000000000..2e2e435645 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/file_upload_spec.rs @@ -0,0 +1,18 @@ +use std::path::Path; + +/// Descriptor of a file to be uploaded +/// Includes necessary metadata +pub enum FileUploadSpec> { + /// Path to the file + Path(P), + + /// Bytes of the file + Bytes { + /// Must be owned. + bytes: Vec, + /// Must be owned. + filename: String, + /// Mime type + mimetype: String, + } +} diff --git a/crates/api_clients/grok_consumer_client/src/datatypes/mod.rs b/crates/api_clients/grok_consumer_client/src/datatypes/mod.rs new file mode 100644 index 0000000000..fa09308d96 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/datatypes/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod file_upload_spec; diff --git a/crates/api_clients/grok_consumer_client/src/error/categorize_grok_http_error.rs b/crates/api_clients/grok_consumer_client/src/error/categorize_grok_http_error.rs new file mode 100644 index 0000000000..4d84b63918 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/error/categorize_grok_http_error.rs @@ -0,0 +1,37 @@ +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use wreq::StatusCode; + +/// We know the response is an error at this point. +/// We're just turning it into the right error. +pub fn categorize_grok_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> GrokError { + + if let Some(body) = maybe_body { + if let Err(err) = filter_cloudflare_errors(status_code.as_u16(), body) { + return GrokGenericApiError::CloudflareError(err).into(); + } + + let body = body.to_lowercase(); + let anti_bot = body.contains("anti-bot") || body.contains("rejected"); + + if anti_bot { + return GrokSpecificApiError::AutomationBlocked.into(); + } + + match status_code { + StatusCode::TOO_MANY_REQUESTS => { + return GrokSpecificApiError::TooManyVideos.into(); + } + _ => {}, + } + + return GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: body.to_string(), + }.into(); + } + + GrokGenericApiError::UncategorizedBadResponseWithStatus(status_code).into() +} diff --git a/crates/api_clients/grok_consumer_client/src/error/grok_client_error.rs b/crates/api_clients/grok_consumer_client/src/error/grok_client_error.rs new file mode 100644 index 0000000000..b33f902558 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/error/grok_client_error.rs @@ -0,0 +1,154 @@ +use base64::DecodeError; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum GrokClientError { + + /// An error was encountered in building the Wreq client + WreqClientError(wreq::Error), + + /// Error serializing a message to send the websocket + WebsocketRequestSerializationError(serde_json::Error), + + /// Error locking the websocket for sending/receiving + WebsocketLockError, + + /// Error reading from a websocket. + WebsocketReadError(wreq::Error), + + /// Error sending to an open websocket. + WebsocketSendError(wreq::Error), + + /// Can't open a local file for uploading. + CannotOpenLocalFileForUpload(std::io::Error), + + /// Can't read a local file for uploading. + CannotReadLocalFileForUpload(std::io::Error), + + /// Couldn't write to the file + CannotOpenLocalFileForWriting(std::io::Error), + + /// Couldn't write to the file + CannotWriteLocalFile(std::io::Error), + + /// File for upload has an invalid path. + FileForUploadHasInvalidPath, + + /// Error parsing HTML + HtmlParsingError, + + /// Error decoding verification token + FailedToDecodeVerificationToken(DecodeError), + + /// Verification token bytes are invalid + InvalidVerificationTokenBytes, + + /// Our script logic is out of date + ScriptLogicOutOfDate, + + /// Our script logic is out of date (SVG) + ScriptSvgLogicOutOfDate, + + /// Our script logic is out of date (script 1) + Script1LogicOutOfDate, + + /// Our script logic is out of date (script 2) + Script2LogicOutOfDate, + + /// Our script logic is out of date (xsid script) + ScriptXsidLogicOutOfDate, + + /// Signature algorithm isn't working with inputs + BadSignatureInputs, + + /// Something is broken with timeout math + TimeoutMathBroken, + + /// Can't make request because cookies aren't present + NoCookiesPresent, + + /// Unknown error generating video + ErrorGeneratingVideo, + +// /// An error reading the file for upload. +// FileForUploadReadError(std::io::Error), +// +// /// The file path provided for upload is invalid. +// FileForUploadHasInvalidPath, +// +// /// Something is wrong with the JWT bearer token. +// /// This error originates on our end as we try to parse the JWT. +// LocalJwtClaimsParseError(String), +// +// /// There was an error constructing the form-multipart request. +// MultipartFormError(wreq::Error), +// +// /// We haven't received a bearer token yet +// /// This is our own internal application state error, not something Sora returns. +// /// We know our client can't make the request, so we preemptively fail it. +// NoBearerTokenForRequest, +// +// /// A sentinel token is not present in the client, which is required for some requests. +// NoSentinelTokenForRequest, +// +// /// Issue with using the SoraCredentialBuilder. +// SoraCredentialBuilderError(&'static str), +// +// /// Error parsing a request URL. +// UrlParseError(url::ParseError), +// +// /// Error serializing the sentinel store token to JSON (typically for persistent storage). +// CouldNotSerializeSentinelTokenStore(serde_json::Error), +// +// /// Error deserializing the sentinel token from JSON (typically for persistent storage). +// CouldNotDeserializeSentinelTokenStore { +// error: serde_json::Error, +// raw_json: String +// }, +} + +impl Error for GrokClientError {} + +impl Display for GrokClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), + Self::WebsocketRequestSerializationError(err) => write!(f, "Websocket request serialization error: {}", err), + Self::WebsocketLockError => write!(f, "Websocket lock error"), + Self::WebsocketReadError(err) => write!(f, "Websocket read error: {}", err), + Self::WebsocketSendError(err) => write!(f, "Websocket send error: {}", err), + Self::CannotOpenLocalFileForUpload(err) => write!(f, "Cannot open local file for upload: {}", err), + Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err), + Self::CannotOpenLocalFileForWriting(err) => write!(f, "Cannot open local file for writing: {}", err), + Self::CannotWriteLocalFile(err) => write!(f, "Cannot write local file: {}", err), + Self::FileForUploadHasInvalidPath => write!(f, "File for upload has invalid path"), + Self::HtmlParsingError => write!(f, "Html parsing error"), + Self::FailedToDecodeVerificationToken(err) => write!(f, "Failed to decode verification token: {}", err), + Self::InvalidVerificationTokenBytes => write!(f, "Invalid verification token bytes"), + Self::ScriptLogicOutOfDate => write!(f, "Script logic out of date"), + Self::ScriptSvgLogicOutOfDate => write!(f, "Script logic out of date (SVG parsing)"), + Self::Script1LogicOutOfDate => write!(f, "Script logic out of date (script 1)"), + Self::Script2LogicOutOfDate => write!(f, "Script logic out of date (script 2)"), + Self::ScriptXsidLogicOutOfDate => write!(f, "Script logic out of date (xsid script)"), + Self::BadSignatureInputs => write!(f, "Bad signature inputs"), + Self::TimeoutMathBroken => write!(f, "Timeout math is broken"), + Self::NoCookiesPresent => write!(f, "No cookies present"), + Self::ErrorGeneratingVideo => write!(f, "Error generating video"), + + //Self::FileForUploadReadError(err) => write!(f, "Error reading file for upload: {}", err), + //Self::FileForUploadHasInvalidPath => write!(f, "The file path provided for upload is invalid."), + //Self::LocalJwtClaimsParseError(msg) => write!(f, "Local JWT claims parse error: {}", msg), + //Self::MultipartFormError(err) => write!(f, "Multipart form error: {}", err), + //Self::NoBearerTokenForRequest => write!(f, "No bearer token available. The client needs a bearer token to make the request."), + //Self::NoSentinelTokenForRequest => write!(f, "No sentinel token available. The client needs a sentinel token to make the request."), + //Self::SoraCredentialBuilderError(msg) => write!(f, "Sora Credential Builder error: {}", msg), + //Self::UrlParseError(err) => write!(f, "URL parse error: {}", err), + //Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err), + //Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err), + //Self::CouldNotDeserializeSentinelTokenStore { error, raw_json} => { + // write!(f, "Could not deserialize sentinel token store from JSON: {:?}, raw JSON: {}", error, raw_json) + //}, + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/error/grok_error.rs b/crates/api_clients/grok_consumer_client/src/error/grok_error.rs new file mode 100644 index 0000000000..31a79ae950 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/error/grok_error.rs @@ -0,0 +1,53 @@ +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; + +#[derive(Debug)] +pub enum GrokError { + Client(GrokClientError), + ApiSpecific(GrokSpecificApiError), + ApiGeneric(GrokGenericApiError), +} + +impl GrokError { + pub fn is_grok_having_downtime_issues(&self) -> bool { + match self { + Self::ApiGeneric(GrokGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true, + Self::ApiGeneric(GrokGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true, + Self::ApiGeneric(GrokGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true, + _ => false, + } + } +} + +impl Error for GrokError {} + +impl std::fmt::Display for GrokError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "GrokClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "GrokSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "GrokGenericApiError: {:?}", e), + } + } +} + +impl From for GrokError { + fn from(error: GrokClientError) -> Self { + Self::Client(error) + } +} + +impl From for GrokError { + fn from(error: GrokSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for GrokError { + fn from(error: GrokGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/error/grok_generic_api_error.rs b/crates/api_clients/grok_consumer_client/src/error/grok_generic_api_error.rs new file mode 100644 index 0000000000..a56d0f3ab2 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/error/grok_generic_api_error.rs @@ -0,0 +1,82 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use wreq::StatusCode; + +#[derive(Debug)] +pub enum GrokGenericApiError { + /// Specific Cloudflare errors. + CloudflareError(CloudflareError), + + /// The downloaded index.html did not include the expected data. + /// This is necessary for handshakes between client and server. + IndexHtmlDidNotIncludeExpectedData { + message: String, + }, + + /// The downloaded javascript did not include the expected data. + /// This is necessary for handshakes between client and server. + ScriptBodyDidNotIncludeExpectedData { + message: String, + }, + + /// Error creating the websocket due to 401 Unauthorized, likely an authentication error with Grok. + /// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues. + LikelyWebsocketAuthentication401, + + /// We received a 403 on websocket upgrade, likely from Cloudflare. + /// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues. + LikelyWebsocketCloudflare403, + + /// Unknown status code on websocket upgrade. + UnexpectedWebsocketUpgradeStatusCode(StatusCode), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + /// Specifically, this is for non-200 (error) responses. + SerdeParseErrorWithBodyOnNon200(serde_json::Error, String), + + /// An uncategorized bad HTTP response from Grok. + UncategorizedBadResponseWithStatus(StatusCode), + + /// An uncategorized bad HTTP response from Grok. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// Our previous upload failed + UploadFailed, + + /// An error upgrading the connection to a websocket. + WreqWebsocketUpgradeError(wreq::Error), + + /// An uncaught error from the API client. + WreqError(wreq::Error), +} + +impl Error for GrokGenericApiError {} + +impl Display for GrokGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), + Self::IndexHtmlDidNotIncludeExpectedData { message } => write!(f, "IndexHtmlDidNotIncludeExpectedData ( message: {} )", message), + Self::ScriptBodyDidNotIncludeExpectedData { message } => write!(f, "ScriptBodyDidNotIncludeExpectedData ( message: {} )", message), + Self::LikelyWebsocketAuthentication401 => write!(f, "Likely websocket authentication 401 error (probably Grok authentication issue with cookies)"), + Self::LikelyWebsocketCloudflare403 => write!(f, "Likely Cloudflare 403 error on websocket upgrade"), + Self::UnexpectedWebsocketUpgradeStatusCode(status) => write!(f, "Unexpected websocket upgrade status code: {}", status), + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), + Self::UncategorizedBadResponseWithStatus(status) => write!(f, "Uncategorized with status code: {}", status), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::UploadFailed => write!(f, "Upload failed"), + Self::WreqWebsocketUpgradeError(err) => write!(f, "Websocket upgrade error: {}", err), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/error/grok_specific_api_error.rs b/crates/api_clients/grok_consumer_client/src/error/grok_specific_api_error.rs new file mode 100644 index 0000000000..e0fd7e4078 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/error/grok_specific_api_error.rs @@ -0,0 +1,78 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum GrokSpecificApiError { + /// Grok is detecting and blocking our bot, likely due to new secrets use. + AutomationBlocked, + + /// Too many videos are being generated. + TooManyVideos, + + +// /// Failed to generate a bearer token despite the `200 OK` response. +// /// This has been generated by sending old/expired session cookies or empty cookies. +// FailedToGenerateBearerToken, +// +// /// The endpoint told us we used an invalid JWT. +// InvalidJwt, +// +// /// Sora has responded with "sentinel block". +// /// This usually means we need to refresh our sentinel token. +// SentinelBlockError, +// +// /// Sora requires users to set up an account username before they can generate images. +// /// This requires the user to set up that username. +// SoraUsernameNotYetCreated, +// +// /// Sora has responded with "token expired". +// /// This (probably?) means we need to refresh our JWT bearer token. +// TokenExpiredError, +// +// /// We're sending too many tasks to Sora. +// /// Sora is telling us to cool it. +// TooManyConcurrentTasks, +// +// // TODO: Try to make concrete error types for these distinct cases. +// /// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login. +// /// +// /// Example message, e.g. from the upload endpoint: +// /// { +// /// "error": { +// /// "message": "Your authentication token has expired. Please try signing in again.", +// /// "type": "invalid_request_error", +// /// "param": null, +// /// "code": "token_expired" +// /// } +// /// } +// UnauthorizedCookieOrBearerExpired, +// +// /// The Sora sentinel token response is missing fields +// SentinelResponseIsMissingFields { +// missing_token: bool, +// missing_turnstile: bool, +// raw_response: String, +// }, +} + +impl Error for GrokSpecificApiError {} + +impl Display for GrokSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::AutomationBlocked => write!(f, "Automation blocked"), + Self::TooManyVideos => write!(f, "Too many videos are being generated"), + + //Self::FailedToGenerateBearerToken => write!(f, "Failed to generate bearer token. Probably invalid or expired session cookies."), + //Self::InvalidJwt => write!(f, "Invalid JWT token. Please refresh the token."), + //Self::SentinelBlockError => write!(f, "Request blocked by Sentinel Token Block. Please refresh the sentinel token."), + //Self::SoraUsernameNotYetCreated => write!(f, "Sora username not yet created. Please create a username in the Sora app or website."), + //Self::TokenExpiredError => write!(f, "JWT token has expired. Please refresh the token."), + //Self::TooManyConcurrentTasks => write!(f, "Too many concurrent tasks. Please wait."), + //Self::UnauthorizedCookieOrBearerExpired => write!(f, "Unauthorized: cookie and/or bearer token expired"), + //Self::SentinelResponseIsMissingFields { missing_token: token_missing, missing_turnstile: turnstile_missing, raw_response } => { + // write!(f, "Sentinel response is missing fields. token_missing: {}, turnstile_missing: {}. Raw response: {}", token_missing, turnstile_missing, raw_response) + //}, + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/error/mod.rs b/crates/api_clients/grok_consumer_client/src/error/mod.rs new file mode 100644 index 0000000000..97c7749fe7 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod grok_client_error; +pub mod grok_error; +pub mod grok_generic_api_error; +pub mod grok_specific_api_error; +pub (crate) mod categorize_grok_http_error; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/lib.rs b/crates/api_clients/grok_consumer_client/src/lib.rs new file mode 100644 index 0000000000..f0d3e6b848 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/lib.rs @@ -0,0 +1,15 @@ +//! A Grok API client. + +#[cfg(test)] +pub (crate) mod test_utils; + +// Library utils +pub (crate) mod client; + +// User lib +pub mod credentials; +pub mod datatypes; +pub mod error; +pub mod recipes; +pub mod requests; +pub mod utils; diff --git a/crates/api_clients/grok_consumer_client/src/recipes/_metadata/video_gen_request_flows.md b/crates/api_clients/grok_consumer_client/src/recipes/_metadata/video_gen_request_flows.md new file mode 100644 index 0000000000..e6e7290d82 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/recipes/_metadata/video_gen_request_flows.md @@ -0,0 +1,93 @@ +Upload Image -> Video Flow +========================== + +1. Upload Image + + Endpoint: https://grok.com/rest/app-chat/upload-file + + Response Fields: + { + **"fileMetadataId": "21a79085-e206-4b0b-88ac-5f2b7a453e45",** + **"fileUri": "users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content",** + "fileMimeType": "image/jpeg", + "fileName": "0_0.jpeg", + "parsedFileUri": "", + "createTime": "2025-10-21T23:40:41.784448Z", + "fileSource": "SELF_UPLOAD_FILE_SOURCE" + } + + + File becomes a 480x640 copy: + + https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content + +2. "Create Media Post" **(needed?)** + + Endpoint: https://grok.com/rest/media/post/create + + Request Fields: + { + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content" + } + + Response: + { + "post": { + "id": "21a79085-e206-4b0b-88ac-5f2b7a453e45", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-21T23:40:41.993980477Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [] + } + } + +3. Chat Conversation for Video + + Endpoint: https://grok.com/rest/app-chat/conversations/new + + Request: + { + "temporary":true, + "modelName":"grok-3", + "message":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content --mode=normal", + "fileAttachments":["21a79085-e206-4b0b-88ac-5f2b7a453e45"], + "toolOverrides": { + "videoGen":true + } + } + +Header 'x-xai-request-id: 7cb93340-5fb6-4fb3-b88d-ff9f5d99d456' is just a random UUID and it can be changed. + + +Generate Image -> Video Flow +============================ + +(TODO) + +2. Create Media Post + + Request + { + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl":"https://imagine-public.x.ai/imagine-public/images/2d8260d7-3b7c-4bc1-84ca-6e82dd892c39.png" + } + + +3. Chat Conversation for Video + + Endpoint: https://grok.com/rest/app-chat/conversations/new + + Request: + { + "temporary": true, + "modelName": "grok-3", + "message": "https://imagine-public.x.ai/imagine-public/images/2d8260d7-3b7c-4bc1-84ca-6e82dd892c39.png alien walks down the hallway --mode=custom", + "toolOverrides": { + "videoGen":true + } + } diff --git a/crates/api_clients/grok_consumer_client/src/recipes/mod.rs b/crates/api_clients/grok_consumer_client/src/recipes/mod.rs new file mode 100644 index 0000000000..68bb011571 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/recipes/mod.rs @@ -0,0 +1,4 @@ +pub mod prompt_websocket_image_with_retry; +pub mod request_client_secrets; +pub mod upload_image_and_generate_video; +pub mod upload_image_and_generate_video_with_retry; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/recipes/prompt_websocket_image_with_retry.rs b/crates/api_clients/grok_consumer_client/src/recipes/prompt_websocket_image_with_retry.rs new file mode 100644 index 0000000000..f6cea7cb1f --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/recipes/prompt_websocket_image_with_retry.rs @@ -0,0 +1,201 @@ +use crate::credentials::grok_cookies::GrokCookies; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs}; +use crate::requests::image_websocket::grok_websocket::GrokWebsocket; +use crate::requests::image_websocket::messages::websocket_client_message::{ClientMessageAspectRatio, WebsocketClientMessage}; +use log::{error, info}; + +pub struct PromptWebsocketImageWithRetryArgs<'a> { + pub websocket: &'a mut GrokWebsocket, + pub prompt: &'a str, + pub aspect_ratio: ClientMessageAspectRatio, + pub cookies: &'a GrokCookies, +} + +pub struct PromptWebsocketImageWithRetryResult { + pub maybe_new_websocket: Option, +} + + +pub async fn prompt_websocket_image_with_retry(args: PromptWebsocketImageWithRetryArgs<'_>) -> Result +{ + let mut maybe_new_websocket : Option = None; + + for i in 0..3 { + info!("Starting Grok Websocket image generation (attempt {}) ...", (i+1)); + + let message = WebsocketClientMessage::new_image_prompt(args.prompt, args.aspect_ratio); + + let result = + if let Some(websocket) = maybe_new_websocket.as_mut() { + websocket.send_serializable(&message).await + } else { + args.websocket.send_serializable(&message).await + }; + + match result { + Ok(()) => { + break; + } + Err(GrokError::Client(GrokClientError::WreqClientError(err))) => { + error!("Error sending message to websocket (WreqClientError): {}", err); + } + Err(GrokError::Client(err)) => { + error!("Error sending message to websocket (GrokClientError): {}", err); + } + Err(GrokError::ApiSpecific(err)) => { + error!("Error sending message to websocket (GrokSpecificApiError): {}", err); + } + Err(GrokError::ApiGeneric(err)) => { + error!("Error sending message to websocket (GrokGenericApiError): {}", err); + } + } + + let result = create_listen_websocket(CreateListenWebsocketArgs { + cookies: args.cookies.as_str(), + }).await?; + + maybe_new_websocket = Some(GrokWebsocket::new(result)); + } + + Ok(PromptWebsocketImageWithRetryResult { + maybe_new_websocket, + }) +} + +#[cfg(test)] +mod tests { + use crate::recipes::prompt_websocket_image_with_retry::{prompt_websocket_image_with_retry, PromptWebsocketImageWithRetryArgs}; + use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs}; + use crate::requests::image_websocket::grok_websocket::GrokWebsocket; + use crate::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket; + use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; + use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage; + use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs}; + use crate::test_utils::get_test_cookies::{get_test_cookies, get_typed_test_cookies}; + use errors::AnyhowResult; + use futures::SinkExt; + use log::warn; + use std::io::Write; + use std::time::Duration; + use wreq::ws::message::{CloseCode, Message, Utf8Bytes}; + + #[tokio::test] + #[ignore] // manually test + async fn prompt() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let cookies = get_typed_test_cookies()?; + + let websocket = create_listen_websocket(CreateListenWebsocketArgs { + cookies: cookies.as_str(), + }).await?; + + let prompt = "A dinosaur on stilts walking on the beach"; + + //let websocket = GrokWrappedWebsocket::new(websocket); + let mut websocket = GrokWebsocket::new(websocket); + + //println!("Sending..."); + //std::io::stdout().flush()?; + + //println!("For our test, we'll close the websocket and retry..."); + //websocket.websocket + // .close(CloseCode::NORMAL, Utf8Bytes::try_from(Vec::new())?).await?; + + //let mut websocket = GrokWebsocket::new(websocket.websocket); + + let result = prompt_websocket_image_with_retry(PromptWebsocketImageWithRetryArgs { + websocket: &mut websocket, + prompt, + aspect_ratio: ClientMessageAspectRatio::WideThreeByTwo, + cookies: &cookies, + }).await?; + + println!("Reading..."); + std::io::stdout().flush()?; + + let mut count = 0; + + println!("Polling."); + + loop { + //let maybe_message = + // websocket.get_response_with_timeout(Duration::from_millis(1000)).await?; + + let maybe_message = + websocket.try_next_timeout(Duration::from_millis(1000)).await?; + + let mut maybe_raw_text : Option = None; + + let maybe_message = match maybe_message { + None => None, + Some(Message::Text(text)) => { + let maybe_message = WebsocketServerMessage::from_json_str(text.as_str())?; + let mut text = text.as_str().to_string(); + text.truncate(100); + //println!("Received message: {}", text); + maybe_raw_text = Some(text); + Some(maybe_message) + }, + Some(_) => { + warn!("Received non-text websocket message."); + None + }, + }; + + match maybe_message { + None => { + println!("No message received within timeout."); + count = count + 1; + } + Some(message) => { + match message { + WebsocketServerMessage::Image(image) => { + println!("IMAGE: {:?}", image.percentage_complete); + if let Some(percent) = image.percentage_complete { + if percent > 90.0 { + println!("Image: {:?}", image); + } + } + } + WebsocketServerMessage::Json(json) => { + println!("JSON : {:?}", json.percentage_complete); + if let Some(percent) = json.percentage_complete { + if percent > 90.0 { + println!("Image: {:?}", json); + } + } + } + WebsocketServerMessage::Unknown(unknown) => { + //let typ = unknown.get("type"); + let unknown_string = unknown.as_str(); + println!("[UNKNOWN] websocket message: {:?}", maybe_raw_text); + } + } + count = 0; + } + } + + // TODO: Make sure we capture all the events for images + // TODO: Explore the other APIs + // TODO understand the IDs + // TODO: Video websocket APIs + // TODO: Upload APIs + + if count > 5 { + println!("No messages after 5 seconds"); + break; + } + } + + println!("Done polling."); + + log::logger().flush(); + + assert_eq!(1,2); + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/recipes/request_client_secrets.rs b/crates/api_clients/grok_consumer_client/src/recipes/request_client_secrets.rs new file mode 100644 index 0000000000..f77afaff54 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/recipes/request_client_secrets.rs @@ -0,0 +1,124 @@ +use crate::credentials::grok_client_secrets::GrokClientSecrets; +use crate::credentials::grok_cookies::GrokCookies; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs}; +use crate::requests::index_page::get_xsid_script::{get_xsid_script, GetXsidScriptArgs}; +use crate::requests::index_page::parsers::index::parse_index_baggage::parse_index_baggage; +use crate::requests::index_page::parsers::index::parse_index_sentry_trace::parse_index_sentry_trace; +use crate::requests::index_page::parsers::index::parse_index_svg_paths::parse_svg_paths_from_index_html; +use crate::requests::index_page::parsers::index::parse_index_user_email::parse_index_user_email; +use crate::requests::index_page::parsers::index::parse_index_user_id::parse_index_user_id; +use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token; +use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::parse_script_actions_and_xsid_script_path; +use crate::requests::index_page::parsers::script::parse_xsid_script_numbers::parse_xsid_script_numbers; +use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim; +use crate::requests::index_page::utils::select_svg_path_by_loading_anim::select_svg_path_by_loading_anim; + +pub struct RequestClientSecretsArgs<'a> { + pub cookies: &'a GrokCookies, +} + +// Video generation needs: +// 1) baggage -- on index page +// 2) sentry_trace -- on index page +// 3) x_statsig + +/// Load all the things needed to make requests. +pub async fn request_client_secrets(args: RequestClientSecretsArgs<'_>) -> Result { + + let payloads = get_index_page_and_scripts(GetIndexPageAndScriptsArgs { + cookie: args.cookies.as_str(), + }).await?; + + let baggage = parse_index_baggage(&payloads.index_body_html) + .ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData { + message: "Index did not include baggage.".to_string() + })?; + + let sentry_trace = parse_index_sentry_trace(&payloads.index_body_html) + .ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData { + message: "Index did not include sentry trace.".to_string() + })?; + + let verification_token = parse_index_verification_token(&payloads.index_body_html) + .ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData { + message: "Index did not include verification token.".to_string() + })?; + + let user_id = parse_index_user_id(&payloads.index_body_html) + .ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData { + message: "Index did not include user id.".to_string() + })?; + + // NB: Optional. + let maybe_user_email = parse_index_user_email(&payloads.index_body_html); + + // NB: We'll only use one of the several SVG paths + let svg_paths = parse_svg_paths_from_index_html(&payloads.index_body_html); + + if svg_paths.is_empty() { + return Err(GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData { + message: "Index did not include any SVG paths.".to_string(), + }.into()); + } + + let loading_anim = convert_verification_token_to_loading_anim(&verification_token)?; + + let svg_path_data = select_svg_path_by_loading_anim(&svg_paths, &loading_anim)?; + + let actions_and_xsid_script = parse_script_actions_and_xsid_script_path(&payloads.scripts)?; + + let xsid_script = get_xsid_script(GetXsidScriptArgs { + client: &payloads.client, + html: &payloads.index_body_html, + cookie: args.cookies.as_str(), + xsid_script_id: &actions_and_xsid_script.xsid_script_path, + }).await?; + + let numbers = parse_xsid_script_numbers(&xsid_script.xsid_script_body); + + if numbers.numbers.is_empty() { + return Err(GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData { + message: "Index did not include any SVG paths.".to_string(), + }.into()); + } + + Ok(GrokClientSecrets { + baggage, + sentry_trace, + verification_token, + svg_path_data, + numbers, + user_id, + user_email: maybe_user_email, + }) +} + +#[cfg(test)] +mod tests { + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookies = get_typed_test_cookies()?; + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + println!("Baggage: {:?}", secrets.baggage); + println!("Sentry trace: {:?}", secrets.sentry_trace); + println!("Verification token: {:?}", secrets.verification_token); + println!("SVG path: {:?}", secrets.svg_path_data); + println!("Numbers: {:?}", secrets.numbers); + println!("User ID: {:?}", secrets.user_id); + println!("User Email: {:?}", secrets.user_email); + + assert_eq!(1, 2); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/recipes/upload_image_and_generate_video.rs b/crates/api_clients/grok_consumer_client/src/recipes/upload_image_and_generate_video.rs new file mode 100644 index 0000000000..01e8421010 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/recipes/upload_image_and_generate_video.rs @@ -0,0 +1,242 @@ +use crate::credentials::grok_full_credentials::GrokFullCredentials; +use crate::datatypes::api::aspect_ratio::AspectRatio; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::post_id::PostId; +use crate::datatypes::api::video_generation_mode::VideoGenerationMode; +use crate::datatypes::file_upload_spec::FileUploadSpec; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::media_posts::create_media_post::grok_create_media_post::{GrokCreateMediaPost, MediaPostType}; +use crate::requests::media_posts::like_media_post::grok_like_media::GrokLikeMediaPost; +use crate::requests::upload_file::grok_upload_file::GrokUploadFile; +use crate::requests::video_chat::grok_video_gen_chat_conversation::{GrokVideoGenChatConversationBuilder, VideoMediaPostType}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; +use log::{error, info}; +use std::path::Path; +use std::time::Duration; + +pub struct UploadImageAndGenerateVideo<'a, P: AsRef> { + pub full_credentials: &'a GrokFullCredentials, + + // NB: Must be owned. + pub file: FileUploadSpec

, + + /// Video generation prompt + pub prompt: Option<&'a str>, + + /// Aspect ratio for the video. + pub aspect_ratio: Option, + + /// Mode to generate the video: normal, fun, spicy, etc. + pub mode: Option, + + /// Wait for the full video to be generated before returning + /// By default, the endpoint stays open for 30-ish seconds while + /// the video generates and streams back JSON progress updates. + /// If we set this to false, we'll wait for the video ID and exit + /// the request early, before the video is finished asynchronously + /// generating. + pub wait_for_generation: bool, + + pub individual_request_timeout: Option, +} + +#[derive(Debug, Clone)] +pub struct ImageUploadAndGenerateVideoResult { + /// The ID of the "post" + /// This often is equivalent to the upload id / `image_file_id`, + /// and this is in the user-facing URL for website navigation to the video. + pub post_id: PostId, + + /// The ID of the file we uploaded + pub image_file_id: FileId, + + /// The ID of the video we're generating, which may or may not exist yet. + pub video_file_id: Option, + + /// The URL for the video, which may or may not exist yet. + pub video_url: Option, + + /// Whether the video generation has already completed. + /// Based on request `wait_for_generation`. + pub generation_is_complete: bool, +} + +pub async fn upload_image_and_generate_video>(args: UploadImageAndGenerateVideo<'_, P>) + -> Result +{ + + info!("Uploading file to Grok..."); + + let request = GrokUploadFile { + file: args.file, + cookie: args.full_credentials.cookies.to_string(), + request_timeout: args.individual_request_timeout, + }; + + let upload_result = request.upload().await?; + + info!("File URI: {:?}", upload_result.file_uri); + info!("File Metadata ID: {:?}", upload_result.file_id); + + let upload_file_id = match upload_result.file_id { + Some(id) => id, + None => { + error!("Previous file upload failed. Cannot continue."); + return Err(GrokGenericApiError::UploadFailed.into()); + } + }; + + let url = user_and_file_id_to_image_url( + &args.full_credentials.client_secrets.user_id, + &upload_file_id + ); + + info!("Uploaded URI: {:?}", url); + + info!("Creating media post..."); + + let request = GrokCreateMediaPost { + user_id: &args.full_credentials.client_secrets.user_id, + file_id: &upload_file_id, + media_type: MediaPostType::UserUploadedImage, + cookie: args.full_credentials.cookies.as_str(), + request_timeout: args.individual_request_timeout, + }; + + let post_result = request.send().await?; + + info!("Posted Image."); + + info!("Video prompt: {:?}", args.prompt); + info!("Video aspect ratio: {:?}", args.aspect_ratio); + + info!("Generate video..."); + + let request = GrokVideoGenChatConversationBuilder { + user_id: &args.full_credentials.client_secrets.user_id, + file_id: &upload_file_id, + media_type: VideoMediaPostType::UserUploadedImage, + cookie: args.full_credentials.cookies.as_str(), + prompt: args.prompt, + mode: args.mode, + aspect_ratio: args.aspect_ratio, + request_timeout: args.individual_request_timeout, + baggage: &args.full_credentials.client_secrets.baggage, + sentry_trace: &args.full_credentials.client_secrets.sentry_trace, + verification_token: &args.full_credentials.client_secrets.verification_token, + svg_data: &args.full_credentials.client_secrets.svg_path_data, + numbers: &args.full_credentials.client_secrets.numbers, + }; + + let maybe_video_file_id; + let generation_is_complete; + + if args.wait_for_generation { + info!("Waiting for video generation..."); + let video_gen_result = request.wait_for_video().await?; + maybe_video_file_id = video_gen_result.video_file_id; + generation_is_complete = true; + } else { + info!("Streaming response partially..."); + let video_gen_result = request.stream_only_video_id().await?; + maybe_video_file_id = video_gen_result.video_file_id; + generation_is_complete = false; + } + + let maybe_video_url = maybe_video_file_id + .as_ref() + .map(|file_id| { + user_and_file_id_to_video_url(&args.full_credentials.client_secrets.user_id, file_id, false) + }); + + info!("Video Generation Enqueued"); + + info!("Liking media..."); + + let request = GrokLikeMediaPost { + file_id: &upload_file_id, + cookie: args.full_credentials.cookies.as_str(), + request_timeout: args.individual_request_timeout, + baggage: &args.full_credentials.client_secrets.baggage, + sentry_trace: &args.full_credentials.client_secrets.sentry_trace, + verification_token: &args.full_credentials.client_secrets.verification_token, + svg_data: &args.full_credentials.client_secrets.svg_path_data, + numbers: &args.full_credentials.client_secrets.numbers, + }; + + let _like_result = request.send().await?; + + info!("Media Liked"); + + Ok(ImageUploadAndGenerateVideoResult { + post_id: post_result.post_id, + image_file_id: upload_file_id, + video_file_id: maybe_video_file_id, + video_url: maybe_video_url, + generation_is_complete, + }) +} + + +#[cfg(test)] +mod tests { + use crate::credentials::grok_full_credentials::GrokFullCredentials; + use crate::datatypes::api::aspect_ratio::AspectRatio; + use crate::datatypes::file_upload_spec::FileUploadSpec; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::recipes::upload_image_and_generate_video::{upload_image_and_generate_video, UploadImageAndGenerateVideo}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + // Result: GrokUploadFileResponse { file_metadata_id: + // Some("acdee48f-9d6f-4bc6-9d06-fcc97dd4418a"), file_uri: + // Some("users/85980643-ffab-4984-a3de-59a608c47d7f/acdee48f-9d6f-4bc6-9d06-fcc97dd4418a/content") } + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_upload_image_and_generate_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + //let image_path = "/Users/bt/Pictures/Zelda 64 Art/FCgYX6tWEAEhpsy.jpg"; + //let prompt = "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura"; + + let image_path = "/Users/bt/Pictures/Midjourney/hipster_girl.png"; + let maybe_prompt = Some("an anime girl runs away from a giant t-rex"); + + let cookies = get_typed_test_cookies()?; + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + println!("[test] Verification Token: {:?}", secrets.verification_token); + println!("[test] Sentry Trace: {:?}", secrets.sentry_trace); + println!("[test] Numbers: {:?}", secrets.numbers); + println!("[test] Svg Path: {:?}", secrets.svg_path_data); + println!("[test] Baggage: {:?}", secrets.baggage); + + let credentials = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets); + + let video_result = upload_image_and_generate_video(UploadImageAndGenerateVideo { + full_credentials: &credentials, + file: FileUploadSpec::Path(image_path), + prompt: maybe_prompt, + aspect_ratio: Some(AspectRatio::Square), + mode: None, + individual_request_timeout: None, + wait_for_generation: false, + }).await?; + + println!("[test] Post ID: {:?}", video_result.post_id); + println!("[test] Image File ID: {:?}", video_result.image_file_id); + println!("[test] Video File ID: {:?}", video_result.video_file_id); + println!("[test] Video URL: {:?}", video_result.video_url); + + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/recipes/upload_image_and_generate_video_with_retry.rs b/crates/api_clients/grok_consumer_client/src/recipes/upload_image_and_generate_video_with_retry.rs new file mode 100644 index 0000000000..856d97338a --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/recipes/upload_image_and_generate_video_with_retry.rs @@ -0,0 +1,348 @@ +use crate::credentials::grok_client_secrets::GrokClientSecrets; +use crate::credentials::grok_full_credentials::GrokFullCredentials; +use crate::datatypes::api::aspect_ratio::AspectRatio; +use crate::datatypes::api::video_generation_mode::VideoGenerationMode; +use crate::datatypes::file_upload_spec::FileUploadSpec; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; +use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; +use crate::recipes::upload_image_and_generate_video::{ImageUploadAndGenerateVideoResult, UploadImageAndGenerateVideo}; +use crate::requests::media_posts::create_media_post::grok_create_media_post::{GrokCreateMediaPost, MediaPostType}; +use crate::requests::media_posts::like_media_post::grok_like_media::GrokLikeMediaPost; +use crate::requests::upload_file::grok_upload_file::GrokUploadFile; +use crate::requests::video_chat::grok_video_gen_chat_conversation::{GrokVideoGenChatConversationBuilder, VideoMediaPostType}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; +use log::{error, info}; +use std::path::Path; +use std::time::Duration; + +pub struct UploadImageAndGenerateVideoWithRetry<'a, P: AsRef> { + pub credentials: &'a GrokFullCredentials, + + // NB: Must be owned. + pub file: FileUploadSpec

, + + /// Video generation prompt + pub prompt: Option<&'a str>, + + /// Aspect ratio for the video. + pub aspect_ratio: Option, + + /// Mode to generate the video: normal, fun, spicy, etc. + pub mode: Option, + + /// Wait for the full video to be generated before returning + /// By default, the endpoint stays open for 30-ish seconds while + /// the video generates and streams back JSON progress updates. + /// If we set this to false, we'll wait for the video ID and exit + /// the request early, before the video is finished asynchronously + /// generating. + pub wait_for_generation: bool, + + pub individual_request_timeout: Option, +} + +#[derive(Clone)] +pub struct ImageUploadAndGenerateVideoWithRetryResult { + /// If new secrets were generated, they're stored here. + pub maybe_new_client_secrets: Option, + + /// The video generation result. + pub upload_result: ImageUploadAndGenerateVideoResult, +} + +pub async fn upload_image_and_generate_video_with_retry>( + args: UploadImageAndGenerateVideoWithRetry<'_, P> +) -> Result { + + let mut current_full_credentials_ref = args.credentials; + let mut maybe_new_credentials = None; + + info!("Uploading file to Grok..."); + + let request = GrokUploadFile { + file: args.file, + cookie: current_full_credentials_ref.cookies.to_string(), + request_timeout: args.individual_request_timeout, + }; + + let upload_result = request.upload().await?; + + info!("File URI: {:?}", upload_result.file_uri); + info!("File Metadata ID: {:?}", upload_result.file_id); + + let upload_file_id = match upload_result.file_id { + Some(id) => id, + None => { + error!("Previous file upload failed. Cannot continue."); + return Err(GrokGenericApiError::UploadFailed.into()); + } + }; + + let url = user_and_file_id_to_image_url( + ¤t_full_credentials_ref.client_secrets.user_id, + &upload_file_id + ); + + info!("Uploaded URI: {:?}", url); + + info!("Creating media post..."); + + let request = GrokCreateMediaPost { + user_id: ¤t_full_credentials_ref.client_secrets.user_id, + file_id: &upload_file_id, + media_type: MediaPostType::UserUploadedImage, + cookie: current_full_credentials_ref.cookies.as_str(), + request_timeout: args.individual_request_timeout, + }; + + let post_result = request.send().await?; + + info!("Posted Image."); + + info!("Video prompt: {:?}", args.prompt); + info!("Video aspect ratio: {:?}", args.aspect_ratio); + + let mut video_enqueued_successfully = false; + let mut generation_is_complete= false; + let mut maybe_video_file_id = None; + let mut last_error = None; + + for i in 0..3 { + info!("Generate video... attempt {}", (i+1)); + + let request = GrokVideoGenChatConversationBuilder { + user_id: ¤t_full_credentials_ref.client_secrets.user_id, + file_id: &upload_file_id, + media_type: VideoMediaPostType::UserUploadedImage, + cookie: current_full_credentials_ref.cookies.as_str(), + prompt: args.prompt, + mode: args.mode, + aspect_ratio: args.aspect_ratio, + request_timeout: args.individual_request_timeout, + baggage: ¤t_full_credentials_ref.client_secrets.baggage, + sentry_trace: ¤t_full_credentials_ref.client_secrets.sentry_trace, + verification_token: ¤t_full_credentials_ref.client_secrets.verification_token, + svg_data: ¤t_full_credentials_ref.client_secrets.svg_path_data, + numbers: ¤t_full_credentials_ref.client_secrets.numbers, + }; + + let video_gen_result = + if args.wait_for_generation { + request.wait_for_video() + .await + .map(|res| res.video_file_id) + } else { + request.stream_only_video_id() + .await + .map(|res| res.video_file_id) + }; + + match video_gen_result { + Ok(res) => { + info!("Video Generation Enqueued!"); + video_enqueued_successfully = true; + generation_is_complete = args.wait_for_generation; // If we synchronously waited, it'll be complete. + maybe_video_file_id = res; + break; + } + Err(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked)) => { + info!("Grok automation blocked; renewing credentials."); + last_error = Some(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked)); + } + Err(err) => { + info!("Grok error; renewing credentials. Error = {:?}", err); + last_error = Some(err); + } + } + + info!("Refreshing Grok client secrets..."); + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: ¤t_full_credentials_ref.cookies, + }).await?; + + // NB: This is just to appease the borrow checker. It doesn't like that we're borrowing `maybe_new_credentials` + // in a loop and replacing it while borrowed, hence this hack. + current_full_credentials_ref = args.credentials; + + maybe_new_credentials = Some(GrokFullCredentials::from_cookies_and_client_secrets( + current_full_credentials_ref.cookies.clone(), + secrets, + )); + + if let Some(creds) = maybe_new_credentials.as_ref() { + current_full_credentials_ref = creds; + } + } + + if !video_enqueued_successfully { + if let Some(err) = last_error { + return Err(err); + } else { + // NB: This branch should be impossible as we should always set last_error. + return Err(GrokError::Client(GrokClientError::ErrorGeneratingVideo)) + } + } + + let mut video_liked_successfully = false; + let mut last_error = None; + + for i in 0..3 { + info!("Liking media ... attempt {}", (i+1)); + + let request = GrokLikeMediaPost { + file_id: &upload_file_id, + cookie: current_full_credentials_ref.cookies.as_str(), + request_timeout: args.individual_request_timeout, + baggage: ¤t_full_credentials_ref.client_secrets.baggage, + sentry_trace: ¤t_full_credentials_ref.client_secrets.sentry_trace, + verification_token: ¤t_full_credentials_ref.client_secrets.verification_token, + svg_data: ¤t_full_credentials_ref.client_secrets.svg_path_data, + numbers: ¤t_full_credentials_ref.client_secrets.numbers, + }; + + let like_result = request.send().await; + + match like_result { + Ok(_) => { + info!("Media Liked"); + video_liked_successfully = true; + break; + } + Err(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked)) => { + info!("Grok automation blocked liking media; renewing credentials."); + last_error = Some(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked)); + } + Err(err) => { + info!("Grok error liking media; renewing credentials. Error = {:?}", err); + last_error = Some(err); + } + } + + info!("Refreshing Grok client secrets..."); + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: ¤t_full_credentials_ref.cookies, + }).await?; + + // NB: This is just to appease the borrow checker. It doesn't like that we're borrowing `maybe_new_credentials` + // in a loop and replacing it while borrowed, hence this hack. + current_full_credentials_ref = args.credentials; + + maybe_new_credentials = Some(GrokFullCredentials::from_cookies_and_client_secrets( + current_full_credentials_ref.cookies.clone(), + secrets, + )); + + if let Some(creds) = maybe_new_credentials.as_ref() { + current_full_credentials_ref = creds; + } + } + + if !video_liked_successfully { + if let Some(err) = last_error { + return Err(err); + } else { + // NB: This branch should be impossible as we should always set last_error. + return Err(GrokError::Client(GrokClientError::ErrorGeneratingVideo)) + } + } + + let maybe_video_url = maybe_video_file_id + .as_ref() + .map(|file_id| { + user_and_file_id_to_video_url(¤t_full_credentials_ref.client_secrets.user_id, file_id, false) + }); + + Ok(ImageUploadAndGenerateVideoWithRetryResult { + maybe_new_client_secrets: maybe_new_credentials + .map(|secrets| secrets.client_secrets), + upload_result: ImageUploadAndGenerateVideoResult { + post_id: post_result.post_id, + image_file_id: upload_file_id, + video_file_id: maybe_video_file_id, + video_url: maybe_video_url, + generation_is_complete, + } + }) +} + + +#[cfg(test)] +mod tests { + use crate::credentials::grok_full_credentials::GrokFullCredentials; + use crate::datatypes::api::aspect_ratio::AspectRatio; + use crate::datatypes::api::video_generation_mode::VideoGenerationMode; + use crate::datatypes::file_upload_spec::FileUploadSpec; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::recipes::upload_image_and_generate_video_with_retry::{upload_image_and_generate_video_with_retry, UploadImageAndGenerateVideoWithRetry}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + // Result: GrokUploadFileResponse { file_metadata_id: + // Some("acdee48f-9d6f-4bc6-9d06-fcc97dd4418a"), file_uri: + // Some("users/85980643-ffab-4984-a3de-59a608c47d7f/acdee48f-9d6f-4bc6-9d06-fcc97dd4418a/content") } + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_upload_image_and_generate_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + //let image_path = "/Users/bt/Pictures/Zelda 64 Art/FCgYX6tWEAEhpsy.jpg"; + //let prompt = "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura"; + + let image_path = "/Users/bt/Pictures/Midjourney/hipster_girl.png"; + let maybe_prompt = Some("an anime girl turns and high fives a giant t-rex"); + + let cookies = get_typed_test_cookies()?; + + let mut bad_secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + // NB: We're purposely messing these up to trigger "retry". + bad_secrets.numbers.numbers = Vec::new(); + bad_secrets.sentry_trace.0 = "".to_string(); + + let credentials = GrokFullCredentials::from_cookies_and_client_secrets(cookies, bad_secrets); + + let result = upload_image_and_generate_video_with_retry(UploadImageAndGenerateVideoWithRetry { + credentials: &credentials, + file: FileUploadSpec::Path(image_path), + prompt: maybe_prompt, + mode: Some(VideoGenerationMode::Custom), + aspect_ratio: Some(AspectRatio::TallTwoByThree), + individual_request_timeout: None, + wait_for_generation: false, + }).await?; + + let new_secrets = result.maybe_new_client_secrets; + + println!("[test] New secrets ? : {:?}", new_secrets.is_some()); + + if let Some(secrets) = new_secrets { + println!("[test] Verification Token: {:?}", secrets.verification_token); + println!("[test] Sentry Trace: {:?}", secrets.sentry_trace); + println!("[test] Numbers: {:?}", secrets.numbers); + println!("[test] Svg Path: {:?}", secrets.svg_path_data); + println!("[test] Baggage: {:?}", secrets.baggage); + } + + let video_result = result.upload_result; + + println!("[test] Post ID: {:?}", video_result.post_id); + println!("[test] Image File ID: {:?}", video_result.image_file_id); + println!("[test] Video File ID: {:?}", video_result.video_file_id); + println!("[test] Video URL: {:?}", video_result.video_url); + + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/download_video_file/download_video_file.rs b/crates/api_clients/grok_consumer_client/src/requests/download_video_file/download_video_file.rs new file mode 100644 index 0000000000..164c25aa77 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/download_video_file/download_video_file.rs @@ -0,0 +1,128 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::user_id::UserId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; +use log::{error, info, warn}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, TE, UPGRADE_INSECURE_REQUESTS, USER_AGENT}; +use wreq::Client; +use wreq_util::Emulation; + +pub struct DownloadVideoFileArgs<'a> { + pub cookies: &'a str, + pub user_id: &'a UserId, + pub file_id: &'a FileId, + pub request_timeout: Option, +} + +pub struct VideoFile { + pub bytes: Vec, +} + +pub async fn download_video_file(args: DownloadVideoFileArgs<'_>) -> Result { + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let video_url = user_and_file_id_to_video_url(&args.user_id, &args.file_id, false); + + info!("Video file URL: {}", video_url); + + let mut request_builder = client.get(&video_url) + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, args.cookies) + .header(UPGRADE_INSECURE_REQUESTS, "1") + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-site", "same-site") + .header("sec-fetch-user", "?1") + .header("priority", "u=0, i"); + + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| GrokGenericApiError::WreqError(err))?; + + let status = response.status(); + + info!("Download video status: {:?}", status); + + let download_bytes = &response.bytes() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + // TODO: Classify errors + if !status.is_success() { + warn!("Not successful liking media (status: {:?})", status); + // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + // return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + Ok(VideoFile { + bytes: download_bytes.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use std::fs::File; + use std::io::Write; + use crate::datatypes::api::file_id::FileId; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_download() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let cookies = get_typed_test_cookies()?; + + let file_id = FileId("d9b300c6-9562-4e24-a87a-3ede2a53f0bc".to_string()); // Ernest + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + let download = download_video_file(DownloadVideoFileArgs { + cookies: cookies.as_str(), + user_id: &secrets.user_id, + file_id: &file_id, + request_timeout: None, + }).await?; + + println!("Video byte length: {:?}", download.bytes.len()); + + let mut file = File::create("download_video_file.test.mp4")?; + file.write_all(&download.bytes)?; + + assert_eq!(1, 2); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/download_video_file/grok_download_video.rs b/crates/api_clients/grok_consumer_client/src/requests/download_video_file/grok_download_video.rs new file mode 100644 index 0000000000..ea415399e2 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/download_video_file/grok_download_video.rs @@ -0,0 +1,171 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::user_id::UserId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; +use futures::{StreamExt, TryStreamExt}; +use log::{error, info, warn}; +use std::io::Write; +use std::path::Path; +use std::time::Duration; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, TE, UPGRADE_INSECURE_REQUESTS, USER_AGENT}; +use wreq::{Client, Request, Response}; +use wreq_util::Emulation; + +pub struct GrokDownloadVideo<'a> { + pub cookies: &'a str, + pub user_id: &'a UserId, + pub file_id: &'a FileId, + pub request_timeout: Option, +} + +pub struct DownloadedVideoBytes { + pub bytes: Vec, +} + +impl <'a> GrokDownloadVideo<'a> { + + pub async fn download_to_path>(&self, path: P) -> Result<(), GrokError> { + let response = self.send_request().await?; + + let mut stream = response.bytes_stream(); + + let mut file = File::create(&path) + .await + .map_err(|err| GrokClientError::CannotOpenLocalFileForWriting(err))?; + + //let _r = stream.for_each_concurrent(None, |chunk_result| async { + // match chunk_result { + // Ok(chunk) => { + // file; + // //if let Err(e) = file.write_all(&chunk).await { + // // //error!("Error writing to file: {}", e); + // //} + // } + // Err(e) => { + // //error!("Error in stream: {}", e); + // } + // } + //}).await; + + while let Some(chunk) = stream.try_next() + .await + .map_err(|err| GrokGenericApiError::WreqError(err))? + { + file.write_all(&chunk) + .await + .map_err(|err| GrokClientError::CannotOpenLocalFileForWriting(err))? + } + + file.flush().await.unwrap(); + + Ok(()) + } + + pub async fn download_bytes(&self) -> Result { + let response = self.send_request().await?; + + let download_bytes = response.bytes() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + Ok(DownloadedVideoBytes { + bytes: download_bytes.to_vec(), + }) + } + + async fn send_request(&self) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let video_url = user_and_file_id_to_video_url(&self.user_id, &self.file_id, false); + + info!("Video file URL: {}", video_url); + + let mut request_builder = client.get(&video_url) + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, self.cookies.to_string()) + .header(UPGRADE_INSECURE_REQUESTS, "1") + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-site", "same-site") + .header("sec-fetch-user", "?1") + .header("priority", "u=0, i"); + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| GrokGenericApiError::WreqError(err))?; + + let status = response.status(); + + // TODO: Classify errors + if !status.is_success() { + warn!("Not successful liking media (status: {:?})", status); + // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + // return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use crate::datatypes::api::file_id::FileId; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_download() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let cookies = get_typed_test_cookies()?; + + let file_id = FileId("d9b300c6-9562-4e24-a87a-3ede2a53f0bc".to_string()); // Ernest + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + // TDOO: This implementation is untested. + //let download = download_video_file(DownloadVideoFileArgs { + // cookies: cookies.as_str(), + // user_id: &secrets.user_id, + // file_id: &file_id, + // request_timeout: None, + //}).await?; + + //println!("Video byte length: {:?}", download.bytes.len()); + + assert_eq!(1, 2); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/download_video_file/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/download_video_file/mod.rs new file mode 100644 index 0000000000..bb83484e8a --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/download_video_file/mod.rs @@ -0,0 +1,2 @@ +pub mod download_video_file; +pub mod grok_download_video; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/_metadata/tls_fingerprint_checking.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/_metadata/tls_fingerprint_checking.rs new file mode 100644 index 0000000000..868d379b34 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/_metadata/tls_fingerprint_checking.rs @@ -0,0 +1,81 @@ +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use std::time::Duration; +use wreq::header::{HeaderMap, USER_AGENT}; +use wreq::{Client, Proxy}; +use wreq_util::Emulation; + +const FINGERPRINT_URL : &str = "https://tools.scrapfly.io/api/tls"; + +fn build_chrome_140_client() -> Result { + Ok(Client::builder() + .emulation(Emulation::Chrome140) + .connection_verbose(true) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?) +} + +fn build_firefox_139_client() -> Result { + Ok(Client::builder() + .emulation(Emulation::Firefox139) // NB: THIS IS IDENTICAL IN FINGERPRINT TO REAL FIREFOX !!! + .connection_verbose(true) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?) +} + +fn build_firefox_143_client() -> Result { + Ok(Client::builder() + .emulation(Emulation::Firefox143) // NB: THIS IS IDENTICAL IN FINGERPRINT TO REAL FIREFOX !!! + .connection_verbose(true) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?) +} + +fn build_raw_firefox_client_with_proxy() -> Result { + let proxy = Proxy::https("http://127.0.0.1:8080") + .map_err(|err| GrokClientError::WreqClientError(err))?; + Ok(Client::builder() + .emulation(Emulation::Firefox143) + .default_headers(HeaderMap::new()) + .proxy(proxy) + .cert_verification(false) + .connection_verbose(true) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?) +} + +async fn get_with_client(client: &Client) -> Result { + let builder = client.get(FINGERPRINT_URL) + .header("Accept", "application/json"); + + let response = builder.send() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + + let body = response.text() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + Ok(body) +} + +#[cfg(test)] +mod tests { + use errors::AnyhowResult; + use super::*; + + #[tokio::test] + #[ignore] // manually test + async fn test() -> AnyhowResult<()> { + let client = build_firefox_143_client()?; + let body = get_with_client(&client).await?; + println!("{}", body); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/create_listen_websocket.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/create_listen_websocket.rs new file mode 100644 index 0000000000..6e3f349d6b --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/create_listen_websocket.rs @@ -0,0 +1,106 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use cloudflare_mitigation::headers::firefox_websocket_http_1_1_headers::get_firefox_websocket_http_1_1_headers; +use std::ops::{Deref, DerefMut}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, CACHE_CONTROL, COOKIE, ORIGIN, PRAGMA, SEC_WEBSOCKET_EXTENSIONS, USER_AGENT}; +use wreq::ws::WebSocket; +use wreq::Client; +use wreq_util::Emulation; + +const WEBSOCKET_URL: &str = "wss://grok.com/ws/imagine/listen"; + +pub struct CreateListenWebsocketArgs<'a> { + pub cookies: &'a str, +} + +pub async fn create_listen_websocket(args: CreateListenWebsocketArgs<'_>) -> Result { + let mut client_builder = Client::builder() + .emulation(Emulation::Firefox143) + .connection_verbose(true) + .connect_timeout(Duration::from_secs(30)); + + let client = client_builder + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + // NB: We need to be careful with this because HTTP/1.1 WebSocket handshake is sensitive to header casing and order. + // Cloudflare doesn't like lower case header names, and we're sending headers in the right order just to be safe. + let builder = client.websocket(WEBSOCKET_URL) + .default_headers(false) + .orig_headers(get_firefox_websocket_http_1_1_headers()) + .header(COOKIE, args.cookies) + .header(ORIGIN,"https://grok.com") + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(SEC_WEBSOCKET_EXTENSIONS, "permessage-deflate") + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "websocket") + .header("Sec-Fetch-Site", "same-origin") + .header(PRAGMA, "no-cache") + .header(CACHE_CONTROL, "no-cache"); + + let mut response = builder.send() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let status = response.status(); + + match status.as_u16() { + 101 => { + // Successful WebSocket upgrade ... fallthrough. + } + 401 => { + // 401 is a likely authentication error with Grok itself. + // Unfortunately we can't read the response body text because wreq wants to consume the Response to get it, but the + // WebsocketResponse type wraps and doesn't let us move it. It's wreq's fault. + return Err(GrokError::ApiGeneric(GrokGenericApiError::LikelyWebsocketAuthentication401)); + } + 403 => { + // 403 is likely a cloudflare error + // Unfortunately we can't read the response body text because wreq wants to consume the Response to get it, but the + // WebsocketResponse type wraps and doesn't let us move it. It's wreq's fault. + return Err(GrokError::ApiGeneric(GrokGenericApiError::LikelyWebsocketCloudflare403)); + } + _ => { + return Err(GrokError::ApiGeneric(GrokGenericApiError::UnexpectedWebsocketUpgradeStatusCode(status))); + }, + } + + let websocket = response.into_websocket() + .await + .map_err(|err| GrokGenericApiError::WreqWebsocketUpgradeError(err))?; + + Ok(websocket) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // manually test + async fn create() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + //let cookies = SESSION_COOKIES_WITHOUT_CF_CLEARANCE; + let cookies = get_test_cookies()?; + let args = CreateListenWebsocketArgs { + cookies: &cookies, + }; + let result = create_listen_websocket(args).await; + if let Err(err) = result { + println!("Error: {:?}", err); + } + log::logger().flush(); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_websocket.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_websocket.rs new file mode 100644 index 0000000000..9780099bb2 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_websocket.rs @@ -0,0 +1,108 @@ +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage; +use futures::stream::FusedStream; +use futures::{Stream, TryStreamExt}; +use log::{info, warn}; +use serde::Serialize; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use wreq::ws::message::Message; +use wreq::ws::WebSocket; + +pub struct GrokWebsocket { + pub(crate) websocket: WebSocket, +} + +impl GrokWebsocket { + pub fn new(websocket: WebSocket) -> Self { + Self { + websocket, + } + } + + pub async fn send(&mut self, message: String) -> Result<(), GrokError> { + let message = Message::text(message); + self.websocket.send(message) + .await + .map_err(|err| { + GrokClientError::WreqClientError(err) + })?; + Ok(()) + } + + pub async fn send_serializable(&mut self, message: T) -> Result<(), GrokError> { + let message_json = serde_json::to_string(&message) + .map_err(|err| { + warn!("Failed to serialize prompt websocket message: {}", err); + GrokClientError::WebsocketRequestSerializationError(err) + })?; + self.send(message_json).await + } + + pub async fn get_response_with_timeout(&mut self, duration: Duration) -> Result, GrokError> { + let result = tokio::time::timeout(duration, self.websocket.try_next()).await; + + match result { + Err(elapsed) => { + info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed); + Ok(None) // Timeout elapsed. + } + Ok(inner) => { + let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?; + + match maybe_message { + None => Ok(None), + Some(Message::Text(text)) => { + let maybe_message = WebsocketServerMessage::from_json_str(&text)?; + Ok(Some(maybe_message)) + }, + Some(_) => { + warn!("Received non-text websocket message."); + Ok(None) + }, + } + } + } + } + + pub async fn try_next_timeout(&mut self, duration: Duration) -> Result, GrokClientError> { + let result = tokio::time::timeout(duration, self.websocket.try_next()).await; + + match result { + Err(elapsed) => { + info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed); + Ok(None) // Timeout elapsed. + } + Ok(inner) => { + let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?; + Ok(maybe_message) + } + } + } + + // pub async fn is_terminated(&self) -> Result { + // match self.websocket.read() { + // Err(_) => Err(GrokClientError::WebsocketLockError.into()), + // Ok(websocket) => Ok(websocket.is_terminated()), + // } + // } + + // /// NB: This will block forever if there is no item. + // pub async fn try_next(&self) -> Result, GrokClientError> { + // match self.websocket.write() { + // Err(_) => Err(GrokClientError::WebsocketLockError), + // Ok(mut websocket) => websocket + // .try_next() + // .await + // .map_err(|err| GrokClientError::WebsocketReadError(err)), + // } + // } + + // pub fn size_hint(&self) -> Result<(usize, Option), GrokClientError> { + // match self.websocket.read() { + // Err(_) => Err(GrokClientError::WebsocketLockError), + // Ok(websocket) => Ok(websocket.size_hint()) + // } + // } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs new file mode 100644 index 0000000000..712d1878f9 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs @@ -0,0 +1,124 @@ +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage; +use futures::stream::FusedStream; +use futures::{Stream, TryStreamExt}; +use log::{info, warn}; +use serde::Serialize; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use wreq::ws::message::Message; +use wreq::ws::WebSocket; + +#[derive(Clone)] +pub struct GrokWrappedWebsocket { + pub(crate) websocket: Arc>, +} + +impl GrokWrappedWebsocket { + pub fn new(websocket: WebSocket) -> Self { + Self { + websocket: Arc::new(RwLock::new(websocket)), + } + } + + pub async fn send(&self, message: String) -> Result<(), GrokError> { + match self.websocket.write() { + Err(err) => Err(GrokClientError::WebsocketLockError.into()), + Ok(mut websocket) => { + let message = Message::text(message); + websocket.send(message) + .await + .map_err(|err| { + GrokClientError::WreqClientError(err) + })?; + Ok(()) + }, + } + } + + pub async fn send_serializable(&self, message: T) -> Result<(), GrokError> { + let message_json = serde_json::to_string(&message) + .map_err(|err| { + warn!("Failed to serialize prompt websocket message: {}", err); + GrokClientError::WebsocketRequestSerializationError(err) + })?; + self.send(message_json).await + } + + pub async fn get_response_with_timeout(&self, duration: Duration) -> Result, GrokError> { + match self.websocket.write() { + Err(_) => Err(GrokClientError::WebsocketLockError.into()), + Ok(mut websocket) => { + let result = tokio::time::timeout(duration, websocket.try_next()).await; + + match result { + Err(elapsed) => { + info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed); + Ok(None) // Timeout elapsed. + } + Ok(inner) => { + let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?; + + match maybe_message { + None => Ok(None), + Some(Message::Text(text)) => { + let maybe_message = WebsocketServerMessage::from_json_str(&text)?; + Ok(Some(maybe_message)) + }, + Some(_) => { + warn!("Received non-text websocket message."); + Ok(None) + }, + } + } + } + }, + } + } + + pub async fn try_next_timeout(&self, duration: Duration) -> Result, GrokClientError> { + match self.websocket.write() { + Err(_) => Err(GrokClientError::WebsocketLockError), + Ok(mut websocket) => { + let result = tokio::time::timeout(duration, websocket.try_next()).await; + + match result { + Err(elapsed) => { + info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed); + Ok(None) // Timeout elapsed. + } + Ok(inner) => { + let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?; + Ok(maybe_message) + } + } + }, + } + } + + // pub async fn is_terminated(&self) -> Result { + // match self.websocket.read() { + // Err(_) => Err(GrokClientError::WebsocketLockError.into()), + // Ok(websocket) => Ok(websocket.is_terminated()), + // } + // } + + // /// NB: This will block forever if there is no item. + // pub async fn try_next(&self) -> Result, GrokClientError> { + // match self.websocket.write() { + // Err(_) => Err(GrokClientError::WebsocketLockError), + // Ok(mut websocket) => websocket + // .try_next() + // .await + // .map_err(|err| GrokClientError::WebsocketReadError(err)), + // } + // } + + // pub fn size_hint(&self) -> Result<(usize, Option), GrokClientError> { + // match self.websocket.read() { + // Err(_) => Err(GrokClientError::WebsocketLockError), + // Ok(websocket) => Ok(websocket.size_hint()) + // } + // } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/listen_for_websocket_images.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/listen_for_websocket_images.rs new file mode 100644 index 0000000000..4a192cb224 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/listen_for_websocket_images.rs @@ -0,0 +1,176 @@ +use crate::datatypes::api::request_id::RequestId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::image_websocket::grok_websocket::GrokWebsocket; +use crate::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket; +use crate::requests::image_websocket::messages::message_image_data::MessageImageData; +use crate::utils::scrub_blobs_for_debug_logging::scrub_blobs_for_debug_logging; +use log::info; +use std::time::{Duration, Instant}; +use wreq::ws::message::Message; + +// Number of images we get per prompt by default +const DEFAULT_IMAGE_COUNT: usize = 6; + +pub struct ListenForWebsocketImagesArgs<'a> { + pub websocket: &'a mut GrokWebsocket, + pub timeout: Duration, +} + +#[derive(Clone, Debug)] +pub struct ImageResults { + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub struct ImageData { + /// The "task" that generated the image + /// Multiple images have the same "task" + pub request_id: RequestId, + + /// Url where we can download the image. + pub url: String, + + /// The user-input prompt + pub user_prompt: String, + + /// An X.ai-enriched prompt + pub enriched_prompt: String, +} + +pub async fn listen_for_websocket_images(args: ListenForWebsocketImagesArgs<'_>) -> Result { + let start = Instant::now(); + let end_at = start.checked_add(args.timeout); + let end_at = match end_at { + Some(end_at) => end_at, + None => return Err(GrokClientError::TimeoutMathBroken.into()), + }; + + let mut images = Vec::new(); + + loop { + if end_at < Instant::now() { + break; + } + + let maybe_message = args.websocket.try_next_timeout(Duration::from_millis(500)).await?; + + let maybe_message = match maybe_message { + None => None, + Some(Message::Text(text)) => Some(text.to_string()), + Some(_) => { + // Got a ping, pong, binary payload, etc. + None + }, + }; + + let message = match maybe_message { + Some(message) => message, + None => continue, + }; + + let maybe_image_data = serde_json::from_str::(&message); + + let image_data = match maybe_image_data { + Ok(image_data) => image_data, + Err(_err) => { + + let message = scrub_blobs_for_debug_logging(&message); + println!("Unknown message: {}", message); + + // NB: Might not have been a parse error, since we're parsing irrelevant message payloads too. + // This is slightly dangerous to do this way as we might mask true parsing errors with image payloads. + continue; + } + }; + + match image_data.percentage_complete { + None => continue, + Some(percent) => { + if percent < 100.0 { + continue; + } + } + } + + images.push(ImageData { + request_id: RequestId(image_data.request_id), + url : image_data.url, + enriched_prompt: image_data.full_prompt, + user_prompt: image_data.prompt, + }); + + if images.len() >= DEFAULT_IMAGE_COUNT { + info!("{} images generated; we're done polling.", images.len()); + break; + } + } + + Ok(ImageResults { + images, + }) +} + + +#[cfg(test)] +mod tests { + use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs}; + use crate::requests::image_websocket::grok_websocket::GrokWebsocket; + use crate::requests::image_websocket::listen_for_websocket_images::{listen_for_websocket_images, ListenForWebsocketImagesArgs}; + use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; + use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + use std::io::Write; + use std::time::Duration; + + #[tokio::test] + #[ignore] // manually test + async fn prompt() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + let prompt = "A tornado hitting the stadium"; + + let cookies = get_test_cookies()?; + + let websocket = create_listen_websocket(CreateListenWebsocketArgs { + cookies: &cookies, + }).await?; + + //let websocket = GrokWrappedWebsocket::new(websocket); + let mut websocket = GrokWebsocket::new(websocket); + + println!("Sending..."); + std::io::stdout().flush()?; + + let _result = prompt_websocket_image(PromptWebsocketImageArgs { + websocket: &mut websocket, + prompt, + aspect_ratio: ClientMessageAspectRatio::WideThreeByTwo, + }).await?; + + println!("Reading..."); + std::io::stdout().flush()?; + + println!("Polling."); + + let images = listen_for_websocket_images(ListenForWebsocketImagesArgs { + websocket: &mut websocket, + timeout: Duration::from_millis(10_000), + }).await?; + + println!("Done polling. Images: {}", images.images.len()); + + for image in images.images { + println!("Image: {:?}", image); + } + + log::logger().flush(); + + assert_eq!(1,2); + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/listen_for_websocket_request_id.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/listen_for_websocket_request_id.rs new file mode 100644 index 0000000000..3a35791dd3 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/listen_for_websocket_request_id.rs @@ -0,0 +1,127 @@ +use crate::datatypes::api::request_id::RequestId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::image_websocket::grok_websocket::GrokWebsocket; +use crate::requests::image_websocket::messages::message_request_id::MessageRequestId; +use std::time::{Duration, Instant}; +use wreq::ws::message::Message; + +pub struct ListenForWebsocketRequestIdArgs<'a> { + pub websocket: &'a mut GrokWebsocket, + pub timeout: Duration, +} + +#[derive(Clone, Debug)] +pub struct RequestIdResult { + pub request_id: Option, +} + +pub async fn listen_for_websocket_request_id(args: ListenForWebsocketRequestIdArgs<'_>) -> Result { + let start = Instant::now(); + let end_at = start.checked_add(args.timeout); + let end_at = match end_at { + Some(end_at) => end_at, + None => return Err(GrokClientError::TimeoutMathBroken.into()), + }; + + let mut request_id = None; + + loop { + if end_at < Instant::now() { + break; + } + + let maybe_message = args.websocket.try_next_timeout(Duration::from_millis(500)).await?; + + let maybe_message = match maybe_message { + None => None, + Some(Message::Text(text)) => Some(text.to_string()), + Some(_) => { + // Got a ping, pong, binary payload, etc. + None + }, + }; + + let message = match maybe_message { + Some(message) => message, + None => continue, + }; + + let maybe_parsed = serde_json::from_str::(&message); + + let parsed = match maybe_parsed { + Ok(parsed) => parsed, + Err(_err) => { + // NB: Might not have been a parse error, since we're parsing irrelevant message payloads too. + // This is slightly dangerous to do this way as we might mask true parsing errors with image payloads. + continue; + } + }; + + request_id = Some(RequestId(parsed.request_id)); + break; + } + + Ok(RequestIdResult { + request_id, + }) +} + + +#[cfg(test)] +mod tests { + use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs}; + use crate::requests::image_websocket::grok_websocket::GrokWebsocket; + use crate::requests::image_websocket::listen_for_websocket_request_id::{listen_for_websocket_request_id, ListenForWebsocketRequestIdArgs}; + use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; + use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + use std::io::Write; + use std::time::Duration; + + #[tokio::test] + #[ignore] // manually test + async fn prompt() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + let prompt = "A tiny tornado on a desk"; + + let cookies = get_test_cookies()?; + + let mut websocket = create_listen_websocket(CreateListenWebsocketArgs { + cookies: &cookies, + }).await?; + + //let websocket = GrokWrappedWebsocket::new(websocket); + let mut websocket = GrokWebsocket::new(websocket); + + println!("Sending..."); + std::io::stdout().flush()?; + + let _result = prompt_websocket_image(PromptWebsocketImageArgs { + websocket: &mut websocket, + prompt, + aspect_ratio: ClientMessageAspectRatio::Square, + }).await?; + + println!("Reading..."); + std::io::stdout().flush()?; + + println!("Polling."); + + let request_data = listen_for_websocket_request_id(ListenForWebsocketRequestIdArgs { + websocket: &mut websocket, + timeout: Duration::from_millis(10_000), + }).await?; + + println!("Done polling. Request Data: {:?}", request_data); + log::logger().flush(); + + assert_eq!(1,2); + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/message_image_data.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/message_image_data.rs new file mode 100644 index 0000000000..1fd37da356 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/message_image_data.rs @@ -0,0 +1,63 @@ +use serde::Deserialize; + + +/// Images with a binary blob and URLs. +/// We may receive several of these for a single prompt. +#[derive(Deserialize, Clone, Debug)] +pub struct MessageImageData { + /// UUID. + /// Probably an identifier for the original request/prompt. + pub request_id: String, // Option, + + /// UUID. + /// This turns out to be the file ID in the image URL (and `job_id`). + pub id: Option, + + /// UUID. + /// This turns out to be the file ID in the image URL (and `id`). + pub job_id: Option, + + /// Eg. null, "0", "50", "100" + pub percentage_complete: Option, + + /// The user's prompt + pub prompt: String, //pub prompt: Option, + + /// The X.ai enriched prompt + pub full_prompt: String, //pub full_prompt: Option, + + /// URL to the image. + pub url: String, //pub url: Option, + + // We don't need to decode - let's just download again + // /// Base64 encoded image blob. + // pub blob: Option, + + // /// NSFW flag + // pub r_rated: Option, + + // /// Name of the model used to generate the image + // /// eg. "imagine_h_1", "imagine_x_1" + // pub model_name: Option, +} + +/* +Received websocket message: { + "type": "image", + "blob": "...", + "job_id": "260270db-2515-470b-a730-1fe87e11d21c", + "grid_index": 0, + "is_alteration": false, + "order": 0, + "section_id": 0, + "request_id": "57b94c87-ae13-4ade-8fc0-ebd39fb511ec", + "url": "https://imagine-public.x.ai/imagine-public/images/260270db-2515-470b-a730-1fe87e11d21c.png", + "id": "260270db-2515-470b-a730-1fe87e11d21c", + "prompt": "a dog riding a motorcycle", + "full_prompt": "A dog sits in a motorcycle sidecar on a paved road with a natural background.", + "percentage_complete": 100.0, + "model_name": "imagine_x_1", + "suggested_moderation_rule": "MODERATION_RULE_DEFAULT", + "r_rated":false +} +*/ diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/message_request_id.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/message_request_id.rs new file mode 100644 index 0000000000..cb7fc20b38 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/message_request_id.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +/// This could intercept any message with a "request_id" +/// We're going to try to use these to end the image prompting early. +/// If we're not careful, we might accidentally ingest completed image messages this way. +#[derive(Deserialize, Clone, Debug)] +pub struct MessageRequestId { + /// UUID. + /// Probably an identifier for the original request/prompt. + pub request_id: String, // Option, +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/mod.rs new file mode 100644 index 0000000000..3e831ada65 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/mod.rs @@ -0,0 +1,4 @@ +pub mod message_image_data; +pub mod message_request_id; +pub mod websocket_client_message; +pub mod websocket_server_message; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/websocket_client_message.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/websocket_client_message.rs new file mode 100644 index 0000000000..f6422fad2c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/websocket_client_message.rs @@ -0,0 +1,90 @@ +use chrono::Utc; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize)] +pub struct WebsocketClientMessage { + /// Type of message + /// eg. "conversation.item.create" + pub r#type: String, + + /// Unix timestamp + pub timestamp: u64, + + pub item: ClientMessageItem, +} + +#[derive(Serialize)] +pub struct ClientMessageItem { + /// Type of item + /// eg. "message" + pub r#type: String, + + pub content: Vec, +} + +#[derive(Serialize)] +pub struct ClientMessageItemContent { + /// UUID + #[serde(rename="requestId")] + pub request_id: String, + + /// Type of item content + /// eg. "input_text" + pub r#type: String, + + /// The prompt for the request + pub text: String, + + pub properties: ClientMessageItemContentProperties, +} + +#[derive(Serialize)] +pub struct ClientMessageItemContentProperties { + pub section_count: usize, + pub is_kids_mode: bool, + pub enable_nsfw: bool, + pub skip_upsampler: bool, + pub is_initial: bool, + pub aspect_ratio: ClientMessageAspectRatio, +} + +#[derive(Serialize, Clone, Copy, Debug)] +pub enum ClientMessageAspectRatio { + #[serde(rename = "2:3")] + TallTwoByThree, + + #[serde(rename = "3:2")] + WideThreeByTwo, + + #[serde(rename = "1:1")] + Square, +} + +impl WebsocketClientMessage { + /// Create a new image prompt websocket client message + pub fn new_image_prompt(prompt: &str, aspect_ratio: ClientMessageAspectRatio) -> Self { + Self { + r#type: "conversation.item.create".to_string(), + timestamp: Utc::now().timestamp_millis() as u64, + item: ClientMessageItem { + r#type: "message".to_string(), + content: vec![ + ClientMessageItemContent { + request_id: Uuid::new_v4().to_string(), + r#type: "input_text".to_string(), + text: prompt.to_string(), + properties: ClientMessageItemContentProperties { + section_count: 0, + is_kids_mode: false, + enable_nsfw: true, + skip_upsampler: false, + is_initial: false, + aspect_ratio, + }, + } + ], + }, + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/websocket_server_message.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/websocket_server_message.rs new file mode 100644 index 0000000000..7238ec3f23 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/messages/websocket_server_message.rs @@ -0,0 +1,205 @@ +use crate::error::grok_generic_api_error::GrokGenericApiError; +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +#[serde(tag = "type")] +pub enum WebsocketServerMessage { + /// Json with root `type = "image"` + #[serde(rename = "image")] + Image(ImageDataMessage), + + /// Json with root `type = "json"` + #[serde(rename = "json")] + Json(JsonDataMessage), + + /// Captures any other JSON messages. + #[serde(untagged)] + Unknown(serde_json::Value), + //Unknown(String), +} + +/// Images with a binary blob and URLs. +/// We may receive several of these for a single prompt. +#[derive(Deserialize, Clone, Debug)] +pub struct ImageDataMessage { + /// UUID. + pub id: Option, + + /// UUID. + pub job_id: Option, + + /// UUID. + pub request_id: Option, + + /// Eg. "0", "50", "100" + pub percentage_complete: Option, + + pub prompt: Option, + + /// The enriched prompt + pub full_prompt: Option, + + /// Base64 encoded image blob. + pub blob: Option, + + /// URL to the image. + pub url: Option, + + /// NSFW flag + pub r_rated: Option, + + /// Name of the model used to generate the image + /// eg. "imagine_h_1" + pub model_name: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct JsonDataMessage { + /// UUID. + pub job_id: Option, + + /// UUID. + pub request_id: Option, + + /// Eg. "0", "50", "100" + pub percentage_complete: Option, + + pub prompt: Option, + pub full_prompt: Option, + + /// NSFW flag + pub r_rated: Option, + + /// UUID. + /// NB: This is observed to be nullable. + pub image_id: Option, +} + +impl WebsocketServerMessage { + pub fn from_json_str(json_str: &str) -> Result { + Ok(serde_json::from_str(json_str) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, json_str.to_string()))?) + } +} + +#[cfg(test)] +mod tests { + use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage; + + fn json_path(file_name: &str) -> String { + // NB: Cargo runs tests with the crate root as the working directory. + format!("test_data/websocket_messages/{}", file_name) + } + + #[test] + fn test_image_complete() -> anyhow::Result<()> { + let path = json_path("create_image_complete_response.json"); + let data = std::fs::read_to_string(path)?; + //let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Image(image_data) => { + assert_eq!(image_data.percentage_complete, Some(50.0)); + assert!(image_data.url.is_some()); + }, + _ => panic!("Expected ImageData message"), + } + + Ok(()) + } + + #[test] + fn test_image_status_complete() -> anyhow::Result<()> { + let path = json_path("create_image_complete_status_notification.json"); + let data = std::fs::read_to_string(path)?; + //let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Json(image_data) => { + assert_eq!(image_data.percentage_complete, Some(100.0)); + }, + _ => panic!("Expected JsonData message"), + } + + Ok(()) + } + + #[test] + fn test_in_progress() -> anyhow::Result<()> { + let path = json_path("create_image_in_progress_response.json"); + let data = std::fs::read_to_string(path)?; + //let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Json(image_data) => { + assert_eq!(image_data.percentage_complete, Some(0.0)); + }, + _ => panic!("Expected JsonData message"), + } + + Ok(()) + } + + #[test] + fn test_wire() -> anyhow::Result<()> { + let path = json_path("wire.json"); + let data = std::fs::read_to_string(path)?; + //let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Json(_value)=> {}, + _ => panic!("Expected Json message"), + } + + Ok(()) + } + + #[test] + fn test_wire_formatted() -> anyhow::Result<()> { + let path = json_path("wire_formatted.json"); + let data = std::fs::read_to_string(path)?; + //let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Json(_value)=> {}, + _ => panic!("Expected Json message"), + } + + Ok(()) + } + + #[test] + fn test_empty() -> anyhow::Result<()> { + let path = json_path("empty.json"); + let data = std::fs::read_to_string(path)?; + //let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Unknown(_value)=> {}, + _ => panic!("Expected Unknown message"), + } + + Ok(()) + } + + #[test] + fn test_other_type() -> anyhow::Result<()> { + let path = json_path("other_type.json"); + let data = std::fs::read_to_string(path)?; + ///let message: WebsocketServerMessage = serde_json::from_str(&data)?; + let message = WebsocketServerMessage::from_json_str(&data)?; + + match message { + WebsocketServerMessage::Unknown(_value)=> {}, + _ => panic!("Expected Unknown message"), + } + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/mod.rs new file mode 100644 index 0000000000..f6c3676adb --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/mod.rs @@ -0,0 +1,7 @@ +pub mod grok_wrapped_websocket; +pub mod create_listen_websocket; +pub mod listen_for_websocket_images; +pub mod listen_for_websocket_request_id; +pub mod messages; +pub mod prompt_websocket_image; +pub mod grok_websocket; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/prompt_websocket_image.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/prompt_websocket_image.rs new file mode 100644 index 0000000000..10b9c859d9 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/prompt_websocket_image.rs @@ -0,0 +1,171 @@ +use crate::error::grok_error::GrokError; +use crate::requests::image_websocket::grok_websocket::GrokWebsocket; +use crate::requests::image_websocket::messages::websocket_client_message::{ClientMessageAspectRatio, WebsocketClientMessage}; + +pub struct PromptWebsocketImageArgs<'a> { + pub websocket: &'a mut GrokWebsocket, + pub prompt: &'a str, + pub aspect_ratio: ClientMessageAspectRatio, +} + + +pub async fn prompt_websocket_image(args: PromptWebsocketImageArgs<'_>) -> Result<(), GrokError> { + let message = WebsocketClientMessage::new_image_prompt(args.prompt, args.aspect_ratio); + + args.websocket.send_serializable(&message).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs}; + use crate::requests::image_websocket::grok_websocket::GrokWebsocket; + use crate::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket; + use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; + use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage; + use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + use log::warn; + use std::io::Write; + use std::time::Duration; + use wreq::ws::message::Message; + + #[tokio::test] + #[ignore] // manually test + async fn prompt() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let cookies = get_test_cookies()?; + + let websocket = create_listen_websocket(CreateListenWebsocketArgs { + cookies: &cookies, + }).await?; + + //let websocket = GrokWrappedWebsocket::new(websocket); + let mut websocket = GrokWebsocket::new(websocket); + + println!("Sending..."); + std::io::stdout().flush()?; + + let result = prompt_websocket_image(PromptWebsocketImageArgs { + websocket: &mut websocket, + prompt: "a dog riding a motorcycle", + aspect_ratio: ClientMessageAspectRatio::WideThreeByTwo, + }).await?; + + println!("Reading..."); + std::io::stdout().flush()?; + + let mut count = 0; + + println!("Polling."); + + //while let Some(message) = websocket.try_next().await? { + // println!("[{count}] Received websocket message: {:?}", message); + // count = count + 1; + // tokio::time::sleep(Duration::from_millis(1000)).await; + // if count > 1000 { + // break; + // } + //} + + //loop { + // count = count + 1; + // let bounds = websocket.size_hint()?; + // println!("Websocket size hint: {:?}", bounds); + // tokio::time::sleep(Duration::from_millis(1000)).await; + // if count > 30 { + // break; + // } + //} + + //loop { + // let maybe_message = + // websocket.try_next_timeout(Duration::from_millis(1000)).await?; + // match maybe_message { + // None => { + // println!("No message received within timeout."); + // count = count + 1; + // } + // Some(message) => { + // println!("[{count}] Received websocket message!");//: {:?}", message); + // count = 0; + // } + // } + // if count > 5 { + // println!("No messages after 5 seconds"); + // break; + // } + //} + + loop { + //let maybe_message = + // websocket.get_response_with_timeout(Duration::from_millis(1000)).await?; + + let maybe_message = + websocket.try_next_timeout(Duration::from_millis(1000)).await?; + + let mut maybe_raw_text : Option = None; + + let maybe_message = match maybe_message { + None => None, + Some(Message::Text(text)) => { + let maybe_message = WebsocketServerMessage::from_json_str(text.as_str())?; + let mut text = text.as_str().to_string(); + text.truncate(100); + //println!("Received message: {}", text); + maybe_raw_text = Some(text); + Some(maybe_message) + }, + Some(_) => { + warn!("Received non-text websocket message."); + None + }, + }; + + match maybe_message { + None => { + println!("No message received within timeout."); + count = count + 1; + } + Some(message) => { + match message { + WebsocketServerMessage::Image(image) => { + println!("IMAGE: {:?}", image.percentage_complete); + } + WebsocketServerMessage::Json(json) => { + println!("JSON : {:?}", json.percentage_complete); + } + WebsocketServerMessage::Unknown(unknown) => { + //let typ = unknown.get("type"); + let unknown_string = unknown.as_str(); + println!("[UNKNOWN] websocket message: {:?}", maybe_raw_text); + } + } + count = 0; + } + } + + // TODO: Make sure we capture all the events for images + // TODO: Explore the other APIs + // TODO understand the IDs + // TODO: Video websocket APIs + // TODO: Upload APIs + + if count > 5 { + println!("No messages after 5 seconds"); + break; + } + } + + println!("Done polling."); + + log::logger().flush(); + + assert_eq!(1,2); + + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/get_index_page.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_index_page.rs new file mode 100644 index 0000000000..89851a68d0 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_index_page.rs @@ -0,0 +1,102 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use log::info; +use std::ops::Deref; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, UPGRADE, USER_AGENT}; +use wreq::ws::message::Message; +use wreq::Client; +use wreq_util::Emulation; + +const INDEX_URL: &str = "https://grok.com"; + +pub struct GetIndexPageArgs<'a> { + pub cookie: &'a str, +} + +pub struct IndexPage { + pub body: String, +} + +pub async fn get_index(args: GetIndexPageArgs<'_>) -> Result { + info!("Building client..."); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + info!("Configuring client..."); + + let builder = client.get(INDEX_URL) + .header("User-Agent", FIREFOX_143_MAC_USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Connection", "keep-alive") + .header("Sec-Fetch-Dest", "document") + .header("Sec-Fetch-Mode", "navigate") + .header("Sec-Fetch-Site", "none") + .header("Sec-Fetch-User", "?1") + .header("Sec-GPC", "1") + .header("priority", "u=0, i") + .header("Pragma", "no-cache") + .header("Cache-Control", "no-cache") + .header("Cookie", args.cookie.to_string()) + .header("TE", "trailers"); + + info!("Sending request for index page..."); + + let response = builder.send() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let status = response.status(); + println!("Status: {}", status); + + //let body = response.text() + // .await + // .map_err(|err| GrokGenericApiError::WreqError(err))?; + + // stblid=08552693-0377-49d3-b17f-8e4c68b153ec + for cookie in response.cookies() { + println!("Cookie: {}={}", cookie.name(), cookie.value()); + } + + for (name, value) in response.headers() { + println!("Header: {}: {}", name.as_str(), value.to_str().unwrap()); + } + + let body = response.text() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + Ok(IndexPage { + body, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // manually test + async fn test() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let cookie = get_test_cookies()?; + let args = GetIndexPageArgs { + cookie: &cookie, + }; + let result = get_index(args).await?; + println!("Body:\n\n"); + println!("{}", result.body); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/get_index_page_and_scripts.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_index_page_and_scripts.rs new file mode 100644 index 0000000000..ed75fbc59e --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_index_page_and_scripts.rs @@ -0,0 +1,88 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::index_page::parsers::index::parse_index_script_list::parse_index_svg_paths; +use crate::requests::index_page::requests::get_index_page_scripts_with_client::{get_index_page_scripts_with_client, GetIndexPageScriptsArgs}; +use crate::requests::index_page::requests::get_index_page_with_client::{get_index_page_with_client, GetIndexPageWithClientArgs}; +use crate::utils::create_firefox_client::create_firefox_client; +use log::info; +use std::collections::HashMap; +use wreq::Client; +use wreq_util::Emulation; + +const INDEX_URL: &str = "https://grok.com"; + +pub struct GetIndexPageAndScriptsArgs<'a> { + pub cookie: &'a str, +} + +pub struct IndexPageAndScripts { + /// Index.html + pub index_body_html: String, + + /// Script path -> Javascript payload. + pub scripts: HashMap, + + // The client that we used. + pub client: Client, +} + +pub async fn get_index_page_and_scripts(args: GetIndexPageAndScriptsArgs<'_>) -> Result { + info!("Building client..."); + let client = create_firefox_client()?; + + info!("Fetching index page..."); + + let index = get_index_page_with_client(GetIndexPageWithClientArgs { + client: &client, + cookie: args.cookie, + }).await?; + + let scripts = parse_index_svg_paths(&index.body); + + info!("Fetching {} scripts...", scripts.len()); + + let scripts = get_index_page_scripts_with_client(GetIndexPageScriptsArgs { + client: &client, + cookie: &args.cookie, + scripts: &scripts, + }).await?; + + Ok(IndexPageAndScripts { + index_body_html: index.body, + scripts, + client, + }) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // manually test + async fn test() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + let cookie = get_test_cookies()?; + let result = get_index_page_and_scripts(GetIndexPageAndScriptsArgs { + cookie: &cookie, + }).await?; + + let mut truncated_body = result.index_body_html.clone(); + truncated_body.truncate(500); + + println!("Body:\n\n"); + println!("{}", truncated_body); + + for (k, v) in result.scripts.iter() { + let mut body = v.to_string(); + body.truncate(100); + println!("{}: {}\n\n", k, body); + } + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/get_numbers.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_numbers.rs new file mode 100644 index 0000000000..df2553a302 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_numbers.rs @@ -0,0 +1,87 @@ +use crate::datatypes::api::xsid_numbers::XsidNumbers; +use crate::error::grok_error::GrokError; +use crate::requests::index_page::parsers::index::parse_index_on_demand_script::parse_index_on_demand_script; +use crate::requests::index_page::parsers::script::parse_xsid_script_numbers::parse_xsid_script_numbers; +use crate::requests::index_page::requests::get_index_page_script_with_client::{get_index_page_script_with_client, GetIndexPageScriptArgs}; +use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::LoadingAnim; +use wreq::Client; +// self.svg_data, self.numbers = Parser.parse_values(c_request.text, self.anim, self.xsid_script) + +pub struct GetNumbersArgs<'a> { + pub client: &'a Client, + pub html: &'a str, + pub cookie: &'a str, + pub loading_anim: &'a LoadingAnim, + pub xsid_script_id: &'a str, +} + +pub async fn get_numbers(args: GetNumbersArgs<'_>) -> Result { + + let script_link = match args.xsid_script_id { + "ondemand.s" => { + // TODO: Not sure this is relevant anymore. Not a full or correct implementation of this branch. + let on_demand = parse_index_on_demand_script(&args.html); + let on_demand = on_demand.unwrap_or_else(|| "".to_string()); + format!("https://abs.twimg.com/responsive-web/client-web/ondemand.s.{}a.js", on_demand) + }, + _ => format!("https://grok.com/_next/{}", args.xsid_script_id), + }; + + let xsid_script_body = get_index_page_script_with_client(GetIndexPageScriptArgs { + client: &args.client, + cookie: args.cookie, + script_url: &script_link, + }).await?; + + Ok(parse_xsid_script_numbers(&xsid_script_body)) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs}; + use crate::requests::index_page::get_numbers::{get_numbers, GetNumbersArgs}; + use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token; + use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::parse_script_actions_and_xsid_script_path; + use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + + let page_and_scripts = get_index_page_and_scripts(GetIndexPageAndScriptsArgs { + cookie: &cookie, + }).await?; + + let verification_token = parse_index_verification_token(&page_and_scripts.index_body_html) + .expect("expected verification token"); + + println!("Verification Token: {:?}", verification_token); + + let loading_anim = convert_verification_token_to_loading_anim(&verification_token)?; + + println!("Loading Animation: {:?}", loading_anim); + + let actions_and_xsid_script = parse_script_actions_and_xsid_script_path(&page_and_scripts.scripts)?; + + println!("Actions and XSID: {:?}", actions_and_xsid_script); + + let result = get_numbers(GetNumbersArgs { + client: &page_and_scripts.client, + html: &page_and_scripts.index_body_html, + cookie: &cookie, + loading_anim: &loading_anim, + xsid_script_id: &actions_and_xsid_script.xsid_script_path, + }).await?; + + println!("Final Numbers: {:?}", result); + + println!("\n\nBody:\n\n"); + println!("{}", page_and_scripts.index_body_html); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/get_xsid_script.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_xsid_script.rs new file mode 100644 index 0000000000..103a3a6e37 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/get_xsid_script.rs @@ -0,0 +1,92 @@ +use crate::error::grok_error::GrokError; +use crate::requests::index_page::parsers::index::parse_index_on_demand_script::parse_index_on_demand_script; +use crate::requests::index_page::requests::get_index_page_script_with_client::{get_index_page_script_with_client, GetIndexPageScriptArgs}; +use wreq::Client; + +// self.svg_data, self.numbers = Parser.parse_values(c_request.text, self.anim, self.xsid_script) + +pub struct GetXsidScriptArgs<'a> { + pub client: &'a Client, + pub html: &'a str, + pub cookie: &'a str, + pub xsid_script_id: &'a str, +} + +/// We pull numbers out of the xsid script +#[derive(Clone, Debug)] +pub struct XsidScript { + pub xsid_script_body: String, +} + +pub async fn get_xsid_script(args: GetXsidScriptArgs<'_>) -> Result { + + let script_link = match args.xsid_script_id { + "ondemand.s" => { + // TODO: Not sure this is relevant anymore. Not a full or correct implementation of this branch. + let on_demand = parse_index_on_demand_script(&args.html); + let on_demand = on_demand.unwrap_or_else(|| "".to_string()); + format!("https://abs.twimg.com/responsive-web/client-web/ondemand.s.{}a.js", on_demand) + }, + _ => format!("https://grok.com/_next/{}", args.xsid_script_id), + }; + + let xsid_script_body = get_index_page_script_with_client(GetIndexPageScriptArgs { + client: &args.client, + cookie: args.cookie, + script_url: &script_link, + }).await?; + + Ok(XsidScript { + xsid_script_body, + }) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs}; + use crate::requests::index_page::get_numbers::{get_numbers, GetNumbersArgs}; + use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token; + use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::parse_script_actions_and_xsid_script_path; + use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + + let page_and_scripts = get_index_page_and_scripts(GetIndexPageAndScriptsArgs { + cookie: &cookie, + }).await?; + + let verification_token = parse_index_verification_token(&page_and_scripts.index_body_html) + .expect("expected verification token"); + + println!("Verification Token: {:?}", verification_token); + + let loading_anim = convert_verification_token_to_loading_anim(&verification_token)?; + + println!("Loading Animation: {:?}", loading_anim); + + let actions_and_xsid_script = parse_script_actions_and_xsid_script_path(&page_and_scripts.scripts)?; + + println!("Actions and XSID: {:?}", actions_and_xsid_script); + + let result = get_numbers(GetNumbersArgs { + client: &page_and_scripts.client, + html: &page_and_scripts.index_body_html, + cookie: &cookie, + loading_anim: &loading_anim, + xsid_script_id: &actions_and_xsid_script.xsid_script_path, + }).await?; + + println!("Final Numbers: {:?}", result); + + println!("\n\nBody:\n\n"); + println!("{}", page_and_scripts.index_body_html); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/mod.rs new file mode 100644 index 0000000000..db4bbb425b --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/mod.rs @@ -0,0 +1,8 @@ +pub (super) mod requests; +pub mod get_index_page; +pub mod get_index_page_and_scripts; +pub mod get_numbers; +pub mod get_xsid_script; +pub mod parsers; +pub mod signature; +pub mod utils; diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/mod.rs new file mode 100644 index 0000000000..b2056eb17f --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/mod.rs @@ -0,0 +1,8 @@ +pub mod parse_index_baggage; +pub mod parse_index_on_demand_script; +pub mod parse_index_script_list; +pub mod parse_index_sentry_trace; +pub mod parse_index_svg_paths; +pub mod parse_index_user_email; +pub mod parse_index_user_id; +pub mod parse_index_verification_token; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_baggage.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_baggage.rs new file mode 100644 index 0000000000..dbbcf08fc7 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_baggage.rs @@ -0,0 +1,44 @@ +use once_cell::sync::Lazy; +use scraper::{Html, Selector}; +use crate::datatypes::api::baggage::Baggage; +// + +static BAGGAGE_SELECTOR : Lazy = Lazy::new(|| { + Selector::parse("meta[name=baggage]") + .expect("HTML selector should parse") +}); + + +pub fn parse_index_baggage(html: &str) -> Option { + let document = Html::parse_document(html); + let selected = document.select(&BAGGAGE_SELECTOR); + let mut values = selected + .filter_map(|node| node.attr("content")) + .map(|s| s.to_string()) + .collect::>(); + values.pop().map(|b| Baggage(b)) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_baggage::parse_index_baggage; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let baggage = parse_index_baggage(&index.body); + + println!("Baggage: {:?}", baggage); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_on_demand_script.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_on_demand_script.rs new file mode 100644 index 0000000000..c3047f69e6 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_on_demand_script.rs @@ -0,0 +1,47 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +static ON_DEMAND_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#""ondemand.s":"([^"]*)""#) + .expect("Regex should parse") +}); + +pub fn parse_index_on_demand_script(html: &str) -> Option { + let on_demand = scrape_on_demand_via_regex(html); + + if let Some(on_demand) = on_demand { + return Some(on_demand); + } + + None +} + +fn scrape_on_demand_via_regex(html: &str) -> Option { + let captures = ON_DEMAND_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_on_demand_script::parse_index_on_demand_script; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let result = parse_index_on_demand_script(&index.body); + println!("{:?}", result); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_script_list.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_script_list.rs new file mode 100644 index 0000000000..0d336f1fc8 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_script_list.rs @@ -0,0 +1,48 @@ +use once_cell::sync::Lazy; +use scraper::{Html, Selector}; + +// scripts: list = [s['src'] for s in BeautifulSoup(load_site.text, 'html.parser').find_all('script', src=True) if s['src'].startswith('/_next/static/chunks/')] + +static SCRIPT_SELECTOR: Lazy = Lazy::new(|| { + Selector::parse("script[src]") + .expect("HTML selector should parse") +}); + + +pub fn parse_index_svg_paths(html: &str) -> Vec { + let document = Html::parse_document(html); + let selected = document.select(&SCRIPT_SELECTOR); + selected + .filter_map(|node| node.attr("src")) + .filter(|src| src.starts_with("/_next/static/chunks/")) + .map(|s| s.to_string()) + .collect::>() +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_script_list::parse_index_svg_paths; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let scripts = parse_index_svg_paths(&index.body); + + println!("Script count: {:?}", scripts.len()); + + for script in scripts { + println!("{}", script); + } + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_sentry_trace.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_sentry_trace.rs new file mode 100644 index 0000000000..a4867ce56d --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_sentry_trace.rs @@ -0,0 +1,85 @@ +use crate::datatypes::api::sentry_trace::SentryTrace; +use once_cell::sync::Lazy; +use scraper::{Html, Selector}; + +/// eg. +static SENTRY_TRACE_SELECTOR : Lazy = Lazy::new(|| { + Selector::parse("meta[name=sentry-trace]") + .expect("HTML selector should parse") +}); + + +/// Extract the sentry trace, then process it. +/// The Python library discards the latter half of the trace. +pub fn parse_index_sentry_trace(html: &str) -> Option { + get_meta_value(html) + .map(|trace| split_sentry_trace(&trace)) + .map(|trace| SentryTrace(trace)) +} + +fn get_meta_value(html: &str) -> Option { + let document = Html::parse_document(html); + let selected = document.select(&SENTRY_TRACE_SELECTOR); + let mut values = selected + .filter_map(|node| node.attr("content")) + .map(|s| s.to_string()) + .collect::>(); + values.pop() +} + +fn split_sentry_trace(trace: &str) -> String { + trace.split("-") + .next() + .map(|v| v.to_string()) + .unwrap_or_else(|| trace.to_string()) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_sentry_trace::parse_index_sentry_trace; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + mod test_request { + use super::*; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let sentry_trace = parse_index_sentry_trace(&index.body); + + println!("Sentry trace: {:?}", sentry_trace); + + assert_eq!(1, 2); + Ok(()) + } + } + + mod test_sentry_split { + use crate::requests::index_page::parsers::index::parse_index_sentry_trace::split_sentry_trace; + + #[test] + fn test_production_case() { + let result = split_sentry_trace("d6d7c55e4a489c0dabaed06e5c7b257b-d2391b601d54494f-0"); + assert_eq!(&result, "d6d7c55e4a489c0dabaed06e5c7b257b"); + } + + #[test] + fn fallback_case_1() { + let result = split_sentry_trace("asdf"); + assert_eq!(&result, "asdf"); + } + + #[test] + fn fallback_case_2() { + let result = split_sentry_trace(""); + assert_eq!(&result, ""); + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_svg_paths.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_svg_paths.rs new file mode 100644 index 0000000000..374ff9491c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_svg_paths.rs @@ -0,0 +1,52 @@ +use crate::datatypes::api::svg_path_data::SvgPathData; +use once_cell::sync::Lazy; +use scraper::{Html, Selector}; + +static SVG_PATH_SELECTOR: Lazy = Lazy::new(|| { + Selector::parse("path[d]") + .expect("HTML selector should parse") +}); + + +pub fn parse_svg_paths_from_index_html(html: &str) -> Vec { + // From https://github.com/realasfngl/Grok-Api : + // all_d_values = findall(r'"d":"(M[^"]{200,})"', html) + // svg_data = all_d_values[int(loading.split("loading-x-anim-")[1])] + + let document = Html::parse_document(html); + let selected = document.select(&SVG_PATH_SELECTOR); + selected + .filter_map(|node| node.attr("d")) + .filter(|path_data| path_data.len() >= 200) // NB: Not sure why these SVG strokes are more important. + .map(|s| SvgPathData(s.to_string())) + .collect::>() +} + +// TODO: Get from JSON instead ? - That's how the python source does it. + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_svg_paths::parse_svg_paths_from_index_html; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let paths = parse_svg_paths_from_index_html(&index.body); + println!("Paths count: {:?}", paths.len()); + + for path in paths { + println!("{}", path.0); + } + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_user_email.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_user_email.rs new file mode 100644 index 0000000000..fdba99ec0c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_user_email.rs @@ -0,0 +1,50 @@ +use crate::datatypes::api::user_email::UserEmail; +use once_cell::sync::Lazy; +use regex::Regex; + +/// Find the user email in the index.html payload +/// eg. +static JSON_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"\\?"email\\?":\s*\\?"([^"\\]+)\\?",?"#) + .expect("Regex should parse") +}); + +/// Parse the user email from the index.html +pub fn parse_index_user_email(html: &str) -> Option { + let maybe_meta = scrape_user_email_via_regex(html); + + if let Some(meta) = maybe_meta { + return Some(UserEmail(meta)) + } + + None +} + +fn scrape_user_email_via_regex(html: &str) -> Option { + let captures = JSON_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_user_email::parse_index_user_email; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let user_email = parse_index_user_email(&index.body); + println!("User Email : {:?}", user_email); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_user_id.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_user_id.rs new file mode 100644 index 0000000000..551cda1728 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_user_id.rs @@ -0,0 +1,50 @@ +use crate::datatypes::api::user_id::UserId; +use once_cell::sync::Lazy; +use regex::Regex; + +/// Find the user id in the index.html payload +/// eg. \"userId\":\"85980643-ffab-4984-a3de-59a608c47d7f\", +static JSON_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"\\?"userI[dD]\\?":\s*\\?"([a-zA-Z0-9-]{25,})\\?",?"#) + .expect("Regex should parse") +}); + +/// Parse the user id from the index.html +pub fn parse_index_user_id(html: &str) -> Option { + let maybe_meta = scrape_user_id_via_regex(html); + + if let Some(meta) = maybe_meta { + return Some(UserId(meta)) + } + + None +} + +fn scrape_user_id_via_regex(html: &str) -> Option { + let captures = JSON_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_user_id::parse_index_user_id; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let user_id = parse_index_user_id(&index.body); + println!("User ID : {:?}", user_id); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_verification_token.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_verification_token.rs new file mode 100644 index 0000000000..7db1188f49 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/index/parse_index_verification_token.rs @@ -0,0 +1,80 @@ +use crate::datatypes::api::verification_token::VerificationToken; +use once_cell::sync::Lazy; +use regex::Regex; +use scraper::{Html, Selector}; + +/// Find the verification token in the meta tags using HTML parsing. +static META_SELECTOR : Lazy = Lazy::new(|| { + Selector::parse("meta[name=grok-site-verification]") + .expect("HTML selector should parse") +}); + +/// Find the verification token in the meta tags using regex +// eg. +static META_TAG_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#""grok-site-verification"\s*content="([A-Za-z0-9/=+-]{5,})""#) + .expect("Regex should parse") +}); + +/// Find the verification token in the script tags using regex +/// eg. {\"name\":\"grok-site-verification\",\"content\":\"iFlTOEJNfQZP1B6YMRf/zuj3eYFrKWi6eNUg5XvvpllTOw5TS82coZkeUBdiHxYr\"}] +static JSON_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#""grok-site-verification\\?",\s*\\?"content\\?":\\?"([A-Za-z0-9/=+-]{5,})\\?""#) + .expect("Regex should parse") +}); + +/// Parse the verification token from the root HTML +pub fn parse_index_verification_token(html: &str) -> Option { + let maybe_meta = scrape_meta_tag_via_parsing(html); + + if let Some(meta) = maybe_meta { + return Some(VerificationToken(meta)) + } + + let maybe_script = scrape_script_via_regex(html); + + if let Some(script) = maybe_script { + return Some(VerificationToken(script)) + } + + None +} + +fn scrape_script_via_regex(html: &str) -> Option { + let captures = META_TAG_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + +fn scrape_meta_tag_via_parsing(html: &str) -> Option { + let document = Html::parse_document(html); + let selected = document.select(&META_SELECTOR); + let mut values = selected + .filter_map(|node| node.attr("content")) + .map(|s| s.to_string()) + .collect::>(); + values.pop() +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs}; + use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Manual test invocation + async fn test() -> AnyhowResult<()> { + let cookie = get_test_cookies()?; + let index = get_index(GetIndexPageArgs { + cookie: &cookie, + }).await?; + + let result = parse_index_verification_token(&index.body); + println!("{:?}", result); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/mod.rs new file mode 100644 index 0000000000..31d99fbdd6 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/mod.rs @@ -0,0 +1,2 @@ +pub mod index; +pub mod script; diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/mod.rs new file mode 100644 index 0000000000..fa61bc36e8 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/mod.rs @@ -0,0 +1,2 @@ +pub mod parse_xsid_script_numbers; +pub mod parse_script_actions_and_xsid_script_path; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/parse_script_actions_and_xsid_script_path.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/parse_script_actions_and_xsid_script_path.rs new file mode 100644 index 0000000000..bd383bc1fa --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/parse_script_actions_and_xsid_script_path.rs @@ -0,0 +1,124 @@ +use crate::error::grok_client_error::GrokClientError; +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::HashMap; +use log::info; + +static ACTIONS_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#"createServerReference\)\("([a-f0-9]+)""#) + .expect("Regex should parse") +}); + +/// 2025-10-01: "(static/chunks/[^"]+\.js)"[^}]*?a\(880932\)" +/// 2025-12-01: "(static/chunks/[^"]+\.js)"[^}]*?\(880932\)" +static XSID_SCRIPT_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#""(static/chunks/[^"]+\.js)"[^}]*?\(880932\)"#) + .expect("Regex should parse") +}); + +#[derive(Debug, Clone)] +pub struct ActionsAndXsid { + /// A list of opaque IDs for "actions" + pub actions: Vec, + + /// The URL path of the xsid script, with no leading slash. + pub xsid_script_path: String, +} + +pub fn parse_script_actions_and_xsid_script_path(scripts: &HashMap) -> Result { + let mut action_script_path = None; + let mut script_content_1 = None; + let mut script_content_2 = None; + + for (script_path, script_body) in scripts.iter() { + if script_body.contains("anonPrivateKey") { + action_script_path = Some(script_path.to_string()); + script_content_1 = Some(script_body.to_string()); + } else if script_body.contains("880932") { + script_content_2 = Some(script_body.to_string()); + } + } + + // NB: Commented out because we don't use it. + // let action_script_path = match action_script_path { + // Some(action_script_path) => action_script_path, + // None => return Err(GrokClientError::ScriptLogicOutOfDate), + // }; + + let script_content_1 = match script_content_1 { + Some(script_content_1) => script_content_1, + None => return Err(GrokClientError::Script1LogicOutOfDate), + }; + + let script_content_2 = match script_content_2 { + Some(script_content_2) => script_content_2, + None => return Err(GrokClientError::Script2LogicOutOfDate), + }; + + //println!("Script content 1:"); + //println!("{}", script_content_1); + //println!("Script content 2:"); + //println!("{}", script_content_2); + + let actions = ACTIONS_REGEX.captures_iter(&script_content_1) + .flat_map(|captures| captures.get(1).map(|m| m.as_str().to_string())) + .collect::>(); + + let xsid_script_path = parse_xsid_script_path_from_turbopack_script(&script_content_2)?; + + Ok(ActionsAndXsid { + actions, + xsid_script_path, + }) +} + +fn parse_xsid_script_path_from_turbopack_script(script_source: &str) -> Result { + let xsid_script_path = XSID_SCRIPT_REGEX.captures(&script_source) + .map(|captures| captures.get(1) + .map(|m| m.as_str() + .to_string())) + .flatten(); + + match xsid_script_path { + Some(xsid_script) => Ok(xsid_script), + None => Err(GrokClientError::ScriptXsidLogicOutOfDate), + } +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs}; + use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::{parse_script_actions_and_xsid_script_path, parse_xsid_script_path_from_turbopack_script}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // manually test + async fn test() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + let cookie = get_test_cookies()?; + let page_and_scripts = get_index_page_and_scripts(GetIndexPageAndScriptsArgs { + cookie: &cookie, + }).await?; + + let result = parse_script_actions_and_xsid_script_path(&page_and_scripts.scripts)?; + + println!("{:?}", result); + + assert_eq!(1, 2); + Ok(()) + } + + #[test] + fn parse_turbopack_script_2_for_xsid() { + // NB: The XSID parsing script broke on December 1st. + // The valid script to parse out is "static/chunks/6450f465e55b5c24.js" (as it contains the magic numbers). This comes before the string (880932) + // They probably just changed the TurboPack configs. + let example_script_2 = r#" + (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,483347,a=>{a.v(s=>Promise.all(["static/chunks/6450f465e55b5c24.js"].map(s=>a.l(s))).then(()=>s(880932)))},330040,a=>{a.v(s=>Promise.all(["static/chunks/5cc0fc78839e38d4.js"].map(s=>a.l(s))).then(()=>s(921398)))},127660,a=>{a.v(s=>Promise.all(["static/chunks/fc24fa4ac57f0fa9.js"].map(s=>a.l(s))).then(()=>s(969999)))},307492,a=>{a.v(s=>Promise.all(["static/chunks/eb46b9f393cc69d9.js"].map(s=>a.l(s))).then(()=>s(529107)))},460973,a=>{a.v(s=>Promise.all(["static/chunks/7ff9ad06da182f94.js"].map(s=>a.l(s))).then(()=>s(296273)))},900864, + "#; + + let parsed = parse_xsid_script_path_from_turbopack_script(example_script_2).unwrap(); + assert_eq!(parsed, "static/chunks/6450f465e55b5c24.js".to_string()); + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/parse_xsid_script_numbers.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/parse_xsid_script_numbers.rs new file mode 100644 index 0000000000..91fe36020c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/parsers/script/parse_xsid_script_numbers.rs @@ -0,0 +1,27 @@ +use crate::datatypes::api::xsid_numbers::XsidNumbers; +use log::error; +use once_cell::sync::Lazy; +use regex::Regex; +use std::str::FromStr; + +static NUMBERS_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#"x\[(\d+)\]\s*,\s*16"#) + .expect("Regex should parse") +}); + +pub fn parse_xsid_script_numbers(script_body: &str) -> XsidNumbers { + let numbers = NUMBERS_REGEX.captures_iter(&script_body) + .flat_map(|captures| captures.get(1).map(|m| m.as_str().to_string())) + .flat_map(|num| match u32::from_str(&num) { + Ok(num) => Some(num), + Err(err) => { + error!("Error parsing: {:?}", err); + None + } + }) + .collect::<_>(); + + XsidNumbers { + numbers, + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_script_with_client.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_script_with_client.rs new file mode 100644 index 0000000000..b7c283384e --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_script_with_client.rs @@ -0,0 +1,63 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use log::warn; +use wreq::Client; + +const INDEX_URL: &str = "https://grok.com"; + +pub struct GetIndexPageScriptArgs<'a> { + pub client: &'a Client, + pub cookie: &'a str, + pub script_url: &'a str, +} + + +/// Get javascript that we'll need for client crypto purposes. +pub async fn get_index_page_script_with_client(args: GetIndexPageScriptArgs<'_>) -> Result { + + let script_url; + + if args.script_url.starts_with("https://") { + script_url = args.script_url.to_string(); + } else { + script_url = get_script_url(args.script_url); + } + + let builder = args.client.get(script_url) + .header("User-Agent", FIREFOX_143_MAC_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", "https://grok.com") + .header("Connection", "keep-alive") + .header("Sec-Fetch-Dest", "script") + .header("Sec-Fetch-Mode", "no-cors") + .header("Sec-Fetch-Site", "same-origin") + .header("TE", "trailers"); + + let response = builder.send() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let status = response.status(); + + // TODO: Cloudflare handling. + let body = response.text() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + if !status.is_success() { + warn!("could not get index page script: {}, body: {}", status, body); + } + + Ok(body) +} + +fn get_script_url(script_path: &str) -> String { + if script_path.starts_with("/") { + format!("{}{}", INDEX_URL, script_path) + } else { + format!("{}/{}", INDEX_URL, script_path) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_scripts_with_client.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_scripts_with_client.rs new file mode 100644 index 0000000000..5825890ea7 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_scripts_with_client.rs @@ -0,0 +1,80 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::requests::index_page::requests::get_index_page_script_with_client::{get_index_page_script_with_client, GetIndexPageScriptArgs}; +use log::error; +use std::collections::HashMap; +use wreq::Client; + +pub struct GetIndexPageScriptsArgs<'a> { + pub client: &'a Client, + pub cookie: &'a str, + pub scripts: &'a Vec, +} + +/// Get javascript that we'll need for client crypto purposes. +pub async fn get_index_page_scripts_with_client(args: GetIndexPageScriptsArgs<'_>) -> Result, GrokError> { + let mut map = HashMap::with_capacity(args.scripts.len()); + + for script_url in args.scripts { + let result = get_index_page_script_with_client(GetIndexPageScriptArgs { + client: args.client, + cookie: args.cookie, + script_url, + }).await; + + let script = match result { + Ok(script) => script, + Err(err) => { + error!("Error fetching script: {}: {:?}", script_url, err); + return Err(err); + } + }; + + map.insert(script_url.to_string(), script); + } + + Ok(map) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::parsers::index::parse_index_script_list::parse_index_svg_paths; + use crate::requests::index_page::requests::get_index_page_scripts_with_client::{get_index_page_scripts_with_client, GetIndexPageScriptsArgs}; + use crate::requests::index_page::requests::get_index_page_with_client::{get_index_page_with_client, GetIndexPageWithClientArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::utils::create_firefox_client::create_firefox_client; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // manually test + async fn test() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let client = create_firefox_client()?; + let cookie = get_test_cookies()?; + + let index = get_index_page_with_client(GetIndexPageWithClientArgs { + client: &client, + cookie: &cookie, + }).await?; + + let scripts = parse_index_svg_paths(&index.body); + + let scripts = get_index_page_scripts_with_client(GetIndexPageScriptsArgs { + client: &client, + cookie: &cookie, + scripts: &scripts, + }).await?; + + for (k, v) in scripts.iter() { + let mut body = v.to_string(); + body.truncate(100); + println!("{}: {}\n\n", k, body); + } + + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_with_client.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_with_client.rs new file mode 100644 index 0000000000..a770734dee --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/get_index_page_with_client.rs @@ -0,0 +1,87 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use log::{info, warn}; +use std::ops::Deref; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, UPGRADE, USER_AGENT}; +use wreq::ws::message::Message; +use wreq::Client; +use wreq_util::Emulation; + +const INDEX_URL: &str = "https://grok.com"; + +pub struct GetIndexPageWithClientArgs<'a> { + pub client: &'a Client, + pub cookie: &'a str, +} + +pub struct IndexPage { + pub body: String, +} + +pub async fn get_index_page_with_client(args: GetIndexPageWithClientArgs<'_>) -> Result { + let builder = args.client.get(INDEX_URL) + .header("User-Agent", FIREFOX_143_MAC_USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Connection", "keep-alive") + .header("Sec-Fetch-Dest", "document") + .header("Sec-Fetch-Mode", "navigate") + .header("Sec-Fetch-Site", "none") + .header("Sec-Fetch-User", "?1") + .header("Sec-GPC", "1") + .header("priority", "u=0, i") + .header("Pragma", "no-cache") + .header("Cache-Control", "no-cache") + .header("Cookie", args.cookie.to_string()) + .header("TE", "trailers"); + + info!("Sending request for index page..."); + + let response = builder.send() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let status = response.status(); + + let body = response.text() + .await + .map_err(|err| GrokClientError::WreqClientError(err))?; + + // TODO: Cloudflare handling + if !status.is_success() { + warn!("could not get index page script: {}, body: {}", status, body); + } + + Ok(IndexPage { + body, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use crate::utils::create_firefox_client::create_firefox_client; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // manually test + async fn test() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let cookie = get_test_cookies()?; + let client = create_firefox_client()?; + let result = get_index_page_with_client(GetIndexPageWithClientArgs { + client: &client, + cookie: &cookie, + }).await?; + println!("Body:\n\n"); + println!("{}", result.body); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/mod.rs new file mode 100644 index 0000000000..03bf5f2f22 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/requests/mod.rs @@ -0,0 +1,3 @@ +pub (super) mod get_index_page_script_with_client; +pub (super) mod get_index_page_scripts_with_client; +pub (super) mod get_index_page_with_client; diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/generate_xsid.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/generate_xsid.rs new file mode 100644 index 0000000000..7f92f715a8 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/generate_xsid.rs @@ -0,0 +1,186 @@ +use crate::datatypes::api::svg_path_data::SvgPathData; +use crate::datatypes::api::verification_token::VerificationToken; +use crate::datatypes::api::xsid_numbers::XsidNumbers; +use crate::error::grok_client_error::GrokClientError; +use crate::requests::index_page::signature::signature_xs::signature_xs; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use byteorder::{LittleEndian, WriteBytesExt}; +use chrono::{TimeDelta, Utc}; +use log::{debug, error}; +use sha2::{Digest, Sha256}; +use std::ops::Sub; + +pub struct GenerateXsidArgs<'a> { + /// Path of the request, + /// eg. `/rest/app-chat/conversations/new` + pub path: &'a str, + + /// HTTP Method + /// eg. `POST` + pub method: &'a str, + + pub verification_token: &'a VerificationToken, + + pub svg_data: &'a SvgPathData, + + // "x_values" + pub numbers: &'a XsidNumbers, +} + +pub fn generate_xsid(args: GenerateXsidArgs<'_>) -> Result { + let timestamp = Utc::now().timestamp(); + let timestamp = timestamp as u32; + generate_xsid_with_timestamp(timestamp, args) +} + +pub fn generate_xsid_with_timestamp(timestamp: u32, args: GenerateXsidArgs<'_>) -> Result { + /* + xsid: str = Signature.generate_sign('/rest/app-chat/conversations/new', 'POST', self.verification_token, self.svg_data, self.numbers) + + @staticmethod + def generate_sign(path: str, method: str, verification: str, svg: str, x_values: list, time_n: int = None, random_float: float = None) -> str: + + n = int(time() - 1682924400) if not time_n else time_n + t = pack(' 0: + first = arr[0] + for i in range(1, len(arr)): + arr[i] = arr[i] ^ first + + return b64encode(bytes(arr)).decode('ascii').replace('=', '') + */ + + let n = timestamp - 1682924400; + + debug!("[xsid] n = {}", n); + + let mut t = vec![]; + t.write_u32::(n).unwrap(); // TODO Unwrap + + debug!("[xsid] t = {:?}", t); + + let r = BASE64_STANDARD.decode(&args.verification_token.0) + .map_err(|err| { + error!("Decode verification_token failed. {}", err); + GrokClientError::FailedToDecodeVerificationToken(err) + })?; // TODO: Not sure this is right. + + debug!("[xsid] r = {:?}", r); + + // o = Signature.xs(r, svg, x_values) + let o = signature_xs(&r, &args.svg_data.0, &args.numbers.numbers)?; + + debug!("[xsid] o = {:?}", o); + + // msg = "!".join([method, path, str(n)]) + "obfiowerehiring" + o + let method = args.method; + let path = args.path; + let msg = format!("{method}!{path}!{n}obfiowerehiring{o}"); + + // POST!/rest/app-chat/conversations/new!78721673obfiowerehiring4320e30fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700 + debug!("[xsid] msg = {}", msg); + + //digest = sha256(msg.encode('utf-8')).digest()[:16] + + let digest_all_bytes = Sha256::digest(msg.as_bytes()).to_vec(); + debug!("[xsid] digest_all_bytes = {:?}", digest_all_bytes); + + if digest_all_bytes.len() < 16 { + error!("Digest is too short: {}", digest_all_bytes.len()); + return Err(GrokClientError::BadSignatureInputs); + } + + let digest : &[u8] = &digest_all_bytes[.. 16]; + + debug!("[xsid] digest = {:?}", digest); + + // prefix_byte = int(floor(random() if not random_float else random_float * 256)) + let prefix_byte = 0; // NB: This is bad code? floor(random) = 0 always. + + // assembled = bytes([prefix_byte]) + r + t + digest + bytes([3]) + let mut assembled = vec![prefix_byte]; + assembled.extend(r); + assembled.extend(t); + assembled.extend(digest); + assembled.extend([3]); + + debug!("[xsid] assembled = {:?}", assembled); + + /* + arr = bytearray(assembled) + if len(arr) > 0: + first = arr[0] + for i in range(1, len(arr)): + arr[i] = arr[i] ^ first + */ + + let mut arr = assembled.clone(); + + // NB: Does not appear to be needed because the first byte is always "0" (for now) + //if arr.len() > 0 { + // let first = *arr.get(0).ok_or(GrokClientError::BadSignatureInputs)?; + // for i in 1..arr.len() { + // let mut a = arr.get_mut(i).ok_or(GrokClientError::BadSignatureInputs)?; + // let av = *a; + // let bv = av ^ first; + // *a = bv; + // } + //} + + debug!("[xsid] arr = {:?}", arr); + + // Uses + and / for base64 + // encoded = b64encode(bytes(arr)).decode('ascii').replace('=', '') + + let encoded_bytes = BASE64_STANDARD.encode(arr); + let encoded_bytes = encoded_bytes.replace("=", ""); + + debug!("[xsid] encoded_bytes = {:?}", encoded_bytes); + + Ok(encoded_bytes) +} + + +#[cfg(test)] +mod tests { + use crate::datatypes::api::svg_path_data::SvgPathData; + use crate::datatypes::api::verification_token::VerificationToken; + use crate::datatypes::api::xsid_numbers::XsidNumbers; + use crate::requests::index_page::signature::generate_xsid::{generate_xsid_with_timestamp, GenerateXsidArgs}; + use errors::AnyhowResult; + + // TODO: More test cases + #[test] + fn test() -> AnyhowResult<()> { + let ver = VerificationToken("yt16CWiUI42s7wGGeMxcdEIT2miPF5PVOGZF1Jcae7K7HMQrLeI4RTYufmdfBIZU".to_string()); + let svg_data = "M 10,30 C 147,16 51,222 136,87 h 31 s 177,77 40,114 C 68,166 18,188 114,44 h 220 s 148,210 202,25 C 220,36 246,218 12,120 h 244 s 34,154 56,161 C 211,204 208,60 174,14 h 155 s 157,116 220,95 C 116,164 53,210 174,232 h 139 s 148,151 135,221 C 167,196 118,27 149,247 h 233 s 129,238 113,21 C 73,44 215,158 218,29 h 68 s 13,98 243,134 C 44,98 149,35 20,178 h 163 s 219,104 41,152 C 237,107 118,120 127,117 h 57 s 41,167 43,136 C 116,168 44,246 182,209 h 143 s 238,93 237,119 C 142,36 64,67 97,111 h 71 s 230,247 119,27 C 206,56 158,51 71,115 h 135 s 120,186 74,57 C 197,165 180,214 102,16 h 88 s 252,48 189,166 C 163,237 57,253 154,60 h 96 s 84,56 15,66 C 177,101 10,202 185,227 h 29 s 204,55 115,219 C 103,82 127,143 194,99 h 102 s 179,63 84,110"; + let svg_data = SvgPathData(svg_data.to_string()); + let numbers = vec![40, 18, 3, 18]; + let numbers = XsidNumbers { numbers }; + let timestamp = 1761646073; + + let observed = generate_xsid_with_timestamp(timestamp, GenerateXsidArgs { + path: "/rest/app-chat/conversations/new", + method: "POST", + verification_token: &ver, + svg_data: &svg_data, + numbers: &numbers, + })?; + + let expected = "AMrdeglolCONrO8BhnjMXHRCE9pojxeT1ThmRdSXGnuyuxzEKy3iOEU2Ln5nXwSGVIkysQRoLOMjee3yLtjtJXbPVwjgAw"; + + assert_eq!(&observed, expected); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/mod.rs new file mode 100644 index 0000000000..d2f7c798d5 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/mod.rs @@ -0,0 +1,8 @@ +pub mod signature_xs; +pub mod generate_xsid; +mod signature_xa; +mod signature_simulate_style; +mod signature_h; +mod signature_cubic_bezier_eased; +mod signature_to_hex; +mod round_two_decimals; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/round_two_decimals.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/round_two_decimals.rs new file mode 100644 index 0000000000..79ef4be60a --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/round_two_decimals.rs @@ -0,0 +1,19 @@ + +pub fn round_two_decimals(f: f64) -> f64 { + // rounded = round(float(f), 2) + let rounded = (f * 100.0).round() / 100.0; // NB: Round to two places like Python + rounded +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::signature::round_two_decimals::round_two_decimals; + + #[test] + fn test() { + assert_eq!(round_two_decimals(0.1234), 0.12); + assert_eq!(round_two_decimals(1.00001), 1.0); + assert_eq!(round_two_decimals(-5.0003), -5.0); + assert_eq!(round_two_decimals(1234.0001), 1234.0); + } +} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_cubic_bezier_eased.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_cubic_bezier_eased.rs new file mode 100644 index 0000000000..56415f87bd --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_cubic_bezier_eased.rs @@ -0,0 +1,148 @@ + + +pub fn signature_cubic_bezier_eased(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { + /* + @staticmethod + def cubicBezierEased(t: float, x1: float, y1: float, x2: float, y2: float) -> float: + def bezier(u: float): + omu = 1.0 - u + b1 = 3.0 * omu * omu * u + b2 = 3.0 * omu * u * u + b3 = u * u * u + x = b1 * x1 + b2 * x2 + b3 + y = b1 * y1 + b2 * y2 + b3 + return x, y + + lo, hi = 0.0, 1.0 + for _ in range(80): + mid = 0.5 * (lo + hi) + if bezier(mid)[0] < t: + lo = mid + else: + hi = mid + u = 0.5 * (lo + hi) + return bezier(u)[1] + */ + + let mut lo = 0.0; + let mut hi = 1.0; + + for _ in 0..80 { + let mid = 0.5 * (lo + hi); + if bezier(mid, x1, y1, x2, y2).0 < t { + lo = mid; + } else { + hi = mid; + } + } + + let u = 0.5 * (lo + hi); + + bezier(u, x1, y1, x2, y2).1 +} + + +fn bezier(u: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> (f64, f64) { + /* + def bezier(u: float): + omu = 1.0 - u + b1 = 3.0 * omu * omu * u + b2 = 3.0 * omu * u * u + b3 = u * u * u + x = b1 * x1 + b2 * x2 + b3 + y = b1 * y1 + b2 * y2 + b3 + return x, y + */ + + let omu = 1.0 - u; + let b1 = 3.0 * omu * omu * u; + let b2 = 3.0 * omu * u * u; + let b3 = u * u * u; + + let x = b1 * x1 + b2 * x2 + b3; + let y = b1 * y1 + b2 * y2 + b3; + + (x, y) +} + +#[cfg(test)] +mod tests { + + mod test_signature_cubic_bezier_eased { + use crate::requests::index_page::signature::signature_cubic_bezier_eased::signature_cubic_bezier_eased; + + #[test] + fn test() { + // Input: + // + // t 0.2197265625 + // cp [0.05, -0.23, 0.95, 0.05] + // cp[0] 0.05 + // cp[1] -0.23 + // cp[2] 0.95 + // cp[3] 0.05 + // + // Output: + // + // easedY -0.06687371608353283 + + let t = 0.2197265625; + let cp0 = 0.05; + let cp1 = -0.23; + let cp2 = 0.95; + let cp3 = 0.05; + + let output = signature_cubic_bezier_eased(t, cp0, cp1, cp2, cp3); + + let expected = -0.06687371608353283; + + assert_eq!(output, expected); + } + } + + mod test_bezier { + use crate::requests::index_page::signature::signature_cubic_bezier_eased::{bezier, signature_cubic_bezier_eased}; + + #[test] + fn test_0() { + // bezier.u 0.5 + // bezier.x1 0.05 + // bezier.y1 -0.23 + // bezier.x2 0.95 + // bezier.y2 0.05 + // bezier.x 0.49999999999999994 + // bezier.y 0.057499999999999996 + let u = 0.5; + let x1 = 0.05; + let y1 = -0.23; + let x2 = 0.95; + let y2 = 0.05; + + let expected_x = 0.49999999999999994; + let expected_y = 0.057499999999999996; + + let output = bezier(u, x1, y1, x2, y2); + + assert_eq!(output.0, expected_x); + assert_eq!(output.1, expected_y); + } + + #[test] + fn test_1() { + // NB: Test values that were run through Python + let u = 0.29268743212748927; // bezier.u 0.29268743212748927 + let x1 = 0.05; // bezier.x1 0.05 + let y1 = -0.23; // bezier.y1 -0.23 + let x2 = 0.95; // bezier.x2 0.95 + let y2 = 0.05; // bezier.y2 0.05 + + let expected_x = 0.2197265625; // bezier.x 0.2197265625 + let expected_y = -0.06687371608353283; // bezier.y -0.06687371608353283 + + let output = bezier(u, x1, y1, x2, y2); + + assert_eq!(output.0, expected_x); + assert_eq!(output.1, expected_y); + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_h.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_h.rs new file mode 100644 index 0000000000..d59d0c561e --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_h.rs @@ -0,0 +1,156 @@ +use log::debug; +use crate::error::grok_client_error::GrokClientError; +use crate::requests::index_page::signature::round_two_decimals::round_two_decimals; +/* + @staticmethod + def _h(x: float, _param: float, c: float, e: bool): + f = ((x * (c - _param)) / 255.0) + _param + if e: + return floor(f) + rounded = round(float(f), 2) + if rounded == 0.0: + return 0.0 + return rounded + */ + + +/// Based on "Grok-Api/core/xctid.py" +/// Some kind of stroke angle calculator or trig function +/// `x` - could be values in the svg path +/// `param` - misc values: flags (-1, 1) and constants (60) +/// `c` - misc values: (1, 360) +/// `e` - some kind of flag +pub fn signature_h(x: f64, param: f64, c: f64, e: bool) -> Result { + // f = ((x * (c - _param)) / 255.0) + _param + let f = ((x * (c - param)) / 255.0) + param; + + debug!("[signature_h] f = {}", f); + + if e { + let floor = f.floor(); + debug!("[signature_h] floor = {}", floor); + return Ok(floor) + } + + // rounded = round(float(f), 2) + let rounded = round_two_decimals(f); + + debug!("[signature_h] rounded = {}", rounded); + + if rounded == 0.0 { + debug!("[signature_h] rounded is zero"); + return Ok(0.0) // TODO: This might not work + } + + Ok(rounded) // TODO: Looks like it can return ints or floats +} + + +#[cfg(test)] +mod tests { + use crate::requests::index_page::signature::signature_h::signature_h; + + // TODO: This needs more tests for other parameter setups + #[test] + fn test_end_angle() { + // "h" gets used lots of times, but the "end angle" calculation case is the most straightforward to use as a test case + // values = [73, 44, 215, 158, 218, 29, 68, 13, 98, 243, 134] + // endAngle = Signature._h(values[6], 60, 360, True) + + // NB: Note that the function type annotations call for floats, but that the "end_angle" case passes integers. + let x = 68f64; // [73, 44, 215, 158, 218, 29, 68, 13, 98, 243, 134] @ index 6 + let param = 60f64; + let c = 360f64; + let e = true; + + let observed = signature_h(x, param, c, e).unwrap(); + + let expected = 140.0; // NB: Python returns in `int` for this (I checked the type), but it can also return floats. Ugh. + + assert_eq!(expected, observed); + } + + #[test] + fn svg_test_1() { + // Actual test case, called in loop: + // cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])] + // Python Inputs: + // + // _h.x 13 + // _h._param 0 + // _h.c 1 + // _h.e False + // + // Python Outputs: + // + // _h.f 0.050980392156862744 + // _h.rounded 0.05 + + let x = 13.0; // NB: This was an int in python + let param = 0.0; // NB: This was an int in python + let c = 1.0; // NB: This was an int in python + let e = false; + + let observed = signature_h(x, param, c, e).unwrap(); + + let expected = 0.05; + + assert_eq!(expected, observed); + } + + #[test] + fn svg_test_2() { + // Actual test case, called in loop: + // cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])] + // Python Inputs: + // + // _h.x 98 + // _h._param -1 + // _h.c 1 + // _h.e False + // + // Python Outputs: + // + // _h.f -0.2313725490196078 + // _h.rounded -0.23 + + let x = 98.0; // NB: This was an int in python + let param = -1.0; // NB: This was an int in python + let c = 1.0; // NB: This was an int in python + let e = false; + + let observed = signature_h(x, param, c, e).unwrap(); + + let expected = -0.23; + + assert_eq!(expected, observed); + } + + #[test] + fn svg_test_3() { + // Actual test case, called in loop: + // cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])] + // Python Inputs: + // + // _h.x 243 + // _h._param 0 + // _h.c 1 + // _h.e False + // + // Python Outputs: + // + // _h.f 0.9529411764705882 + // _h.rounded 0.95 + + let x = 243.0; // NB: This was an int in python + let param = 0.0; // NB: This was an int in python + let c = 1.0; // NB: This was an int in python + let e = false; + + let observed = signature_h(x, param, c, e).unwrap(); + + let expected = 0.95; + + assert_eq!(expected, observed); + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_simulate_style.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_simulate_style.rs new file mode 100644 index 0000000000..4ac5d62d67 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_simulate_style.rs @@ -0,0 +1,321 @@ +use crate::error::grok_client_error::GrokClientError; +use crate::requests::index_page::signature::signature_cubic_bezier_eased::signature_cubic_bezier_eased; +use crate::requests::index_page::signature::signature_h::signature_h; +use log::{debug, error}; +use once_cell::sync::Lazy; +use regex::Regex; +use std::fmt::format; + +static CLEAN_NON_DIGIT_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#"[^\d]+"#) + .expect("Regex should parse") +}); + +/* + @staticmethod + def simulateStyle(values: List[int], c: int) -> Dict[str,str]: + duration = 4096 + currentTime = round(c / 10.0) * 10 + t = currentTime / duration + + cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])] + + easedY = Signature.cubicBezierEased(t, cp[0], cp[1], cp[2], cp[3]) + + start = [float(x) for x in values[0:3]] + end = [float(x) for x in values[3:6]] + r = round(start[0] + (end[0] - start[0]) * easedY) + g = round(start[1] + (end[1] - start[1]) * easedY) + b = round(start[2] + (end[2] - start[2]) * easedY) + color = f"rgb({r}, {g}, {b})" + + endAngle = Signature._h(values[6], 60, 360, True) + angle = endAngle * easedY + rad = angle * pi / 180.0 + + def is_effectively_zero(val: float) -> bool: + return abs(val) < 1e-7 + + def is_effectively_integer(val: float) -> bool: + return abs(val - round(val)) < 1e-7 + + cosv = cos(rad) + sinv = sin(rad) + + if is_effectively_zero(cosv): + a = 0 + d = 0 + else: + if is_effectively_integer(cosv): + a = int(round(cosv)) + d = int(round(cosv)) + else: + a = f"{cosv:.6f}" + d = f"{cosv:.6f}" + + if is_effectively_zero(sinv): + bval = 0 + cval = 0 + else: + if is_effectively_integer(sinv): + bval = int(round(sinv)) + cval = int(round(-sinv)) + else: + bval = f"{sinv:.7f}" + cval = f"{(-sinv):.7f}" + + transform = f"matrix({a}, {bval}, {cval}, {d}, 0, 0)" + return {"color": color, "transform": transform} + */ + +pub struct SimulatedStyle { + pub color: String, + pub transform: String, +} + +/// Based on "Grok-Api/core/xctid.py" +/// `values` is one svg path stroke calculated by the `xa` algo +/// `c` is the multiplied value from `xs` +/// +pub fn signature_simulate_style(values: &[u32], c: u32) -> Result { + // duration = 4096 + let duration = 4096; + + // currentTime = round(c / 10.0) * 10 + let current_time = ((c as f32 / 10.0).round()) as u32 * 10; + + debug!("[simulate_style] current_time = {:?}", current_time); + + // t = currentTime / duration + let t = current_time as f64 / duration as f64; + + debug!("[simulate_style] t = {:?}", t); + + // cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])] + let subvalues = &values[7..]; + let mut cp = Vec::with_capacity(subvalues.len()); + for (i, v) in subvalues.iter().enumerate() { + let param = if i % 2 != 0 { - 1.0 } else { 0.0 }; + let val = signature_h(*v as f64, param, 1.0, false)?; + cp.push(val); + } + + debug!("[simulate_style] cp = {:?}", cp); + + // easedY = Signature.cubicBezierEased(t, cp[0], cp[1], cp[2], cp[3]) + let cp_0 = cp.get(0).map(|x| *x).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let cp_1 = cp.get(1).map(|x| *x).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let cp_2 = cp.get(2).map(|x| *x).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let cp_3 = cp.get(3).map(|x| *x).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let eased_y = signature_cubic_bezier_eased(t, cp_0, cp_1, cp_2, cp_3); + + debug!("[simulate_style] eased_y = {:?}", eased_y); + + // NB: Safety + if values.len() < 6 { + error!("Values array is too short."); + return Err(GrokClientError::BadSignatureInputs); + } + + // start = [float(x) for x in values[0:3]] + let start = values[0..3].iter().map(|x| *x as f64).collect::>(); + + // end = [float(x) for x in values[3:6]] + let end = values[3..6].iter().map(|x| *x as f64).collect::>(); + + debug!("[simulate_style] start = {:?}", start); + debug!("[simulate_style] end = {:?}", end); + + // r = round(start[0] + (end[0] - start[0]) * easedY) + // g = round(start[1] + (end[1] - start[1]) * easedY) + // b = round(start[2] + (end[2] - start[2]) * easedY) + + let r = (start[0] + (end[0] - start[0]) * eased_y).round() as u32; + let g = (start[1] + (end[1] - start[1]) * eased_y).round() as u32; + let b = (start[2] + (end[2] - start[2]) * eased_y).round() as u32; + + // color = f"rgb({r}, {g}, {b})" + let color = format!("rgb({r}, {g}, {b})"); + + debug!("[simulate_style] color = {:?}", color); + + // endAngle = Signature._h(values[6], 60, 360, True) + let value_6 = values.get(6).map(|v| *v as f64).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let end_angle = signature_h(value_6, 60.0, 360.0, true)?; + + debug!("[simulate_style] end_angle = {:?}", end_angle); + + // angle = endAngle * easedY + let angle = end_angle * eased_y; + + debug!("[simulate_style] angle = {:?}", angle); + + // rad = angle * pi / 180.0 + let pi = std::f64::consts::PI; + let rad = angle * pi / 180.0; + + debug!("[simulate_style] rad = {:?}", rad); + + // cosv = cos(rad) + // sinv = sin(rad) + let cosv = rad.cos(); + let sinv = rad.sin(); + + debug!("[simulate_style] cosv = {:?}", cosv); + debug!("[simulate_style] sinv = {:?}", sinv); + + /* + if is_effectively_zero(cosv): + a = 0 + d = 0 + else: + if is_effectively_integer(cosv): + a = int(round(cosv)) + d = int(round(cosv)) + else: + a = f"{cosv:.6f}" + d = f"{cosv:.6f}" + + if is_effectively_zero(sinv): + bval = 0 + cval = 0 + else: + if is_effectively_integer(sinv): + bval = int(round(sinv)) + cval = int(round(-sinv)) + else: + bval = f"{sinv:.7f}" + cval = f"{(-sinv):.7f}" + + transform = f"matrix({a}, {bval}, {cval}, {d}, 0, 0)" + return {"color": color, "transform": transform} + */ + + let a; + let d; + + if is_effectively_zero(cosv) { + // a = 0 + // d = 0 + a = "0".to_string(); + d = "0".to_string(); + } else { + if is_effectively_integer(cosv) { + // a = int(round(cosv)) + // d = int(round(cosv)) + let rounded = cosv.round() as i32; + a = format!("{:.0}", rounded); + d = format!("{:.0}", rounded); + } else { + //a = f"{cosv:.6f}" + //d = f"{cosv:.6f}" + a = format!("{:.6}", cosv); + d = format!("{:.6}", cosv); + } + } + + let bval; + let cval; + + if is_effectively_zero(sinv) { + // bval = 0 + // cval = 0 + bval = "0".to_string(); + cval = "0".to_string(); + } else { + if is_effectively_integer(cosv) { + // bval = int(round(sinv)) + // cval = int(round(-sinv)) + let rounded_pos = sinv.round() as i32; + let rounded_neg= (-1.0 * sinv).round() as i32; + bval = format!("{:.0}", rounded_pos); + cval = format!("{:.0}", rounded_neg); + } else { + // bval = f"{sinv:.7f}" + // cval = f"{(-sinv):.7f}" + bval = format!("{:.7}", sinv); + cval = format!("{:.7}", (sinv * -1.0)); + } + } + + debug!("[simulate_style] a = {}", a); + debug!("[simulate_style] d = {}", d); + debug!("[simulate_style] bval = {}", bval); + debug!("[simulate_style] cval = {}", cval); + + // transform = f"matrix({a}, {bval}, {cval}, {d}, 0, 0)" + let transform = format!("matrix({a}, {bval}, {cval}, {d}, 0, 0)"); + + debug!("[simulate_style] transform = {:?}", transform); + + Ok(SimulatedStyle { + color, + transform, + }) +} + + +fn is_effectively_zero(val: f64) -> bool { + val.abs() <= 1e-7 +} + +fn is_effectively_integer(val: f64) -> bool { + (val - val.round()).abs() <= 1e-7 +} + +#[cfg(test)] +mod tests { + + mod test_signature_simulate_style { + use crate::requests::index_page::signature::signature_simulate_style::signature_simulate_style; + + #[test] + fn test_1() { + // from xs - 'vals' + let values = vec![73, 44, 215, 158, 218, 29, 68, 13, 98, 243, 134]; + // from xs - 'c' + let c = 900; + + let output = signature_simulate_style(&values, c).unwrap(); + + // NB: Values from python code + let expected_color = "rgb(67, 32, 227)"; + let expected_transform = "matrix(0.986679, -0.1626771, 0.1626771, 0.986679, 0, 0)"; + + assert_eq!(&output.color, expected_color); + assert_eq!(&output.transform, expected_transform); + } + } + + mod helper_functions { + use crate::requests::index_page::signature::signature_simulate_style::{is_effectively_integer, is_effectively_zero}; + + #[test] + fn test_is_effectively_zero() { + // Yes + assert!(is_effectively_zero(0.0)); + assert!(is_effectively_zero(0.00000001)); + assert!(is_effectively_zero(0.0000000001)); + // No + assert!(!is_effectively_zero(0.000001)); + assert!(!is_effectively_zero(1.0)); + assert!(!is_effectively_zero(-1.0)); + assert!(!is_effectively_zero(10.0)); + assert!(!is_effectively_zero(0.231)); + } + + #[test] + fn test_is_effectively_integer() { + // Yes + assert!(is_effectively_integer(0.0)); + assert!(is_effectively_integer(1.0)); + assert!(is_effectively_integer(1.00000001)); + assert!(is_effectively_integer(3.00000001)); + assert!(is_effectively_integer(-0.00000001)); + // No + assert!(!is_effectively_integer(3.5)); + assert!(!is_effectively_integer(0.2)); + assert!(!is_effectively_integer(-0.01234)); + } + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_to_hex.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_to_hex.rs new file mode 100644 index 0000000000..b4f8fdeedc --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_to_hex.rs @@ -0,0 +1,144 @@ +use log::debug; +use crate::error::grok_client_error::GrokClientError; +use crate::requests::index_page::signature::round_two_decimals::round_two_decimals; + +/* + @staticmethod + def tohex(num: float) -> str: + rounded = round(float(num), 2) + if rounded == 0.0: + return "0" + sign = "-" if copysign(1.0, rounded) < 0 else "" + absval = abs(rounded) + intpart = int(floor(absval)) + frac = absval - intpart + if frac == 0.0: + return sign + format(intpart, "x") + frac_digits = [] + f = frac + for _ in range(20): + f *= 16 + digit = int(floor(f + 1e-12)) + frac_digits.append(format(digit, "x")) + f -= digit + if abs(f) < 1e-12: + break + frac_str = "".join(frac_digits).rstrip("0") + if frac_str == "": + return sign + format(intpart, "x") + return sign + format(intpart, "x") + "." + frac_str +*/ + +/// Convert the floating point number to a hexadecimal representation +pub fn signature_to_hex(num: f64) -> Result { + // rounded = round(float(num), 2) + let rounded = round_two_decimals(num); + + debug!("[to_hex] rounded = {}", rounded); + + if rounded == 0.0 { + return Ok("0".to_string()); + } + + // sign = "-" if copysign(1.0, rounded) < 0 else "" + let test = 1.0f64.copysign(rounded); + let sign = if test < 0.0 { "-" } else { "" }; + + debug!("[to_hex] sign = {}", sign); + + let absval = rounded.abs(); + let intpart = absval.floor() as u32; + + debug!("[to_hex] absval = {}", absval); + debug!("[to_hex] intpart = {}", intpart); + + let frac = absval - (intpart as f64); + + debug!("[to_hex] frac = {}", frac); + + if frac == 0.0 { + // return sign + format(intpart, "x") -- this is hex formatting + let ret = format!("{sign}{intpart:x}"); + debug!("[to_hex] return = {}", ret); + return Ok(ret); + } + + /* + frac_digits = [] + f = frac + for _ in range(20): + f *= 16 + digit = int(floor(f + 1e-12)) + frac_digits.append(format(digit, "x")) + f -= digit + if abs(f) < 1e-12: + break + frac_str = "".join(frac_digits).rstrip("0") + if frac_str == "": + return sign + format(intpart, "x") + return sign + format(intpart, "x") + "." + frac_str + */ + + let mut frac_digits = Vec::new(); + + let mut f = frac; + + for _ in 0..20 { + // f *= 16 + f *= 16.0; + + // digit = int(floor(f + 1e-12)) + let digit = (f + 1e-12).floor() as i32; + + // frac_digits.append(format(digit, "x")) + frac_digits.push(format!("{digit:x}")); + + // f -= digit + f -= digit as f64; + if f.abs() < 1e-12 { + break; + } + } + + // frac_str = "".join(frac_digits).rstrip("0") + let frac_str = frac_digits.join(""); + let frac_str = frac_str.trim_end_matches('0'); + + debug!("[to_hex] frac_str = {}", frac_str); + + if frac_str.is_empty() { + // return sign + format(intpart, "x") + let ret = format!("{sign}{intpart:x}"); + debug!("[to_hex] return = {}", ret); + return Ok(ret); + } + + // return sign + format(intpart, "x") + "." + frac_str + let ret = format!("{sign}{intpart:x}.{frac_str}"); + debug!("[to_hex] return = {}", ret); + + Ok(ret) +} + +#[cfg(test)] +mod tests { + use errors::AnyhowResult; + use crate::requests::index_page::signature::signature_to_hex::signature_to_hex; + + #[test] + fn integers() -> AnyhowResult<()> { + assert_eq!(&signature_to_hex(67.0)?, "43"); // Test case from Python + assert_eq!(&signature_to_hex(32.0)?, "20"); // Test case from Python + assert_eq!(&signature_to_hex(227.0)?, "e3"); // Test case from Python + assert_eq!(&signature_to_hex(0.0)?, "0"); // Test case from Python + Ok(()) + } + + #[test] + fn fractions() -> AnyhowResult<()> { + assert_eq!(&signature_to_hex(0.986679)?, "0.fd70a3d70a3d7"); // Test case from Python + assert_eq!(&signature_to_hex(-0.1626771)?, "-0.28f5c28f5c28f6"); // Test case from Python + assert_eq!(&signature_to_hex(0.1626771)?, "0.28f5c28f5c28f6"); // Test case from Python + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_xa.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_xa.rs new file mode 100644 index 0000000000..f5cdf38c73 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_xa.rs @@ -0,0 +1,100 @@ +use crate::error::grok_client_error::GrokClientError; +use log::debug; +use once_cell::sync::Lazy; +use regex::Regex; + +static CLEAN_NON_DIGIT_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#"[^\d]+"#) + .expect("Regex should parse") +}); + +/* + @staticmethod + def xa(svg: str) -> List[List[int]]: + s = (svg) + substr = s[9:] + parts = substr.split("C") + out = [] + for part in parts: + cleaned = sub(r"[^\d]+", " ", part).strip() + if cleaned == "": + nums = [0] + else: + nums = [int(tok) for tok in cleaned.split() if tok != ""] + out.append(nums) + return out + */ + +/// Based on "Grok-Api/core/xctid.py" +/// `svg_data` is the svg path stroke data +pub fn signature_xa(svg_data: &str) -> Result>, GrokClientError> { + // s = (svg) -- not this is needed, looks like a NO-OP to me. + let s = svg_data; + debug!("[xa] s.len() ={}", s.len()); + + // substr = s[9:] + let substr = &s[9..]; + debug!("[xa] substr ={}", substr); + debug!("[xa] substr.len() ={}", substr.len()); + + // parts = substr.split("C") + let parts = substr.split("C").collect::>(); + debug!("[xa] parts.len() = {}", parts.len()); + debug!("[xa] parts = {:?}", parts); + + let mut out = Vec::new(); + + for part in parts { + // cleaned = sub(r"[^\d]+", " ", part).strip() + let cleaned = CLEAN_NON_DIGIT_REGEX.replace_all(part, " ").to_string().trim().to_string(); + debug!("[xa] cleaned len = {} value = {}", cleaned.len(), cleaned); + let mut nums; + if cleaned == "" { + // nums = [0] + nums = vec![0u32] + } else { + // nums = [int(tok) for tok in cleaned.split() if tok != ""] + nums = cleaned.split_whitespace() + .filter(|x| !x.is_empty()) + .filter_map(|s| s.parse::().ok()) // TODO: Don't choke on parse errors + .collect(); + } + out.push(nums); + } + + debug!("[xa] out = {:?}", out); + + Ok(out) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::signature::signature_xa::signature_xa; + + #[test] + fn test() { + let svg_data = "M 10,30 C 147,16 51,222 136,87 h 31 s 177,77 40,114 C 68,166 18,188 114,44 h 220 s 148,210 202,25 C 220,36 246,218 12,120 h 244 s 34,154 56,161 C 211,204 208,60 174,14 h 155 s 157,116 220,95 C 116,164 53,210 174,232 h 139 s 148,151 135,221 C 167,196 118,27 149,247 h 233 s 129,238 113,21 C 73,44 215,158 218,29 h 68 s 13,98 243,134 C 44,98 149,35 20,178 h 163 s 219,104 41,152 C 237,107 118,120 127,117 h 57 s 41,167 43,136 C 116,168 44,246 182,209 h 143 s 238,93 237,119 C 142,36 64,67 97,111 h 71 s 230,247 119,27 C 206,56 158,51 71,115 h 135 s 120,186 74,57 C 197,165 180,214 102,16 h 88 s 252,48 189,166 C 163,237 57,253 154,60 h 96 s 84,56 15,66 C 177,101 10,202 185,227 h 29 s 204,55 115,219 C 103,82 127,143 194,99 h 102 s 179,63 84,110"; + let expected = vec![ + vec![147, 16, 51, 222, 136, 87, 31, 177, 77, 40, 114], + vec![68, 166, 18, 188, 114, 44, 220, 148, 210, 202, 25], + vec![220, 36, 246, 218, 12, 120, 244, 34, 154, 56, 161], + vec![211, 204, 208, 60, 174, 14, 155, 157, 116, 220, 95], + vec![116, 164, 53, 210, 174, 232, 139, 148, 151, 135, 221], + vec![167, 196, 118, 27, 149, 247, 233, 129, 238, 113, 21], + vec![73, 44, 215, 158, 218, 29, 68, 13, 98, 243, 134], + vec![44, 98, 149, 35, 20, 178, 163, 219, 104, 41, 152], + vec![237, 107, 118, 120, 127, 117, 57, 41, 167, 43, 136], + vec![116, 168, 44, 246, 182, 209, 143, 238, 93, 237, 119], + vec![142, 36, 64, 67, 97, 111, 71, 230, 247, 119, 27], + vec![206, 56, 158, 51, 71, 115, 135, 120, 186, 74, 57], + vec![197, 165, 180, 214, 102, 16, 88, 252, 48, 189, 166], + vec![163, 237, 57, 253, 154, 60, 96, 84, 56, 15, 66], + vec![177, 101, 10, 202, 185, 227, 29, 204, 55, 115, 219], + vec![103, 82, 127, 143, 194, 99, 102, 179, 63, 84, 110] + ]; + + let output = signature_xa(&svg_data).unwrap(); + + assert_eq!(output, expected); + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_xs.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_xs.rs new file mode 100644 index 0000000000..e6b8e636c7 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/signature/signature_xs.rs @@ -0,0 +1,162 @@ +use crate::error::grok_client_error::GrokClientError; +use crate::requests::index_page::signature::signature_simulate_style::signature_simulate_style; +use crate::requests::index_page::signature::signature_to_hex::signature_to_hex; +use crate::requests::index_page::signature::signature_xa::signature_xa; +use log::{debug, error}; +use once_cell::sync::Lazy; +use regex::Regex; + +static NUMBER_REGEX : Lazy = Lazy::new(|| { + Regex::new(r#"[\d\.\-]+"#) + .expect("Regex should parse") +}); + +/* + @staticmethod + def xs(x_bytes: bytes, svg: str, x_values: list) -> str: + arr = list(x_bytes) -- we already have this + idx = arr[x_values[0]] % 16 + c = ((arr[x_values[1]] % 16) * (arr[x_values[2]] % 16)) * (arr[x_values[3]] % 16) + o = Signature.xa(svg) + vals = o[idx] + k = Signature.simulateStyle(vals, c) + + concat = str(k["color"]) + str(k["transform"]) + matches = findall(r"[\d\.\-]+", concat) + converted = [] + for m in matches: + num = float(m) + hexstr = Signature.tohex(num) + converted.append(hexstr) + joined = "".join(converted) + cleaned = joined.replace(".", "").replace("-", "") + return cleaned + */ + +/// Based on "Grok-Api/core/xctid.py" +/// `bytes` is +/// `svg_data` is the svg path stroke data +/// `x_values` are the magic numbers from the script +pub fn signature_xs(x_bytes: &[u8], svg_data: &str, x_values: &[u32]) -> Result { + // arr = list(x_bytes) -- we already have this as the input + let arr = x_bytes; + + // idx = arr[x_values[0]] % 16 + let i = x_values.get(0).map(|x| *x as usize).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let a = arr.get(i).map(|x| *x).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + let idx = a % 16; + + debug!("[xs] idx = {}", idx); + + // c = ((arr[x_values[1]] % 16) * (arr[x_values[2]] % 16)) * (arr[x_values[3]] % 16) + let xv1 = ith_usize(x_values, 1)?; + let av1 = ith_u32(arr, xv1)? % 16; + let xv2 = ith_usize(x_values, 2)?; + let av2 = ith_u32(arr, xv2)? % 16; + let xv3 = ith_usize(x_values, 3)?; + let av3 = ith_u32(arr, xv3)? % 16; + let c = av1 * av2 * av3; // Max value: 4096 (16x16x16) + + debug!("[xs] c = {}", c); + + // o = Signature.xa(svg) + let o = signature_xa(svg_data)?; + + // vals = o[idx] + let vals = o.get(idx as usize).ok_or_else(|| GrokClientError::BadSignatureInputs)?; + + debug!("[xs] vals.len = {:?}", vals.len()); + debug!("[xs] vals = {:?}", vals); + + // k = Signature.simulateStyle(vals, c) + let k = signature_simulate_style(vals, c)?; + + debug!("[xs] k.color = {}", &k.color); + debug!("[xs] k.transform = {}", &k.transform); + + /* + concat = str(k["color"]) + str(k["transform"]) + matches = findall(r"[\d\.\-]+", concat) + converted = [] + for m in matches: + num = float(m) + hexstr = Signature.tohex(num) + converted.append(hexstr) + joined = "".join(converted) + cleaned = joined.replace(".", "").replace("-", "") + return cleaned + */ + + // concat = str(k["color"]) + str(k["transform"]) + let concat = format!("{}{}", k.color, k.transform); + + debug!("[xs] concat = {}", concat); + + // matches = findall(r"[\d\.\-]+", concat) + let matches = NUMBER_REGEX.captures_iter(&concat) + .filter_map(|captures| captures.get(0)) + .map(|m| m.as_str().to_string()) + .collect::>(); + + debug!("[xs] matches = {:?}", matches); + + /* + converted = [] + for m in matches: + num = float(m) + hexstr = Signature.tohex(num) + converted.append(hexstr) + joined = "".join(converted) + cleaned = joined.replace(".", "").replace("-", "") + */ + + let mut converted = Vec::new(); + + for m in matches.iter() { + let num = m.as_str().parse::().map_err(|err| { + error!("Invalid floating point: {} - err: {:?}", m, err); + GrokClientError::BadSignatureInputs + })?; + + let hexstr = signature_to_hex(num)?; + converted.push(hexstr); + } + + let joined = converted.join(""); + let cleaned = joined.replace(".", "").replace("-", ""); + + debug!("[xs] joined = {}", joined); + debug!("[xs] cleaned = {}", cleaned); + + Ok(cleaned) +} + +fn ith_usize(numbers: &[u32], i: usize) -> Result { + numbers.get(i) + .map(|x| *x as usize) + .ok_or_else(|| GrokClientError::BadSignatureInputs) +} + +fn ith_u32(bytes: &[u8], i: usize) -> Result { + bytes.get(i) + .map(|x| *x as u32) + .ok_or_else(|| GrokClientError::BadSignatureInputs) +} + +#[cfg(test)] +mod tests { + use crate::requests::index_page::signature::signature_xs::signature_xs; + + #[test] + fn test() { + let x_bytes = [202, 221, 122, 9, 104, 148, 35, 141, 172, 239, 1, 134, 120, 204, 92, 116, 66, 19, 218, 104, 143, 23, 147, 213, 56, 102, 69, 212, 151, 26, 123, 178, 187, 28, 196, 43, 45, 226, 56, 69, 54, 46, 126, 103, 95, 4, 134, 84]; + let svg_data = "M 10,30 C 147,16 51,222 136,87 h 31 s 177,77 40,114 C 68,166 18,188 114,44 h 220 s 148,210 202,25 C 220,36 246,218 12,120 h 244 s 34,154 56,161 C 211,204 208,60 174,14 h 155 s 157,116 220,95 C 116,164 53,210 174,232 h 139 s 148,151 135,221 C 167,196 118,27 149,247 h 233 s 129,238 113,21 C 73,44 215,158 218,29 h 68 s 13,98 243,134 C 44,98 149,35 20,178 h 163 s 219,104 41,152 C 237,107 118,120 127,117 h 57 s 41,167 43,136 C 116,168 44,246 182,209 h 143 s 238,93 237,119 C 142,36 64,67 97,111 h 71 s 230,247 119,27 C 206,56 158,51 71,115 h 135 s 120,186 74,57 C 197,165 180,214 102,16 h 88 s 252,48 189,166 C 163,237 57,253 154,60 h 96 s 84,56 15,66 C 177,101 10,202 185,227 h 29 s 204,55 115,219 C 103,82 127,143 194,99 h 102 s 179,63 84,110"; + let x_values = [40, 18, 3, 18]; + + let observed = signature_xs(&x_bytes, &svg_data, &x_values).unwrap(); + + let expected = "4320e30fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700"; // NB: From python + + assert_eq!(&observed, expected); + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/convert_verification_token_to_loading_anim.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/convert_verification_token_to_loading_anim.rs new file mode 100644 index 0000000000..04c132785e --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/convert_verification_token_to_loading_anim.rs @@ -0,0 +1,60 @@ +use crate::datatypes::api::verification_token::VerificationToken; +use crate::error::grok_client_error::GrokClientError; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use log::{debug, error}; + +#[derive(Debug, Clone)] +pub struct LoadingAnim(pub(crate) usize); + +impl LoadingAnim { + pub fn to_id(&self) -> String { + format!("loading-x-anim-{}", self.0) + } +} + +/// This arbitrary algorithm is used by Grok. +/// See https://github.com/realasfngl/Grok-Api +/// The verification token comes from the index HTML. +pub fn convert_verification_token_to_loading_anim(verification_token: &VerificationToken) -> Result { + // array: list = list(b64decode(verification_token)) -- note: we don't need to convert to a list + let decoded_bytes = BASE64_STANDARD.decode(&verification_token.0) + .map_err(|err| { + error!("Failed to decode verification token: {} ; err = {:?}", verification_token.0, err); + GrokClientError::FailedToDecodeVerificationToken(err) + })?; + + // anim: str = "loading-x-anim-" + str(array[5] % 4) + let byte = decoded_bytes.get(5) + .map(|byte| *byte) + .ok_or(GrokClientError::InvalidVerificationTokenBytes)?; + + debug!("verification token byte: {}", byte); + + // note, we'll put the string construction in the `to_id()` method since we need the raw integer again + let anim = (byte % 4) as usize; + + Ok(LoadingAnim(anim)) +} + +#[cfg(test)] +mod tests { + use crate::datatypes::api::verification_token::VerificationToken; + use crate::error::grok_client_error::GrokClientError; + use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim; + use errors::AnyhowResult; + + #[test] + fn test() -> AnyhowResult<()> { + assert_eq!(&to_id("aezWdJW6+WJhvtMA1faxopO11Q1JRcM6j7JcW/R0gO09CQhO7GzIs760yo1A0MnF")?, "loading-x-anim-2"); + assert_eq!(&to_id("wROL4+VivbPuVc37N+4VNq0K3cEMW0bPKuToO6JWxJ9fzsP3Rsn2eC8DDDZFFlov")?, "loading-x-anim-2"); + assert_eq!(&to_id("ckWUvquxT8AdPRpdjXTeNkkXRfKiGGnQFH3Hq4aznsUrSsjtDSI4JcO59j7U/+VQ")?, "loading-x-anim-1"); + Ok(()) + } + + fn to_id(verification_token: &str) -> Result { + let verification_token = VerificationToken(verification_token.to_string()); + let anim = convert_verification_token_to_loading_anim(&verification_token)?; + Ok(anim.to_id()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/mod.rs new file mode 100644 index 0000000000..fe07f5ea5b --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod select_svg_path_by_loading_anim; +pub mod convert_verification_token_to_loading_anim; diff --git a/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/select_svg_path_by_loading_anim.rs b/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/select_svg_path_by_loading_anim.rs new file mode 100644 index 0000000000..999700cf08 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/index_page/utils/select_svg_path_by_loading_anim.rs @@ -0,0 +1,23 @@ +use crate::datatypes::api::svg_path_data::SvgPathData; +use crate::error::grok_client_error::GrokClientError; +use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::LoadingAnim; +use log::error; + +pub fn select_svg_path_by_loading_anim( + svg_paths: &[SvgPathData], + loading_anim: &LoadingAnim +) -> Result { + svg_paths.get(loading_anim.0) + .map(|path| path.clone()) + .ok_or_else(|| { + error!("There was not an SVG path data at index {} (array len {})", loading_anim.0, svg_paths.len()); + GrokClientError::ScriptSvgLogicOutOfDate + }) +} + +// #[cfg(test)] +// mod tests { +// #[test] +// fn test() { +// } +// } diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/grok_create_media_post.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/grok_create_media_post.rs new file mode 100644 index 0000000000..a0ce3d97de --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/grok_create_media_post.rs @@ -0,0 +1,165 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::post_id::PostId; +use crate::datatypes::api::user_id::UserId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::media_posts::create_media_post::request::CreateMediaPostWireRequest; +use crate::requests::media_posts::create_media_post::response::CreateMediaPostRawResponse; +use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use log::{error, info}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, CACHE_CONTROL, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, USER_AGENT}; +use wreq::Client; +use wreq_util::Emulation; + +const CREATE_MEDIA_POST_URL : &str = "https://grok.com/rest/media/post/create"; + +/// Request builder +pub struct GrokCreateMediaPost<'a> { + pub user_id: &'a UserId, + pub file_id: &'a FileId, + pub media_type: MediaPostType, + pub cookie: &'a str, + pub request_timeout: Option, +} + +#[derive(Debug, Copy, Clone)] +pub enum MediaPostType { + UserUploadedImage, + GrokGeneratedImage, + Video, +} + +/// Response type +#[derive(Debug, Clone)] +pub struct GrokCreateMediaPostResponse { + pub post_id: PostId, +} + +impl <'a> GrokCreateMediaPost<'a> { + pub async fn send(&self) -> Result { + info!("Configuring client..."); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + // TODO: Headers were from Chromium, not Firefox. Partial implementation. + let mut request_builder = client.post(CREATE_MEDIA_POST_URL) + // TODO: Missing header "baggage" + // TODO: Missing header "sentry-trace" + // TODO: Missing header "traceparent" + // TODO: Missing header "traceparent" + // TODO: Missing header "x-statsig-id" + // TODO: Missing header "x-xai-request-id" + //.header("sec-ch-ua", "") // NB: Not present in firefox + //.header("sec-ch-ua-mobile", "") // NB: Not present in firefox + //.header("sec-ch-ua-platform", "") // NB: Not present in firefox + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(CACHE_CONTROL, "no-cache") + .header(CONTENT_TYPE, "application/json") + .header(COOKIE, self.cookie.to_string()) + .header(ORIGIN, "https://grok.com") + .header(PRAGMA, "no-cache") + .header("priority", "u=1, i") + .header(REFERER, "https://grok.com/imagine/favorites") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT); + + //info!("Sending..."); + //let response = builder.send() + // .await + // .map_err(|err| GrokClientError::WreqClientError(err))?; + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let media_url = match self.media_type { + MediaPostType::UserUploadedImage => user_and_file_id_to_image_url(self.user_id, self.file_id), + MediaPostType::GrokGeneratedImage => unimplemented!("implement for generated images"), + MediaPostType::Video => unimplemented!("implement for videos"), + }; + + let request_body = CreateMediaPostWireRequest { + media_type: "MEDIA_POST_TYPE_IMAGE".to_string(), + media_url, + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building create media request: {:?}", err); + GrokClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during create media: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + let response : CreateMediaPostRawResponse = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(GrokCreateMediaPostResponse { + post_id: PostId(response.post.id), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Client side tests only + async fn create_media_post() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + let cookies = get_test_cookies()?; + + let user_id = UserId("85980643-ffab-4984-a3de-59a608c47d7f".to_string()); // User + let file_id = FileId("990ddf90-8f34-42b1-81a5-39c509d62ff7".to_string()); // Mochi + + let request = GrokCreateMediaPost { + user_id: &user_id, + file_id: &file_id, + media_type: MediaPostType::UserUploadedImage, + cookie: &cookies, + request_timeout: None, + }; + + let result = request.send().await?; + + println!("Result: {:?}", result); + println!("Post ID: {:?}", result.post_id); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/mod.rs new file mode 100644 index 0000000000..9fc4c9c3f9 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/mod.rs @@ -0,0 +1,3 @@ +pub mod grok_create_media_post; +pub (super) mod request; +mod response; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/request.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/request.rs new file mode 100644 index 0000000000..e812cb4345 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/request.rs @@ -0,0 +1,10 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub (super) struct CreateMediaPostWireRequest { + #[serde(rename = "mediaType")] + pub (super) media_type: String, + + #[serde(rename = "mediaUrl")] + pub (super) media_url: String, +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/response.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/response.rs new file mode 100644 index 0000000000..feb01ffc9a --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/create_media_post/response.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct CreateMediaPostRawResponse { + pub post: PostData, +} + +#[derive(Deserialize)] +pub struct PostData { + /// The identifier for the post + pub id: String, +} + +/* +{ + "post": { + "id": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + "userId":"85980643-ffab-4984-a3de-59a608c47d7f", + "createTime":"2025-10-30T23:22:19.528702688Z", + "prompt":"", + "mediaType":"MEDIA_POST_TYPE_IMAGE", + "mediaUrl":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/3c896963-8e6b-4e71-83e1-4253b2bf2ac8/content", + "mimeType":"image/jpeg", + "audioUrls":[], + "childPosts":[] + } +} +*/ diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/grok_like_media.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/grok_like_media.rs new file mode 100644 index 0000000000..20aeabf9af --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/grok_like_media.rs @@ -0,0 +1,190 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::baggage::Baggage; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::sentry_trace::SentryTrace; +use crate::datatypes::api::svg_path_data::SvgPathData; +use crate::datatypes::api::verification_token::VerificationToken; +use crate::datatypes::api::xsid_numbers::XsidNumbers; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::index_page::signature::generate_xsid::{generate_xsid, GenerateXsidArgs}; +use crate::requests::media_posts::like_media_post::request::LikeMediaWireRequest; +use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use log::{error, info, warn}; +use std::time::Duration; +use uuid::Uuid; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, USER_AGENT}; +use wreq::Client; +use wreq_util::Emulation; + +const LIKE_MEDIA_POST_URL: &str = "https://grok.com/rest/media/post/like"; + +/// "Liking" media is currently necessary to preserve it in your account +/// Request builder +pub struct GrokLikeMediaPost<'a> { + pub file_id: &'a FileId, + + pub cookie: &'a str, + pub baggage: &'a Baggage, + pub sentry_trace: &'a SentryTrace, + pub verification_token: &'a VerificationToken, + pub svg_data: &'a SvgPathData, + pub numbers: &'a XsidNumbers, + + pub request_timeout: Option, +} + +/// Response type +#[derive(Debug)] +pub struct GrokLikeMediaPostResponse { +} + +impl <'a> GrokLikeMediaPost<'a> { + pub async fn send(&self) -> Result { + info!("Configuring client..."); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + let xai_request_id = Uuid::new_v4().to_string(); + println!("xai_request_id (uuid) = {}", xai_request_id); + + let sentry_trace_header = self.sentry_trace.to_http_request_header(); + println!("sentry_trace = {}", sentry_trace_header); + + let x_statsig_id = generate_xsid(GenerateXsidArgs { + path: "/rest/app-chat/conversations/new", + method: "POST", + verification_token: &self.verification_token, + svg_data: &self.svg_data, + numbers: &self.numbers, + })?; + + println!("x_statsig_id = {}", x_statsig_id); + + // NB: Adapting to firefox + let mut request_builder = client.post(LIKE_MEDIA_POST_URL) + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/imagine/favorites") + // TODO: Missing header "traceparent" + .header(CONTENT_TYPE, "application/json") + .header("x-xai-request-id", xai_request_id) + .header("x-statsig-id", x_statsig_id) + .header("sentry-trace", sentry_trace_header) + .header("baggage", &self.baggage.0) + .header(ORIGIN, "https://grok.com") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, self.cookie.to_string()) + .header(CACHE_CONTROL, "no-cache") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("priority", "u=4") + .header(TE, "trailers"); + + //info!("Sending..."); + //let response = builder.send() + // .await + // .map_err(|err| GrokClientError::WreqClientError(err))?; + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let request_body = LikeMediaWireRequest { + id: self.file_id.0.to_string(), + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building create media request: {:?}", err); + GrokClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during create media: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + info!("Like Media Status: {:?}", status); + + let response_body = &response.text() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + // TODO: Classify errors + if !status.is_success() { + warn!("Not successful liking media (status: {:?}) : {:?}", status, response_body); + // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + // return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + //let response : GrokApiUploadFileResponse = serde_json::from_str(response_body) + // .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(GrokLikeMediaPostResponse { + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Client side tests only + async fn like_media_post() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + let cookies = get_typed_test_cookies()?; + + let file_id = FileId("990ddf90-8f34-42b1-81a5-39c509d62ff7".to_string()); // Mochi + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + let request = GrokLikeMediaPost { + file_id: &file_id, + + cookie: cookies.as_str(), + baggage: &secrets.baggage, + sentry_trace: &secrets.sentry_trace, + verification_token: &secrets.verification_token, + svg_data: &secrets.svg_path_data, + numbers: &secrets.numbers, + + request_timeout: None, + }; + + let result = request.send().await?; + + println!("Result: {:?}", result); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/mod.rs new file mode 100644 index 0000000000..6d256bf653 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/mod.rs @@ -0,0 +1,2 @@ +mod request; +pub mod grok_like_media; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/request.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/request.rs new file mode 100644 index 0000000000..60f483eaf9 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/like_media_post/request.rs @@ -0,0 +1,8 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub (super) struct LikeMediaWireRequest { + /// The file ID + /// Videos can be favorited before they are generated ! + pub (super) id: String, +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/grok_list_media_posts.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/grok_list_media_posts.rs new file mode 100644 index 0000000000..7c78ecbc28 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/grok_list_media_posts.rs @@ -0,0 +1,224 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::post_id::PostId; +use crate::datatypes::api::user_id::UserId; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::media_posts::list_media_posts::request::{FilterData, MediaPostListRawRequest}; +use crate::requests::media_posts::list_media_posts::response::{PostItem, PostListRawResponse}; +use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use log::{error, info}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, USER_AGENT}; +use wreq::Client; +use wreq_util::Emulation; + +const MEDIA_LIST_URL: &str = "https://grok.com/rest/media/post/list"; + +/// Request builder +pub struct GrokMediaPostListRequest<'a> { + pub cookie: &'a str, + + /// Optional cursor + pub cursor: Option, + + pub request_timeout: Option, +} + +/// Response type +#[derive(Debug, Clone)] +pub struct GrokMediaPostList { + /// List of posts + pub posts: Vec, + + /// Cursor for the next items. + pub next_cursor: Option, +} + +#[derive(Debug, Clone)] +pub struct MediaPost { + pub post_id: PostId, + + /// Only populated if there's a video and all the video data is present. + pub video_data: Option +} + +#[derive(Debug, Clone)] +pub struct VideoData { + pub file_id: FileId, + pub video_url: String, + pub prompt: Option, +} + +impl <'a> GrokMediaPostListRequest<'a> { + + pub async fn send(&self) -> Result { + info!("Configuring client..."); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + // TODO: Headers were from Chromium, not Firefox. Partial implementation. + let mut request_builder = client.post(MEDIA_LIST_URL) + // TODO: Missing header "baggage" + // TODO: Missing header "sentry-trace" + // TODO: Missing header "traceparent" + // TODO: Missing header "x-statsig-id" + // TODO: Missing header "x-xai-request-id" + //.header(CACHE_CONTROL, "no-cache") // NB: Not present in firefox + //.header(PRAGMA, "no-cache") // NB: Not present in firefox + //.header("sec-ch-ua", "") // NB: Not present in firefox + //.header("sec-ch-ua-mobile", "") // NB: Not present in firefox + //.header("sec-ch-ua-platform", "") // NB: Not present in firefox + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/imagine/favorites") + .header(CONTENT_TYPE, "application/json") + .header(ORIGIN, "https://grok.com") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, self.cookie.to_string()) + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("Priority", "u=4") + .header(TE, "trailers"); + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let request_body = MediaPostListRawRequest { + limit: 40, + filter: FilterData { + source: "MEDIA_POST_SOURCE_LIKED".to_string(), + }, + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building create media request: {:?}", err); + GrokClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during create media: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + let response : PostListRawResponse = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let mut posts = Vec::new(); + + for post in response.posts.into_iter() { + let post_id = post.id; + + // If the child video doesn't have a prompt, this parent prompt could + // be a VLM-based auto-prompt we can copy to the video. + let maybe_parent_prompt = empty_to_none(post.prompt); + + let mut video_data = None; + + for child in post.child_posts.into_iter() { + // Maybe children can have more than one video? + if let Some(url) = child.media_url { + let mut has_video = false; + if url.to_lowercase().contains(".mp4") { + has_video = true; + } + if has_video { + let mut maybe_video_prompt = empty_to_none(child.original_prompt); + + if maybe_video_prompt.is_none() { + maybe_video_prompt = maybe_parent_prompt.clone(); + } + + video_data = Some(VideoData { + file_id: FileId(child.id), + video_url: url, + prompt: maybe_video_prompt, + }); + } + } + } + + posts.push(MediaPost { + post_id: PostId(post_id), + video_data, + }) + } + + Ok(GrokMediaPostList { + posts, + next_cursor: response.next_cursor, + }) + } +} + +pub fn empty_to_none(opt: Option) -> Option { + match opt.as_deref() { + None => None, + Some("") => None, + _ => opt, + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Client side tests only + async fn create_media_post() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + let cookies = get_test_cookies()?; + + let request = GrokMediaPostListRequest { + cookie: &cookies, + request_timeout: None, + cursor: None, + }; + + let result = request.send().await?; + + println!("[test] Next Cursor: {:?}", result.next_cursor); + + for post in result.posts { + println!("[test] Post : {:?}", post); + } + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/mod.rs new file mode 100644 index 0000000000..2f3476f8e8 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/mod.rs @@ -0,0 +1,3 @@ +mod response; +mod request; +pub mod grok_list_media_posts; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/request.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/request.rs new file mode 100644 index 0000000000..89d9a47123 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/request.rs @@ -0,0 +1,24 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct MediaPostListRawRequest { + /// eg. 40 + pub limit: usize, + pub filter: FilterData, +} + +#[derive(Serialize)] +pub struct FilterData { + /// eg. "MEDIA_POST_SOURCE_LIKED" + pub source: String, +} + +/* +{ + "limit":40, + "filter":{ + "source":"MEDIA_POST_SOURCE_LIKED" + } +} +*/ + diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/response.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/response.rs new file mode 100644 index 0000000000..2d6eac94cc --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/list_media_posts/response.rs @@ -0,0 +1,88 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub (crate) struct PostListRawResponse { + pub posts: Vec, + + #[serde(rename = "nextCursor")] + pub next_cursor: Option, +} + +#[derive(Deserialize, Debug)] +pub (crate) struct PostItem { + /// The "id" of the post. + /// This is how we assert video task completion + pub id: String, + + /// Sometimes this is a VLM-generated prompt + /// This might become the prompt of the video if the video has no prompt. + pub prompt: Option, + + /// eg. `MEDIA_POST_TYPE_IMAGE` + #[serde(rename = "mediaType")] + pub media_type: Option, + + /// eg. "https://imagine-public.x.ai/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg" + #[serde(rename = "mediaUrl")] + pub media_url: Option, + + #[serde(rename = "childPosts")] + pub child_posts: Vec, +} + +#[derive(Deserialize, Debug)] +pub (crate) struct ChildPostItem { + pub id: String, + + /// eg. `MEDIA_POST_TYPE_VIDEO` + #[serde(rename = "mediaType")] + pub media_type: Option, + + /// eg. "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ecec690e-15bd-4f5f-b4e9-9b8733c3fd69/generated_video.mp4" + #[serde(rename = "mediaUrl")] + pub media_url: Option, + + /// The unmodified user prompt + #[serde(rename = "originalPrompt")] + pub original_prompt: Option, +} + +/* +{ + "posts": [ + { + "id": "abd9331b-c5ac-49cc-8bec-c410c7efde98", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:49:37.583477Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_IMAGE", + "mediaUrl": "https://imagine-public.x.ai/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg", + "mimeType": "image/jpeg", + "audioUrls": [], + "childPosts": [ + { + "id": "ecec690e-15bd-4f5f-b4e9-9b8733c3fd69", + "userId": "85980643-ffab-4984-a3de-59a608c47d7f", + "createTime": "2025-10-30T23:50:04.862487Z", + "prompt": "", + "mediaType": "MEDIA_POST_TYPE_VIDEO", + "mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/generated/ecec690e-15bd-4f5f-b4e9-9b8733c3fd69/generated_video.mp4", + "mimeType": "video/mp4", + "originalPostId": "abd9331b-c5ac-49cc-8bec-c410c7efde98", + "audioUrls": [], + "childPosts": [], + "originalPrompt": "an anime girl runs away from a giant t-rex", + "mode": "custom", + "modelName": "imagine_xdit_1" + } + ], + "originalPrompt": "", + "userInteractionStatus": { + "likeStatus": true + }, + "modelName": "imagine_h_1", + "thumbnailImageUrl": "https://imagine-public.x.ai/cdn-cgi/image/width=500,fit=scale-down,format=auto/imagine-public/share-images/abd9331b-c5ac-49cc-8bec-c410c7efde98.jpg" + }, + { + "id": "3c896963-8e6b-4e71-83e1-4253b2bf2ac8", + */ \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/media_posts/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/media_posts/mod.rs new file mode 100644 index 0000000000..4e09b02490 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/media_posts/mod.rs @@ -0,0 +1,3 @@ +pub mod create_media_post; +pub mod like_media_post; +pub mod list_media_posts; diff --git a/crates/api_clients/grok_consumer_client/src/requests/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/mod.rs new file mode 100644 index 0000000000..cc125f1174 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/mod.rs @@ -0,0 +1,6 @@ +pub mod download_video_file; +pub mod image_websocket; +pub mod index_page; +pub mod media_posts; +pub mod upload_file; +pub mod video_chat; diff --git a/crates/api_clients/grok_consumer_client/src/requests/upload_file/grok_upload_file.rs b/crates/api_clients/grok_consumer_client/src/requests/upload_file/grok_upload_file.rs new file mode 100644 index 0000000000..e6e17d424c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/upload_file/grok_upload_file.rs @@ -0,0 +1,220 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::file_upload_spec::FileUploadSpec; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::upload_file::request::UploadFileRequest; +use crate::requests::upload_file::response::GrokApiUploadFileResponse; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use log::{error, info}; +use serde::Deserialize; +use std::path::Path; +use std::time::Duration; +use tokio::fs::File; +use tokio::io::AsyncReadExt; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, USER_AGENT}; +use wreq::Client; +use wreq_util::Emulation; + +const UPLOAD_FILE_URL : &str = "https://grok.com/rest/app-chat/upload-file"; + +/// Try to prevent buffer reallocations. +/// There's a better way to implement this. +const INITIAL_BUFFER_SIZE : usize = 1024*1024; + +/// Request builder +pub struct GrokUploadFile> { + pub file: FileUploadSpec

, + pub cookie: String, + pub request_timeout: Option, +} + + +/// Response type +#[derive(Clone, Debug)] +pub struct GrokUploadFileResponse { + pub file_id: Option, + pub file_uri: Option, +} + +impl

GrokUploadFile

where P: AsRef { + + pub async fn upload(&self) -> Result { + match &self.file { + FileUploadSpec::Path(path) => { + self.upload_from_file(path.as_ref()).await + } + FileUploadSpec::Bytes { bytes, filename, mimetype } => { + unimplemented!("todo: implement bytes upload") + } + } + } + + async fn upload_from_file(&self, file_path: &Path) -> Result { + let mut file = File::open(file_path) + .await + .map_err(|err| { + error!("Failed to open file for upload: {}", err); + GrokClientError::CannotOpenLocalFileForUpload(err) + })?; + + let mut buffer = Vec::with_capacity(INITIAL_BUFFER_SIZE); + + file.read_to_end(&mut buffer) + .await + .map_err(|err| { + error!("Failed to read file for upload: {}", err); + GrokClientError::CannotReadLocalFileForUpload(err) + })?; + + let filename = file_path + .file_name() + .ok_or_else(|| GrokClientError::FileForUploadHasInvalidPath)? + .to_string_lossy() + .to_string(); + + let maybe_ext = file_path + .extension() + .and_then(|e| e.to_str()); + + // TODO: Share library code. + // TODO: Read file magic bytes first, then fall back to this. + let mime_type = match maybe_ext { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + // Some("webp") => "image/webp", + // Some("gif") => "image/gif", + // Some("mp4") => "video/mp4", + // Some("mov") => "video/quicktime", + // Some("webm") => "video/webm", + _ => "application/octet-stream", + }; + + // The encoded images have '/' and '+'. + // The files have base64 padding! + let base64_file = BASE64_STANDARD.encode(buffer); + + self.do_upload(base64_file, &filename, &mime_type ).await + } + + async fn do_upload(&self, base64_file: String, file_name: &str, mime_type: &str) -> Result { + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?; + + info!("Configuring client..."); + + // TODO: Headers were from Chromium, not Firefox. Partial implementation. + let mut request_builder = client.post(UPLOAD_FILE_URL) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + // TODO: Missing header "baggage" + .header(CACHE_CONTROL, "no-cache") + .header(CONTENT_TYPE, "application/json") + .header(COOKIE, self.cookie.to_string()) + .header(ORIGIN, "https://grok.com") + .header(PRAGMA, "no-cache") + .header("priority", "u=1, i") + .header(REFERER, "https://grok.com/imagine/favorites") + //.header("sec-ch-ua", "") // TODO + //.header("sec-ch-ua-mobile", "") // TODO + //.header("sec-ch-ua-platform", "") // TODO + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + // TODO: Missing header "sentry-trace" + // TODO: Missing header "traceparent" + // TODO: Missing header "traceparent" + // TODO: Missing header "x-statsig-id" + // TODO: Missing header "x-xai-request-id" + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT); + + //info!("Sending..."); + //let response = builder.send() + // .await + // .map_err(|err| GrokClientError::WreqClientError(err))?; + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let request_body = UploadFileRequest { + fileName: file_name.to_string(), + fileMimeType: mime_type.to_string(), + content: base64_file, + fileSource: "IMAGINE_SELF_UPLOAD_FILE_SOURCE".to_string(), + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building image upload request: {:?}", err); + GrokClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during image upload: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = &response.text() + .await + .map_err(|err| { + error!("Error reading Grok image upload response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + // TODO: Handle bad statuses + if !status.is_success() { + error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + let response : GrokApiUploadFileResponse = serde_json::from_str(response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let file_id = response.file_metadata_id + .map(|id| FileId(id)); + + Ok(GrokUploadFileResponse { + file_id, + file_uri: response.file_uri, + }) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] + async fn upload_file() -> AnyhowResult<()> { + //setup_test_logging(LevelFilter::Trace); + + let cookies = get_test_cookies()?; + + let request = GrokUploadFile { + file: FileUploadSpec::Path("/Users/bt/dev/storyteller/storyteller-rust/test_data/image/mochi.jpg"), + cookie: cookies.to_string(), + request_timeout: None, + }; + + let result = request.upload().await?; + + println!("Result: {:?}", result); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/upload_file/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/upload_file/mod.rs new file mode 100644 index 0000000000..a3fb932f84 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/upload_file/mod.rs @@ -0,0 +1,4 @@ +pub mod grok_upload_file; + +pub (super) mod request; +pub (super) mod response; diff --git a/crates/api_clients/grok_consumer_client/src/requests/upload_file/request.rs b/crates/api_clients/grok_consumer_client/src/requests/upload_file/request.rs new file mode 100644 index 0000000000..b3b19015c7 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/upload_file/request.rs @@ -0,0 +1,18 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub (super) struct UploadFileRequest { + #[serde(rename="fileName")] + pub fileName: String, + + #[serde(rename="fileMimeType")] + pub fileMimeType: String, + + /// Base64-encoded content + pub content: String, + + /// eg. 'IMAGINE_SELF_UPLOAD_FILE_SOURCE' + #[serde(rename="fileSource")] + pub fileSource: String, +} + diff --git a/crates/api_clients/grok_consumer_client/src/requests/upload_file/response.rs b/crates/api_clients/grok_consumer_client/src/requests/upload_file/response.rs new file mode 100644 index 0000000000..92f10b8ced --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/upload_file/response.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub (super) struct GrokApiUploadFileResponse { + + /// The uploaded file_id, which is used to reference the file later. + #[serde(rename = "fileMetadataId")] + pub file_metadata_id: Option, + + /// Partial path to the media file, not a full URI. + /// eg. `users/{user_id}/{file_id}/content` + #[serde(rename = "fileUri")] + pub file_uri: Option, +} + +/* +{ + "fileMetadataId": "21a79085-e206-4b0b-88ac-5f2b7a453e45", + "fileMimeType": "image/jpeg", + "fileName": "0_0.jpeg", + "fileUri": "users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content", + "parsedFileUri": "", + "createTime": "2025-10-21T23:40:41.784448Z", + "fileSource": "SELF_UPLOAD_FILE_SOURCE" +} +*/ diff --git a/crates/api_clients/grok_consumer_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs b/crates/api_clients/grok_consumer_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs new file mode 100644 index 0000000000..10b7b50f47 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/video_chat/grok_video_gen_chat_conversation.rs @@ -0,0 +1,459 @@ +use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT; +use crate::datatypes::api::aspect_ratio::AspectRatio; +use crate::datatypes::api::baggage::Baggage; +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::sentry_trace::SentryTrace; +use crate::datatypes::api::svg_path_data::SvgPathData; +use crate::datatypes::api::user_id::UserId; +use crate::datatypes::api::verification_token::VerificationToken; +use crate::datatypes::api::video_generation_mode::VideoGenerationMode; +use crate::datatypes::api::xsid_numbers::XsidNumbers; +use crate::error::categorize_grok_http_error::categorize_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::index_page::signature::generate_xsid::{generate_xsid, GenerateXsidArgs}; +use crate::requests::upload_file::grok_upload_file::{GrokUploadFile, GrokUploadFileResponse}; +use crate::requests::video_chat::parse_video_id::parse_video_id; +use crate::requests::video_chat::parse_video_id_from_partial_byte_stream_buffer::parse_video_id_from_partial_byte_stream_buffer; +use crate::requests::video_chat::request::{CreateChatConversationWireRequest, ModelConfigOverride, ModelMap, ResponseMetadata, ToolOverrides, VideoGenAspectRatio, VideoGenModelConfig}; +use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; +use futures::StreamExt; +use log::{debug, error, info, warn}; +use std::time::Duration; +use uuid::Uuid; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, USER_AGENT}; +use wreq::{Client, Request, RequestBuilder, Response}; +use wreq_util::Emulation; + +const CHAT_CONVERSATION_URL: &str = "https://grok.com/rest/app-chat/conversations/new"; + +/// Request builder +/// NOTE: Without sending a "like" request after generation, the video will likely never render! +pub struct GrokVideoGenChatConversationBuilder<'a> { + pub file_id: &'a FileId, + pub media_type: VideoMediaPostType, + pub prompt: Option<&'a str>, + pub mode: Option, + pub aspect_ratio: Option, + + pub cookie: &'a str, + pub user_id: &'a UserId, + + pub baggage: &'a Baggage, + pub sentry_trace: &'a SentryTrace, + pub verification_token: &'a VerificationToken, + pub svg_data: &'a SvgPathData, + pub numbers: &'a XsidNumbers, + + pub request_timeout: Option, +} + +#[derive(Debug, Copy, Clone)] +pub enum VideoMediaPostType { + UserUploadedImage, + GrokGeneratedImage, + Video, +} + +/// Response type +#[derive(Debug)] +pub struct GrokVideoGenChatConversationResponse { + pub video_file_id: Option, +} + +/// Partial response type +/// This is from an incomplete stream +#[derive(Debug)] +pub struct GrokVideoGenChatConversationPartialResponse { + pub video_file_id: Option, +} + +impl <'a> GrokVideoGenChatConversationBuilder<'a> { + + /// Send the request and only wait long enough to get the video ID. + pub async fn stream_only_video_id(&self) -> Result { + info!("Configuring client and request..."); + let client = Self::build_client()?; + let http_request = self.build_request(&client)?; + + info!("Sending request..."); + let response = Self::send_request(&client, http_request).await?; + + info!("Reading status..."); + let status = response.status(); + + info!("Video Generation Enqueue Status: {:?}", status); + + let mut stream = response.bytes_stream(); + + let mut buffer = Vec::with_capacity(1024 * 1024); + + let mut file_id = None; + + while let Some(chunk) = stream.next().await { + if let Ok(bytes) = chunk { + buffer.extend(bytes); + } + + if let Some(id) = parse_video_id_from_partial_byte_stream_buffer(&buffer) { + file_id = Some(id); + info!("Got video id; ending stream early..."); + break; + } + } + + if file_id.is_none() { + warn!("Request ended without video id!"); + if let Some(id) = parse_video_id_from_partial_byte_stream_buffer(&buffer) { + file_id = Some(id); + } + } + + Ok(GrokVideoGenChatConversationPartialResponse { + video_file_id: file_id, + }) + } + + + pub async fn wait_for_video(&self) -> Result { + info!("Configuring client and request..."); + let client = Self::build_client()?; + let http_request = self.build_request(&client)?; + + info!("Sending request..."); + let response = Self::send_request(&client, http_request).await?; + + info!("Reading status..."); + let status = response.status(); + + info!("Video Generation Enqueue Status: {:?}", status); + + info!("Reading body..."); + + /* + Reading the body is what takes time. + 18:03:09.085612 [INFO] - Configuring client... + 18:03:09.089810 [INFO] - Sending request... + 18:03:09.089872 [INFO] - Pending response... + 18:03:09.274033 [INFO] - Reading status... + 18:03:09.274262 [INFO] - Video Generation Enqueue Status: 200 + 18:03:09.274304 [INFO] - Reading body... <-- This is what takes a while. + 18:03:35.186344 [INFO] - Read body. + */ + + /// TODO: Handle anti-bot detection + /// Body: {"error":{"code":7,"message":"Request rejected by anti-bot rules.","details":[]}} + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading Grok create media response body: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + info!("Read body."); + + // TODO: Handle unsuccessful request, cloudflare, etc. + if !status.is_success() { + warn!("Not successful enqueuing video gen (code: {}) : {:?}", status.as_u16(), response_body); + // error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body); + // return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + // TODO: Just for now... + println!("Video Body: {:?}", response_body); + + let file_id = parse_video_id(&response_body); + + Ok(GrokVideoGenChatConversationResponse { + video_file_id: file_id, + }) + } + + + fn build_client() -> Result { + Ok(Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err))?) + } + + fn build_request(&self, client: &Client) -> Result { + let xai_request_id = Uuid::new_v4().to_string(); + let sentry_trace_header = self.sentry_trace.to_http_request_header(); + + debug!("xai_request_id (uuid) = {}", xai_request_id); + debug!("sentry_trace = {}", sentry_trace_header); + + let x_statsig_id = generate_xsid(GenerateXsidArgs { + path: "/rest/app-chat/conversations/new", + method: "POST", + verification_token: &self.verification_token, + svg_data: &self.svg_data, + numbers: &self.numbers, + })?; + + debug!("x_statsig_id = {}", x_statsig_id); + + // TODO: Headers were from Chromium, not Firefox. Partial implementation. + let mut request_builder = client.post(CHAT_CONVERSATION_URL) + //.header(PRAGMA, "no-cache") // Not on firefox + //.header(CACHE_CONTROL, "no-cache") // Not on firefox + //.header("priority", "u=1, i") // Different on firefox + //.header("sec-ch-ua", "") // NB: NOT IN FIREFOX + //.header("sec-ch-ua-mobile", "") // NB: NOT IN FIREFOX + //.header("sec-ch-ua-platform", "") // NB: NOT IN FIREFOX + //.header("traceparent", "") // TODO: Not sure where this originates + .header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, "https://grok.com/imagine/favorites") + .header(CONTENT_TYPE, "application/json") + .header("x-xai-request-id", xai_request_id) + .header("x-statsig-id", x_statsig_id) + .header("sentry-trace", sentry_trace_header) + .header("baggage", &self.baggage.0) + .header(ORIGIN, "https://grok.com") + .header("priority", "u=4") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("Sec-GPC", "1") + .header(CONNECTION, "keep-alive") + .header(COOKIE, self.cookie.to_string()) + .header(TE, "trailers"); + + if let Some(timeout) = self.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let media_url = match self.media_type { + VideoMediaPostType::UserUploadedImage => user_and_file_id_to_image_url(self.user_id, self.file_id), + VideoMediaPostType::GrokGeneratedImage => unimplemented!("implement for generated images"), + VideoMediaPostType::Video => unimplemented!("implement for videos"), + }; + + let mode_string = self.mode + .unwrap_or(VideoGenerationMode::Normal) + .as_api_mode_arg(); + + let mut prompt = format!("{media_url} --mode={mode_string}"); + + if let Some(user_prompt) = self.prompt { + // If a text prompt is set, mode seems to typically be "custom". + let mode_string = self.mode + .unwrap_or(VideoGenerationMode::Custom) + .as_api_mode_arg(); + + prompt = format!("{media_url} {user_prompt} --mode={mode_string}"); + } + + info!("Grok video prompt: {prompt}"); + + let aspect_ratio = match self.aspect_ratio { + Some(AspectRatio::Square) => VideoGenAspectRatio::Square, + Some(AspectRatio::WideThreeByTwo) => VideoGenAspectRatio::WideThreeByTwo, + Some(AspectRatio::TallTwoByThree) => VideoGenAspectRatio::TallTwoByThree, + None => VideoGenAspectRatio::WideThreeByTwo, + }; + + let request_body = CreateChatConversationWireRequest { + temporary: true, + model_name: "grok-3".to_string(), + message: prompt, + file_attachments: vec![ + self.file_id.0.to_string(), + ], + tool_overrides: ToolOverrides { + video_gen: true, + }, + response_metadata: ResponseMetadata { + model_config_override: ModelConfigOverride { + model_map: ModelMap { + video_gen_model_config: VideoGenModelConfig { + parent_post_id: self.file_id.0.to_string(), + video_length: 6, // NB: API defaults to "6" + aspect_ratio, + }, + }, + }, + }, + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building create media request: {:?}", err); + GrokClientError::WreqClientError(err) + })?; + + Ok(http_request) + } + + async fn send_request(client: &Client, request: Request) -> Result { + let response = client.execute(request) + .await + .map_err(|err| { + error!("Error during create media: {:?}", err); + GrokGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + if !status.is_success() { + let maybe_body = response.text() + .await + .map_err(|err| { + error!("Error reading Grok video generation response body: {:?}", err); + err + }) + .ok(); + + return Err(categorize_grok_http_error(status, maybe_body.as_deref())) + } + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::datatypes::file_upload_spec::FileUploadSpec; + use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn create_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + //let file_id = FileId("990ddf90-8f34-42b1-81a5-39c509d62ff7".to_string()); // Mochi + + let image_path = "/Users/bt/Pictures/Zelda 64 Art/7j8baxv9m8u61.jpg"; + let maybe_prompt = Some("The hero shoots an arrow and it hits a skeleton monster"); + + let cookies = get_typed_test_cookies()?; + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + let upload_request = GrokUploadFile { + file: FileUploadSpec::Path(image_path), + cookie: cookies.to_string(), + request_timeout: None, + }; + + let upload_result = upload_request.upload().await?; + + let file_id = upload_result.file_id.expect("upload should have file_id"); + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + let request = GrokVideoGenChatConversationBuilder { + file_id: &file_id, + media_type: VideoMediaPostType::UserUploadedImage, + prompt: maybe_prompt, + mode: Some(VideoGenerationMode::Custom), + aspect_ratio: Some(AspectRatio::TallTwoByThree), + + cookie: cookies.as_str(), + user_id: &secrets.user_id, + baggage: &secrets.baggage, + sentry_trace: &secrets.sentry_trace, + verification_token: &secrets.verification_token, + svg_data: &secrets.svg_path_data, + numbers: &secrets.numbers, + + request_timeout: None, + }; + + let result = request.wait_for_video().await?; + + println!("Result: {:?}", result); + + let video_id = result.video_file_id.expect("should have video id"); + + // NOTE: WITHOUT A "LIKE" REQUEST, THIS FILE WILL LIKELY CEASE TO EXIST. + let video_url = user_and_file_id_to_video_url(&secrets.user_id, &video_id, false); + + println!("Video URL: {:?}", video_url); + + assert_eq!(1, 2); + Ok(()) + } + + + #[tokio::test] + #[ignore] + async fn stream_video_id() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + let image_path = "/Users/bt/Pictures/Zelda 64 Art/7j8baxv9m8u61.jpg"; + let maybe_prompt = Some("The hero puts down his bow"); + + let cookies = get_typed_test_cookies()?; + + let secrets = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await?; + + let upload_request = GrokUploadFile { + file: FileUploadSpec::Path(image_path), + cookie: cookies.to_string(), + request_timeout: None, + }; + + let upload_result = upload_request.upload().await?; + + let file_id = upload_result.file_id.expect("upload should have file_id"); + + println!("Verification Token: {:?}", secrets.verification_token); + println!("Sentry Trace: {:?}", secrets.sentry_trace); + println!("Numbers: {:?}", secrets.numbers); + println!("Svg Path: {:?}", secrets.svg_path_data); + println!("Baggage: {:?}", secrets.baggage); + + let request = GrokVideoGenChatConversationBuilder { + file_id: &file_id, + media_type: VideoMediaPostType::UserUploadedImage, + prompt: maybe_prompt, + mode: None, + aspect_ratio: Some(AspectRatio::Square), + + cookie: cookies.as_str(), + user_id: &secrets.user_id, + baggage: &secrets.baggage, + sentry_trace: &secrets.sentry_trace, + verification_token: &secrets.verification_token, + svg_data: &secrets.svg_path_data, + numbers: &secrets.numbers, + + request_timeout: None, + }; + + let result = request.stream_only_video_id().await?; + + println!("Result: {:?}", result); + + let video_id = result.video_file_id.expect("should have video id"); + + // NOTE: WITHOUT A "LIKE" REQUEST, THIS FILE WILL LIKELY CEASE TO EXIST. + let video_url = user_and_file_id_to_video_url(&secrets.user_id, &video_id, false); + + println!("Video URL: {:?}", video_url); + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/video_chat/mod.rs b/crates/api_clients/grok_consumer_client/src/requests/video_chat/mod.rs new file mode 100644 index 0000000000..075805f744 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/video_chat/mod.rs @@ -0,0 +1,4 @@ +pub mod grok_video_gen_chat_conversation; +pub (super) mod request; +mod parse_video_id; +mod parse_video_id_from_partial_byte_stream_buffer; \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/src/requests/video_chat/parse_video_id.rs b/crates/api_clients/grok_consumer_client/src/requests/video_chat/parse_video_id.rs new file mode 100644 index 0000000000..e104d6816c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/video_chat/parse_video_id.rs @@ -0,0 +1,44 @@ +use crate::datatypes::api::file_id::FileId; +use once_cell::sync::Lazy; +use regex::Regex; + +/// Find the video id in the response (which is streamed, appended JSON) +/// eg. {\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\" +static JSON_REGEX: Lazy = Lazy::new(|| { + // NB: Should be a 36 character UUID. + Regex::new(r#"\\?"videoI[dD]\\?":\s*\\?"([a-zA-Z0-9-]{25,})\\?",?"#) + .expect("Regex should parse") +}); + +/// Parse the video ID from the response +pub fn parse_video_id(html: &str) -> Option { + let maybe_meta = scrape_video_id_via_regex(html); + + if let Some(meta) = maybe_meta { + return Some(FileId(meta)) + } + + None +} + +fn scrape_video_id_via_regex(html: &str) -> Option { + let captures = JSON_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + +#[cfg(test)] +mod tests { + use crate::requests::video_chat::parse_video_id::parse_video_id; + use errors::AnyhowResult; + + const PAYLOAD : &str = r#"{\"result\":{\"conversation\":{\"conversationId\":\"ba9499ba-4a59-46f5-8d41-a04c527c1693\",\"title\":\"New conversation\",\"starred\":false,\"createTime\":\"2025-10-30T19:04:27.650012Z\",\"modifyTime\":\"2025-10-30T19:04:27.652930Z\",\"systemPromptName\":\"\",\"temporary\":true,\"mediaTypes\":[],\"workspaces\":[],\"taskResult\":null}}}\n{\"result\":{\"response\":{\"userResponse\":{\"responseId\":\"62d85bb5-746f-45e1-9a9e-40f8a9ef2a3e\",\"message\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero --mode=custom\",\"sender\":\"human\",\"createTime\":\"2025-10-30T19:04:27.706802818Z\",\"manual\":false,\"partial\":false,\"shared\":false,\"query\":\"\",\"queryType\":\"\",\"webSearchResults\":[],\"xpostIds\":[],\"xposts\":[],\"generatedImageUrls\":[],\"imageAttachments\":[],\"fileAttachments\":[\"eabcc2a5-8a9b-4382-82d2-020b4b286e87\"],\"cardAttachmentsJson\":[],\"fileUris\":[],\"fileAttachmentsMetadata\":[],\"isControl\":false,\"steps\":[],\"imageEditUris\":[],\"mediaTypes\":[],\"webpageUrls\":[],\"metadata\":{\"modelConfigOverride\":{\"modelMap\":{\"videoGenModelConfig\":{\"parentPostId\":\"eabcc2a5-8a9b-4382-82d2-020b4b286e87\"}}}},\"citedWebSearchResults\":[],\"toolResponses\":[],\"model\":\"grok-3\",\"ragResults\":[],\"citedRagResults\":[],\"searchProductResults\":[]},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"62d85bb5-746f-45e1-9a9e-40f8a9ef2a3e\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":1,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":5,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":14,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":23,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":32,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":42,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":52,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":95,\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"progress\":100,\"assetId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"videoUrl\":\"users/85980643-ffab-4984-a3de-59a608c47d7f/generated/34fa0313-3cde-4693-b046-319093374dbe/generated_video.mp4\",\"audioUrls\":[],\"videoPrompt\":\"our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero\",\"imageReference\":\"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/eabcc2a5-8a9b-4382-82d2-020b4b286e87/content\",\"audioTranscripts\":[],\"moderated\":false,\"mode\":\"custom\",\"modelName\":\"imagine_xdit_1\",\"thumbnailImageUrl\":\"users/85980643-ffab-4984-a3de-59a608c47d7f/generated/34fa0313-3cde-4693-b046-319093374dbe/preview_image.jpg\"},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"token\":\"I generated a video with the prompt: 'our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero'\",\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"token\":\"\",\"isThinking\":false,\"isSoftStop\":true,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"response\":{\"modelResponse\":{\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\",\"message\":\"I generated a video with the prompt: 'our hero link on horseback galloping through the fantasy world, bright sun behind him. a dormant volcano has a circling ring of smoke. the camera pulls back and follows our hero'\",\"sender\":\"ASSISTANT\",\"createTime\":\"2025-10-30T19:04:52.510270096Z\",\"parentResponseId\":\"62d85bb5-746f-45e1-9a9e-40f8a9ef2a3e\",\"manual\":false,\"partial\":false,\"shared\":false,\"query\":\"\",\"queryType\":\"\",\"webSearchResults\":[],\"xpostIds\":[],\"xposts\":[],\"generatedImageUrls\":[],\"imageAttachments\":[],\"fileAttachments\":[\"34fa0313-3cde-4693-b046-319093374dbe\"],\"cardAttachmentsJson\":[],\"fileUris\":[],\"fileAttachmentsMetadata\":[],\"isControl\":false,\"steps\":[],\"imageEditUris\":[],\"mediaTypes\":[],\"webpageUrls\":[],\"metadata\":{\"deepsearchPreset\":\"\",\"request_metadata\":{\"effort\":\"low\",\"mode\":\"fast\",\"model\":\"grok-3\"},\"request_trace_id\":\"5447a9522af26a244b86bdaef90e4a25\"},\"citedWebSearchResults\":[],\"toolResponses\":[],\"model\":\"grok-3\",\"requestMetadata\":{\"model\":\"grok-3\",\"mode\":\"MODEL_MODE_FAST\",\"effort\":\"LOW\"},\"ragResults\":[],\"citedRagResults\":[],\"searchProductResults\":[]},\"isThinking\":false,\"isSoftStop\":false,\"responseId\":\"791ddc68-d4c1-4e37-95d1-88ad9c7903dc\"}}}\n{\"result\":{\"title\":{\"newTitle\":\"Video Generation: Fantasy Hero Journey\"}}}\n"#; + + #[test] + fn test() -> AnyhowResult<()> { + let video_id = parse_video_id(PAYLOAD).unwrap(); + let expected = "34fa0313-3cde-4693-b046-319093374dbe"; + assert_eq!(&video_id.0, expected); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/video_chat/parse_video_id_from_partial_byte_stream_buffer.rs b/crates/api_clients/grok_consumer_client/src/requests/video_chat/parse_video_id_from_partial_byte_stream_buffer.rs new file mode 100644 index 0000000000..66c2da434b --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/video_chat/parse_video_id_from_partial_byte_stream_buffer.rs @@ -0,0 +1,69 @@ +use crate::datatypes::api::file_id::FileId; +use bstr::ByteSlice; +use once_cell::sync::Lazy; +use regex::Regex; + +/// Find the video id in the response (which is streamed, appended JSON) +/// eg. {\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\" +static JSON_REGEX: Lazy = Lazy::new(|| { + // NB: Should be a 36 character UUID. + Regex::new(r#"\\?"videoI[dD]\\?":\s*\\?"([a-zA-Z0-9-]{25,})\\?",?"#) + .expect("Regex should parse") +}); + +/// Look for this in the incomplete byte stream to try parsing it out. +const VIDEO_BYTES : &[u8] = b"videoI"; + +/// Parse the video ID from the streamed response +pub fn parse_video_id_from_partial_byte_stream_buffer(bytes: &[u8]) -> Option { + let found = bytes.find(VIDEO_BYTES).is_some(); + if !found { + return None; + } + + let partial_json = bytes.to_str_lossy(); + let maybe_meta = scrape_video_id_via_regex(&partial_json); + + if let Some(meta) = maybe_meta { + return Some(FileId(meta)) + } + + None +} + +fn scrape_video_id_via_regex(html: &str) -> Option { + let captures = JSON_REGEX.captures(html)?; + let capture = captures.get(1)?; + Some(capture.as_str().to_string()) +} + +#[cfg(test)] +mod tests { + use crate::requests::video_chat::parse_video_id_from_partial_byte_stream_buffer::parse_video_id_from_partial_byte_stream_buffer; + use errors::AnyhowResult; + + #[test] + fn present_in_partial_bytes() -> AnyhowResult<()> { + let partial_bytes = b"}}}\n{\"result\":{\"response\":{\"streamingVideoGenerationResponse\":{\"videoId\":\"34fa0313-3cde-4693-b046-319093374dbe\",\"pro"; + let video_id = parse_video_id_from_partial_byte_stream_buffer(partial_bytes).unwrap(); + let expected = "34fa0313-3cde-4693-b046-319093374dbe"; + assert_eq!(&video_id.0, expected); + Ok(()) + } + + #[test] + fn field_present_but_data_incomplete() -> AnyhowResult<()> { + let partial_bytes = b"Response\":{\"videoId\":\"34fa0313-3cde-4693-b046-"; + let result = parse_video_id_from_partial_byte_stream_buffer(partial_bytes); + assert!(result.is_none()); + Ok(()) + } + + #[test] + fn not_present() -> AnyhowResult<()> { + let partial_bytes = b"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}]"; + let result = parse_video_id_from_partial_byte_stream_buffer(partial_bytes); + assert!(result.is_none()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_consumer_client/src/requests/video_chat/request.rs b/crates/api_clients/grok_consumer_client/src/requests/video_chat/request.rs new file mode 100644 index 0000000000..2fa53073f2 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/requests/video_chat/request.rs @@ -0,0 +1,163 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub (super) struct CreateChatConversationWireRequest { + #[serde(rename = "temporary")] + pub (super) temporary: bool, + + #[serde(rename = "modelName")] + pub (super) model_name: String, + + /// The prompt + #[serde(rename = "message")] + pub (super) message: String, + + #[serde(rename = "fileAttachments")] + pub (super) file_attachments: Vec, + + #[serde(rename = "toolOverrides")] + pub (super) tool_overrides: ToolOverrides, + + #[serde(rename = "responseMetadata")] + pub (super) response_metadata: ResponseMetadata, +} + +#[derive(Serialize)] +pub (super) struct ToolOverrides { + #[serde(rename = "videoGen")] + pub (super) video_gen: bool, +} + +#[derive(Serialize)] +pub (super) struct ResponseMetadata { + #[serde(rename = "modelConfigOverride")] + pub (super) model_config_override: ModelConfigOverride, +} + +#[derive(Serialize)] +pub (super) struct ModelConfigOverride { + #[serde(rename = "modelMap")] + pub (super) model_map: ModelMap, +} + + +#[derive(Serialize)] +pub (super) struct ModelMap { + #[serde(rename = "videoGenModelConfig")] + pub (super) video_gen_model_config: VideoGenModelConfig, +} + +#[derive(Serialize)] +pub (super) struct VideoGenModelConfig { + #[serde(rename = "parentPostId")] + pub (super) parent_post_id: String, + + /// Newer field as of November + /// values: "1:1", "2:3" (tall), + #[serde(rename = "aspectRatio")] + pub (super) aspect_ratio: VideoGenAspectRatio, + + /// Newer field as of November + /// default seems to be "6" + #[serde(rename = "videoLength")] + pub (super) video_length: u32, +} + +#[derive(Serialize, Clone, Copy, Debug)] +pub (super) enum VideoGenAspectRatio { + #[serde(rename = "2:3")] + TallTwoByThree, + + #[serde(rename = "3:2")] + WideThreeByTwo, + + #[serde(rename = "1:1")] + Square, +} + +/* + +November 27 no image (text only) square aspect ratio - + +--data-raw + { + "temporary":true, + "modelName":"grok-3", + "message":"A waterspout wrecks an oil tanker, misty, huge waves --mode=custom", + "toolOverrides": { + "videoGen":true + }, + "responseMetadata": { + "modelConfigOverride": { + "modelMap": { + "videoGenModelConfig": { + "parentPostId":"b0b88b45-9724-49bd-a82c-c83ddcd2937f", + "aspectRatio":"2:3", + "videoLength":6 + } + } + } + } + } + +October 29 request struct - + +--data-raw + { + "temporary":true, + "modelName":"grok-3", + "message":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/d6d73ffd-ce87-40a3-9cc2-3432d7192652/content --mode=normal", + "fileAttachments":["d6d73ffd-ce87-40a3-9cc2-3432d7192652"], + "toolOverrides":{ + "videoGen":true + }, + "responseMetadata":{ + "modelConfigOverride":{ + "modelMap":{ + "videoGenModelConfig":{ + "parentPostId":"d6d73ffd-ce87-40a3-9cc2-3432d7192652" + } + } + } + } + } + + +October 29 generate from already uploaded image with prompt struct - +--data-raw + { + "temporary":true, + "modelName":"grok-3", + "message":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/d6d73ffd-ce87-40a3-9cc2-3432d7192652/content building gets hit by tornado --mode=custom", + "fileAttachments":["d6d73ffd-ce87-40a3-9cc2-3432d7192652"], + "toolOverrides":{ + "videoGen":true + }, + "responseMetadata":{ + "modelConfigOverride":{ + "modelMap":{ + "videoGenModelConfig":{ + "parentPostId":"d6d73ffd-ce87-40a3-9cc2-3432d7192652" + } + } + } + } + } + +October 22 request struct - + +--data-raw + { + "temporary":true, + "modelName":"grok-3", + "message":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content --mode=normal", + "fileAttachments": [ + "21a79085-e206-4b0b-88ac-5f2b7a453e45" + ], + "toolOverrides": { + "videoGen":true + } + } + +*/ + diff --git a/crates/api_clients/grok_consumer_client/src/test_utils/get_test_cookies.rs b/crates/api_clients/grok_consumer_client/src/test_utils/get_test_cookies.rs new file mode 100644 index 0000000000..d406647978 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/test_utils/get_test_cookies.rs @@ -0,0 +1,15 @@ +use crate::credentials::grok_cookies::GrokCookies; +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub fn get_test_cookies() -> AnyhowResult { + let cookies = read_to_string("/Users/bt/Artcraft/credentials/grok_cookies.txt")?; + let cookies = cookies.trim().to_string(); + Ok(cookies) +} + +pub fn get_typed_test_cookies() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(GrokCookies::new(cookies)) +} diff --git a/crates/api_clients/grok_consumer_client/src/test_utils/mod.rs b/crates/api_clients/grok_consumer_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..54c16f3413 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/test_utils/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +pub (crate) mod get_test_cookies; + +#[cfg(test)] +pub (crate) mod setup_test_logging; diff --git a/crates/api_clients/grok_consumer_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/grok_consumer_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..9438da106c --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,52 @@ +use chrono::Local; +use env_logger::Builder; +use log::{debug, error, info, trace, warn, LevelFilter}; +use std::io::Write; + +const RUST_LOG : &str = "RUST_LOG"; + +pub fn setup_test_logging(level: LevelFilter) { + let level_str = get_level_str(level); + + println!("Previous log level: {:?}", env::var(RUST_LOG)); + + let is_unsafe = env::set_var(RUST_LOG, level_str); + + if is_unsafe.is_none() { + println!("Unsafely setting Rust Log Level..."); + unsafe { std::env::set_var(RUST_LOG, level_str) } + } + + println!("Log level: {:?}", env::var(RUST_LOG)); + + Builder::new() + .is_test(true) + .format(|buf, record| { + writeln!(buf, + "{} [{}] - {}", + Local::now().format("%H:%M:%S%.6f"), + record.level(), + record.args() + ) + }) + .filter(None, level) + .filter_level(level) + .init(); + + trace!("Test trace log"); + debug!("Test debug log"); + info!("Test info log"); + warn!("Test warn log"); + error!("Test error log"); +} + +fn get_level_str(level: LevelFilter) -> &'static str { + match level { + LevelFilter::Error => "error", + LevelFilter::Warn => "warn", + LevelFilter::Info => "info", + LevelFilter::Debug => "debug", + LevelFilter::Trace => "trace", + LevelFilter::Off => "off", + } +} diff --git a/crates/api_clients/grok_consumer_client/src/utils/create_firefox_client.rs b/crates/api_clients/grok_consumer_client/src/utils/create_firefox_client.rs new file mode 100644 index 0000000000..c3ade7e315 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/utils/create_firefox_client.rs @@ -0,0 +1,10 @@ +use crate::error::grok_client_error::GrokClientError; +use wreq::Client; +use wreq_util::Emulation; + +pub fn create_firefox_client() -> Result { + Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| GrokClientError::WreqClientError(err)) +} diff --git a/crates/api_clients/grok_consumer_client/src/utils/mod.rs b/crates/api_clients/grok_consumer_client/src/utils/mod.rs new file mode 100644 index 0000000000..22c38941b9 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/utils/mod.rs @@ -0,0 +1,6 @@ +pub mod user_and_file_id_to_image_url; +pub mod user_and_file_id_to_video_url; + +// Just for the crate +pub (crate) mod create_firefox_client; +pub (crate) mod scrub_blobs_for_debug_logging; diff --git a/crates/api_clients/grok_consumer_client/src/utils/scrub_blobs_for_debug_logging.rs b/crates/api_clients/grok_consumer_client/src/utils/scrub_blobs_for_debug_logging.rs new file mode 100644 index 0000000000..fa1d3404cc --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/utils/scrub_blobs_for_debug_logging.rs @@ -0,0 +1,12 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +static LARGE_BLOB_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"[^"]{100,}"#) + .expect("Regex should parse") +}); + +/// Helpful for debugging large websocket payloads with in-stream base64-encoded image binaries. +pub fn scrub_blobs_for_debug_logging(blob: &str) -> String { + LARGE_BLOB_REGEX.replace(blob, "...").to_string() +} diff --git a/crates/api_clients/grok_consumer_client/src/utils/user_and_file_id_to_image_url.rs b/crates/api_clients/grok_consumer_client/src/utils/user_and_file_id_to_image_url.rs new file mode 100644 index 0000000000..f2a594c24d --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/utils/user_and_file_id_to_image_url.rs @@ -0,0 +1,25 @@ +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::user_id::UserId; + +/// Return media URLs to images given the user and file IDs. +/// These are non-public and will return 403s unless you're logged in. +pub fn user_and_file_id_to_image_url(user: &UserId, file: &FileId) -> String { + let user_id = &user.0; + let file_id = &file.0; + format!("https://assets.grok.com/users/{user_id}/{file_id}/content") +} + +#[cfg(test)] +mod tests { + use crate::datatypes::api::file_id::FileId; + use crate::datatypes::api::user_id::UserId; + use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url; + + #[test] + fn test_url() { + let user = UserId(String::from("USER_UUID")); + let file = FileId(String::from("FILE_UUID")); + let url = user_and_file_id_to_image_url(&user, &file); + assert_eq!(&url, "https://assets.grok.com/users/USER_UUID/FILE_UUID/content"); + } +} diff --git a/crates/api_clients/grok_consumer_client/src/utils/user_and_file_id_to_video_url.rs b/crates/api_clients/grok_consumer_client/src/utils/user_and_file_id_to_video_url.rs new file mode 100644 index 0000000000..07fa438762 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/src/utils/user_and_file_id_to_video_url.rs @@ -0,0 +1,32 @@ +use crate::datatypes::api::file_id::FileId; +use crate::datatypes::api::user_id::UserId; + +/// Return media URLs to images given the user and file IDs. +/// These are non-public and will return 403s unless you're logged in. +pub fn user_and_file_id_to_video_url(user: &UserId, file: &FileId, cache_bust: bool) -> String { + let user_id = &user.0; + let file_id = &file.0; + if cache_bust { + format!("https://assets.grok.com/users/{user_id}/generated/{file_id}/generated_video.mp4?cache=1") + } else { + format!("https://assets.grok.com/users/{user_id}/generated/{file_id}/generated_video.mp4") + } +} + +#[cfg(test)] +mod tests { + use crate::datatypes::api::file_id::FileId; + use crate::datatypes::api::user_id::UserId; + use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url; + + #[test] + fn test_url() { + let user = UserId(String::from("USER_UUID")); + let file = FileId(String::from("FILE_UUID")); + let url = user_and_file_id_to_video_url(&user, &file, false); + assert_eq!(&url, "https://assets.grok.com/users/USER_UUID/generated/FILE_UUID/generated_video.mp4"); + + let url = user_and_file_id_to_video_url(&user, &file, true); + assert_eq!(&url, "https://assets.grok.com/users/USER_UUID/generated/FILE_UUID/generated_video.mp4?cache=1"); + } +} diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_complete_response.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_complete_response.json new file mode 100644 index 0000000000..ee9ac6b9a9 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_complete_response.json @@ -0,0 +1,18 @@ +{ + "type": "image", + "blob": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCATgA0ADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDpBThTRThTJHinimCn0hjhS00U6kMKWkoqQFpRSUtAx1LTaWgB1LTaXNAxaSiigANNpTQaZIlJS0UxCUUuKSmAUtAooGFFFFIYYopaKAEpKWimJjTSU40lBI2ilxRikIQU8UgFKKBjhTxTBTxSGLS0lFMYtNNLSGgBppppxpKAGGkxTqKBCYoFLiigBRThSCnCmAtLSUtMYtFFFABRRRSASig0lJjCkNFJUgJSUppppAFJS0UCEpaKUUigpRRSigYopaQUopgLTqbS00SxRSikopiFzRmkozQAZpKM0maQAabS5pKBiGkNBpKYCUUUlMBaM0lGaBDqKSloAWlpKKAFptLSUxCUhpaQ0ANNJS0lACUlLijFIY2inYpMUgDFGKXFLipYxKdRilFJgUxTxTRTxWpI4U4U0U4UhjhS0gpaQwpaSjNIBaWkooAWlpKKYDqWm0tAC0tJRQAUUUYpkhikpcUUwEooooAKKKKQwpaSloGFLikFLQAlFLSUCEpMU6kpiG4oxTsUYpCsJSgUYpRQMUU4UgpaAFooooGFJS0lIBDTadSUANopaKAExS4oxS0AGKWilFMApaSloAKWiimAUlKaSkMSiikpAIaTNBpKACkoopAFFFFIApaKWgApaQUtIYtKKSlpgLS02lqhC0UlFMQtJmikoAWkopKQBSUUlAAaaaU0hoGJSZpaSmAUUUUALRSUtAC0uaSimAZooooEJSUtJQMSkpaKQCUUtLQA3FLilxS4pANxS4pcUYqRiYpaMUtICpilFLilxWhICnCkFLikMWloFLSGFFFFABRRRTELS02loAdS0gpaBBmlpKWmAtFFFMQUUYooASilooAbRS0UhiUtFFIYtFFFAgopaKYCYpKdikoASilooCwlLS0UAFLRS0gCilooASg0tJQA2ilopANxRilooGJS0UUAFLSUtMAFLQKBQAtFFFAAaaaWkNACGmmnGmmgBKSlNJSGFFFFAgpRSUtAgpaSlpDFFFJRQMWlpKKYDqKSlpiFzRmkopiCkoooAKSikoAKSlpKBiUhpaSkAlFLSUAFFFFMYUtFFABS0YooAKSlpKAENFKaSgAxRiiigApaKWgAxRS0UhhiilxRSASiiikBXxS4p2KMVZIgFGKdiikMSloooAKKKKQBRRRTAKWkpaBCinU0U4UAFKKBRTELS0lKKYgooooAMUlLRQMSkpaKQxKWiikAUtJS0AFFFFMAooooAKKKKACloooAWlpKUUgFopKKBBQaKDQAlJS0lAwNJRRSAKKSgUALS0lLTAWiiigBaSiigApKWkpAIaaadSUANoxS0UhiUUtGKYhKKXFFAhKKWkoAXNGaSigYtLTaWgBaWkpaYC5pKKKYBQaKQ0AFJSmkoASiiigBKSlopAJRRiigAooooAKWiimAUuKKKQxMUUtFADaKXFFMBMUUuKKACgUUtACiikpaBhRRRSGFFFJSAbikxTsUVRA2ilooASilpMUhhSUtFACUUtFACUtFLigApRSUtAhaWkpaBC0tNpaYC0UlLQAUUUtACUUtFAxKKXFGKQCUUtFACUUtFACUUUUwCikzRmgBc0uaZmjNIY/NGaZmlzQA/NGaZmlzQA7NFNzS5oEFFFFACUUUUAFFFFAwpaSloAWiiigQUUUUAFFFFIBKTFLRikA3FGKdijFACYoxS0UxCYpKdSGgBtJSmkNACUUUZoAWikzRTGOpabS0AOopKKYhaSiigYUlLSGgApKWikAlJS0UAJRS4pKACiiigBaKBRQAuKKKKQBRilpKAEopaTFMAooopgFFFFABRRRQMWikpaQxKKWigYlJRmimZgaSlpKBhRRRSAKKKKBhRRilxSASjFLRigAooooAKWkopiFpabRTAdS02loAdRSUtAC0UUUgCiiigAooozQAUlFJmgBc03NBNNJoAXNJmmlqaWoGPLUm6oy1N30ATbqN1Qb6N9IZY3Uu6q4enB6AJ91LmoQ1ODUxEuaWow1OBoAdRSZpaYBRRRQAUtFFAC0UUUAFAoopCA0UUUCCjFFLSASilooASkNLTSaBAaQmgmmk0wAmkJpCaaTQMXNGaZuo3UDH5ozTN1LmgB+admo80uaAJM0ZpmaXNMB+aKbmlzQAuaSiigAooooAKSlopAJRRSGkAUUUUCFFFJRQA6ikpaACiiigAooopgFJS0UAJRS0lMYUYopaAExS0UuKQwoopaBkWaM03NGaCB1FJRQMWikFLQAUtFFAC0UUtIBKKWigBKSlpKAEzRRmkzTAWikzRmmA6lzTc0uaAHUtNBpc0AOozSZozSAdmkzTc0ZpAOzSZpuaTNADs00mkJppagYpamlqaWqNmoAeWphao2eo2egCQvTS9QGSmGSgZZ8yk8yqvmUeZSGXBJThJVIS08SUAXQ9PD1SWSpVemIthqeGqqr1KrUxFgGlBqINTgaYEmaWmZpc0CHUuabmjNAx1GabmjNADs0ZpmaN1IQ/NGaZupc0CHZpc0zNGaQD80Zpm6k3UAOJppNNLU0tTEOLUwtTC9Rl6BkhamlqiL0wvQMm3Ubqg30b6Bk+6l3VBvpQ9ICwGpwaq4enB6AJwaXNQhqcGpgS5pc1GGpQaYiTNLTAaXNAD6KbmloELQaKQ0gCkpTSUhhRRSUCDNGaKKQC0tNpaYDs0ZptLmmIKKTNFAxc0tJRQAtFJS0AFFFFAwpaKKAFpcUlLSGVc0ZpuaXNAh1LTacKAFpaaKcKYhaWkFLQAUtFFABSUpppNAwJppNBNNJoAXNJmmk03dQA/NLmo91G6gCTNLmo91LupgS5pc1Fuo3UgJc0m6o91IWpAS7qTdUW+k30AS7qaWqIvTS9AyUvTS9Ql6YZKBkrPUbPUTSVE0lAErPULSVG0lQtJSAlaSozJUDS1E0tAy0Zab5tVDLTfNoGXhLTxLWeJaeJfegDSWWplkrMWWpklpiNNZKmV6zkkqdJKYi8r1IGqmslSq9MRaDU7dVcPS76AJ91G6od9JvoAm3Ubqg30m+kBY3Um6oPMo30AT76XfVffS76AJ99Luqvvpd9IRNupC1Ql6aXoCxKXpjPURkqNpKYWJWeomkqJpKiaSgCYyUwyVAZKYZKBlnzKPMqp5lL5lIZbD04PVMSU8SUDLgenB6piSniSgC2Hp4eqgenh6BFsPTg1Vg9PD0xFgNTwarhqeGpiJgacDUQanA0ASUU3NGaQhaKTNGaQC0lGaSgAopM0UALS03NLmgBaM0maM0xC5opKKYx1FJRQA6ikpaQxaKKKQC0UlFIYtLTaM0DKmacDTKcKCRwpwpopwpgOFLSCloEKKdSUtMAoopDQAE0wmlJphNACE0wtQzVEzUDHFqaWqMvTC9AE2+jfVffR5lAFnfS76q+ZS+ZQBZ30u+q3mUnmUgLJekL1WMlNMlAFkvTTJVYyU0y0hlkyUwyVWMtMMtAyyZKYZKrGX3pjS0DLDSVC0tQNLUTSUATNLULS1E0lQPLQMmaWomlqBpKiaSgCwZab5vvVUyUzzKALwlpwlqgJacJaANJZanSWstJanSWmI1UlqyktZSS1YSWmI01kqZZKzklqZZKYi+JKd5lUxJTvMpAW/MpPMqr5lHmUAWfMpPMqsZKb5lAFrzKPMqr5lJ5lAy35lL5lU/MpfMpAW/MpfMqn5tL5tAWLXmUhkqr5tIZaAsTmSo2kqAyVG0lMLEzSVC0lRNJUTSUCsTGSmGSq5kphkoGWvMo8yqnmUokpDLgkpwkqmJKeJKQy4JKeJKpCSniSgC8JKeJKpCSpFkpiLoepFeqQkqRZKYi8r1IGqkslSq9Ai2Gp4aqoenh6BFkNS5qAPTg9AEuaM1Huo3UhEmaM1Huo3UDJM0maZuozQIfmjNMzRmgCTNGaZupc0wH5ozTM0uaYx2aWm5pc0AOpabS0gHZopuaWkAtFJRmpGGaM0lGaBlYU4U0U4VRI4U4U0U8UAKKdSClpgLS0lFABSE0E00mgBCaiY05jULNQAjNULPQ7VA70AOZ6iMlRs9QtJSGWDJTfMqqZaaZaALvmUebVLzfejzvekBd82jzapebSeb70AXDLTTLVQy00y0AWzLTDLVQy0wy0DLZlphlqqZaYZaALRlphl96qmWmGSgZZaWomlqAyVG0lAyZpKhaSomkqFpKAJWkqJpKiaSomegCYyU3zKrl6bvoAteZThJVMPTw9AF5ZKnSSs5XqdHpiNJJKsJJWYklWEkpiNNJKmWSs5JKmWSmBoCSniSqIkp4kpCLfme9HmVU8yjzKALRkpvmVWMlIZKYFrzKTzKq+ZR5lIZa8yjzKqeZS+ZQMt+ZR5lVPMo8ykMt+ZTTJVXzKaZKYFkyVG0lQGSmGSgRK0lRNJUTPTC9AEpemF6hL00vQBY30u+q2+l30hlkPTg9VQ9OD0AWxJThJVQPTg9IC4JKkWSqQepBJQIvLJUiyVRWSpVkpiLyyVKslUFkqVZKAL6yVIJKoLJUglpiLokp4kqkJKcJKQFwSUu+qglpfNoEW99G+qvmUeZQBa30b6reZS+ZQOxY30u6q4kpQ9AWLIalBquHp4amBODS5qINTgaYEmaUUwGnA0CH0tNFLSAdRSUtIAooopDEpKWilYCuKcKQCnAVYhwpwpop4oGKKUUgpRQAtJQaQ0CEJpjGnE1ExoAazVA7U92qu7UAMd6rO9Okaqsj0hg8lV2kpsklVnkoAmaWmGX3qs0tRmWkMueb70nm+9UvNo82gC75vvR5tUfNo82mBdMvvTTLVMy0hloAtGWmmWqpkppkoAtGWmmSq3mU0yUAWTJTDJVcvTS9IZOZKjaSoi9Rs9AyVpKiZ6jZ6jLUAPZ6jL00tUZagQ8tSbqjLUm6gCXdTg1QZpwNAFlWqZXqopqVWpgXFerCPVFWqZHpiL6yVMslUVeplegRdElPElUw9PD0wLW+jfVbfS76BE5ekL1Bvo3UwJt9G+oN1G6gZPvo31Buo3UWGTb6N9QbqN1IZPvppeod1IWpDJS9ML1EWppagQ8vTC9MLUwtQA8tSbqjLU3dQBLuo31Duo3UAT76cHqvupQ1AFkPTg9Vg1ODUgLQenh6qB6kD0AW1epFkqmHp4egC6slSCSqQeniSgRdEtPEtURJThLQBfEvvThLVAS0ol96YjQEtKJfeqAm96XzvekBoebS+bWf51OEtAy/5tKJPeqIlpRL70AXxJThJVES1IsnvQMvB6er1SWSplegRcVqkVqqK1TK1MRYBpwNRKaeDQIlFKKaKcKAHClFIKUUAFFFLRYBKKWiiwFcU4UgpRTGOFOFNFOFAC0tJRQAtNNKaaTSAaxqFzUjGoHNAEbtVWRqmkaqkjUgIZXqnI9TStVKV6Bkcj1Wd6WR6qu9ADmkqIyVGz1EXoAnMlJ5lVy9JvoAs+ZR5lVt9G+gCz5lJ5lV99G+gCcvTd9Q7qTdQBNvoL1DupN1AEpeml6jLUhagY8vTC1NLUwtSAcWphakJphNMBSaYTSE00mkIUmjNNopgOBp4NRinCkBKpqVTUANSKaYFhTUqtVZTUqmgC0rVKrVUVqlDUwLQenh6rBqeGpiJ91LuqANTt1AiXdRuqLdS5pgSbqN1R5ozTAk3UbqjzRmgY/dRupmaM0ih26kJpuaTNIBSaYTQTTSaAAmmE0pNMJoAQmkJpCaaTQIdmk3Uwmk3UASbqXdUW6jdSAnDU4NUAalDUAWA1PDVXDU8NQBYDU8PVYNTw1ICwHpweq+6l3UAWPMpfMqtvo30wLXmUvm1T8yjzKYF0S0vm1S8yl8ykBdEtOEvvVHzaUS0AXhLTxLWeJakEtIZfEtSrJWeslSrJSGaKyVOj1nJJViN6BGij1OjVQR6so1MRcVqlU1WRqmU0yScGnio1NSCmA8UtNFOFAhaXFIKWnYAopaMUCK9LRilpFgKcKSloABS0lFAgNMJpTTTQMYxqBzUrVA5pAV5DVSQ1ZkNU5TQMqytVGVqtSmqMppDK8jVVdqlkNVXNIBjNUZahjUZNMBxak3UzNJmgCTdRuqPNGaYiTdRuqPNGaAH7qN1MzSZoAk3UbqjzRmkA/NNJpM0maBik00mkJpCaAAmmE0pppoEJSUtJQAlLRRQAopRSUopAPFPBpgpwoAlBp4NRA08GmBMpqQNUANPBpgThqeGqAGng0CJw1KGqEGng0wJc0uaYDThTELmlzSUtMBc0maKKADNGaSkpFC0lFJQMDTTSmmmkAhphpxphoENNMJpxphoAaTSE0Gmk0CFzRmm5pM0wJM04Gos04GkBMDTwagBp4NAEwanBqiBpQaAJt1G6o80maBkm6kL1HmkJoESbqTfURak3UwJt9G+oN1G+gCx5lL5lVt1LvpDLIkqRZKph6eHpDLqyVMklUFepkepGaKPVmN6zo3qzG1AGlG9Wo2rPjarcbUEsvI1WENU4zVlDVCLSmpBUKGpVNMRKKcKYKeKYhwpaQUtMQoooopgQUCloqCgooooAWiikpgIaYaeaaaQETVXkqw1QPQBUkqlL3q7IKpSikUUZqoy1flqjKKQylJVV6tSCqr0gK7VGakaozTAbSUppKACikpaYgzRmkooELmkzSUUALmjNNzRQMXNGaSikMKSg0UAIaaadSUCG0lOpKAEpaKWgAFKKKUUgFFOFNFOFMBwpwNMFOFADwaeDUYpQaAJQaeDUQNPBpgSg1IDUS1ItAiUU4U1akApiFFLilAp2KYDMUmKkxSEUARmkpxFNoGJSUpppoGFIaM0hNIBDTDTjTTQAw1GakNRmgBhpppxphoASkzRSZpiHZpQaZSg0ASg04GowacDQBKDTs1GDTgaAH5ozTc0maAHE00mkJpCaAAmmk0E00mmIXNJmm5pM0AP3UbqZmjNIZKGpwaoAaeDUjLCtUyNVVTUyGkxl1Gq1G1UENW4zSAvxNVyI1nxGrsRoAvxmrSGqURq3HTJLSGplqBKmWqETCnCmLTxTEPFLSCloELS0gopgRUUUVIwooooAKKKKAGmmmn00igZEwqBxVlhUDigCnIKpyir8gqpKKQzNlFUZRWlKKoyrSAzpBVVxV2UVVkFAyowqIip3FRMKBkRpKcRSUAJSUtFAhKSlpKoApKWkoEFFFFIAooxRQMKMUtFIY2kxTqSgQ2jFLijFADcUuKXFGKADFLijFLigAApaKXFAAKdSUtAC0tJS0AOFPFMFPWmBKtSrUa1MgoESKKmUUxBU6rQIAtO208LTttMRFtphFTlaYy0wISKjNTMKiYUDIzTTTjTDQMDTc0pppoGBNNNKaaTSAQ1GaeaYaAGGmGnmmGgBppKU000CFopKWmA4GnA0wU4UASA0uaYKdQA7NGaSigBc00mikNMQGmmg0hoAQmkzQaTNAgozSUZpDFzTgaZThSGSqamQ1AtTJUsZajNWo6qR1bipDLkVXYqpRVeiFAFyKrkdVIhVuOmhFlKmWoUFTrTJJFp4pgp4pgOpaQUtAhaWkoouBHRRRSAKKKKACiiloASkNOpDQBGwqFxU5FRsKYypIKqSrV5xVWRaQGbKtUZVrTlWqMq0AZsq1TkFaEq1TkWkMpOKhYVYcVCwoGQkU01IRTCKBjaSnUlAhKSnUlMBtFLSYpiExS0YoxQAUUtFIYmKKWikAlJTsUmKAEpMU6igQmKKXFGKADFLijFLimAUtFGKACloopDClopRQAoqRaYKkUUwJVFToKiQVZQUCJUWrCLUca1ZRaCQC07bT1Wn7aYEBWmMtWCtRsKYiqwqFhVlxUDigZA1RmpWqJqBjCaQ0pptAxCaQ0GkpAIaYacaaaQxpphpxppoAaaaaU0lACUopKBTEOFPFMFOFADxS00U6mAtFFJQIKQ0UhpgIaaaU000CA0maCaaTQAZozSUZpDHU4UwU4UgJVqdKgWp0FIZZjq5EKqRirsQpDLcQq9EKqRCrsQpAW4hVuMVWiFW4xTEToKmWolFTKKAHinimCnimIWlpKWgBc0UlFAhtFFLSASilpKYBRRRigApDS0UDGmo2FSmmMKAKziq0gq44qtIKYFCVaozLWlKKoyrQBmSiqcq1oyrVGQUAUZBVdhVyQVWcUhlcimmpGFMIpDGUlONJQA2ilxRTAbRilpMUAJijFLRQAYooooEGKMUUUAJijFLRQAmKXFLijFAhKKdijFMBKKWigAopaSkMMUtFFAwpRSCnCkA4VKgqNamQUwJkFWYxUCCrUYoETxirKLUUYqygoJHKtO205RTsUxERFQuKssKhcUwKriq7irTiqz0xldqhap3qFqBkRpppxpppDGmm04000gENNNKaaaQxppppxpppANNNpTSUAFAooFMBwpwpop4oAUU4U0U4UxBRS0lMBDTTTjTTQIaaaacaaaYDTSUppDSAM0lFFIBRTxTBUi0DJUqeMVCgqzGKQFiIVeiFVIhV6JaQFuIVeiFVIlq9EKQFmMVbQVXjFWkFAEqipRUa1KKYDhThTRS0ALS0lGaAFopM0ZoELiinUmKRIlFLRTGJRS0lAwopcUhoGIRTDUhphpgQuKryCrTCq8gpiKUoqlKK0JBVKUUAZ0q1RlFaUq1RlWgChIKrOKuyLVZxQMqsKjIqdhUZFICIim4qQimkUAMop2KbQAlJS0lACGig0UAFJRRmgApaSgUCFpcUCnAUwExS4pQKXFAhMUYp2KMUAMxRTsUmKBiUlOpKBgKKKKQwFOFJThSAetTIKiWpkFAFiMVbjFVY6tximIsxirSCq8Yq0gpiJFFLigCnUySNhUD1YaoHpiK0lVnqzJVd6CkV3qBqnaoWoKIjTDTzTDSAaaaacaaaQxpphp5ppqQGGmmnGmmgBppKU0lABQKKBQA4U4U0U4UwHCnU0UopiHUlLSUCEpppxpDTAYaaaeaYaAGmkpxptIBKKMUuKQwFSqKYBUqigCVBVmMVAgq3EtAFmJavQrVWJavwrSEWolq7EKrRLV2NaAJ4xVlBUKCrCigB61IKYKeKBjqWkooAWikozQAuaM03NJmgCxRiiikQJiilpKBhSUtJQUFBooNAxDTTTjTTTAjaoHFWGqFxTEU5BVSUVekFVJBTEZ8q1SlWtGVapSrQBnyLVZ1q9ItVXWkMqMKiIqw4qFhQBCRTSKkIppFAEZppp5phoAQ02lNNJoACabmgmm5oEOzSUmaKYDqUU0U4UCHCniminimAoFOxSAU4CgQmKMU7FGKBjMUmKkxSYpFDMUmKfijbSuMjxRin4oxSuMbinCjFKBRcB61MlRLUyUAWI6tx1Vjq3HQItR1aSq0dWUpkslFKaQUpqhDGqB6maoHpiK71WerMlVnoKRA1QtUzVE1IoiIphqQimkUhkZppqQimEUgGEUw1IaYaQhhphp5pppAMNJSmkoAKKKTNMBwpwpgpwNMB4pwpgNKDQIfRTc0uaYgpDRQaAGmmmn000ANNNp5pMUhjcUYp2KUCgYKKmQUxRU6LQBJGtW4lqKNauRJSETwrV+FarRLV6JaBFmJauRiq8S1bjFICZBUyio0FSimA8U4U0U6mAtFJRQMXNJmkzSZpALmkzSZozQMt0UmaXNIzCkNLSUFBSUtJQMKDSUUDA000ppDTEMaomqY1G1MCs4qrIKuOKrSCgRQkWqcq1oyLVOVaBmfItVHWr8i1VkWgCk4qFhVpxUDCmBXYVGRUzCo2FAERqM1K1RNQIYaYacaYaAEJpM0E03NAC5pabSigQ8U4UwVIKYDxTxTBUgoEOApwFIKeBTATFLilxS4pFIbijbT8Uu2pZSI9tJtqbbRtqGykiDbRtqfbTStK47EO2lAqQrSbaLhYRRUyVGBUqimInjq1HVVKsx1RJcjqyhqohqyhpkk4pSaYDSk0xDWNQOalY1C5pgQPVd6neoGouUiFhUTCpmFMIpXKSISKaRUpFNIqbjsREUwipiKYRSuBCRTDUpFMIoEREUw1IaYaYhhptONNNACUUUlAC0uabS0xDwaXNMzS5piH5pc0wGlzQA7NFJmloAKQ0tFIY2jFLijFACYpwFAFSKKQwVasItMRasxrQBLEtXYkqCJKvRJQImiSr0S1BElXYlpATRrVlBUca1YQUAPUVIKaop4FMQtFLRQAZpKKKYxKSlpKQxKSlpKQFvNGabmlzQZjqKbmloGFBopKBhRRSUxhSUtFMQw0xqkNMYUAQOKruKtMKgcUAU5BVOVavyCqkgoGZ8i1VkWr8gqpIKAKTiq7irbiqzimMrMKiap2FQtQIhaomqZqhagRGajNPaozQAhptKabSAWnCm04UxDhUi0wVItAD1qRRTFFSAUxDxThSCnCmAoFOApAKeBUspCAU4LSgU8CoZaGhaXbUgFOC1m2aJEOykK1PtpClTcdiuVpu2pytMK00xNEWKetLigCrRLJVqwhqstToapEMtoasKaqIanVqoRZDUpaoQ1LuoEKxqJzSlqjY0XGiNqiapGNRmlcpIjIppFSEUmKlstIiIppFTYppFK4WICKYwqdhUbCi4FdhUbVMwqJhVEsiNMNSNUbUyRhpppxpppgNpKcaSgQlFFJTAXNKDTaWgQ4GlBpmaXNAEgNLTAacDQA6ikzS0hhSgUClFACgVIopFFSoKQx6LVqNaijWrcS0ATRJV6JKgiWrsS0CJ4lq5GtQxLVuNaQEiLVhRTEFTKKYhQKdigCnYoATFJTsUlACUlOpKBjaSnYoxSGMop2KTFAEuaXNNBpRQZjqWkFKKBhRSijFACUUUUxiUUYpcUxDTTSKfTSKAIWFQOKssKhcUDKkgqrIKuuKqyCgZQkFVJBV6QVUkFAFKQVWcVckFVXFAyq4qB6suKrvTEQNULVM1QtQBE1RmntTDQIYaSlNFIAFPApAKeBTAcoqRRTVFSKKAHKKkApqipAKYhQKcKAKcBQIBTxTRTxSZSHAVIopgp61mzRDwKeBTRUgrNmiExQVp+KCKgohZajIqcioyKaJZCRSYp5FNrREMUVKpqIU9TVohllDUytVVTUoaqEWA1LuqENRuoESFqYxppamk1LKQE02gmipbLQYoxRS1DZaG4ppFSU00XAhYVEwqdqiamiWQNULVM1RNVolkTVEalaozVEjDTTTzTTTEMpMU+kpgNppp9NoEJRRSUALS5ptFADwacDUYNOBpDHg04UwGlBoAkFOFMFSLQA9RU6ColqdBQMnjFXIlqvGKuRCkBaiWrsS1WiFXYhQIsRLVtBUEYqygoESqKlApiipAKBDgKWgUtACUYpcUUDG4oxTsUmKBjcUmKfijFAxmKMU7FGKLCGilFIKUVJA4U6kFLTAWiiloGJRS0UwExSYp1JimAlNNPppoAjYVC4qdhUTigZVcVVkFXHFVpBQMoyCqkgq7IKqSCgZSkFVZKuSCqkgoAqvVZ6svVd6AK71C1TvULUCIWqM1K1MIoER4pcUuKUCmAAU9RSAVIooAcoqRRSKtSKKAFAp4FAFPApiAClxSgUuKAEpwpKUUmNDhUgqMU4GoZaJlNSCoQaeDWbRaZKDS0wGlzUWKuIajank0xjTSFcjamGnk0w1aJYU4GmZpQa0RDJlNSBqgBp4NMRNuo3VFupc0ASFqbupmaTNQxokzQDTM0oNQzREmaXNMzS5qSkOppoJppNADGqJqkY1ExqkSyJqiapWqJq0RDImphqQ0w1RJGaQ0402gQ2kxTqMUwG4pMU7FJigBhFJTyKaRQA2ig0lIBaUGm0uaBjwacDUYNOBoAlFSLUS1KtAyZKsx1XSrMdIC1EKuRCqkVXYhQBciFXYhVOKrsVAi3HVlKrR1ZSgRMtSCo1qQUCHClFIKWgBaKKKBhRRS0DExRiloxTATFGKXFGKqwiEU4UgpRWQhwpwpopwpgLS0lLQAUUtFMBKTFLRTASkNOppoAjaomFTNUTUDK7iq0gq09VpKBlKQVUkFXZBVOWkMpSVUkq5LVSSgZUeq71Zeq70wKzVC1WGFQsKBEJFNIqQikxQIjxShafinBaYDQtSKtKq1Iq0CBVqQLSqtPC0AAFOApwFKBQIbilxT8UmKAGYop2KTFAAKcKZSg1LKRIDTwahzTg1Q0UmTBqXdUQajdU2KuSFqYTTS1NLU7BcUmmE0E03NUiWLThTKcKpEjwacDTBThQA7NLmm0UALmkzSGkzSY0PzSg1HmlzUNFolBpc1HmjdU2KuSbqaTTd1NJp2C4rGomNOJqMmqSJY1qjanmmGqRDIzTDUhppqhEZFNxUhFNIoENxSYp2KMUANpMU/FJigBhFMNSEU0igCM02nkU00ANooopDHCnA00U4UwJFqZahWplpFE6VZjqslWY6ALcVXYqpR1dipBYuxVdiqlFVyOgLFuM1ZQ1VjqyhoJJ1qQVEpqQUxDxTqYKcKBDqKSloGApwpop1MYUYpaKaAMUUtFUhEAFKKXFLWIgFOFJSimAopaSlzTAKKM0UwCkpaSgApppaQmgBjVE1SMaiY0DIXqtJVhzVaQ0ikVZaqS1akNVJKBlSSqklW5KqSUDKslVnqy9V3FAiBhURFTMKYRTEQkUm2pdtG2gCMLTwtPC08JQAxVqVVpypUipQFhqrUgWlC08LQIaFp22nhaXbTAjxRipNtJigRERTSKmIphFAEZFNp5FNNIBM0ZpDSZpWGP3UbqjzSZpWHck3UmajzRmiwXH5ozTM0op2AeKcKYKkAoEKKeBSAU8CgBMUU7FIRQAw0008imGgYlGaSkzSsO4/NG6o80bqVirkm6mlqbupM0WC4pNITSZpKdiQNMNONJTEMNNIp+KQigRGRSYqQikxQBHijFPxRigBmKTFPxSYpARkU0ipSKYRQBERTSKlIphFMRGRSU8ikxQMSnCkxThQMetTJUK1MtIpE6VZjqslWY6Q7FuOrkVU46tx0XHYuxVcjNUojVyM0CLcZqyhqohqyhoJZYU1IDUKmpVNMkkFOFMFOFMQ6lptLQA6lFNFOoGOopKWqAWlpBS0wIsUtLiisyBKWiimMWikopgLRSUUALSZpM0maYCk00mgmmE0DEY1ExpzGoXakMjc1XkNSO1VpGqRohkNVJDU8jVUkagogkNVZDU8hqs5oAgeoGqZjURpgQkU0ipSKTbQIi204LUgWnBKVx2IwlSBKkCVIEouFiIJUgWpAlOC0XCwwLTgtPC07FAhgFLin4oxTJGYpCKkxQRTAhIphFTEUwimIhIqMipiKjYUwIjTDUjVGaQDSaTNBppoGLmjNNpRSAcDTxTBUiigByipVFNUVKooAUCnhaVVqQLQBHtpCKm200igCBhUbCp2FRMKAIjTTTjTDRYYhNNzQTTc0WGOzRmmZozRYB+aKbmlpALRRRQAmKTFPpMUCGYpMU/FGKQDMUmKfijFAxmKTFSYoxSAiIppFTFaQrRcRXK0wirBWmFaYWICKaRUxWmEUAMxSgU7FGKYxVqZajWpFpDROlWEqulWEqSi3HVuOqcdW46Qy5HVuM1TjNWozQJlyM1YQ1VjNWUNUSywtSrUK1KtMgkFPFMFOFMB1LSUUAOp1Mp1ADqWm0tFxjhS00UoouAlBoooMxKKKKBhRSUUwFpKKSmAE00mgmmk0DAmoyaUmo2agBrNULtTnaoHakUhkjVVkapHaq0jVJRHI1VJGqSRqrSNQMjdqrOakdqgY0ANY1GacTTaBCYpQtKBUirRcY0LUgSnKtTKlTcdiMJTwlSqlPCUrjsRBaXbUu2jbRcRFtpcU8ikqkSxuKKWkqkSFJS0lMQ00winmmGmIjaomqVqjamBC1RmpmFRkUDIjTTUhFMIoAbSilxSgUgFUVIopqipVFIB6ip1FMQVOi0AKoqQLQq1IFpiGbaaRU22mlaAKzCoWFWmFQOKBlZhUTVOwqFqAIzTCac1MNABmjNJRmgY7NOBpmaUGkMeKWmg0opAOooopAFGKWikAmKMU7FLigY3FG2pAKXbUgRbaQrU+2k20XCxWK0wrVopUbLTuFiqVphWrLLUbLTuKxARRinkU3FMBRT1pgp60DJkqxGarrU6GpKLcdWozVOM1ajNIZdjNW4zVKM1bjNAmW4zVlDVWM1ZQ1RLLKGpVqFDUq0ySUU8UwU8UCFFLSUtAC0tJS0DFpaQUooGKKdTRSigAopKKZmFJRRQAUUUUDEpDS000wENMJpxqMmmA1jUTGnMahY0hjHaq7tT3aq7tSKRHI1VZGqWRqqyNSKI5Gqq7VLI1VnagCN2qFjTmNRk0AGaUU2nCgQ9RUqrTVFTotSykOVKmVKEWp1WpbGhoSnbakC07bU3KIStNIqYio2FUiWREUw1I1RmrRLGmkpTTapEBmjNJmkqhAaaaU000wGmozUhphoAiIphFSEU0igCIim4qUik20hke2nBadtpwWkAirUqrQq1Kq0AORanRaYi1Oi0xDlWnhaVVqQCgRHtprCpiKYwpgVnFQOKtOKruKQFVxUDVZcVA4oGV2qM1KwqM0ANozQaSkMXNOBplLQMeDTgaYDThUgPpaaKUUgHUtNFOFAxRTgKQU4UgHAU4CkAqQCpYxNtG2pAtO20hlcrTGWrRWo2Wi4WKjLULLVtlqF1q0xFVhTCKmYVERTENp4plOFMCZTU6Gqymp0NIZajNWozVOM1ajNIZdjNW4zVKM1ajNAi7GasoaqRmrMZpklpDUy1XQ1OppiJlp4qNTTxQIeKWkFLQMWgUUUgFFOFNpaAHClpBS0AJSUZpM1ZItFJmkzSAdmkpM0maAFNNJoJppNMBCajY05jULGgBrmoHanu1QO1BSI3aq8jVI7VWkakUiJ2qs5qZzVZzSGQuaruamc1Xc0wImNRmntTKBAKkWmCpEpATIKsxioIxVqMVLKRMi1Oq1GgqdRWbKQoFGKeBQRSGRMKiapmqJqpCZC1RGpmqJhWiIZGabTyKaapEjaSnUlUIaaQ04imkUwGGmmpCKaRQBGRSYqTFG2kBFto21LtpQtICILTglShKcEpARqtSqtOCVKqUwEVanVaRVqZVpiBVqQLShadigQwimMKmIpjLQBWcVXcVcdarutAyk4qu4q461XdaQFRhUTCrLLULLRcZCabUhFNIpANpaKKBjhThTRSikA8UopopwpAOFKKQUopAPFPFRipFoGSLUiimLUq1LKQ8CnYoUU8CpGhhWo2WpyKjYUhlZ1qu4q24qu4q0Syo4qJqncVC1WiSM0Cg0CmIetToarrUyUAWkNWYzVNDVqM0DLsZq1GapRmrUZoAvRmrMZqlGasxmgRdQ1MhqqhqwhoJLCmpAahU1KDQBIKUU0U4UALS0lLQMWlpKUUAOFLSCnCgRFmjNNzSZqgHZpM00mmlqQD80maZupC1Ah5amFqaWphagBzNULNQzVEzUDEdqru1OdqgZqBjHaoHNPdqgc0DInNQOalc1A9BRC5qBqnaoGoERNTKkamYpiAVKlRgVKgpMCxHVqMVVSrUdZspFlKnWoEqZahlokFBoFIakYxqiYVMaiYVSEyFhUZFTEUwitESyEimkVMVpNtUmSQ4pNtTbaTZVXEQ4pMVMVppWi4EWKTbUu2k20XCxFto21NtpQlICILTglShKcEoAiCU8JUoSnhKQEQSpFSpAlSKlMQxUqRVpwSpAtMQ0LTttPC07bTERbaaVqcrTGWgCq61A61cZagdaQyi61XdavOtV3SpGUnWoWWrjrUDLRcdiqy0wirDLUZWlcdiHFGKkK0m2i4WGgU4ClxSgUgAClpQKXFACClFLilApAAqRaaBTxQBItSrUa1KtSykSrTxTFp4qWUhajYVJTGoGQOKruKsPVd6pEsrOKgarD1A1aIhkRpKU0lUIcKlWohUi0AWENWENVkqdDSGW4zVqNqpIasoaAL0bVZjaqUbVZjagReQ1OhqojVYQ0CLampVNV0NTKaBEwpwpgNPFADqWkFOpgKKUUgpwoELS0lLQBV3Uhaoi9NL0FEpamlqiL0wvQIlLUheoS9MMlMCYvTC9RGSozJQBKz1Ez1G0lRM9IBzvULNSM9RM1AwZqhZqVmqJjSGIxqJqeTUbUwImqJqmYVGwpgQEU3FSEU00CEFSLUY61ItJgTpVlDVVKsIazZaLSGp1NVkNTKahlomBpaYDS5qRgaYRT6SmgIiKbtqbFJtq0yWiLbSbKn20bKdxEGyk2VY2UmyquIrFKaUq0UppSi4FbZSbasFKTZTuBDspwSpQlOCUARBKeEqYJUgjoEQhKkEdTLHTxHTEQCOnhKnEdOCUxEISnBKmCUoWmIjC0bal20baAIttMK1ORTStICsy1C61bZaiZaTGUnSq7pV9kqB0qRlB0qBkq+6VA6UiiiyVGUq4yVEyUrjsVStG2pylJspXHYh20u2pdtLtouKxFtpdtS7KNtFwsRYpcVJto20XCw0CnAUuKcBRcLCqKlUUwCpBSGPFSCmCnVJSFpjU40xjQBC9V3qw9V3qkSyu9QNU71C1aIhkRpKcetNqhCipFpgp60ASpU6GoFqZaBlhDVlDVRDVhDSAuRtVlGqkhqyhoEXo2qyhqlGasxmgTLiGp1NVkNTqaBE61IKjWpBQIeKcKaKcKYhRThSUtAC0tAoFAGQXppeoDJTDJSNLE5eoy9QmSozJTAnMlMMlQGSmGSmInMlMMlQGSmGSgCZpKjL1EXphekBIXphaoy9NLUDHFqaTTc0ZoADTTS0hoERmo2FTEUwimBARTCKmIphFAiOnrTcUopMaJlNTIarqamQ1DLRaQ1MpqqpqZTWbLRYBp2aiBp4NSUPpaaKcKAFxS4pRTgKdxWGhadtpwFPC07isRbaTbU+2k207isQFKaUqwVpCtNMVitso2VPspQtVcCAJTwlShKkCUxESpUix1KqVKqUxEIjqQR1MqU8JTEQBKXZVjZRspkkGyjbU22kK0wIttJipSKbigCMimkVIRSYpAQlajZasEUxlqRoqstQulXGWoWWpZSKTpUDpV9kqBkpMpFFkqJkq6yVEyVNyioUpNlWSlJsqbjsVtlLsqfZRsouFiDZRtqxspNlFwsQbaNtTbaTbRcViLbSgU/bRtp3CwgFOApQKUUrhYUU6kpc0ihDTGNONRtTQiNzVd6mc1A9UiWQvUDVM9QtVoljDSUppKskUU8UwU8UCJFqZahWpVoGTrUyGoFqVTQMtIasoapoasoaQi7GatRmqUZq1GaBF2M1YQ1UjNWUNAiytSrUKVKtMRIKeKYKeKBDhS0gpRTAWloFLQByRkphlqsZaaZKk2LBkqNpKrmSozJTEWDJTDJVcyU0yUxFgyU0yVXMlJvoAnL00vUW6jdSAk3UmajzS5oAfmlpgNOFIB1FApaYhpFMIqTFIRTEQMKYRUzCo2FAERpKcRSUmNCg1KpqGng1DLRYU1MrVWU1KpqGWiypqQGoFNSqahlkwp4qJTUimkBIKkFRg1ItFwHgU8Cminii4rBilxSilouKwzbSbakxRirTER7aNlS7acFqkIjCVIqU8LUipVoljFSpVSnqlSKlUJjAlPCVIFpdtMkj20m2pcUhFAiEimkVKajNAEZFNIqQ0w0AMIpMU6kpANIppFPNIRSGiErUbLVgimEUmNFVlqFkq4y1Ey1DLRTZKjKVbZaiK1DLRVKUhSrJWmlallFfZSbKn20baVwINtIVqfbTStMCErTSKmK00igLEJFJipSKbimFhuKXFLRQAmKKKTNIBDUbGnk1ExpoTI3NQOalc1A5q0QyJzULVIxqJjVohjTSUUVRI4U8UwU9aYEi1ItRipFoGSqalU1CtSrQBYQ1YQ1VQ1YQ0gLkZq1GapRmrcZoEXYzVpDVKM1ajNAi0hqZagQ1MtMRMKeKjFPFAh4pwpopwpgOFLigUooA85MlMMlQGSmGSpNycyVGZKhL00vTETF6TfUG+jdTETb6N1Q7qUNSAm3UuaiBpwNICQGnA1GKeKBjxThTBTxQIeKcKaKcKYgxSEU6kIpiIyKjYVMRUbCgRARTcVKwphFJjQynCkxS1LKRIpqRTUIqRTWbNEWFNSqarqalU1DLROpqVTUCmpVNSMmU1KtQKalU0gJlNPFRA1IDSESCnCmCnCmhDqXFApwFWhABTwtAFSKK0RLBVqVVoVakArREMFWpAtAFPAqiWIBS4paQmmIaaYacTUZNIQhqNjSsajJoGITTSaCaaTSAXNJSZpM0ALRSZooGBppFOpDSGiIimMtTEUwioZSK5Wo2WrJWoytQzRFcrTStTlaaRWbKIStNIqUimkUhkZFMIqU0w0wIyKYRUhFMNMCM0008000ANpKU00mgBc00mgmmE0CBjUTGnE1ExqkSxjGoHNSMahY1SIZGxqImnsaiJrREMSgUUVQh4p61GKeKAJVqRajWpBQBItSLUQqQGgZMpqdDVZTU6GkBcjNWozVKM1ajNAi9GatRmqUZq1GaALiGp1NVUNWFNAidakFQqalFMRIKeKjFPFMQ8U4U0U4UAeSl6aXqItSbqk3JC1NLVGWpM0xEm6lzUeaUGgRIDTgajFPFICQGnioxUgpDHinimCnigdh4pwpgp4oAeKcKYKeKZI4UlApaYmNIphFSEU0imSQsKjIqdhTCKQyEijFPIpMVLKQgp4puKeKhloetSqaiWpVrNlolWpVqJalWpKJVNSrUS1ItICUVIKiFSLSAkFPFMFPFNCHingUwVKoq0Sx6ipFFNUVIorVEMeoqRRTVFSCtEQxRTqSkzVkik0xjQTUbNQIGao2akZqiZ6QCs1MLUxmphepGPLU3dUZak3UASbqM1Fuo3UDJc0uaiDU4GgCTNFNBpc0gFppFOpDUspEZFMIqUimkVmy0QkUwipSKYRUMtERFRmpWqM1JQw0w081GaYxhphp5qM0ANNMNONMNACE0wmnE1GTQICaYTQTTCaYhCaiY05mqFjVIhiOahY05jUTGrRDGsajJpSaaatEsKBSUoqiR4p4pgp4oGSLUgqNakFADxTxTBTxSAlWp0qutTpQMsxmrUZqpHVqOgRcjNWozVOOrUdAFtDVhDVZKnSmIsLUq1CtSrTESinioxTxTAkFOFMFOFAjxvNJmiiszcKKKXFAgFKKAKcBQAop4poFPAoActSCmAVIKRQ4U8U0CngUhjhThTRTqYhwpwpgpRVIlklLTAadTJYtIRS0uKZJGRTCKmIppFICErTdtTFabtqWUiPFKBT9tKFqGWgAqRRSAVIoqGWhVFSrTVWpVWpHccoqVRTVFSKKVh3HAVIBTQKeBRYVxwqQCmgU8CmkK49RUi0xRUiirSJbJFFSqKjUVKtaJENjxTxTRS5rREsXNITSE0xjVkgzVEzUM1QM9ACs9Qs9NZ6hZ6lgPZ6YXqNnqMvSGTF6bvqEvSb6AJ91LuqvvpQ9AFgNTw1Vg9PDUDLAang1AGp4akBNmlpgNOzUsaENNNPpprNlojNRNUrVG1Qy0RNUbVI1RGpLQw1Gae1MNAxhphpxphoAYaYaeaY1ADCajJp5qNqBDWNRMae1RsapEsYxqJjTmNRtVohjGNRsac1MNWiGMNNpxpKtEiU4UmKcBTEKKeKaBUiikMctSCmCpBQA4VIKYKkUUhj1qdKiUVOgoAmSrMdQIKsoKBFiOrUdVoxVmOgRZSp0qBBU6UxE61KtRLUq0wJFqQVGKeKYDxThTRSigDx3FAFSbaXbWRuMxS7aeFpdtAhgFKBT9tKFoAaBTwKULTgKQABUgFIBTwKBiinCkApwFIYtKKKKYBS0lFUiWPBpwNRg04GqIY8U6mA08UyRcUmKcKXFAiMrSbalxRtqWUiLbShal20oWpaLTIwtSKtOC1Iq1DRVxqrUqrSqtSKtKw7gq1Iq0qrUgWlYLiBaeBShacBTsK4gFPApQKcBTsTcVRUiikAqRRVJCuOUVIKaop4FWkTcUUtFIatCEJqJmpzGoXNUIY7VA7U52qs7UAI71Az0jvUDvUgPZ6YXqJnqMvSGTF6TfVcyUnmUAWt9KHqp5lOD0AXA9PV6pq9Sq9AFxWqVWqorVMrUhloGng1ApqQGpYyTNNNGaQ1LKQ1qiapDUTVDLTI2qJqkao2qbFpkZphp5phpFDDTDTyKaRSAjNMNSkUwigCI1E1TEVGwpkkLVE1TMKiYVSJZC1RmpmFRkVaJZCRTCKlIppWrRDIiKTFSbaNtWSMxSgU7FLigBAKeKQCngUDFFSKKaBT1FIB6ipVFMUVKopAPUVOi0xBU6LQBIi1ZQVGi1YRaBEiCrKCokWrCCgRKgqdaiQVMopiJVqVaiWpVpiJBTxTBThTGPFOpgpwoA8o2UuypglKErI6CEJTtlShKdsoEQbKXbU2yjZSAi20oWpNtLtoAYBTwKULTgKQxAKXFOApcUhjcUYp2KTFFxDaSnEUhqkxMKUGm0oqkyGSCng1GKepqkSyQU4U0U8VRI4Cl20oFOApDGhacFp4WnhalopMjC1IFpwWpFWpaHcaq1Iq04LUirSsO40LUgWnBaeFpWFcaFpwFOC04CnYVxgWnAU4CnAU7CuIBUgFIBTwKpIVxQKeKQCnVSEFNNOpjVQEbmoHNSuaryGmBBI1VZGqaQ1UkagCN2qu70sjVWdqQCs9Rl6Yz1EXpDJS9J5lQF6bvpAWd9OD1V304PQBcV6lV6pq9TI1AF5GqwjVRRqso1IC4pqUGqyGp1NJjJM0E0goNSxoa1RtTzUbVLLTI2qM1I1RkVLLTIzTTUhFNIqSkyM00ipCKQikMiIphFTEUwigCFhUTCrBWmFaCSswqNlqyy1Ey1aJZWK0wrVkrTCtWiWVitNK1YKU0rVollcrSbanKUm2mIh20uKk20mKYDMU4ClxRQMUU9aaKetICRanQVEtTpSAlQVZRahQVZjFAiVFqyi1FGKsotAh6LVhBUaip1FAh6ipVFMUVIBTEPWpBTBTxTAeKeKYKcKAHinUwUuaBnnOynbKseXS+XWZsV9lLsqx5dGygCvspNlWdlJsoAr7aNtT7KTZSAh207bUm2l21IyPbRipNtG2kMjxSEVJikIoAiIppqQimkU0JjKKXFJVIhjhThTBTxVolkq1ItRLUq1SJJVqQCo1qVaYDwKeFpFFSqKAALT1WlValValodxAtPC0oWpAtTYLiBacFpwFOApWFcaFpcU/FGKBXG4pQKXFKBTEAFPFIBThVIBwpaBS00AhqNqkNRtVAQOaqyGrMlVJDTArSmqUrValNUpTQMryNVV2qaQ1VdqQDGaoWah2qJmpDHF6TfURak3Uhkwanq1Vw1PU0CLStVhGqmhqwhoAuxmrUZqlGatx0AW0NTqarx1OtICUUUDpS1IxhpjVIRTCKllIiNMIqUimEVLLTIyKbipcUmKllJkRFJipdtJtqR3ISKaVqcrTStAEBWmFasFaYVpiKxWoytWStMK1SEVitMK1aK1GVq0SyuVphWrBWmlapCK5WmlanK0wiqEQkU0ipSKYRQBHRS0lAxRTxTBTlNICdKnSq6GrCUCLMdW4xVWOrcdAixGKtIKrx1ZSgRMoqZRUaVMtADgKkFNFPFMQ4U4U0U4UxDhThTBThQMfmlzTM0uaQHH+XS7KseXS7Kk0uV9lGyrGyjZQMrFKQpVnZTdlICtso2VYKUmykMr7aNtT7aaVpMaIdtJtqbbSFakZCRTSKmIphFICEimEVMRUZFNCIyKaRUhFNIqkSxtOFJSirRLJFqVaiWpVq0STLUy1CtTrVCJVFTKKiSp1FAh6ipAKRRUiikAoWpAKQCngVIXACnYoxS0hCYpaWikAlAopRTAUU4U0U4UwHClzTRS1SADUT1IaiamIgkqpLVqSqktMCnKaoymrstUpaQynIaqyGrUlVJKQyu5qJjUr1C1AxhNJmlNNxSGOBqRajFSKKBEyVZjqulWYxQBajq1HVWMVbjFAFqOp1qGMVOopASCloAp2KljGEU0ipCKaRUsaIyKaRUhFJipZRFijbUm2jbUspEe2k21LtpNtIoiK00ipitNK0BcgK0wrU5WmkU0K5XK0wrU5FRkVaJISKYRUxFMIqkIhIphFSmmGmIhIphFStUTUwI2qI1K1RtQBGaSlNMNAxaetRinrQBOlWEqstWI6Qi3HVuOqcdW46BFyOrKVVjNWUNAFlKmWoENTKaYiUU8VGDTxTEPFKKaKdTAcKWminUALRmkpaQzD2Umyp9tG2ixVyDZRsqbbSbaQEO2mlan20hWkMgK03bU5WmlaTGQlaaVqYrTSKljRCVppFTEUwikUQkUwipSKYRSAhIphFSsKYRTEREU0ipCKaRVIljMUAU7FKBVITACpVpoFSKKpEkqVOgqFBVhBVCJUFToKiQVOopkkiipFFMUVKKBDgKeBTRTxSEFLRRSAKKKSkAUUUZpDFpQabmlzTAeDRmm5ozTAUmo2pxNRsaoCF6qy1aeqslAFKWqcoq9LVOQUhlGQVVkFXZFqs60DKTiomFWmWoWWkMgIpMVKVpNtIBgFSKKULUirTEOQVZjFRItWY1oAnjFW4xVeMVajFAE6CrCioUFTqKQx4p1IKdUgJimkU+kxUjGYpMU/FGKkYzFGKfijFIoZikxUmKQilYdyMimEVKRTDTsFyIimEVIajaqSFcjao2qRqiY1SRNxjVGTTmNRsadhDTUZNKxphNMBrVG1OJqNjQAxqjNPamGgYw000402gYCnLSAU8CkBItTpUC1OlIC1HVqOqkdWozQIuRmrKGqkZqyhpiLSGplNV0NTKaaETg08GolNSA1QDxThTAacKBD6WminCgBaBRSikBn7aTbUmKTFVYdyPbSEVIRSEVIERFNIqUimkUhkeKaRUhFNNIojIphFSkUwipGREUwipSKYRSsO5CRTGFSkVGRRYLkRFMIqUimEU7CIiKbipCKTFMBmKUCnYpwWmIRRUiihVqVVqhCqKnQUxVqZRVEskUVMoqNRUyimSyRakFMWnigkeKUU0U6gBaWkzRSEFFFJSGFJRSUhi5pc02jNAx2aXNMzRmgBxNRsaUmmE0xEbmq8lTsagei4yrIKqyCrjiqzii4ylItV3WrrrUDrSApMtRMlW2SoylAyqUpNlWClJsoAhCVIqU8JUipQAiLVhFpqpVhFpiHxrVlBUaLVhBSGSIKnWo1FSrSAcKdSClpDCkpaKkBKKXFFIYmKMUtFIYlNNOpposA01G1PNRsadguMaomNPY1ExqkhXGMahY052qFmqrCuIxqJmoZqiZqYhWaoy1IWqMtQA4mmE0hNNzSADTTSk0lAxppMU7FLikUNAp4FAWnhaQCqKmQUxRUqikBMlWUqulWEoAspVlDVVDVhDVIRZQ1OpquhqZTVIROpqQGoVNSg0xEgNOFMFOBoAeKcKYDTgaBDhThTM07NICrikxT6StLE3GEU0ipKaRUsq5GRSEU8ikIqRkZFNIqQimkUh3IyKaRUhFNIpWHciIqMipitMK0WHcgIphFTFaYVosFyAimkVKRTSKLAREUmKkIpMUAM204LTgtPC0wEVakVaFFSBaYhVWplFNUVKBTJHKKkFNAp4pkseKeKYKUGmIkFLmmA0uaBD80U3NLSAWkoooASiikpDCkzQaSkMXNJmkzSZoACaaTQTTSaB2GtUTVIajalcdiF6gcVO1RMKLhYrOtQstWmFRMtIZUZKYUq0VphSmBVKUbKslKTZQIgCU5UqYJTwlMCNUqdFoVKmVaABFqdRTVWplFADlFSCmgU8UgFFLSUtIYUUUlIBaKSikMWkozSZpAFNJoJphNMBGNRsaVmqFmqkgEZqgdqczVA7UxCO1QM1KzVCzUxCM1RM1KzVEzUABamFqQmmk0gFJozTc0ZoGOooFKKQwxTgKAKeBU3GIBTwKAKeBSuMFFSqKaBTxQMkUVOtQrUy0CJ0qdDVdKnU1SEWUNTqarIanU1SJJ1NSKahU1KDVCJRTgajBpwNAEgNKDTM0uaYh+aXNR5pc0gG0UtFaszTG4pCKfikxUsq5HikIqQikxUjuREUhFS4ppFIdyIimkVMRTStKwXIStMK1OVppWgdyuRUZWrJWomWiwXK5WmEVYIqMikO5CRRinkUmKChAKcBQBTwKABRUqimgVIKBDlFSAUwU8UyWPFOFNFLmmIeKWmZpwNBI4GnA1HmnA0xD80tNBpaAHZpKKKACkpaSkMaaQ0ppDQMSmmnGmmkMQ000ppppDGmozTzTTSGRMKYwqU0wilcCEioytTkUwigZAVppSrBWm7aAINlGyp9tG2mIhCU4JUu2nBaYEYWpFWnBaeFoECrUgFAFOApAKBThSAU6gAooopDCkopKAFpM0U3NIYpNNJoJphNACk1GzUM1RM1MQM1Qs1DNULNVIBHaoHaldqhZqYhGaoWalZqjY0AIxqImnE0w0gGk03NOpKVxiU4UlKKVxiinimingUrjSHAU8CkFPFSUKBTgKQU4UhjgKcKaKdTAkWpFNQg1IppoTLCmp1NVVNTqapElpDUymqyGpkNUIsqalU1ApqVTTESg04GowadmmIkzRmmZozQA/NGaZmk3UAWMUuKXFLitmYjcUYp2KXFSxkeKQipMUYqRkW2k21LikxSHci20hWpcUmKAIStNK1ORTCKQFdlqJhVlhULCgZAwqNhUzVC1IpEZpppzGmE0ikOFOBqPNOBpFEoqQGoQaeDQImFPBqEGng07isS5pQajBpc0XJsPzTgaYDS0xDwacKYDThTEOFOpopRTJHUtNpaYC0lFFIY00hp1NNIYhppp9NNSykMNNNPNNNIYw00inkU0ipGRkU0ipCKQigCIimkVKRTcUAR7aTbUmKMUwI9tLtp+2lxQAwLShafilAoAaFp4FKBSgUCAClApaWmAUUUUgCiikoAKSikoAKaTQaaTQMQmmE0rGo2NADWaomalZqhdqYDXaoWaldqgZqYgZqhZqGaoyaYATTCaCabQAhppFPpMVLGMxSYqTFG2pHYjxTgKdtpcUrjsIBTxSAU4CpuMcKcKaKcKBjhTs0zNLmgY/NGaZmjdTESg08NUAanhqaEyyrVOjVTVqmVqokuI1WEaqSNVhGpiLamplNVUap1NMRODTs1EDTgaYEmaTNNzRmgBc0maTNNzTEaeKXFLilArYxExS4p2KMVLAbikxUmKTFIYzFJipMUmKQyPFIRUmKQikBERUbCpiKjagCFqgep2qBzSGQOahY1I5qB2pFIaxqMtSM1RlqRSJN1KGqDfSh6RZZDU8NVUPTw9IC2Gp4aqoepA1AFgNTgagDU8NQSTA08GogaeDVCZIKcKYKeKaJHCnCmilqiR1FJmlpiFpM0UZpDCkoopDEpDTqSpZSGGkIp1IakZGRSU8imkUhjCKaRTzSGgBhFJin4pMUAMxRinYoxQA3FLinYoxQAmKXFLRQAUtFFMApaSigQtFJRQAUlFJmgApDS000AIaYTTjTCaBjWNRMaexqFjQBG5qBzUrmq7mmBG5qFjT2NQsaYDWNRmnGmGgAoxRTgKAG4pdtPApQtSxjNtG2pNtLtqWMi20YqTbSYqShmKdS4opDEpc0lJmgB2aTNNJpC1MB+6k3VGWpN1MkmDU4NVfdTg1UhFtWqZWqkrVMjUxF5GqwjVRRqso1MRdRqnU1URqnQ0AWVNPBqFTUgNAD80UgopiCkpTSUwNjFLijFLWxgFLiilqQDFJTqSkMTFIRS0UAMpDTjTTSGMaoWqVqhc0AROarSGp3NVZDQMhkNVnapZGqrI1IpDWaoWekZqiZqRSJC9G+oN9JvqRotB6kD1TD08PSKLivUqvVJXqVXpAXFapVaqitUytQIsqakU1XVqmU0xE4NPFRKaeDVJkskFOpgNKDVXJY6jNJmjNMQuaXNNpc0ALRSUUhhRRRUsYlIaWkNJjGmmkU8000hjTTSKeabSGNxSYp1FADcUYp2KTFACUYpaKADFFFJQAuaSikpgLmikzRmgQuaTNJmjNAC0lGaSgApDRSGgBDTDTjTGoAY1QtUrVC1AyFzUD1O9V3pgQNUTVM1REUARmm4p5FJigYgFOAoAp4FAABTgKUCngUmMbtoxT8UYqRkZFJipCKaRUjGEU0080w0hjTTSaU0wmgBCaYWoJphNNCFLUm6mE00tVCJd1KGqDdTg1Mksq1To1U1apkamIvI1WY2qjG1WY2oAvI1WUNUo2qyhpgWlNSg1ApqZTQBIKWminUAFJinUYpiNiloorYwFpaSlpAFJS0lIYUlLSUANNNNONMNIZG1QualaoHNAyCQ1VkNWJDVSQ0DK8hqpI1WJDVSQ0hkLtULNT3NQMaQwLUm6mE03NIZOHp4eqwanq1IotK1TK1U1aplapGXEap0aqaNVhDSAtK1TqaqoamU0XCxZU1IDUCmpAadybEoNOzUQNOzVJktEmaM0zNGadxWH5pc0zNGaYh+aWmZpc0AOopM0uaACkpc0lIYhpDS0lIoaaSnUlIBtFLRQMSkxTqSkAlIadSUAIaSlNNoAKTNFJTAM0ZpM0maAHZozTc0ZoAXNFJS0CCkNLSGgBppjU8000ARNULVM1RNQBA1QOKsNULCgZXYVGRU7CoyKYyIikxUpFJtoAaBTwKAKeBSGIBTwKAKXFIYYpMU6kqWMaaYakNMNIZGaYakNRmkBG1RsakaomoEMJqNjT2qJjTENJppNBNMJqhDs04Go80oNMROpqZDVVTU6GgRbjarUbVSjNWozTAvRmrKGqUZq3GaALaGplquhqdaYEwp4qNakFMQtLiilxSEa1FFFbGItFFFABRRmkoGFJRRSAQ1G1PNMNAyNqrvU7VXekMrSVVkq09VJKRRUkqpJVuSqsgpDKr1A1WHqBhSGRGm5pzUw0AKDTlNMpwpDJlNTIagWpkqWUiwhqwhqslTpUNlWLKGp1NVlqZTSuFidTUgaoQacDRcViYNTt1Qg0u6qTFYm3Uu6od1LuqkyWiXNLmog1OzVJk2JQaUGogacDVCJM0uaYDS5oEPopuaXNAwoNFJSGJSUpopDEpMU6kpDEopaSgBKSlpKQDTSGlNNNMBDSGlNNNACUUUlAC0UlKKAFopBS0CFpDS0GgBpphp5ppoAiaomqZqiamBAwqJhU7VEwpDIWFMIqUimkUDIsUmKkxSYoGNApwFKBS0hhS0UUmxhSUtJUtjGmmmnmmGpGMNRtUpFRsKLgQtUTVMwqJhTEQtUTVM1RMKaJIjTDT2phFUISlFJSimIepqZDUK1KtAFlDVqM1USrMdAF2M1ajNU4zVqM0xFxDVhKqoasIaYiwtSColNSimA4U4U0U4UhGrRS0YrUwAUlLSUDCkoooGFITRSE0AIaY1ONMY0DI3NQPUrGoXpFFeSqslWnqrJUsoqSCqzirbiqzikMqOKgYVacVAwpDK7CmEVMy00rQBHinAUuKcFpMYqipkFNVamRalspD0FToKjQVOorNspIkUVItMUVIKm5VhwpwNNpc0XFYdml3UzNGaaYrEm6jdUeaN1WmS0TBqUNUIanBqtMlomDU4GoQ1ODVSIJgacDUQNOBqhEoNLmowacDQA/NGabmjNAxaKTNFIAooopDA0lFJSGFIaKSgBDTTSmkNADTTTTjTTQAlFFFMApaKKQC0UUUCFpKKKAENNNKaaaYDGqJqkNMNAETVG1SNUbUDIyKYRUhphpDG4pKWikMSlopaTKExS4opcVLYxMUlOxRipuMYRTSKkxSEVNxkRFRsKnIqMigRXYVEwqyy1Ey00IrMKiYVZZaiZatElYimkVMVphWqQiLFAFSbaMVQhAKlUU0CpFFAEqVYjqBBVhKALMdW46qR1ajoEWkNWENVkNWENMRYU1MpqBTUqmmIlFOFMBpwpAbFJinUYrUxG0lOpDQA2kpaQ0DENIaU000DEJqNjTiajY0DQxjULmpGNQOakoieqz1Yc1XekMrvVdxVl6gYVJRWcVCy1ZYVEy0gK5WmlanK00rQMh204LUgSnBaTZSGqtTKtCrUypUNlIFWplWhVqVVrNlABTwKULTsVIxuKSn4pMUANpKcRTSKpEsTNGaDSVaEx2aUGo80uapEMlDU4NUINOBq0SyYNTw1QBqcGqkSThqcGqENTgaYE2aXNRA04GmBJmjNMzS5pDHUUmaM0gCkzRSUgFzSGkpCaQAaaaUmkNMYhpppxpKAEooooAKKKKAFopKTNAC5pCaTNITQApNMJoJppNACE0wmlJqNmoAaxqM05jUZNACGmGlJppNIoKKTNFSNC0tApQKljFxRilAp2KkY3FGKfil21IyPFJipdtJtpDIStNK1OVppWgCsy1Ey1aZajZaaEVGWoWWrjLUTJVoRUK0wrVlkphWqRJX20bam20baoRGFp6inBacFpiFUVOgqNRUyigCZKspVZKsJQIsoasIaqoasIaYiypqVTUCmpVNMCYGng1EDTwaAN2kNLRWhgNNJTqQ0DGmmmnGmmmA000mnGmGkMYxqNjTmNRsaRSI2NQsakY1CxpMpEbmoGNSMahY1I0RtULVK1MNIZCwqMrUxFJtqRkJWm7an20baQ0QhKcEqUJTwlJspEapUypTlSpFSobKQirUqrShakC1LKGhaXbUgWl20gIttIVqbbSFaEibkBFNIqcrTStWkK5ARTcVMVphFUkTcipKeRTSKaEJmlBpDSVSESA04NUOaUNVCJw1ODVAGpwaqEWA1ODVXDU8NQBMGpwNQhqcGoAlzRmmA0uaAH5pKbmlzUgFJRSUAFJS0lMApKWkoAKKKKAEooppNIBSaaTQTTSaBgTTS1IWphagBxamFqaWphagBxaoy1NLUwtQA4tTC1NLUwtSGOJpM0zNGaQx9KKaKeKQxwFPApFFSqKkYgWnBaeFp4WpC5HtpdtShaNtILkW2k21PtpNtKw7kBWmlasFaaVpDKxWoytWitMK0wKjJUTJVtkqNkpoCmyUwpVopTClWmTYqlKTbVgpTdtO4iLbTgtP20uKYhoFSKKQCngUxEi1MlQrUy0xE61OhqutTKaYiwpqZTVdTUymgZMDTwaiBp4NMDoaKTNFamAUhpaQ0ANNIaU0hoGMNRtUhqNqQ0RNUTVK1RNSGQsahapWqFqllETVC1StURqSkRmmmnmm4pMYzFGKfil21IyPbS7KkC08LUtjREEqQJTwlSBKlspEYSpFSpAlPCUihgWnhakC04LSAYFpdtSbaXbRYTZFtpCtTbaNtUkS2VytNK1YK00rVJEtlYrTCtWStRstVYVysVphFWGWomFOwrkJFMNSsKjIpgNzSZoNMJoAk3U4NUOaA1MCwGp4aq4anBqYFkNTg1QBqeGoETg0uaiDUoagCXNGaYDS5pAPzSUmaM0ALRSUtMQUUUUwEpKWkpAITTSaUmmE0DEJphNDGmE0gAtUZakZqjZqBilqYWprNUZagBxamFqaWphagBxamlqYWpM0hkmaUGowacKQEoqVRUS1OgpDuSKKmVaai1Oi0guCrUgWnKtSBaVguRhaXbUoSl20rBch20m2p9tJtosO5BtppWrBWmlaVh3KxWmFaslaYy0rDKrLUTLVplqNloGVWWoytWmWoytMCsVppWrBWmFadxEG2jFSlaTFVckZinAUuKUCmIVakWmAVIopkkq1MtQrUq0wJlNTKagWplpgSrUgNRCng0DOiopKK3OcWkoooASmmnU00gGmo2qQ0xqTGiFqiap2FQsKllIgcVA1WWFQsKRSK7CoyKnYVGRUlEJFJipCKTFSxjMU4CnbacFqWMaFp4WnBaeFqWMQLTwtOVakC1JSGhakC0oWpAtIY0LTgtPC04LTFcj20u2pNtLtqkhNkW2kK1LigiqSIbIStNK1MRTStVYm5AVqMrVkrTCtMVyqy1Ey1aZahZaAKrComFWXFQOKBkDVGalYVC1AxCaTNIabmgCUNTg1Qg04GgCwGp4aq4apA1AE4anBqhDU4NTETA07NQg04GgCXNLmowadmmA8GlpgNLmmIdRSUZoAKQ0ZppNIBCaYTTiajY0ANY1ExpzGomNIY1jUbGlY1ExoGIzUwmgmmE0AITTCaCaaaQC5opKUUAOFSKKYoqVRQBIgqwgqJFqzGtILkqLVhFqNFqwi0guOVakC0KtShaLCuNC0u2nhaXFFguRbaTbU2KTFFh3IStNK1MVppWlYdyArTCtWCtRstTYpMrstRMtWWWo2WkUVmWoytWWWoitIZXK00rU5WmEUwIStNxUxFNK0yWRYpcU/FLtqkJjQKeBQFp4FUSKoqVRTVFSqKYDlFSrTFFSAUAPFOFNFPFAzoKKKK6DlCiiikAUlLSUDGmmGpDTSKQ0RMKiYVMRUbCpKIGFQstWWFRMtJjRWYUwrVgrTCtSy0VytG2pStJtqWNEe2nAU7bSgVDKEAqQCkAp4FSxjlFSAU1RUiipGOAqQLSKKeBTC4BacBTgKcBVJE3G7aMU/FGKpIlsjxSYqTFNIqiWyPFIRUhFNIpkkRFMIqYimEUxEDComFWGFRMKBlVxUDirbiq7igZUcVAwq04qu4pDIDTTUhFRkUhhmlBptKKAJAaeDUQpwNMCYGnA1EDTwaAJQacDUQNPBpiJAacDUYNPBpgSA0uaYDS5piHZozTc0ZoAXNITRTTSAQmo2NPNRmkBG1RtUjVGaBkTVE1SsKjIoGRGmGpCKYRQBGaTFSbaNtICPFOC08LTwtMBqrUyLQqVOiUCFRasotNRasItIQ9FqdFpqLUyigByipAKRRTwKADFLilooAbijFOpKLANIppFSUlFhkRFRkVORTCKloaZXZajYVYIqNhUtFJldhUbLU7CoyKktEBFMIqYimEUDISKTbUpFJtoQiLbS7ak20oWqJGBaeFpwWnhaoQ0LUirShakC0xCAU8CgCngUwEApwoxS4pjN+iiitjmCiiigAooxRQAlNIp9IRQMjIqNhUxFMIqWMgIqNlqwRUZFSykVytNK1OVphWpZSICtJtqYrTStQykRYpcU/FGKhlDQKcBRinAUhiipFpgqVaLAPUVKoqNalFNIVx4FOApAKeKpIlsTFJinYoqiRmKaaeaaaYhhppp5ptMkaaYRTzTTQBGwqJhUxqJqAK7ioHFWWFQuKBlRxUDirTioHFIaKrCmEVOy1GRQURYpMU8ijFIY0U4UuKUCmAop4poFOApiHCnimgU4UxDhThTRTxTEKKUUCloAKWjFFAhKQ0tIaAGmmGpCKYRSGRMKjIqYimEUhkDCmEVOVphWgZXK0hWpytJsoAg20oSptlKEoAiCU8JUoSnqlAhipUyJT1SplSgBESp0WhUqZVoECrUyikVakApAKBTqQCloAKKKSmAtFJRmmAUlLSUgENNIp1NNJgRsKjIqY1GwqWikQsKjYVMwqMioZaZCRTCKlIppFIojxSYqTFJigBm2nBadinAVQmNC08LTgKcBTRIgWngUoFOAqhCAU4ClApcUwExRinYpcUwNqlxSUtanOFFFFMAopcUUwEpCKdSUhjCKaRUhppFICMioyKmIphFSykREUwipiKaRUMpEJFNIqUimkVDKIyKTFPIpMVJQzFKBS4pQKVhigVItMAqRadhXHrUq1GtSrVJEtjxThTRThVWFcWkNLSGgQ00004mmE0CGmmmlNNJoADTTQTTSaYhDUTU8mo2NAEbVC9TNUTUDIGFQsKsNULCkUiuy1GVqwwqMigZCVpNtSlaTFAyPFKBT8UYpoBoFOApwFKBVEgBTgKUCnAUxCAU8CgCnAUCEpcUuKXFAhuKMU7FGKAG0Yp2KMUhjMU0ipMUYpAQlaaVqbFIVoGVytNK1YK03bQMrlaNlT7aNtAEGynBKm20oWgCMJUipUipUipSAYqVKqU9UqRVoEIqVIq05Vp4WgBAKcBSgU7FIBuKKWkNAAaSg00mgBc0maTNJmmA7NGabmjNAC0lGaSgBDTGp5phqWNEZqMipTUZqWWiMimkU800ipKuNxRilxS4oHcQCngUAU4CmSAFPAoApwFUhABTgKUClApkhilxS0uKoBuKXFOxRimK5rUCiitTEWlFJRQMWijNJmmAtJRSUCCkNGaQmkMQ0w04mmk1LGNNNNONNNQykNIppFPNNNQ0UhhFNxTzTTSsMSiloFFh3FAqQUwVIoppCuPWpFpi1IKqwhwp1NFLQIWmk0pNMJoEBNMJoJphNIYE00mgmmE0CFJppNITTSaYATTCaCaYTQAjGomp5NRsaBjGqM08mmGkURkUwipDTTQMjIpMU80lMYzFLilopoQYpwFJThVEigU4CkFOFMQ4ClxSCnUEhilxRS0AJijFLRQAmKMUtFIBMUYp2KMUAMxSYp+KTFKw7jCtN21LSYoAj20bafilxQMYFpwWnAU4CgBFWpFWlAp4FIQKtSBaAKkAoAAKcBSgUuKQwxRS0GkA2mmnmmGgY00wmnGmGgAJpM0hNJmgB2aM0zNGaYD80ZpuaTNACk00mlpppDENMNOJppqRoYaaaeabSsUJilAopRRYAFPApop4p2EOApwFIKeBTRLACnAUAU4CqEIBS4pcU7FMQ3FLinYoxTA0aXNJRWpmLRmkozQAuaM03NJmgQ7NJmmlqaWoAeTTS1MLU0tSAfupC1RlqaXpMaJCaaTTC1NLVLRQ8mkJqMtSbqmw7kmaTNR7qN1KwySlFRhqcDRYCQU8VGDUgNFgJBTxUYNPBpgPpc03NGaAFJphNBNMJpABNMJoY0wmkAE0wmgmmk0DAmmk0hNNLUABNMLUhamFqAAmmE0hNNLUhgTTCaC1NJoGKTTCaCaaTTGBNJmkJppagB2aM0zdRuqhEmacDUQalDUySYGnA1CGpwaqEyYGnA1EGpd1BJLmlzUe6jdQBJmjNM3UbqBD80uaj3UbqAJM0Zpm6jdQA/NGaZupN1IY+imbqN1ADqWmbqUNQMeKeKjBpwNAEoqRaiBqQGkBIKkFRA1IDSAeKdTAadmkMdSGkzRmkAhphpxNMJoGNNMNOJphNACGkzQTTc0ALRmm5ozQMdmjNMzRmmA7NNJpM0hNIYE0maQmkJoAKSkJpM0rAOpRTM04GiwXHinimCninYVx4p4pgqQUxDgKcBSAU8CmIAKXFLS0xCYpcUUtAFyikorYzFpM0hNNJpAKTTS1IWqNmpAOLUwvTGeoy9AiUvTS9Ql6YZKAJy9NL1AZKaZKBk5emmSq5kppkpWHcs76TfVbzKPMqbFFnfQHqt5lOD0rBcshqkDVVD1Ir0DuWVNSKarq1Sq1ICcGng1CGp4agZJmgmmbqTdSAcTTCaQtTC1IBWamE00tTC1AxxamFqaWppakMUmmk00tTC1IBSaYWpC1Rs1MYpamFqaWphakA4tSFqjLU0tTGPLU0tUZeml6AJC1NLVEXppemBLuo3VAXpN9MRZD0oeq2+lD1RJaD04PVUPTg9MRaD04PVUPThJTEWQ9Lvqtvo8ygRZ30b6r+ZR5lMRY30u+q3mUb6ALO+l31W8yjzKQFjfRvqt5lHmUDLG+jfVbzKPMoAtb6UPVXzKcJKALQepA1Uw9SB6QFxWqRWqor1Ir0AWw1PDVVV6kD0gLIanbqrh6cGpDJt1Gai3UbqQyQmmE0hamFqQxSaYTSFqaWoAUmmk0hamk0DHZpM03dSbqYD80maZupC1Ax5amlqYWpC1IB5amlqYWppamA/dRmot1G6gRNupQ1QhqcGoETg1KpqupqVTQBOtSrUKmpVpiJBTxTRTxTEKBS4oFLigQmKXFLRQBYzSZpM0hNbECk0wmgmmM1IBGao2ahmqFmpADPUTPTXaoWegRIXqMvUbPUZegCYyU0yVAXphegCcyU0yVAXppegZY8yk8yq2+jfSGWhJTxJVMPTlekMvK9Sq9UVeplekMvK9Sq1UleplekMuK1PDVUV6eHpDLG6gvUG+k30hkxaoy1Rl6YXpASFqYWqMvTC9ICQtTC1Rl6aXoGPLUwtUZemF6QyQvUTPTC9Rl6AJC9RlqjL0wvQMlL0wvUReml6YEhemF6iL0wvQBMXpheoi9ML0xExek31Bvo30xFjfSh6rb6cHpiLIelD1WD0oemItb6USVWD0u+mBZ8yl8yqu+l30xFrzKPMqrvo30CLXmUeZVbfR5lMCz5lHmVV8yjzKQFnzKTzKreZSeZQBa8yjzKq+ZR5lAy2JKcJKpiSnCSgC6JKkWSqIkqRZKQi8slSrJVBZKkWSgC+slSCSqKyVIJKQy8JKcHqkJKeJKQFzfRvqr5lL5lIZZ300vUHmUhegCYtTC1RF6aXoGTFqaWqIvSb6Bku6k3VDvpN9AE26kLVCXpC9AyUtTS1RF6YXoAlL0heoS9JvpiZLvo3VDvo30yScNTw1Vw1PVqALStUymqitU6NSAtqamU1VQ1YU0xE6mpBUKmpVoESClpopwoELiiilpgOzTSaTNNJrQkUmo2agtUTNSARmqB2pztUDtQA12qFmpXaoGagAZqjLUjNUZagBxamlqYWphagB5eml6YWppagB5ajfUW6k3UDJw9OD1XDU4NSGW1epVeqatUqvSGXVepleqKvUqyUhl4SU4SVTElO8ypGW/MpDJVbzKQyUDLBkppkquZKaZKQE5emF6gMlMMlICcvTC9Ql6YZKQyYvUZkqEyUwyUATNJUZeoTJUZkpgTF6YXqEvTS9ICUvTC9RF6aXoHckL0wvUZamlqYrkhemlqiLUm6mFyXdSbqi3UbqYrk26l3VDmlzTETbqXdUOaXdTAm30u+od1G6gRNvo31Duo3UwJ99G+oN1G6gCffRvqDdRuoETb6N9Q7qTdQBMXpN9Q7qTdQBPvo31Bupd1AycPTg9Vw1KGoAtB6eHqoGp4ekBcElSLJVEPUgkoAvLJUgkqgJKeJKQF8SU8S1QElPEtIZeEtL5lUhLSiWgC75lJ5lVPMo8ykBaMlIZKreZSeZQMsF6TzKr+ZSGSgCx5lJvqt5lJ5lAyyXppeoPMpu+gCwXppeoN9JvoAmL0m+ot1Jupkku6lDVDupQ1MROGqRWquDUimmBaVqnQ1UU1OhpAXENToaqIasIaALSmplNVkNTKaBEwp4qNakFMQ4UtIKdQIgJppamlqYWrQQ5mqJmoZqiZqQxrtUDtTnaoHagBrtULGnM1QsaAEY1GTSk0wmgBCaaTQaaaQCE00mlNNNABmkzRSUDHZpQajzS5pDJg1PVqgBpwagCyHqQPVUNTg9IZbElL5lVd9HmUgLXmUhkqt5lIZKBlkyU0yVWMlNMlICwZKaZKrmSmmSkMsGSmGSoDJTC9AE5kqMyVCXpheiwEpkphkqEvTC9AExkppeoS9JuoAlL0heot1JuoAkLU0tTc0madhXHE0mabmjNOwrjs0uaZS0wH5pc0yloAdmlzTaKAHZozTaTNMB+aTNNzRmgB+aTNNzSZoAfmjdTM0ZoAfupN1MzRmgB2aM0zNGaBD80uaZmjNMB+aUNTM0ZpAShqcGqHNLmgCYNTw9V80oakMsh6cHqtupd9IC0JKcJKqB6UPSGXBJSiWqfmUvmUAXPNpfMqn5lO8ygC15lHmVV8yjzKALPmUhkqvvppegCwZKTzKrl6TfQMs76TfVffRvoAsb6N1QBqcGoAm3UZqMGnA0xD804GmCnCmIkBqRTUQqRaAJ0NToarKanQ0AWkNWENVENWENIC0hqdTVZDU6mgRYU1IKhU1KKZJIKcKaKcKBGeWphamlqYWrQBWaomahmqJmpDEZqgZqczVCzUAIzVExpWNRk0AITTSaCaQ0hiGmmlNNNACGmmnGmmkMSkpaMUAJRRRQMWlzTaM0gH7qXdUeaTNAEu6jfUO6k3UxExeml6hLUhelYCUvSF6hL00vSsMmL00vUJeml6LDJi9NL1CXpC1ICQvTC9MLUwtQMeXppamE0hNADt1G6mZozQIfmjNNpaYhc0UlLQAUtJS0xBS0YpcU7AAoFFLQAUUUUAFFFFACUUUUAJRRRTAKSg0UgCkoooAKKKKYC0UUUCFooopDClzSUUALmjNJSZpDH7qN1MzSZoGSbqXdUWaN1IRLvpd9Q7qN1AE4el31X3Uu6gCxvo31Buo3UATb6TfUW6k3UAS76TfUW6jdQBNupQ1QA08GiwyYNTwahBqRaYEoNPFRrUi0CHinimCnimA4VItMFPFAEq1MhqBalU0gLKGp0NVkNToaALSGp1NVkNToaBFlDUy1XQ1OtAiVaeKjFSCkIxS1RlqaWphatRCs1RM1DNUbNQAjNUTGlY1GxpDEJphNKTTCaAA0lFJSGIaQ0tIaQDTSUppKBiUUtFIYlJS0UAJSUtJQAhppNKaaaYgJppNBppoACaYTQTTSaBAWppakJppNAxS1NLUhNNJpDHE0mabmigYuaaTRSUDAmkNFFAhKUUYpcUAApwpAKcBQIBS0Yp2KYhMUYpcUYpgFFLiigAoopaACiiikAUlLRTASjFLSUAJSU6koASkp1JQAlFLRQAlFLijFAhKWijFABRRRQAUUUUhiUlLSUDEpKWkoATNGaKSgAzRmkopAOzRmm5ooEPzRmmZozQA7NGabmkzQMdmjNNpRTAeDThTBT1oAkWpVqJalWgCValWolqVaAHiniminikAop4popwoAkFSKaiBp6mgCwpqZDVZTU6GkBaQ1YQ1UQ1YQ0CLSGp1NVkNTKaBFhTUgNQqakBpAc8WphNBNMJrYkRjUbGnE1GTQA0mmE0rGmE0hiE00mg000DDNFJRmkAUlFFACGg0UUhiUUUtACUlLRSGJTTTjTTQIaaaacaaaYDTTDTjTTTEMNNNONNNADDTDTzTDQA00lOpKQxtFLilxQMbRinYoxQMbijFOxRigBuKXFLilxQAgFOAoxS4oEAFLQKWmITFLRRQIKKKKACiiigYUUUUgFpKWigBKKKKYAaSlpKADFJS0UAJRilxS4pgNxRinYpKQhKKWkxQAUlLRQAlFFBoGJSUtFAxtJTqSgBtJSmikIbRS0YoAbilpaMUAJSUuKKAEooxRigApRRilAoAUVIKaBTxQA9akWoxUq0DJFqVahWpAaAJRTxUYNPBpAPBpwNRg04GgCQGng1EDTwaQE6mplNVlNTKaALSGrCGqaNVhGoEW0NTqaqI1Tq1AFpTTwagVqeGpCMAmmk0pNMJrYkQmmGnE0w0DGGmGnGmGkMaaQ0pNNJoASkzRmigAopKKQC0UlLSAKSlooGJRRSUAJSGlNNNAhDTTTjTDTAaaaacaaaBDTTDTzTTQAwimEVIRTSKBjKTFPxSYoGNxS4pcU7FAxmKXFOxRigY3FGKdijFADMUuKWikITFLRRQIWiiimIKKKKACikooAWkoopDFopKM0DFoozRmgQUUmaM0wFoozRQAYooooAKWiimAYpKWikAlJSmg0ANNFLSUAJRRRQMSiikoAKSlNJQAhpKWigQlFLRigBKKXFGKAExSYp2KMUANxRinYoxQAmKUClxTgKAEAp4FAFOAoAUU8U0U4UAPFSA1EDTwaQyUGng1CDTwaQEoNOBqMGnA0ASA04GowacDQBKDUqmoAakU0gLKNU6NVRWqZGoEXUaplaqaNUytSAtq1SBqqq1SBqQGQTTSaCaaTW5AGoyacTTCaBoaaYacTTDQMaaaacaaaBiUmaDSUALRSUUhC0tJRSAXNFFFABSUUhoAQ0hpTTTTEIaaaU000AIabTqSkA0000+mkUANIppFPxSYoGMxSYp+KMUANxRinYpcUDG4pcUuKMUDG4pKfikoAZSGnGkoASiiigQUUUUCFpKKKACkzRRSGFLmkooGLmjNJRQAuaM0lFAhc0maKKAFopKWgBaKSlpgLRSUtABRRRSAKSlpKBiUlOpKAG0UtJTASkpaSgBDRS0lABSUtFABS4opcUCEoxS0YoATFGKXFLigBuKXFLilxQAmKUClxS4oATFOoxS0AFLRRQA4U4GmClFIZIDTwaiBp4NAEgNPBqIGng0gJAacDUYNOBoAkBp4NRA04GgCwrVKrVVVqlVqALatU6tVNWqZWpAW1apA1VVapA1IDOLUmaZupC1bmYpNNJpC1NJpDAmmk0hNNJoKAmkJpCaQmgAJpKTNJmgB1GabmjNIQ7NLmmZp2aAHUU3NGaQC0hNGaTNMANNNBNIaBCGkopKBBSUuaSkMKSlooAbikxTqSkA3FGKdikpjExRS0UAJiilopjEppp1IaAGmm040lACUlLSUgCkpaSgAoopM0gFpKKKACiiigAooooAKKKKYBS0lLQAUUUUgFopKM0wFpaSigBaWkpaQBRQKWgYlJS0lACU2nUlMQ2kp1JQAlFGKXFAxKKWloEJS0YpaAExS4pcUUAJilxS4oxQAmKXFLilxQAmKXFLRQAYooxRQIKKKKBhS0lKKAHCnCmCnCkMkBpwNMBpwNADwaUGmZpc0CJAacDUQNKDSGTBqkVqrg1IrUAWVap1aqimpkNIZaVqlDVWU1KpoAziaaWppNITWxkOJppNNzSE0ihSaaTSE00mgBSaQmkJpM0ALmkzTc0maAHZozTM0ZpASZpc1HmlBoAkzRmmZpc0gHZpM0maTNMBSaaaKSgQUlFJmgQtFJRSGLRSZooAKKKKQCUUtFMY3FFLRQAlFFFMBDSUppDQAlIacaaaAG0lONJikAlJTqSgY3FFLRSASiiigBKWiigAoopcUxCUClxS4oASjFLijFAxMUuKXFJigBKKWigBBS0UUALS0lLQAUtFFABikpaTFACUlOpKYhtJTqMUANxRTsUmKAExS0YpcUhiUtLijFIAopcUuKAEpaMUuKYBilxRijFAgopaKAEpDS0UwEpKU0lIYUtJRQA4U4GmU4UhjwadmowaXNAD80uaZmlzQIfmlBpmaUGgCQGnqaiBp60DLCmpkNV0qZKQywpqVTUK1KtIZm001IRTSK3MhhpppxFNIpANJppNKaaaQwJppNBppoEGaM0lJQAuaM02igB2acDTKUUhjwaWmilFAC5oooxQAGkpcUmKBCUlOIpMUANozS4oxSAKKXFGKACijFGKBiUU7FJigBMUlOxRimA2kxT8UmKBDaSn4pMUDGYpMU/FGKQEeKMU/FG2gCPFJipMUmKQxmKTFSbaTbQAzFGKfto20AMxRin7aXbTER4pcU/bS7aYiPFLipNtG2gYzFGKk20baQEeKMU/bRtoGR4oxT9tG2mBHilxT9tG2gQ3FGKdilxQA3FGKdijFADcUU7FGKAGYpMVJto20wI8UmKl20baBEeKMVJto20gI8UYqTbRtpDGYoxT9tLtpAMxS4p2KNtADcUuKdtpcUwG4oxT8UYoAZijFPxSYoAZSU/FJimAykp+KTFAxtFLijFIBBThSYooAdS02loAWlpKWkIWnCkApwFAxRUi00CpVFAD0FToKiQVYRaCh6iplFMValVaQyiUppWrRSmFK1MSoVphWrbJUbJQMqlaYRVkpTClAFcimkVOUppWgCAikxUxWk20AQ4oxUu2jbSAjxTgKfspQlAxoFKBTwlOCUgI8Uu2pQlLsoAh20m2p9lJsoEQ7aTbU+yk2UAQbaNtTbKNtAEW2jbUuyl2UDIdtLtqXZS7KQEO2jbU2yjZQBDto21NspdlMCDbSban2UbKBEG2k21Pso2UAV9tG2p9lGykMr7aNtT7KNlAEG2k2VZ2UmykMr7aNlWPLo8ugRX2UbKseXRspgV9lGyrHl0vl0xFfZS7KseXS+XQBX2UbKs+XR5dAFfZRsqx5dHl0DK2yk2Va8uk8ugCtspNlWfLo8ugZW20baseXR5ftQIr7aNtWPLo8umBX20u2p/Lo8ukBBto21P5dLspgV9tLsqfy6Xy6BFfZRsqz5dHl0wK2yjZVny6PLpAVtlGyrPl0eXSArbKNtWPLo2UgK+2l21Pso8ugCDbS7am2UuygCDbRtqfZRsoAg20m2rGyk2UwK5Wk21YKUmygZWK0m2rBSkKUxlfbRiptlJsoAhxRipdlLspARYoxUuyjZQIjApwFPCU8JSAYFpwWpAlPVKAGqtSqtOVKlVKQxFSp0WlRKmVKBiKtSqtOVKkCUDK5jphjq4UphStDIpmOo2jq8Y6jMdAyiY6jMdXmjqMx0CKRSmFKumOmmOgCkUo8urZipPLoAqeXR5dW/LpfKoGVPLpwjq0IqURUgKwjpwjq0IqcIqAKojpfLq0IqXyqAKnl0nl1c8qjyqAKfl0nl1c8qk8qgCn5dHl1b8qjyqAKnl0vl1b8qjyqQyp5dL5dW/KpfKoAqeXR5dW/KpfKoEU/Lo8urnlUeVTAp+XR5dXPKo8qgCl5dHl1c8qk8qkBT8uk8urnlUeXSAp+XR5dXPLo8ugZT8ujy6ueXR5VAFTy6Ty6ueVR5VAin5dHlVd8qjyqYFPy6Xyqt+VS+VQBT8uneVVvyqXyqBFTy6PKq55VL5VAyl5VHl1d8qk8r2oApeXR5VXPKo8qgZS8qk8qrvlUeV7UAUvKo8qrvlUeVQBS8ujy6ueVR5VMCn5VJ5ftV3yqPK9qBFPy6PLq55XtR5VAFTy6XyqueVS+VQBT8ql8qrnlUvlUxFLyvak8qr3le1HlUgKPlUnlVe8qk8qgCl5dJ5dXfKo8qkBS8ujy6ueVR5XtQBT8ul8urflUvlUDKfl0eXVzyqPKoEU/LpPLq55VHlUxlIx03y6vGKmmKgCkY6aY6umOmmKmMpeXSeXV0xUnlUAUvLo8urnle1J5VICp5dHl1b8ql8qkIqiOnCOrQipRFQBXEdSrHUwiqVY6QECx1MsdTLHUqx0DIljqZUqRY6lWOgdyNUqQJUoSnhKQXINlNKVY20myrIKxSmGOreykKUAUjHTDFV4x00x0wKBippiq+YqaYqBFHyqTyqveVSeVQMpeVR5VXfKo8qkBTEVOEVWxFThFTAqCKnCKrYipRFSAq+VS+VVvyqXyqYFPyqTyqu+VR5VAFHyqPKq75VJ5VAFPyqPKq55dL5VAFLyqPKq75VHlUhlPyqPKq55dL5VAFPyqPKq55VL5VAin5VHle1XPKpfKoApeVR5VXfKo8qgCj5VJ5VXvKpPKpAUfKpPKq8YqTyqAKXlUeVVzyqPKoAp+V7UeVVzy6Xy6AKflUeVVzy6Xy6AKflUeVV3yqPKoAp+VR5VXfKo8qgCn5VL5VXPLo8ugCp5VHlVc8ujy6AKfl0eVVzy6Ty6BlPyqTyqu+XR5dAFLyvajyqueXR5dAFPyqPKq75VHlUAUvKo8qrvlUnl0wKXlUeV7Vd8uk8ugCn5XtS+VVvy6Xy6BFPyqXyqt+XS+XQBU8ql8qrfl0vl0wKnlUeVVzy6Ty6AKflUnlVd8uk8ukBS8qjyqu+XSeXQBS8r2o8qrnl0eXQBT8r2o8qrnl0vle1ICl5VHlVd8ujy6AKXlUeVVzy6Ty6YFIxU0xVeMdIY6YygYqQxVeMdNMVMCl5VJ5VXfKpPKpAUvK9qPK9qu+V7UeV7UgKXlUvlVd8qjyqAKYipwiq35VOEVIRUEVSLFVkRU8R0AV1jqRY6nEdPEdAyJUqRUqUJTwlAEYSnBakC04LQBX20bakxS7aBEWyk2VPto20AV9lIY6s7KNlAFUx0nl1a2UmymIq+XSeXVrZSbKAK3l0eXVnZRsoGVvLpwjqxspQlAEAjpRHU4SnBKAIBHS+XU+yl2UCK/l+1Hl1Z2UbKYFby6Ty6tbKNlAFXy6PLqzso2Uhlby6PLqzso2UAVvLpfLqxto2UAV/Lo2VY2UbKBEGyjZVjZRsoAg8ujy6sbKNtAFby6PLqzspClICqY6Ty6slKTZQMreXR5dWNlGygCvso8urGyl2UxFfy6Xy6sbKXZQBW8ul8urGyl2UgK/l+1Hl1Y20bKBlfy6NlWNtG2gCDZR5dT7aNtAEGyk2VY20baYFfZRsqfbRtoAg2UbKn20baQEGyjZU+2jbQBBspNlWNtG2gCv5dGyrG2k2UwK+yl2VPto20AQbKXZU22l20CIdlGypttLtpgQ7KNlTbaXbQBBspNlT7aNtAFfZRsqxtpNtAFfZRsqxto20gINlHl1Pto20AQeXR5dWNtJtpDK+yk2VZ20hWmBW2UmyrG2k20wKxjpPLqztpNlAFby6PLqzto2UDK3l0vl1Z2UBKQFby6Xy6s7KXZQIreXSiOrOyjZSAriOnCOp9lLtoAhCU4JUu2l20AMC04LTsUuKBDQtOxS4paBlcClxSgU4CgQgFLtpwFKBQMbto21JijFAEe2k21LikxQBFtpNtS4pMUAR7aNtSYoxQMj20u2n4pcUAM20oWn4pcUxDQtLtpwFOxQAzbRtp+KXFAiPbRtqXFG2gRFto21LtoxQMh20bal20mKAI9tG2pMUmKAGbaNtPxRigBm2l207FLigBm2jbT8UUgGbaTbUmKMUhkW2k21LikxQBHto21JijFAEe2jbUm2lxTER7aXbUmKXFMRHto21JikxSGM20m2pMUmKAGYpMU+koAbijFOooAbijFOopgNxRinYoxQA3FGKdijFIY3FGKdijFADcUYp1FADMUYp+KSgBuKMU6imA3FGKdiigQ3FLilpaAG4oxTqMUwG4oxTsUUANxSYp+KSgBuKXFLS0AMxS4p1FIBuKMU7FFIYzFJipKSmBHik21JikxQBHto21JikxQAzbRtp+KXFAxm2l20/FGKAGbaXbT8UuKBDNtLtp+KMUAM20Yp+KMUgGYoxTqSgBKWkopgLRTc0ZoAaBTgKQU4UhCgUooFLQMKWiloASkpaQ0DENJSmkzQOwlFGaM0BYKWkzS0AKKWkFOFMQopaSnCgQoFKBRSigQYoxTsUuKBDMUYp+KTFADMUmKfikxQMZikxT8UlADcUYpaSkMMUUlGaAFzRSZozSAWim5ozQMWikzS0AJS0UtABilxQKWmITFLilooEJSU4000AJTTSmmk0DCkoJpuaAHZopuaXNMBaWm0tAC0UUUCCiiigAoopKQxaSiimAUUUlABRRRmgAoozRSAWiiii4BS0UUAFFJRTAKKTNFMQtFJS5oAKWkzRmkMXNJRmikAUlKaSgYGkpaSmAUUUUAFFFFAC0tJS0AKKdTRTqYgxSYpaKQCUlLSUAIaaaUmmk0DCkzQTSZoAXNJmkzSZoAkpRSUtIQ6lpBSigBaKBSUDA0hpaaaBgTTc0E03NIYtFJmjNMYuacDTKUGmIfThTKcKCR9OFMFPFMQ8Uopop4oEKKdikFOoEJikNOpDQA002nmmmgY00004000hiUlBpDSGJSUE0maQxc0ZpM0maQDs0U3NLmgBaWm5paAHUtNFKKYDqWkFLQIWiiimAhpppTTTQIQmmk0pNMJoAQmkzSE0maYDs0optKKAHUtIKWmAtLSUtAgoopKACiiikMSiiigYUUlFABRSUUCFopKWpGLS0lKKBhRRRQAUlFJVIQUUlFMQtFJRQA6ikopDFopKM0gFzSZpM0uaACiiigApKWkpjClpKKAHUtNpaBDgaWmilpiFoopKADNITQaaTQMCaaTQTTSaQATTc0E0lMBc0maSigD//2Q==", + "job_id": "4123b4ec-ea71-49f4-b7e2-ad86d622b947", + "grid_index": 0, + "is_alteration": false, + "order": 2, + "section_id": 0, + "request_id": "fc39cefd-fcf9-4775-b490-7e17ce670060", + "url": "https://imagine-public.x.ai/imagine-public/images/7693b927-1c34-488c-8ccc-6750367c66b1.png", + "id": "7693b927-1c34-488c-8ccc-6750367c66b1", + "prompt": "a dog typing on a keyboard", + "full_prompt": "a dog typing on a keyboard", + "percentage_complete": 50, + "model_name": "imagine_h_1", + "suggested_moderation_rule": null, + "r_rated": false +} diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_complete_status_notification.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_complete_status_notification.json new file mode 100644 index 0000000000..fcbeb21691 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_complete_status_notification.json @@ -0,0 +1,25 @@ +{ + "type": "json", + "current_status": "completed", + "percentage_complete": 100, + "job_id": "c1bed796-3f05-4716-8ef0-a4d4900dfc6e", + "elapsed": null, + "prompt": "a dog typing on a keyboard", + "confirmation": "Drew a dog typing", + "full_prompt": "a dog typing on a keyboard", + "flags": null, + "is_alteration": false, + "section_id": 0, + "input_type": "text", + "request_id": "fc39cefd-fcf9-4775-b490-7e17ce670060", + "asr_transcript": null, + "asr_accepted": null, + "seed": null, + "image_reference": null, + "is_image_edit": null, + "moderated": false, + "r_rated": false, + "order": 1, + "model_name": "imagine_h_1", + "image_id": "78b3e6ef-defc-41a6-a9ac-b7a370d26ce5" +} diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_in_progress_response.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_in_progress_response.json new file mode 100644 index 0000000000..e84278d197 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_in_progress_response.json @@ -0,0 +1,25 @@ +{ + "type": "json", + "current_status": "start_stage", + "percentage_complete": 0, + "job_id": "57baeffe-d5cf-483b-a5bb-7794899f905c", + "elapsed": null, + "prompt": "a dog typing on a keyboard", + "confirmation": "Drew a dog typing", + "full_prompt": "a dog typing on a keyboard", + "flags": null, + "is_alteration": true, + "section_id": 0, + "input_type": "text", + "request_id": "fc39cefd-fcf9-4775-b490-7e17ce670060", + "asr_transcript": null, + "asr_accepted": null, + "seed": null, + "image_reference": null, + "is_image_edit": null, + "moderated": false, + "r_rated": null, + "order": 7, + "model_name": "imagine_h_1", + "image_id": null +} diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_request.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_request.json new file mode 100644 index 0000000000..5ea498f971 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/create_image_request.json @@ -0,0 +1,21 @@ +{ + "type": "conversation.item.create", + "timestamp": 1761010203630, + "item": { + "type": "message", + "content": [ + { + "requestId": "fc39cefd-fcf9-4775-b490-7e17ce670060", + "text": "a dog typing on a keyboard", + "type": "input_text", + "properties": { + "section_count": 0, + "is_kids_mode": false, + "enable_nsfw": true, + "skip_upsampler": false, + "is_initial": false + } + } + ] + } +} diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/empty.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/empty.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/empty.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/other_type.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/other_type.json new file mode 100644 index 0000000000..9bb5b8947a --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/other_type.json @@ -0,0 +1,4 @@ +{ + "type": "other_type", + "foo": "bar" +} diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/wire.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/wire.json new file mode 100644 index 0000000000..a7900b55f3 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/wire.json @@ -0,0 +1 @@ +{"type":"json","current_status":"start_stage","percentage_complete":0.0,"job_id":"0080eaac-f7f7-4369-90a2-346491939c1b","elapsed":null,"prompt":"a dog riding a motorcycle","confirmation":"Drew a dog riding a motorcycle","full_prompt":"a dog riding a motorcycle","flags":null,"is_alteration":false,"section_id":0,"input_type":"text","request_id":"bbc15402-3257-4415-922e-b156c151f3c2","asr_transcript":null,"asr_accepted":null,"seed":null,"image_reference":null,"is_image_edit":null,"moderated":false,"r_rated":null,"order":0,"model_name":"imagine_h_1","image_id":null} \ No newline at end of file diff --git a/crates/api_clients/grok_consumer_client/test_data/websocket_messages/wire_formatted.json b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/wire_formatted.json new file mode 100644 index 0000000000..2f458d5128 --- /dev/null +++ b/crates/api_clients/grok_consumer_client/test_data/websocket_messages/wire_formatted.json @@ -0,0 +1,25 @@ +{ + "type": "json", + "current_status": "start_stage", + "percentage_complete": 0, + "job_id": "0080eaac-f7f7-4369-90a2-346491939c1b", + "elapsed": null, + "prompt": "a dog riding a motorcycle", + "confirmation": "Drew a dog riding a motorcycle", + "full_prompt": "a dog riding a motorcycle", + "flags": null, + "is_alteration": false, + "section_id": 0, + "input_type": "text", + "request_id": "bbc15402-3257-4415-922e-b156c151f3c2", + "asr_transcript": null, + "asr_accepted": null, + "seed": null, + "image_reference": null, + "is_image_edit": null, + "moderated": false, + "r_rated": null, + "order": 0, + "model_name": "imagine_h_1", + "image_id": null +} diff --git a/crates/api_clients/kinovi_web_client/Cargo.toml b/crates/api_clients/kinovi_web_client/Cargo.toml new file mode 100644 index 0000000000..d15b4e3e8a --- /dev/null +++ b/crates/api_clients/kinovi_web_client/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "kinovi_web_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "kinovi_web_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true + +# External +chrono.workspace = true +cloudflare_errors.workspace = true +log.workspace = true +rand.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +wreq-util.workspace = true +wreq.workspace = true +url.workspace = true + +[dev-dependencies] +env.workspace = true +env_logger.workspace = true +test_data = { path = "../../testing/test_data" } +test_utils = { path = "../../testing/test_utils" } +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/kinovi_web_client/costs_seedance2.md b/crates/api_clients/kinovi_web_client/costs_seedance2.md new file mode 100644 index 0000000000..2f603df96e --- /dev/null +++ b/crates/api_clients/kinovi_web_client/costs_seedance2.md @@ -0,0 +1,68 @@ +Cost table built on 2026-06-11 + +Seedance 2.0 (~$0.19/sec) +Seedance 2.0 Fast (~$0.13/sec) + +Without Video Uploads +===================== + +### Seedance 2.0 + +4 sec 480p = 60 credits +5 sec 480p = 75 credits +10 sec 480p = 150 credits +15 sec 480p = 225 credits + +4 sec 720p = 160 credits +5 sec 720p = 200 credits +10 sec 720p = 400 credits +15 sec 720p = 600 credits + +4 sec 1080p = 360 credits +5 sec 1080p = 450 credits +10 sec 1080p = 900 credits +15 sec 1080p = 1350 credits + +### Seedance 2.0 Fast + +fast 4 sec 480p = 56 credits +fast 5 sec 480p = 70 credits +fast 10 sec 480p = 140 credits +fast 15 sec 480p = 210 credits + +fast 4 sec 720p = 112 credits +fast 5 sec 720p = 140 credits +fast 10 sec 720p = 280 credits +fast 15 sec 720p = 420 credits + +With Video Uploads +================== + +### Seedance 2.0 With Video Refs + +4 sec 480p + 10 sec video ref = 60 credits + 16 credits +5 sec 480p + 10 sec video ref = 75 credits + 20 credits +10 sec 480p + 10 sec video ref = 150 credits + 40 credits +15 sec 480p + 10 sec video ref = 225 credits + 60 credits + +4 sec 720p + 10 sec video ref = 160 credits + 32 credits +5 sec 720p + 10 sec video ref = 200 credits + 40 credits +10 sec 720p + 10 sec video ref = 400 credits + 80 credits +15 sec 720p + 10 sec video ref = 600 credits + 120 credits + +4 sec 1080p + 10 sec video ref = 360 credits + 72 credits +5 sec 1080p + 10 sec video ref = 450 credits + 90 credits +10 sec 1080p + 10 sec video ref = 900 credits + 180 credits +15 sec 1080p + 10 sec video ref = 1350 credits + 270 credits + +### Seedance 2.0 Fast With Video Refs + +fast 4 sec 480p + 10 sec video ref = 56 credits + 16 credits +fast 5 sec 480p + 10 sec video ref = 70 credits + 20 credits +fast 10 sec 480p + 10 sec video ref = 140 credits + 40 credits +fast 15 sec 480p + 10 sec video ref = 210 credits + 60 credits + +fast 4 sec 720p + 10 sec video ref = 112 credits + 24 credits +fast 5 sec 720p + 10 sec video ref = 140 credits + 30 credits +fast 10 sec 720p + 10 sec video ref = 280 credits + 60 credits +fast 15 sec 720p + 10 sec video ref = 420 credits + 90 credits diff --git a/crates/api_clients/kinovi_web_client/src/billing/get_billing_payments_history.rs b/crates/api_clients/kinovi_web_client/src/billing/get_billing_payments_history.rs new file mode 100644 index 0000000000..94c4d2e7d0 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/billing/get_billing_payments_history.rs @@ -0,0 +1,423 @@ +use chrono::{DateTime, Utc}; +use log::info; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use crate::utils::number_coercion::de_u64_int_or_float; + +/// Page size the kinovi.ai billing dashboard uses. +pub const DEFAULT_BILLING_PAGE_LIMIT: u32 = 20; + +// --- Args & response --- + +pub struct GetBillingPaymentsHistoryArgs<'a> { + pub session: &'a KinoviWebSession, + + /// Page size. The web dashboard uses [`DEFAULT_BILLING_PAGE_LIMIT`]. + pub limit: u32, + + /// Zero-based row offset. Pagination is offset-based: advance by the + /// number of payments actually returned until a page comes back empty. + pub offset: u64, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct BillingPaymentsHistoryPage { + /// Payments on this page, newest first. + pub payments: Vec, + + /// Cursor of the row after this page (`None` on the last page). The + /// dashboard paginates by limit/offset; this is informational. + pub maybe_next_cursor: Option, + + /// Total payment rows on the account, per the server. + pub total: u64, +} + +// --- Public types --- + +/// One payment on the Kinovi billing dashboard (a credit-package purchase). +#[derive(Debug, Clone)] +pub struct BillingPayment { + pub id: String, + + /// Payment amount in US dollars (the API returns dollars: `2159`, `99.99`). + pub amount_usd: f64, + + pub status: BillingPaymentStatus, + + pub payment_type: BillingPaymentType, + + /// Kinovi credits granted by this payment (e.g. 525,000). + pub credits_earned: u64, + + /// e.g. "Credits Package" + pub product_name: String, + + pub transaction_id: String, + pub payment_id: String, + pub product_id: String, + + /// ISO 8601 creation timestamp (e.g. `"2026-07-13T20:40:49.405Z"`). + pub created_at: String, + + /// Parsed `created_at`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, + + pub updated_at: String, +} + +/// Payment status. Only `PAID` has been observed; unrecognised values are +/// preserved in `Unknown` so future statuses (refunds, chargebacks, pending) +/// don't break deserialization. +#[derive(Debug, Clone, PartialEq)] +pub enum BillingPaymentStatus { + Paid, + Unknown(String), +} + +impl BillingPaymentStatus { + fn from_str(s: &str) -> Self { + match s { + "PAID" => Self::Paid, + other => Self::Unknown(other.to_string()), + } + } +} + +/// Payment type. Only `ONE_TIME` has been observed. +#[derive(Debug, Clone, PartialEq)] +pub enum BillingPaymentType { + OneTime, + Unknown(String), +} + +impl BillingPaymentType { + fn from_str(s: &str) -> Self { + match s { + "ONE_TIME" => Self::OneTime, + other => Self::Unknown(other.to_string()), + } + } +} + +// --- Implementation --- + +pub async fn get_billing_payments_history( + args: GetBillingPaymentsHistoryArgs<'_>, +) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/billing.getPayments", base_url); + + info!("Fetching billing payments (limit: {}, offset: {})...", args.limit, args.offset); + + let input_json = build_input_json(args.limit, args.offset); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/dashboard/billing", base_url); + + let request = client.get(&url) + .query(&[("batch", "1"), ("input", input_json.as_str())]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.execute(request) + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Billing payments response status: {}", status); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + parse_response_body(&response_body) +} + +fn parse_response_body(response_body: &str) -> Result { + let batch_response: Vec = serde_json::from_str(response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let json = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.to_string(), + })? + .result + .data + .json; + + let payments = json.payments + .into_iter() + .map(|p| { + let created_at_utc = DateTime::parse_from_rfc3339(&p.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .ok(); + BillingPayment { + id: p.id, + amount_usd: p.amount, + status: BillingPaymentStatus::from_str(&p.payment_status), + payment_type: BillingPaymentType::from_str(&p.payment_type), + credits_earned: p.credits_earned, + product_name: p.product_name, + transaction_id: p.transaction_id, + payment_id: p.payment_id, + product_id: p.product_id, + created_at: p.created_at, + created_at_utc, + updated_at: p.updated_at, + } + }) + .collect(); + + Ok(BillingPaymentsHistoryPage { + payments, + maybe_next_cursor: json.next_cursor, + total: json.total, + }) +} + +/// Builds the tRPC `input` JSON for the billing.getPayments endpoint. +fn build_input_json(limit: u32, offset: u64) -> String { + format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#) +} + +// --- Raw response types --- + +#[derive(Deserialize, Debug)] +struct BatchResponseItem { + result: RawResult, +} + +#[derive(Deserialize, Debug)] +struct RawResult { + data: RawData, +} + +#[derive(Deserialize, Debug)] +struct RawData { + json: RawJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawJson { + payments: Vec, + #[serde(default)] + next_cursor: Option, + #[serde(deserialize_with = "de_u64_int_or_float")] + total: u64, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawPayment { + id: String, + amount: f64, + payment_status: String, + #[serde(default)] + transaction_id: String, + #[serde(default)] + payment_id: String, + #[serde(default)] + product_id: String, + #[serde(rename = "type")] + payment_type: String, + #[serde(deserialize_with = "de_u64_int_or_float")] + credits_earned: u64, + #[serde(default)] + product_name: String, + created_at: String, + #[serde(default)] + updated_at: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod offline_parsing { + use super::*; + + /// Trimmed verbatim from a captured billing.getPayments response + /// (external/requests/sites/kinovi.ai/2026-07-13-billing/). + const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"payments":[ + {"id":"31722","amount":2159,"paymentStatus":"PAID","transactionId":"","paymentId":"","createdAt":"2026-07-13T20:40:49.405Z","updatedAt":"2026-07-13T20:40:49.405Z","productId":"","type":"ONE_TIME","creditsEarned":525000,"productName":"Credits Package"}, + {"id":"11491","amount":99.99,"paymentStatus":"PAID","transactionId":"202603141400154949180","paymentId":"202603141400154949180","createdAt":"2026-03-14T14:00:15.782Z","updatedAt":"2026-03-14T14:01:02.630Z","productId":"","type":"ONE_TIME","creditsEarned":22000,"productName":"Credits Package"} + ],"nextCursor":"29564","total":997}}}}]"#; + + #[test] + fn parses_captured_page() { + let page = parse_response_body(CAPTURED_PAGE).expect("parse"); + assert_eq!(page.payments.len(), 2); + assert_eq!(page.maybe_next_cursor.as_deref(), Some("29564")); + assert_eq!(page.total, 997); + + let first = &page.payments[0]; + assert_eq!(first.id, "31722"); + assert_eq!(first.amount_usd, 2159.0); + assert_eq!(first.status, BillingPaymentStatus::Paid); + assert_eq!(first.payment_type, BillingPaymentType::OneTime); + assert_eq!(first.credits_earned, 525_000); + assert!(first.created_at_utc.is_some()); + + let second = &page.payments[1]; + assert_eq!(second.amount_usd, 99.99); + assert_eq!(second.transaction_id, "202603141400154949180"); + } + + #[test] + fn last_page_has_no_next_cursor() { + let body = r#"[{"result":{"data":{"json":{"payments":[],"nextCursor":null,"total":997}}}}]"#; + let page = parse_response_body(body).expect("parse"); + assert!(page.payments.is_empty()); + assert_eq!(page.maybe_next_cursor, None); + } + + #[test] + fn unknown_status_and_type_are_preserved() { + let body = r#"[{"result":{"data":{"json":{"payments":[ + {"id":"1","amount":5,"paymentStatus":"REFUNDED","type":"SUBSCRIPTION","creditsEarned":0,"createdAt":"2026-07-13T20:40:49.405Z"} + ],"nextCursor":null,"total":1}}}}]"#; + let page = parse_response_body(body).expect("parse"); + assert_eq!(page.payments[0].status, BillingPaymentStatus::Unknown("REFUNDED".to_string())); + assert_eq!(page.payments[0].payment_type, BillingPaymentType::Unknown("SUBSCRIPTION".to_string())); + } + } + + mod live { + use super::*; + + #[tokio::test] + #[ignore] // manual test — requires real cookies + async fn live_fetch_first_payments_page() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs { + session: &session, + limit: DEFAULT_BILLING_PAGE_LIMIT, + offset: 0, + host_override: None, + }).await?; + + println!("payments: {}, total: {}, next_cursor: {:?}", + page.payments.len(), page.total, page.maybe_next_cursor); + for payment in &page.payments { + println!(" {} | {} | ${:.2} | {} credits | {:?} | {:?}", + payment.id, payment.created_at, payment.amount_usd, + payment.credits_earned, payment.status, payment.payment_type); + } + assert!(!page.payments.is_empty()); + Ok(()) + } + + /// Pages through the ENTIRE payments history with throttling and prints + /// lifetime totals. ~1,000 rows / 50 pages as of 2026-07. + /// cargo test -p kinovi_web_client live_paginate_entire_payments_history -- --ignored --nocapture + #[tokio::test] + #[ignore] // manual test — requires real cookies; fetches every page + async fn live_paginate_entire_payments_history() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + const PAGE_DELAY_MS: u64 = 250; + const MAX_PAGES: usize = 2_000; // runaway guard + + let mut offset: u64 = 0; + let mut pages = 0usize; + let mut total_rows = 0u64; + let mut total_amount_usd = 0f64; + let mut total_credits = 0u64; + let mut last_reported_total = 0u64; + + for _ in 0..MAX_PAGES { + let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs { + session: &session, + limit: DEFAULT_BILLING_PAGE_LIMIT, + offset, + host_override: None, + }).await?; + + pages += 1; + last_reported_total = page.total; + if page.payments.is_empty() { + break; + } + + for payment in &page.payments { + total_rows += 1; + total_amount_usd += payment.amount_usd; + total_credits += payment.credits_earned; + } + + println!("page {} (offset {}): {} payments, running total ${:.2}", + pages, offset, page.payments.len(), total_amount_usd); + + // Advance by what was actually returned so a server-side limit cap + // can never skip rows. + offset += page.payments.len() as u64; + tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await; + } + + println!("── payments history totals ──"); + println!("pages: {}", pages); + println!("payments: {} (server reports total {})", total_rows, last_reported_total); + println!("lifetime spend: ${:.2}", total_amount_usd); + println!("lifetime credits: {}", total_credits); + assert!(total_rows > 0); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/billing/get_credits_history.rs b/crates/api_clients/kinovi_web_client/src/billing/get_credits_history.rs new file mode 100644 index 0000000000..1468df40d3 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/billing/get_credits_history.rs @@ -0,0 +1,417 @@ +use chrono::{DateTime, Utc}; +use log::info; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use crate::utils::number_coercion::{de_i64_int_or_float, de_u64_int_or_float}; + +/// Page size the kinovi.ai billing dashboard uses. +pub const DEFAULT_CREDITS_PAGE_LIMIT: u32 = 20; + +// --- Args & response --- + +pub struct GetCreditsHistoryArgs<'a> { + pub session: &'a KinoviWebSession, + + /// Page size. The web dashboard uses [`DEFAULT_CREDITS_PAGE_LIMIT`]. + pub limit: u32, + + /// Zero-based row offset. Pagination is offset-based: advance by the + /// number of entries actually returned until a page comes back empty. + /// NB: the ledger grows while you page (offsets shift under live traffic), + /// so dedupe by entry `id` when sweeping the full history. + pub offset: u64, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct CreditsHistoryPage { + /// Ledger entries on this page, newest first. + pub entries: Vec, + + /// Total ledger rows on the account, per the server. + pub total: u64, +} + +// --- Public types --- + +/// One row of the Kinovi credits ledger: a signed credit delta attributed to +/// a generation order (consumption) or a grant (purchase, registration bonus). +#[derive(Debug, Clone)] +pub struct CreditHistoryEntry { + pub id: u64, + + pub entry_type: CreditHistoryEntryType, + + /// Signed credit delta: negative for consumption, positive for grants. + pub credit_delta: i64, + + /// The generation order this entry bills, when applicable (`ord_...`). + /// `None` for grants (purchases, registration bonuses). + pub maybe_order_id: Option, + + /// e.g. "Seedance 2.0 Video", "Credit Package - xl (25000 credits)" + pub product_name: String, + + /// ISO 8601 creation timestamp (e.g. `"2026-07-14T00:31:12.820Z"`). + pub created_at: String, + + /// Parsed `created_at`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, +} + +/// Ledger entry type. Observed values are mapped; anything new (refunds, +/// failure credits, expirations, ...) lands in `Unknown` so deserialization +/// never breaks. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CreditHistoryEntryType { + /// Credits consumed by a successful generation (negative delta). + ConsumptionSuccess, + /// Credits granted by a package purchase (positive delta). + PurchasedCredits, + /// Sign-up bonus credits (positive delta). + NewUserRegistration, + Unknown(String), +} + +impl CreditHistoryEntryType { + pub fn as_str(&self) -> &str { + match self { + Self::ConsumptionSuccess => "CONSUMPTION_SUCCESS", + Self::PurchasedCredits => "PURCHASED_CREDITS", + Self::NewUserRegistration => "NEW_USER_REGISTRATION", + Self::Unknown(other) => other.as_str(), + } + } + + fn from_str(s: &str) -> Self { + match s { + "CONSUMPTION_SUCCESS" => Self::ConsumptionSuccess, + "PURCHASED_CREDITS" => Self::PurchasedCredits, + "NEW_USER_REGISTRATION" => Self::NewUserRegistration, + other => Self::Unknown(other.to_string()), + } + } +} + +// --- Implementation --- + +pub async fn get_credits_history( + args: GetCreditsHistoryArgs<'_>, +) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/credits.getCreditHistory", base_url); + + info!("Fetching credits history (limit: {}, offset: {})...", args.limit, args.offset); + + let input_json = build_input_json(args.limit, args.offset); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/dashboard/billing", base_url); + + let request = client.get(&url) + .query(&[("batch", "1"), ("input", input_json.as_str())]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.execute(request) + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Credits history response status: {}", status); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + parse_response_body(&response_body) +} + +fn parse_response_body(response_body: &str) -> Result { + let batch_response: Vec = serde_json::from_str(response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let json = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.to_string(), + })? + .result + .data + .json; + + let entries = json.credits + .into_iter() + .map(|entry| { + let created_at_utc = DateTime::parse_from_rfc3339(&entry.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .ok(); + CreditHistoryEntry { + id: entry.id, + entry_type: CreditHistoryEntryType::from_str(&entry.entry_type), + credit_delta: entry.credit, + maybe_order_id: entry.order_id, + product_name: entry.product_name, + created_at: entry.created_at, + created_at_utc, + } + }) + .collect(); + + Ok(CreditsHistoryPage { + entries, + total: json.total, + }) +} + +/// Builds the tRPC `input` JSON for the credits.getCreditHistory endpoint. +fn build_input_json(limit: u32, offset: u64) -> String { + format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#) +} + +// --- Raw response types --- + +#[derive(Deserialize, Debug)] +struct BatchResponseItem { + result: RawResult, +} + +#[derive(Deserialize, Debug)] +struct RawResult { + data: RawData, +} + +#[derive(Deserialize, Debug)] +struct RawData { + json: RawJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawJson { + credits: Vec, + #[serde(deserialize_with = "de_u64_int_or_float")] + total: u64, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawCreditEntry { + #[serde(deserialize_with = "de_u64_int_or_float")] + id: u64, + #[serde(rename = "type")] + entry_type: String, + #[serde(deserialize_with = "de_i64_int_or_float")] + credit: i64, + #[serde(default)] + order_id: Option, + created_at: String, + #[serde(default)] + product_name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod offline_parsing { + use super::*; + + /// Trimmed verbatim from a captured credits.getCreditHistory response + /// (external/requests/sites/kinovi.ai/2026-07-13-billing/). + const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"credits":[ + {"id":2750659,"type":"CONSUMPTION_SUCCESS","credit":-454,"orderId":"ord_mff2cjk2feczmzqweizcdal2","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"}, + {"id":591799,"type":"PURCHASED_CREDITS","credit":25000,"orderId":null,"createdAt":"2026-02-19T08:16:58.524Z","productName":"Credit Package - xl (25000 credits)"}, + {"id":563856,"type":"NEW_USER_REGISTRATION","credit":200,"orderId":null,"createdAt":"2026-02-18T01:02:52.747Z","productName":"unknown"} + ],"total":304617}}}}]"#; + + #[test] + fn parses_captured_page() { + let page = parse_response_body(CAPTURED_PAGE).expect("parse"); + assert_eq!(page.entries.len(), 3); + assert_eq!(page.total, 304_617); + + let consumption = &page.entries[0]; + assert_eq!(consumption.id, 2_750_659); + assert_eq!(consumption.entry_type, CreditHistoryEntryType::ConsumptionSuccess); + assert_eq!(consumption.credit_delta, -454); + assert_eq!(consumption.maybe_order_id.as_deref(), Some("ord_mff2cjk2feczmzqweizcdal2")); + assert!(consumption.created_at_utc.is_some()); + + let purchase = &page.entries[1]; + assert_eq!(purchase.entry_type, CreditHistoryEntryType::PurchasedCredits); + assert_eq!(purchase.credit_delta, 25_000); + assert_eq!(purchase.maybe_order_id, None); + + let registration = &page.entries[2]; + assert_eq!(registration.entry_type, CreditHistoryEntryType::NewUserRegistration); + assert_eq!(registration.credit_delta, 200); + } + + #[test] + fn unknown_entry_type_is_preserved() { + let body = r#"[{"result":{"data":{"json":{"credits":[ + {"id":1,"type":"CONSUMPTION_REFUND","credit":454,"orderId":"ord_x","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"} + ],"total":1}}}}]"#; + let page = parse_response_body(body).expect("parse"); + assert_eq!( + page.entries[0].entry_type, + CreditHistoryEntryType::Unknown("CONSUMPTION_REFUND".to_string()), + ); + assert_eq!(page.entries[0].credit_delta, 454); + } + + #[test] + fn entry_type_round_trips_as_str() { + for raw in ["CONSUMPTION_SUCCESS", "PURCHASED_CREDITS", "NEW_USER_REGISTRATION", "SOMETHING_NEW"] { + assert_eq!(CreditHistoryEntryType::from_str(raw).as_str(), raw); + } + } + } + + mod live { + use super::*; + use std::collections::HashMap; + use std::collections::HashSet; + + #[tokio::test] + #[ignore] // manual test — requires real cookies + async fn live_fetch_first_credits_page() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + let page = get_credits_history(GetCreditsHistoryArgs { + session: &session, + limit: DEFAULT_CREDITS_PAGE_LIMIT, + offset: 0, + host_override: None, + }).await?; + + println!("entries: {}, total: {}", page.entries.len(), page.total); + for entry in &page.entries { + println!(" {} | {} | {:?} | {} | {:?}", + entry.id, entry.created_at, entry.entry_type, entry.credit_delta, + entry.maybe_order_id); + } + assert!(!page.entries.is_empty()); + Ok(()) + } + + /// Pages through the ENTIRE credits ledger with throttling and prints + /// per-type tallies (this is where any refund-like entry types will + /// surface). ~300k rows as of 2026-07 — expect a long run; the ledger + /// also grows while paging, so entries are deduped by id. + /// cargo test -p kinovi_web_client live_paginate_entire_credits_history -- --ignored --nocapture + #[tokio::test] + #[ignore] // manual test — requires real cookies; fetches every page + async fn live_paginate_entire_credits_history() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + const PAGE_LIMIT: u32 = 100; + const PAGE_DELAY_MS: u64 = 250; + const MAX_PAGES: usize = 50_000; // runaway guard + + let mut offset: u64 = 0; + let mut pages = 0usize; + let mut seen_ids: HashSet = HashSet::new(); + let mut type_counts: HashMap = HashMap::new(); + let mut last_reported_total = 0u64; + + for _ in 0..MAX_PAGES { + let page = get_credits_history(GetCreditsHistoryArgs { + session: &session, + limit: PAGE_LIMIT, + offset, + host_override: None, + }).await?; + + pages += 1; + last_reported_total = page.total; + if page.entries.is_empty() { + break; + } + + for entry in &page.entries { + if !seen_ids.insert(entry.id) { + continue; // offset shifted under live traffic; already counted + } + let tally = type_counts.entry(entry.entry_type.clone()).or_insert((0, 0)); + tally.0 += 1; + tally.1 += entry.credit_delta; + } + + if pages % 25 == 0 { + println!("page {} (offset {}): {} unique entries so far (server total {})", + pages, offset, seen_ids.len(), page.total); + } + + // Advance by what was actually returned so a server-side limit cap + // can never skip rows. + offset += page.entries.len() as u64; + tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await; + } + + println!("── credits ledger totals ──"); + println!("pages: {}", pages); + println!("unique entries: {} (server reports total {})", seen_ids.len(), last_reported_total); + let mut types: Vec<_> = type_counts.iter().collect(); + types.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count)); + for (entry_type, (count, delta_sum)) in types { + println!(" {:<28} n={:<8} credit_sum={}", entry_type.as_str(), count, delta_sum); + } + assert!(!seen_ids.is_empty()); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/billing/mod.rs b/crates/api_clients/kinovi_web_client/src/billing/mod.rs new file mode 100644 index 0000000000..fb75bf5bce --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/billing/mod.rs @@ -0,0 +1,2 @@ +pub mod get_billing_payments_history; +pub mod get_credits_history; diff --git a/crates/api_clients/kinovi_web_client/src/creds/kinovi_web_cookies.rs b/crates/api_clients/kinovi_web_client/src/creds/kinovi_web_cookies.rs new file mode 100644 index 0000000000..bf4120bb6c --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/creds/kinovi_web_cookies.rs @@ -0,0 +1,24 @@ +/// Cookies are the credential required to interact with the Seedance2 Pro API. + +#[derive(Clone)] +pub struct KinoviWebCookies { + cookies: String, +} + +impl KinoviWebCookies { + pub fn new(cookies: String) -> Self { + KinoviWebCookies { cookies } + } + + pub fn as_str(&self) -> &str { + &self.cookies + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookies.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookies.clone() + } +} diff --git a/crates/api_clients/kinovi_web_client/src/creds/kinovi_web_session.rs b/crates/api_clients/kinovi_web_client/src/creds/kinovi_web_session.rs new file mode 100644 index 0000000000..c2cddacb19 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/creds/kinovi_web_session.rs @@ -0,0 +1,19 @@ +use crate::creds::kinovi_web_cookies::KinoviWebCookies; + +/// Holds the full session data needed to make authenticated requests to Seedance2 Pro. +#[derive(Clone)] +pub struct KinoviWebSession { + pub cookies: KinoviWebCookies, +} + +impl KinoviWebSession { + pub fn new(cookies: KinoviWebCookies) -> Self { + KinoviWebSession { cookies } + } + + pub fn from_cookies_string(cookies: String) -> Self { + KinoviWebSession { + cookies: KinoviWebCookies::new(cookies), + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/creds/mod.rs b/crates/api_clients/kinovi_web_client/src/creds/mod.rs new file mode 100644 index 0000000000..86959a77ba --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/creds/mod.rs @@ -0,0 +1,2 @@ +pub mod kinovi_web_cookies; +pub mod kinovi_web_session; diff --git a/crates/api_clients/kinovi_web_client/src/error/kinovi_web_bad_request_api_error.rs b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_bad_request_api_error.rs new file mode 100644 index 0000000000..304b89c816 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_bad_request_api_error.rs @@ -0,0 +1,29 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// These represent known "400s" that are the fault of the client sending a bad request. +#[derive(Debug)] +pub enum KinoviWebBadRequestApiError { + /// The video generation request was flagged as a content violation. + VideoGenerationViolation { raw_body: String }, + + /// The prompt exceeded Kinovi's maximum length (10,000 characters at the + /// time of writing). + PromptIsTooLong { raw_body: String }, + + /// Too many media URLs were attached to the request (`uploadedUrls` is + /// capped at 9 elements at the time of writing). + TooManyUrls { raw_body: String }, +} + +impl Error for KinoviWebBadRequestApiError {} + +impl Display for KinoviWebBadRequestApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::VideoGenerationViolation { raw_body } => write!(f, "Video generation violation: {}", raw_body), + Self::PromptIsTooLong { raw_body } => write!(f, "Prompt is too long: {}", raw_body), + Self::TooManyUrls { raw_body } => write!(f, "Too many urls: {}", raw_body), + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/error/kinovi_web_client_error.rs b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_client_error.rs new file mode 100644 index 0000000000..e0aa9069ae --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_client_error.rs @@ -0,0 +1,33 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum KinoviWebClientError { + /// No cookies are present in the session. + NoCookiesPresent, + + /// Error parsing a URL. + UrlParseError(url::ParseError), + + /// An error was encountered in building the Wreq client. + WreqClientError(wreq::Error), + + /// A request field couldn't be parsed into the value expected on the wire. + /// Carries the field name and the raw input the caller supplied. + InvalidRequestField { field: &'static str, raw_value: String, reason: String }, +} + +impl Error for KinoviWebClientError {} + +impl Display for KinoviWebClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoCookiesPresent => write!(f, "No cookies present in the session."), + Self::UrlParseError(err) => write!(f, "URL parse error: {}", err), + Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), + Self::InvalidRequestField { field, raw_value, reason } => { + write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason) + } + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/error/kinovi_web_error.rs b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_error.rs new file mode 100644 index 0000000000..f171f421b0 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_error.rs @@ -0,0 +1,70 @@ +use crate::error::kinovi_web_bad_request_api_error::KinoviWebBadRequestApiError; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::error::kinovi_web_specific_api_error::KinoviWebSpecificApiError; +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; + +#[derive(Debug)] +pub enum KinoviWebError { + Client(KinoviWebClientError), + ApiSpecific(KinoviWebSpecificApiError), + ApiGeneric(KinoviWebGenericApiError), + ApiBadRequest(KinoviWebBadRequestApiError), +} + +impl KinoviWebError { + pub fn is_having_downtime_issues(&self) -> bool { + match self { + Self::ApiGeneric(KinoviWebGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true, + Self::ApiGeneric(KinoviWebGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true, + Self::ApiGeneric(KinoviWebGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true, + Self::ApiGeneric(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => { + match status_code.as_u16() { + 502 => true, + 504 => true, + 524 => true, + _ => false, + } + }, + _ => false, + } + } +} + +impl Error for KinoviWebError {} + +impl std::fmt::Display for KinoviWebError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "KinoviWebClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "KinoviWebSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "KinoviWebGenericApiError: {:?}", e), + Self::ApiBadRequest(e) => write!(f, "KinoviWebBadRequestApiError: {:?}", e), + } + } +} + +impl From for KinoviWebError { + fn from(error: KinoviWebClientError) -> Self { + Self::Client(error) + } +} + +impl From for KinoviWebError { + fn from(error: KinoviWebSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for KinoviWebError { + fn from(error: KinoviWebGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} + +impl From for KinoviWebError { + fn from(error: KinoviWebBadRequestApiError) -> Self { + Self::ApiBadRequest(error) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/error/kinovi_web_generic_api_error.rs b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_generic_api_error.rs new file mode 100644 index 0000000000..5304f519fa --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_generic_api_error.rs @@ -0,0 +1,49 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use wreq::StatusCode; + +#[derive(Debug)] +pub enum KinoviWebGenericApiError { + /// Specific Cloudflare errors. + CloudflareError(CloudflareError), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// serde_json::Error on a non-200 (error) response. + /// Includes the original body. + SerdeParseErrorWithBodyOnNon200(serde_json::Error, String), + + /// A response that parsed, but didn't have the shape we expected + /// (eg. an empty batch response array). + UnexpectedResponseShape { + explanation: String, + raw_body: String, + }, + + /// An uncategorized bad HTTP response with status code and body. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// An uncaught error from the HTTP client. + WreqError(wreq::Error), +} + +impl Error for KinoviWebGenericApiError {} + +impl Display for KinoviWebGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), + Self::UnexpectedResponseShape { explanation, raw_body } => write!(f, "Unexpected response shape: {}. Body: {}", explanation, raw_body), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/error/kinovi_web_specific_api_error.rs b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_specific_api_error.rs new file mode 100644 index 0000000000..d91f100a39 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/error/kinovi_web_specific_api_error.rs @@ -0,0 +1,27 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use wreq::StatusCode; + +#[derive(Debug)] +pub enum KinoviWebSpecificApiError { + /// The session cookies are expired or invalid. + UnauthorizedSessionExpired, + + /// The account does not have enough credits to perform the operation. + BillingError { + status_code: StatusCode, + body: String, + }, +} + +impl Error for KinoviWebSpecificApiError {} + +impl Display for KinoviWebSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."), + Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body), + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/error/mod.rs b/crates/api_clients/kinovi_web_client/src/error/mod.rs new file mode 100644 index 0000000000..f264a6a100 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod kinovi_web_bad_request_api_error; +pub mod kinovi_web_client_error; +pub mod kinovi_web_error; +pub mod kinovi_web_generic_api_error; +pub mod kinovi_web_specific_api_error; diff --git a/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_music.rs b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_music.rs new file mode 100644 index 0000000000..5872bf485f --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_music.rs @@ -0,0 +1,263 @@ +use serde_derive::Serialize; + +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse, +}; + +const BUSINESS_TYPE: &str = "suno-music-generation"; +const MODEL: &str = "suno-music"; +const MODEL_VERSION: &str = "chirp-v5-5"; + +/// Flat credit price per generation. +const CREDITS_PER_GENERATION: u64 = 16; + +// ── Args ── + +pub struct GenerateSunoMusicArgs<'a> { + pub request: GenerateSunoMusicRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Suno Music: text-to-music generation. +/// +/// NB: the Kinovi UI offers a voice choice (any / male / female), but it is +/// never transmitted on the wire — steer the vocalist through the prompt or +/// style tags instead. +#[derive(Clone, Debug)] +pub struct GenerateSunoMusicRequest { + /// What the song should be about. + pub prompt: String, + + /// Optional style/genre direction (the UI's "style prompt"), e.g. + /// "EDM style meets dance". + pub style_tags: Option, + + /// Generate an instrumental-only track (no vocals). + pub instrumental: bool, +} + +// ── Pricing ── +// +// Suno Music: flat 16 Kinovi credits per generation, regardless of options. + +impl GenerateSunoMusicRequest { + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents. + pub fn calculate_costs(&self) -> KinoviGenerationCost { + KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION) + } +} + +// ── Response ── + +pub struct GenerateSunoMusicResponse { + pub task_id: String, + pub order_id: String, +} + +// ── Entry point ── + +pub async fn generate_suno_music( + args: GenerateSunoMusicArgs<'_>, +) -> Result { + let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs { + business_type: BUSINESS_TYPE, + api_params: build_api_params(&args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSunoMusicResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + }) +} + +// ── Wire payload ── + +/// The `apiParams` shape for `suno-music-generation`. Field order matches the +/// captured browser traffic. +#[derive(Serialize, Debug)] +struct SunoMusicApiParams { + model: &'static str, + prompt: String, + custom: bool, + instrumental: bool, + mv: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + tags: Option, + #[serde(rename = "generateVideo")] + generate_video: bool, + #[serde(rename = "isStorage")] + is_storage: bool, +} + +fn build_api_params(request: &GenerateSunoMusicRequest) -> SunoMusicApiParams { + SunoMusicApiParams { + model: MODEL, + prompt: request.prompt.clone(), + custom: false, + instrumental: request.instrumental, + mv: MODEL_VERSION, + tags: request.style_tags.clone(), + generate_video: false, + is_storage: true, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + + mod pricing_tests { + use super::*; + + fn base_request() -> GenerateSunoMusicRequest { + GenerateSunoMusicRequest { + prompt: "A song".to_string(), + style_tags: None, + instrumental: false, + } + } + + #[test] + fn flat_sixteen_credits() { + let costs = base_request().calculate_costs(); + assert_eq!(costs.kinovi_credits, 16); + } + + #[test] + fn usd_cents() { + // 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢. + let costs = base_request().calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 7); + assert_eq!(costs.usd_cents_rounded_down, 6); + assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn options_do_not_affect_cost() { + let base = base_request().calculate_costs(); + let styled = GenerateSunoMusicRequest { + prompt: "Another song".to_string(), + style_tags: Some("EDM".to_string()), + instrumental: true, + }.calculate_costs(); + assert_eq!(base, styled); + } + } + + mod request_shape_tests { + use super::*; + + /// Mirrors capture 1_suno_music.txt (no style tags, with vocals). + #[test] + fn basic_prompt() { + let params = build_api_params(&GenerateSunoMusicRequest { + prompt: "A song about corgi super heroes fighting shiba super villains.".to_string(), + style_tags: None, + instrumental: false, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-music","prompt":"A song about corgi super heroes fighting shiba super villains.","custom":false,"instrumental":false,"mv":"chirp-v5-5","generateVideo":false,"isStorage":true}"#, + ); + } + + /// Mirrors capture 2_suno_music_style_female.txt (style tags present). + #[test] + fn with_style_tags() { + let params = build_api_params(&GenerateSunoMusicRequest { + prompt: "A song about Mario and Luigi fighting Bowser".to_string(), + style_tags: Some("EDM style meets dance".to_string()), + instrumental: false, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-music","prompt":"A song about Mario and Luigi fighting Bowser","custom":false,"instrumental":false,"mv":"chirp-v5-5","tags":"EDM style meets dance","generateVideo":false,"isStorage":true}"#, + ); + } + + /// Mirrors capture 3_suno_music_instrumental.txt. + #[test] + fn instrumental() { + let params = build_api_params(&GenerateSunoMusicRequest { + prompt: "An epic journey for a JRPG".to_string(), + style_tags: Some("Japanese JRPG adventure music".to_string()), + instrumental: true, + }); + let json = serde_json::to_string(¶ms).unwrap(); + assert!(json.contains(r#""instrumental":true"#), "{json}"); + assert!(json.contains(r#""custom":false"#), "{json}"); + assert!(json.contains(r#""mv":"chirp-v5-5""#), "{json}"); + } + + #[test] + fn business_type() { + assert_eq!(BUSINESS_TYPE, "suno-music-generation"); + } + } + + mod live_tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn test_music_with_vocals() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_suno_music(GenerateSunoMusicArgs { + session: &session, + host_override: None, + request: GenerateSunoMusicRequest { + prompt: "A song about a corgi who learns to sail the open sea".to_string(), + style_tags: Some("Sea shanty, folk".to_string()), + instrumental: false, + }, + }).await?; + println!("suno music — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn test_music_instrumental() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_suno_music(GenerateSunoMusicArgs { + session: &session, + host_override: None, + request: GenerateSunoMusicRequest { + prompt: "An epic journey across a frozen mountain range".to_string(), + style_tags: Some("Cinematic orchestral score".to_string()), + instrumental: true, + }, + }).await?; + println!("suno music instrumental — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_remix.rs b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_remix.rs new file mode 100644 index 0000000000..a03cb412f7 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_remix.rs @@ -0,0 +1,283 @@ +use serde_derive::Serialize; + +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse, +}; + +const BUSINESS_TYPE: &str = "suno-remix-generation"; +const MODEL: &str = "suno-remix"; +const MODEL_VERSION: &str = "chirp-v5-5"; + +/// Flat credit price per generation. +const CREDITS_PER_GENERATION: u64 = 16; + +// ── Args ── + +pub struct GenerateSunoRemixArgs<'a> { + pub request: GenerateSunoRemixRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Suno Remix: transform an existing piece of audio. +#[derive(Clone, Debug)] +pub struct GenerateSunoRemixRequest { + /// How to transform the audio, e.g. "Make this electronic". + pub prompt: String, + + /// The audio to remix. + pub source: KinoviSunoRemixSource, + + /// Optional style/genre direction (the UI's "style prompt"). + pub style_tags: Option, + + /// Keep the original lyrics in the remix. + pub keep_lyrics: bool, +} + +// ── Enums ── + +/// The audio input for a remix: either an uploaded file (by CDN URL) or a +/// previously generated Suno track (by its music id). +#[derive(Clone, Debug)] +pub enum KinoviSunoRemixSource { + /// A Kinovi CDN URL to an uploaded audio file. + AudioUrl(String), + /// The id of a previously generated Suno track + /// (e.g. "fea7e70c-5373-452d-9c5e-f079ff837fcd"). + MusicId(String), +} + +// ── Pricing ── +// +// Suno Remix: flat 16 Kinovi credits per generation, regardless of options. + +impl GenerateSunoRemixRequest { + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents. + pub fn calculate_costs(&self) -> KinoviGenerationCost { + KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION) + } +} + +// ── Response ── + +pub struct GenerateSunoRemixResponse { + pub task_id: String, + pub order_id: String, +} + +// ── Entry point ── + +pub async fn generate_suno_remix( + args: GenerateSunoRemixArgs<'_>, +) -> Result { + let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs { + business_type: BUSINESS_TYPE, + api_params: build_api_params(&args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSunoRemixResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + }) +} + +// ── Wire payload ── + +/// The `apiParams` shape for `suno-remix-generation`. Field order matches the +/// captured browser traffic. Exactly one of `audio_url` / `music_id` is set. +#[derive(Serialize, Debug)] +struct SunoRemixApiParams { + model: &'static str, + prompt: String, + #[serde(rename = "audioUrl", skip_serializing_if = "Option::is_none")] + audio_url: Option, + #[serde(rename = "musicId", skip_serializing_if = "Option::is_none")] + music_id: Option, + custom: bool, + #[serde(rename = "keepLyrics")] + keep_lyrics: bool, + mv: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + tags: Option, + #[serde(rename = "isStorage")] + is_storage: bool, +} + +fn build_api_params(request: &GenerateSunoRemixRequest) -> SunoRemixApiParams { + let (audio_url, music_id) = match &request.source { + KinoviSunoRemixSource::AudioUrl(url) => (Some(url.clone()), None), + KinoviSunoRemixSource::MusicId(id) => (None, Some(id.clone())), + }; + + SunoRemixApiParams { + model: MODEL, + prompt: request.prompt.clone(), + audio_url, + music_id, + custom: false, + keep_lyrics: request.keep_lyrics, + mv: MODEL_VERSION, + tags: request.style_tags.clone(), + is_storage: true, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + + mod pricing_tests { + use super::*; + + fn base_request() -> GenerateSunoRemixRequest { + GenerateSunoRemixRequest { + prompt: "Make this electronic".to_string(), + source: KinoviSunoRemixSource::AudioUrl("https://example.com/a.mp3".to_string()), + style_tags: None, + keep_lyrics: false, + } + } + + #[test] + fn flat_sixteen_credits() { + let costs = base_request().calculate_costs(); + assert_eq!(costs.kinovi_credits, 16); + } + + #[test] + fn usd_cents() { + // 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢. + let costs = base_request().calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 7); + assert_eq!(costs.usd_cents_rounded_down, 6); + assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn options_do_not_affect_cost() { + let base = base_request().calculate_costs(); + let other = GenerateSunoRemixRequest { + prompt: "Make this a symphony".to_string(), + source: KinoviSunoRemixSource::MusicId("abc".to_string()), + style_tags: Some("Classical music".to_string()), + keep_lyrics: true, + }.calculate_costs(); + assert_eq!(base, other); + } + } + + mod request_shape_tests { + use super::*; + + /// Mirrors capture 5_suno_remix.txt (uploaded audio URL, no lyric keep). + #[test] + fn audio_url_source() { + let params = build_api_params(&GenerateSunoRemixRequest { + prompt: "Make this electronic".to_string(), + source: KinoviSunoRemixSource::AudioUrl( + "https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3".to_string(), + ), + style_tags: Some("EDM style".to_string()), + keep_lyrics: false, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-remix","prompt":"Make this electronic","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3","custom":false,"keepLyrics":false,"mv":"chirp-v5-5","tags":"EDM style","isStorage":true}"#, + ); + } + + /// Mirrors capture 6_suno_remix_lyrics.txt (existing track by music id, + /// keeping the lyrics). + #[test] + fn music_id_source_with_kept_lyrics() { + let params = build_api_params(&GenerateSunoRemixRequest { + prompt: "Make this a symphony".to_string(), + source: KinoviSunoRemixSource::MusicId("fea7e70c-5373-452d-9c5e-f079ff837fcd".to_string()), + style_tags: Some("Classical music".to_string()), + keep_lyrics: true, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-remix","prompt":"Make this a symphony","musicId":"fea7e70c-5373-452d-9c5e-f079ff837fcd","custom":false,"keepLyrics":true,"mv":"chirp-v5-5","tags":"Classical music","isStorage":true}"#, + ); + } + + #[test] + fn business_type() { + assert_eq!(BUSINESS_TYPE, "suno-remix-generation"); + } + } + + mod live_tests { + use super::*; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file. + async fn test_remix_uploaded_audio() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let audio_url = upload_test_audio(&session).await?; + println!("Uploaded audio: {}", audio_url); + + let result = generate_suno_remix(GenerateSunoRemixArgs { + session: &session, + host_override: None, + request: GenerateSunoRemixRequest { + prompt: "Make this electronic".to_string(), + source: KinoviSunoRemixSource::AudioUrl(audio_url), + style_tags: Some("EDM style".to_string()), + keep_lyrics: false, + }, + }).await?; + println!("suno remix — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + async fn upload_test_audio(session: &KinoviWebSession) -> AnyhowResult { + let audio_path = test_utils::test_file_path::test_file_path( + "test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3", + )?; + let audio_bytes = std::fs::read(&audio_path)?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "mp3".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: audio_bytes, + host_override: None, + }).await?; + + Ok(upload_result.public_url) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_sample.rs b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_sample.rs new file mode 100644 index 0000000000..2fbf0c8cd5 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_sample.rs @@ -0,0 +1,289 @@ +use serde_derive::Serialize; + +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse, +}; + +const BUSINESS_TYPE: &str = "suno-sample-generation"; +const MODEL: &str = "suno-sample"; +const MODEL_VERSION: &str = "chirp-v5-5"; + +/// Flat credit price per generation. +const CREDITS_PER_GENERATION: u64 = 16; + +// ── Args ── + +pub struct GenerateSunoSampleArgs<'a> { + pub request: GenerateSunoSampleRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Suno Sample: build a track from a chopped sample of an uploaded audio file. +/// +/// NB: the Kinovi UI offers a voice choice (male / female / no voice), but it +/// is never transmitted on the wire — `instrumental` controls whether vocals +/// are added at all; steer the vocalist through the prompt or style tags. +#[derive(Clone, Debug)] +pub struct GenerateSunoSampleRequest { + /// What to build from the sample, e.g. "Mystical RPG adventure, make it + /// have a grand climax". + pub prompt: String, + + /// A Kinovi CDN URL to the uploaded audio file to sample. + pub audio_url: String, + + /// Start of the sample window within the audio, in seconds. + pub chop_sample_start_seconds: u32, + + /// End of the sample window within the audio, in seconds. + pub chop_sample_end_seconds: u32, + + /// Optional style/genre direction (the UI's "style prompt"). + pub style_tags: Option, + + /// Generate an instrumental-only track (no vocals). + pub instrumental: bool, +} + +// ── Pricing ── +// +// Suno Sample: flat 16 Kinovi credits per generation, regardless of options. + +impl GenerateSunoSampleRequest { + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents. + pub fn calculate_costs(&self) -> KinoviGenerationCost { + KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION) + } +} + +// ── Response ── + +pub struct GenerateSunoSampleResponse { + pub task_id: String, + pub order_id: String, +} + +// ── Entry point ── + +pub async fn generate_suno_sample( + args: GenerateSunoSampleArgs<'_>, +) -> Result { + let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs { + business_type: BUSINESS_TYPE, + api_params: build_api_params(&args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSunoSampleResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + }) +} + +// ── Wire payload ── + +/// The `apiParams` shape for `suno-sample-generation`. Field order matches the +/// captured browser traffic. +#[derive(Serialize, Debug)] +struct SunoSampleApiParams { + model: &'static str, + prompt: String, + #[serde(rename = "audioUrl")] + audio_url: String, + #[serde(rename = "chopSampleStartS")] + chop_sample_start_s: u32, + #[serde(rename = "chopSampleEndS")] + chop_sample_end_s: u32, + custom: bool, + instrumental: bool, + #[serde(rename = "autoLyrics")] + auto_lyrics: bool, + mv: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + tags: Option, + #[serde(rename = "isStorage")] + is_storage: bool, +} + +fn build_api_params(request: &GenerateSunoSampleRequest) -> SunoSampleApiParams { + SunoSampleApiParams { + model: MODEL, + prompt: request.prompt.clone(), + audio_url: request.audio_url.clone(), + chop_sample_start_s: request.chop_sample_start_seconds, + chop_sample_end_s: request.chop_sample_end_seconds, + custom: false, + instrumental: request.instrumental, + auto_lyrics: true, + mv: MODEL_VERSION, + tags: request.style_tags.clone(), + is_storage: true, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + + mod pricing_tests { + use super::*; + + fn base_request() -> GenerateSunoSampleRequest { + GenerateSunoSampleRequest { + prompt: "Mystical RPG adventure".to_string(), + audio_url: "https://example.com/a.aac".to_string(), + chop_sample_start_seconds: 0, + chop_sample_end_seconds: 41, + style_tags: None, + instrumental: true, + } + } + + #[test] + fn flat_sixteen_credits() { + let costs = base_request().calculate_costs(); + assert_eq!(costs.kinovi_credits, 16); + } + + #[test] + fn usd_cents() { + // 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢. + let costs = base_request().calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 7); + assert_eq!(costs.usd_cents_rounded_down, 6); + assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn options_do_not_affect_cost() { + let base = base_request().calculate_costs(); + let other = GenerateSunoSampleRequest { + prompt: "A vocal fantasy track".to_string(), + audio_url: "https://example.com/b.aac".to_string(), + chop_sample_start_seconds: 10, + chop_sample_end_seconds: 30, + style_tags: Some("Fantasy video game".to_string()), + instrumental: false, + }.calculate_costs(); + assert_eq!(base, other); + } + } + + mod request_shape_tests { + use super::*; + + /// Mirrors capture 11_suno_sample_1.txt (instrumental). + #[test] + fn instrumental_sample() { + let params = build_api_params(&GenerateSunoSampleRequest { + prompt: "Mystical RPG adventure, make it have a grand climax".to_string(), + audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(), + chop_sample_start_seconds: 0, + chop_sample_end_seconds: 41, + style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()), + instrumental: true, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-sample","prompt":"Mystical RPG adventure, make it have a grand climax","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac","chopSampleStartS":0,"chopSampleEndS":41,"custom":false,"instrumental":true,"autoLyrics":true,"mv":"chirp-v5-5","tags":"Fantasy video game, Motoi Sakuraba, Golden Sun","isStorage":true}"#, + ); + } + + /// Mirrors capture 12_suno_sample_2.txt (with vocals). + #[test] + fn vocal_sample() { + let params = build_api_params(&GenerateSunoSampleRequest { + prompt: "Give this a lyrics track, a woman singing in a fantasy language".to_string(), + audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(), + chop_sample_start_seconds: 0, + chop_sample_end_seconds: 41, + style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()), + instrumental: false, + }); + let json = serde_json::to_string(¶ms).unwrap(); + assert!(json.contains(r#""instrumental":false"#), "{json}"); + assert!(json.contains(r#""autoLyrics":true"#), "{json}"); + assert!(json.contains(r#""chopSampleStartS":0"#), "{json}"); + assert!(json.contains(r#""chopSampleEndS":41"#), "{json}"); + } + + #[test] + fn business_type() { + assert_eq!(BUSINESS_TYPE, "suno-sample-generation"); + } + } + + mod live_tests { + use super::*; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file. + async fn test_sample_instrumental() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let audio_url = upload_test_audio(&session).await?; + println!("Uploaded audio: {}", audio_url); + + let result = generate_suno_sample(GenerateSunoSampleArgs { + session: &session, + host_override: None, + request: GenerateSunoSampleRequest { + prompt: "Mystical RPG adventure, make it have a grand climax".to_string(), + audio_url, + chop_sample_start_seconds: 0, + chop_sample_end_seconds: 30, + style_tags: Some("Fantasy video game score".to_string()), + instrumental: true, + }, + }).await?; + println!("suno sample — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + async fn upload_test_audio(session: &KinoviWebSession) -> AnyhowResult { + let audio_path = test_utils::test_file_path::test_file_path( + "test_data/audio/aac/golden_sun_elemental_stars_cyanne.aac", + )?; + let audio_bytes = std::fs::read(&audio_path)?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "aac".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: audio_bytes, + host_override: None, + }).await?; + + Ok(upload_result.public_url) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_sound.rs b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_sound.rs new file mode 100644 index 0000000000..0f7d757ec6 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/audio/generate_suno_sound.rs @@ -0,0 +1,360 @@ +use serde_derive::Serialize; + +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse, +}; + +const BUSINESS_TYPE: &str = "suno-sounds-generation"; +const MODEL: &str = "suno-sounds"; +const MODEL_VERSION: &str = "chirp-v5-5"; + +/// Flat credit price per generation. +const CREDITS_PER_GENERATION: u64 = 4; + +// ── Args ── + +pub struct GenerateSunoSoundArgs<'a> { + pub request: GenerateSunoSoundRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Suno Sounds: short sound-effect / sample generation. +#[derive(Clone, Debug)] +pub struct GenerateSunoSoundRequest { + /// What the sound should be, e.g. "Rain sound effects". + pub prompt: String, + + /// Single hit vs seamless loop. + pub sound_type: KinoviSunoSoundType, + + /// Optional tempo in BPM. + pub bpm: Option, + + /// Musical key. `Auto` lets Kinovi pick. + pub key: KinoviSunoSoundKey, +} + +// ── Enums ── + +/// Single hit ("one-shot") vs seamless loop ("loop"). +#[derive(Debug, Clone, Copy)] +pub enum KinoviSunoSoundType { + SingleHit, + Loopable, +} + +/// Musical key for the generated sound. +/// +/// NB: the Kinovi UI's "Auto" selection still transmits "C" on the wire +/// (observed in captured traffic), so `Auto` and `CMajor` are wire-identical. +#[derive(Debug, Clone, Copy)] +pub enum KinoviSunoSoundKey { + Auto, + CMajor, + CMinor, + DMajor, + DMinor, + FMajor, + FMinor, + GMajor, + GMinor, + AMajor, + AMinor, + BMajor, + BMinor, +} + +// ── Pricing ── +// +// Suno Sounds: flat 4 Kinovi credits per generation, regardless of options. + +impl GenerateSunoSoundRequest { + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents. + pub fn calculate_costs(&self) -> KinoviGenerationCost { + KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION) + } +} + +// ── Response ── + +pub struct GenerateSunoSoundResponse { + pub task_id: String, + pub order_id: String, +} + +// ── Entry point ── + +pub async fn generate_suno_sound( + args: GenerateSunoSoundArgs<'_>, +) -> Result { + let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs { + business_type: BUSINESS_TYPE, + api_params: build_api_params(&args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSunoSoundResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + }) +} + +// ── Wire payload ── + +/// The `apiParams` shape for `suno-sounds-generation`. Field order matches the +/// captured browser traffic. +#[derive(Serialize, Debug)] +struct SunoSoundApiParams { + model: &'static str, + prompt: String, + mv: &'static str, + #[serde(rename = "type")] + sound_type: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + bpm: Option, + key: &'static str, + #[serde(rename = "isStorage")] + is_storage: bool, +} + +fn build_api_params(request: &GenerateSunoSoundRequest) -> SunoSoundApiParams { + SunoSoundApiParams { + model: MODEL, + prompt: request.prompt.clone(), + mv: MODEL_VERSION, + sound_type: sound_type_as_api_str(request.sound_type), + bpm: request.bpm, + key: key_as_api_str(request.key), + is_storage: true, + } +} + +fn sound_type_as_api_str(sound_type: KinoviSunoSoundType) -> &'static str { + match sound_type { + KinoviSunoSoundType::SingleHit => "one-shot", + KinoviSunoSoundType::Loopable => "loop", + } +} + +fn key_as_api_str(key: KinoviSunoSoundKey) -> &'static str { + match key { + // The UI's Auto choice sends "C" on the wire. + KinoviSunoSoundKey::Auto => "C", + KinoviSunoSoundKey::CMajor => "C", + KinoviSunoSoundKey::CMinor => "Cm", + KinoviSunoSoundKey::DMajor => "D", + KinoviSunoSoundKey::DMinor => "Dm", + KinoviSunoSoundKey::FMajor => "F", + KinoviSunoSoundKey::FMinor => "Fm", + KinoviSunoSoundKey::GMajor => "G", + KinoviSunoSoundKey::GMinor => "Gm", + KinoviSunoSoundKey::AMajor => "A", + KinoviSunoSoundKey::AMinor => "Am", + KinoviSunoSoundKey::BMajor => "B", + KinoviSunoSoundKey::BMinor => "Bm", + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + + mod pricing_tests { + use super::*; + + fn base_request() -> GenerateSunoSoundRequest { + GenerateSunoSoundRequest { + prompt: "Rain sound effects".to_string(), + sound_type: KinoviSunoSoundType::SingleHit, + bpm: None, + key: KinoviSunoSoundKey::Auto, + } + } + + #[test] + fn flat_four_credits() { + let costs = base_request().calculate_costs(); + assert_eq!(costs.kinovi_credits, 4); + } + + #[test] + fn usd_cents() { + // 4 credits; 400/243 = 1.6461… → up 2¢, down 1¢. + let costs = base_request().calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 2); + assert_eq!(costs.usd_cents_rounded_down, 1); + assert!((costs.usd_cents_fractional - (400.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn options_do_not_affect_cost() { + let base = base_request().calculate_costs(); + let other = GenerateSunoSoundRequest { + prompt: "Thunder".to_string(), + sound_type: KinoviSunoSoundType::Loopable, + bpm: Some(180), + key: KinoviSunoSoundKey::DMinor, + }.calculate_costs(); + assert_eq!(base, other); + } + } + + mod request_shape_tests { + use super::*; + + /// Mirrors capture 7_suno_sounds_1.txt (single hit, no BPM, auto key — + /// which transmits "C"). + #[test] + fn single_hit_no_bpm_auto_key() { + let params = build_api_params(&GenerateSunoSoundRequest { + prompt: "Rain sound effects".to_string(), + sound_type: KinoviSunoSoundType::SingleHit, + bpm: None, + key: KinoviSunoSoundKey::Auto, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-sounds","prompt":"Rain sound effects","mv":"chirp-v5-5","type":"one-shot","key":"C","isStorage":true}"#, + ); + } + + /// Mirrors capture 8_suno_sounds_2.txt (loop, 60 BPM, D minor). + #[test] + fn loop_with_bpm_and_minor_key() { + let params = build_api_params(&GenerateSunoSoundRequest { + prompt: "Thunder".to_string(), + sound_type: KinoviSunoSoundType::Loopable, + bpm: Some(60), + key: KinoviSunoSoundKey::DMinor, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-sounds","prompt":"Thunder","mv":"chirp-v5-5","type":"loop","bpm":60,"key":"Dm","isStorage":true}"#, + ); + } + + /// Mirrors capture 9_suno_sounds_3.txt (loop, 180 BPM, F major). + #[test] + fn loop_with_major_key() { + let params = build_api_params(&GenerateSunoSoundRequest { + prompt: "Plates crashing in the kitchen".to_string(), + sound_type: KinoviSunoSoundType::Loopable, + bpm: Some(180), + key: KinoviSunoSoundKey::FMajor, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-sounds","prompt":"Plates crashing in the kitchen","mv":"chirp-v5-5","type":"loop","bpm":180,"key":"F","isStorage":true}"#, + ); + } + + /// Mirrors capture 10_suno_sounds_4.txt (single hit, 100 BPM, C minor). + #[test] + fn single_hit_with_bpm_and_minor_key() { + let params = build_api_params(&GenerateSunoSoundRequest { + prompt: "Car crash".to_string(), + sound_type: KinoviSunoSoundType::SingleHit, + bpm: Some(100), + key: KinoviSunoSoundKey::CMinor, + }); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"model":"suno-sounds","prompt":"Car crash","mv":"chirp-v5-5","type":"one-shot","bpm":100,"key":"Cm","isStorage":true}"#, + ); + } + + #[test] + fn every_key_wire_value() { + let cases = [ + (KinoviSunoSoundKey::Auto, "C"), + (KinoviSunoSoundKey::CMajor, "C"), + (KinoviSunoSoundKey::CMinor, "Cm"), + (KinoviSunoSoundKey::DMajor, "D"), + (KinoviSunoSoundKey::DMinor, "Dm"), + (KinoviSunoSoundKey::FMajor, "F"), + (KinoviSunoSoundKey::FMinor, "Fm"), + (KinoviSunoSoundKey::GMajor, "G"), + (KinoviSunoSoundKey::GMinor, "Gm"), + (KinoviSunoSoundKey::AMajor, "A"), + (KinoviSunoSoundKey::AMinor, "Am"), + (KinoviSunoSoundKey::BMajor, "B"), + (KinoviSunoSoundKey::BMinor, "Bm"), + ]; + for (key, expected) in cases { + assert_eq!(key_as_api_str(key), expected, "wire value for {key:?}"); + } + } + + #[test] + fn business_type() { + assert_eq!(BUSINESS_TYPE, "suno-sounds-generation"); + } + } + + mod live_tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn test_single_hit() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_suno_sound(GenerateSunoSoundArgs { + session: &session, + host_override: None, + request: GenerateSunoSoundRequest { + prompt: "A heavy castle door creaking open".to_string(), + sound_type: KinoviSunoSoundType::SingleHit, + bpm: None, + key: KinoviSunoSoundKey::Auto, + }, + }).await?; + println!("suno sound single hit — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs credits. + async fn test_loop_with_bpm_and_key() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_suno_sound(GenerateSunoSoundArgs { + session: &session, + host_override: None, + request: GenerateSunoSoundRequest { + prompt: "Lo-fi vinyl crackle groove".to_string(), + sound_type: KinoviSunoSoundType::Loopable, + bpm: Some(85), + key: KinoviSunoSoundKey::AMinor, + }, + }).await?; + println!("suno sound loop — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/audio/mod.rs b/crates/api_clients/kinovi_web_client/src/generate/audio/mod.rs new file mode 100644 index 0000000000..afba02de5a --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/audio/mod.rs @@ -0,0 +1,4 @@ +pub mod generate_suno_music; +pub mod generate_suno_remix; +pub mod generate_suno_sample; +pub mod generate_suno_sound; diff --git a/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v7.rs b/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v7.rs new file mode 100644 index 0000000000..0c656a4fa2 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v7.rs @@ -0,0 +1,385 @@ +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::generate_image::generate_image::{ + generate_image, GenerateImageArgs, KinoviGenerateImageRequest, KinoviMidjourneyModel, +}; +use crate::requests::kinovi_host::KinoviHost; + +// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we +// re-export it from the base. Aspect ratio and quality, however, are pinned +// to model-specific enums on this wrapper to keep their valid values typed +// at the call site. +pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount; + +// ── Args ── + +pub struct GenerateMidjourneyV7Args<'a> { + pub request: GenerateMidjourneyV7Request, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateMidjourneyV7Request { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV7AspectRatio, + pub negative_prompt: Option, + pub stylize: Option, + pub weird: Option, + pub chaos: Option, + pub quality: Option, + pub raw_mode: bool, + pub batch_count: KinoviMidjourneyBatchCount, + pub reference_image_urls: Option>, +} + +impl GenerateMidjourneyV7Request { + /// Calculate the cost of this request, in Kinovi credits and USD cents + /// (rounded up). + pub fn calculate_costs(&self) -> KinoviGenerationCost { + self.to_inner_request().calculate_costs() + } + + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_credits(&self) -> u32 { + self.calculate_costs().kinovi_credits as u32 + } + + /// NB: Rounds UP fractional cents (the historical behavior rounded to nearest). + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_cost_in_usd_cents(&self) -> u64 { + self.calculate_costs().usd_cents_rounded_up + } + + pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest { + KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7, + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.as_api_str().to_string(), + negative_prompt: self.negative_prompt.clone(), + stylize: self.stylize, + weird: self.weird, + chaos: self.chaos, + quality: self.quality.map(|q| q.as_api_str().to_string()), + raw_mode: self.raw_mode, + batch_count: self.batch_count, + reference_image_urls: self.reference_image_urls.clone(), + } + } +} + +// ── Model-specific enums ── + +/// Aspect ratios supported by Midjourney v7. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GenerateMidjourneyV7AspectRatio { + Square1x1, + Landscape16x9, + Portrait9x16, + UltraWide21x9, + UltraTall9x21, + Standard4x3, + Portrait3x4, + Wide5x4, + Tall4x5, + Wide3x2, + Tall2x3, +} + +impl GenerateMidjourneyV7AspectRatio { + fn as_api_str(&self) -> &'static str { + match self { + Self::Square1x1 => "1:1", + Self::Landscape16x9 => "16:9", + Self::Portrait9x16 => "9:16", + Self::UltraWide21x9 => "21:9", + Self::UltraTall9x21 => "9:21", + Self::Standard4x3 => "4:3", + Self::Portrait3x4 => "3:4", + Self::Wide5x4 => "5:4", + Self::Tall4x5 => "4:5", + Self::Wide3x2 => "3:2", + Self::Tall2x3 => "2:3", + } + } +} + +/// Quality presets supported by Midjourney v7. Higher = more compute and +/// slower; Kinovi credit pricing is flat regardless. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GenerateMidjourneyV7Quality { + /// 0.25 + Quarter, + /// 0.5 + Half, + /// 1 (default) + Full, +} + +impl GenerateMidjourneyV7Quality { + fn as_api_str(&self) -> &'static str { + match self { + Self::Quarter => "0.25", + Self::Half => "0.5", + Self::Full => "1", + } + } +} + +// ── Response ── + +pub struct GenerateMidjourneyV7Response { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_midjourney_v7( + args: GenerateMidjourneyV7Args<'_>, +) -> Result { + let inner_request = args.request.to_inner_request(); + let result = generate_image(GenerateImageArgs { + request: inner_request, + session: args.session, + host_override: args.host_override, + }).await?; + Ok(GenerateMidjourneyV7Response { + task_id: result.task_id, + order_id: result.order_id, + task_ids: result.task_ids, + order_ids: result.order_ids, + }) +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV7Request { + GenerateMidjourneyV7Request { + prompt: "test".to_string(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count, + reference_image_urls: None, + } + } + + // ── Inner-request mapping ── + + mod inner_request_tests { + use super::*; + + #[test] + fn inner_request_pins_model_to_v7() { + let inner = make_request(KinoviMidjourneyBatchCount::One).to_inner_request(); + assert_eq!(inner.model, KinoviMidjourneyModel::V7); + } + + #[test] + fn inner_request_preserves_all_fields() { + let req = GenerateMidjourneyV7Request { + prompt: "a corgi in space".to_string(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Landscape16x9, + negative_prompt: Some("dark".to_string()), + stylize: Some(500), + weird: Some(1500), + chaos: Some(50), + quality: Some(GenerateMidjourneyV7Quality::Half), + raw_mode: true, + batch_count: KinoviMidjourneyBatchCount::Four, + reference_image_urls: Some(vec!["https://example.com/x.png".to_string()]), + }; + let inner = req.to_inner_request(); + assert_eq!(inner.model, KinoviMidjourneyModel::V7); + assert_eq!(inner.prompt, "a corgi in space"); + assert_eq!(inner.aspect_ratio, "16:9"); + assert_eq!(inner.negative_prompt.as_deref(), Some("dark")); + assert_eq!(inner.stylize, Some(500)); + assert_eq!(inner.weird, Some(1500)); + assert_eq!(inner.chaos, Some(50)); + assert_eq!(inner.quality.as_deref(), Some("0.5")); + assert!(inner.raw_mode); + assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Four); + assert_eq!( + inner.reference_image_urls.as_deref(), + Some(&["https://example.com/x.png".to_string()][..]), + ); + } + + /// Every wrapper-side aspect ratio must serialize to the exact wire + /// string Midjourney expects. Catches silent drift between the enum + /// surface and the API. + #[test] + fn every_aspect_ratio_maps_to_canonical_wire_string() { + let cases = [ + (GenerateMidjourneyV7AspectRatio::Square1x1, "1:1"), + (GenerateMidjourneyV7AspectRatio::Landscape16x9, "16:9"), + (GenerateMidjourneyV7AspectRatio::Portrait9x16, "9:16"), + (GenerateMidjourneyV7AspectRatio::UltraWide21x9, "21:9"), + (GenerateMidjourneyV7AspectRatio::UltraTall9x21, "9:21"), + (GenerateMidjourneyV7AspectRatio::Standard4x3, "4:3"), + (GenerateMidjourneyV7AspectRatio::Portrait3x4, "3:4"), + (GenerateMidjourneyV7AspectRatio::Wide5x4, "5:4"), + (GenerateMidjourneyV7AspectRatio::Tall4x5, "4:5"), + (GenerateMidjourneyV7AspectRatio::Wide3x2, "3:2"), + (GenerateMidjourneyV7AspectRatio::Tall2x3, "2:3"), + ]; + for (variant, expected) in cases { + let req = GenerateMidjourneyV7Request { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant); + } + } + + #[test] + fn every_quality_maps_to_canonical_wire_string() { + let cases = [ + (GenerateMidjourneyV7Quality::Quarter, "0.25"), + (GenerateMidjourneyV7Quality::Half, "0.5"), + (GenerateMidjourneyV7Quality::Full, "1"), + ]; + for (variant, expected) in cases { + let req = GenerateMidjourneyV7Request { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant); + } + } + } + + // ── Pricing (delegates to the inner module, but pinned via explicit values) ── + + mod pricing_tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12); + } + + #[test] + fn batch_two_is_twentyfour_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24); + } + + #[test] + fn batch_three_is_thirtysix_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Three).calculate_costs().kinovi_credits, 36); + } + + #[test] + fn batch_four_is_fortyeight_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48); + } + + #[test] + fn usd_cents_batch_one_is_five() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4); + assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_batch_four_is_twenty() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19); + assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9); + } + + /// Pricing must match the inner module byte-for-byte (sanity that no + /// wrapper drift introduces an extra rounding step). + #[test] + fn matches_inner_pricing_exactly() { + for batch in [ + KinoviMidjourneyBatchCount::One, + KinoviMidjourneyBatchCount::Two, + KinoviMidjourneyBatchCount::Three, + KinoviMidjourneyBatchCount::Four, + ] { + let outer = make_request(batch); + let inner = outer.to_inner_request(); + assert_eq!(outer.calculate_costs().kinovi_credits, inner.calculate_costs().kinovi_credits, "batch={:?}", batch); + assert_eq!(outer.calculate_costs().usd_cents_rounded_up, inner.calculate_costs().usd_cents_rounded_up, "batch={:?}", batch); + } + } + } + + // ── Real requests ── + + mod real_requests { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v7_minimal() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_midjourney_v7(GenerateMidjourneyV7Args { + session: &session, + host_override: None, + request: GenerateMidjourneyV7Request { + prompt: "A corgi astronaut floating among stars".to_string(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }).await?; + println!("v7 minimal — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v7_all_knobs() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_midjourney_v7(GenerateMidjourneyV7Args { + session: &session, + host_override: None, + request: GenerateMidjourneyV7Request { + prompt: "abandoned skyscrapers".to_string(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + negative_prompt: Some("dark, gloomy, night".to_string()), + stylize: Some(1000), + weird: Some(3000), + chaos: Some(100), + quality: Some(GenerateMidjourneyV7Quality::Half), + raw_mode: true, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }).await?; + println!("v7 all knobs — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + Ok(()) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v7_niji.rs b/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v7_niji.rs new file mode 100644 index 0000000000..91ec9a57a4 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v7_niji.rs @@ -0,0 +1,355 @@ +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::generate_image::generate_image::{ + generate_image, GenerateImageArgs, KinoviGenerateImageRequest, KinoviMidjourneyModel, +}; +use crate::requests::kinovi_host::KinoviHost; + +// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we +// re-export it from the base. Aspect ratio and quality, however, are pinned +// to model-specific enums on this wrapper to keep their valid values typed +// at the call site. +pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount; + +// ── Args ── + +pub struct GenerateMidjourneyV7NijiArgs<'a> { + pub request: GenerateMidjourneyV7NijiRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateMidjourneyV7NijiRequest { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio, + pub negative_prompt: Option, + pub stylize: Option, + pub weird: Option, + pub chaos: Option, + pub quality: Option, + pub raw_mode: bool, + pub batch_count: KinoviMidjourneyBatchCount, + pub reference_image_urls: Option>, +} + +impl GenerateMidjourneyV7NijiRequest { + /// Calculate the cost of this request, in Kinovi credits and USD cents + /// (rounded up). + pub fn calculate_costs(&self) -> KinoviGenerationCost { + self.to_inner_request().calculate_costs() + } + + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_credits(&self) -> u32 { + self.calculate_costs().kinovi_credits as u32 + } + + /// NB: Rounds UP fractional cents (the historical behavior rounded to nearest). + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_cost_in_usd_cents(&self) -> u64 { + self.calculate_costs().usd_cents_rounded_up + } + + pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest { + KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7Niji, + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.as_api_str().to_string(), + negative_prompt: self.negative_prompt.clone(), + stylize: self.stylize, + weird: self.weird, + chaos: self.chaos, + quality: self.quality.map(|q| q.as_api_str().to_string()), + raw_mode: self.raw_mode, + batch_count: self.batch_count, + reference_image_urls: self.reference_image_urls.clone(), + } + } +} + +// ── Model-specific enums ── + +/// Aspect ratios supported by Midjourney v7 Niji. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GenerateMidjourneyV7NijiAspectRatio { + Square1x1, + Landscape16x9, + Portrait9x16, + UltraWide21x9, + UltraTall9x21, + Standard4x3, + Portrait3x4, + Wide5x4, + Tall4x5, + Wide3x2, + Tall2x3, +} + +impl GenerateMidjourneyV7NijiAspectRatio { + fn as_api_str(&self) -> &'static str { + match self { + Self::Square1x1 => "1:1", + Self::Landscape16x9 => "16:9", + Self::Portrait9x16 => "9:16", + Self::UltraWide21x9 => "21:9", + Self::UltraTall9x21 => "9:21", + Self::Standard4x3 => "4:3", + Self::Portrait3x4 => "3:4", + Self::Wide5x4 => "5:4", + Self::Tall4x5 => "4:5", + Self::Wide3x2 => "3:2", + Self::Tall2x3 => "2:3", + } + } +} + +/// Quality presets supported by Midjourney v7 Niji. Higher = more compute +/// and slower; Kinovi credit pricing is flat regardless. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GenerateMidjourneyV7NijiQuality { + /// 0.25 + Quarter, + /// 0.5 + Half, + /// 1 (default) + Full, +} + +impl GenerateMidjourneyV7NijiQuality { + fn as_api_str(&self) -> &'static str { + match self { + Self::Quarter => "0.25", + Self::Half => "0.5", + Self::Full => "1", + } + } +} + +// ── Response ── + +pub struct GenerateMidjourneyV7NijiResponse { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_midjourney_v7_niji( + args: GenerateMidjourneyV7NijiArgs<'_>, +) -> Result { + let inner_request = args.request.to_inner_request(); + let result = generate_image(GenerateImageArgs { + request: inner_request, + session: args.session, + host_override: args.host_override, + }).await?; + Ok(GenerateMidjourneyV7NijiResponse { + task_id: result.task_id, + order_id: result.order_id, + task_ids: result.task_ids, + order_ids: result.order_ids, + }) +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV7NijiRequest { + GenerateMidjourneyV7NijiRequest { + prompt: "test".to_string(), + aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count, + reference_image_urls: None, + } + } + + // ── Inner-request mapping ── + + mod inner_request_tests { + use super::*; + + #[test] + fn inner_request_pins_model_to_v7_niji() { + let inner = make_request(KinoviMidjourneyBatchCount::One).to_inner_request(); + assert_eq!(inner.model, KinoviMidjourneyModel::V7Niji); + } + + #[test] + fn inner_request_preserves_all_fields() { + let req = GenerateMidjourneyV7NijiRequest { + prompt: "anime mecha duel".to_string(), + aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9, + negative_prompt: Some("blurry".to_string()), + stylize: Some(750), + weird: Some(0), + chaos: Some(25), + quality: Some(GenerateMidjourneyV7NijiQuality::Full), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::Two, + reference_image_urls: Some(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ]), + }; + let inner = req.to_inner_request(); + assert_eq!(inner.model, KinoviMidjourneyModel::V7Niji); + assert_eq!(inner.prompt, "anime mecha duel"); + assert_eq!(inner.aspect_ratio, "21:9"); + assert_eq!(inner.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(inner.stylize, Some(750)); + assert_eq!(inner.weird, Some(0)); + assert_eq!(inner.chaos, Some(25)); + assert_eq!(inner.quality.as_deref(), Some("1")); + assert!(!inner.raw_mode); + assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Two); + assert_eq!(inner.reference_image_urls.as_ref().map(|v| v.len()), Some(2)); + } + + /// Every wrapper-side aspect ratio must serialize to the canonical wire string. + #[test] + fn every_aspect_ratio_maps_to_canonical_wire_string() { + let cases = [ + (GenerateMidjourneyV7NijiAspectRatio::Square1x1, "1:1"), + (GenerateMidjourneyV7NijiAspectRatio::Landscape16x9, "16:9"), + (GenerateMidjourneyV7NijiAspectRatio::Portrait9x16, "9:16"), + (GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9, "21:9"), + (GenerateMidjourneyV7NijiAspectRatio::UltraTall9x21, "9:21"), + (GenerateMidjourneyV7NijiAspectRatio::Standard4x3, "4:3"), + (GenerateMidjourneyV7NijiAspectRatio::Portrait3x4, "3:4"), + (GenerateMidjourneyV7NijiAspectRatio::Wide5x4, "5:4"), + (GenerateMidjourneyV7NijiAspectRatio::Tall4x5, "4:5"), + (GenerateMidjourneyV7NijiAspectRatio::Wide3x2, "3:2"), + (GenerateMidjourneyV7NijiAspectRatio::Tall2x3, "2:3"), + ]; + for (variant, expected) in cases { + let req = GenerateMidjourneyV7NijiRequest { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant); + } + } + + #[test] + fn every_quality_maps_to_canonical_wire_string() { + let cases = [ + (GenerateMidjourneyV7NijiQuality::Quarter, "0.25"), + (GenerateMidjourneyV7NijiQuality::Half, "0.5"), + (GenerateMidjourneyV7NijiQuality::Full, "1"), + ]; + for (variant, expected) in cases { + let req = GenerateMidjourneyV7NijiRequest { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant); + } + } + } + + // ── Pricing ── + + mod pricing_tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12); + } + + #[test] + fn batch_two_is_twentyfour_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24); + } + + #[test] + fn batch_three_is_thirtysix_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Three).calculate_costs().kinovi_credits, 36); + } + + #[test] + fn batch_four_is_fortyeight_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48); + } + + #[test] + fn usd_cents_batch_one_is_five() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4); + assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_batch_four_is_twenty() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19); + assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9); + } + + /// Pricing must match the inner module exactly. + #[test] + fn matches_inner_pricing_exactly() { + for batch in [ + KinoviMidjourneyBatchCount::One, + KinoviMidjourneyBatchCount::Two, + KinoviMidjourneyBatchCount::Three, + KinoviMidjourneyBatchCount::Four, + ] { + let outer = make_request(batch); + let inner = outer.to_inner_request(); + assert_eq!(outer.calculate_costs().kinovi_credits, inner.calculate_costs().kinovi_credits, "batch={:?}", batch); + assert_eq!(outer.calculate_costs().usd_cents_rounded_up, inner.calculate_costs().usd_cents_rounded_up, "batch={:?}", batch); + } + } + } + + // ── Real requests ── + + mod real_requests { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v7_niji_anime() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_midjourney_v7_niji(GenerateMidjourneyV7NijiArgs { + session: &session, + host_override: None, + request: GenerateMidjourneyV7NijiRequest { + prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(), + aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }).await?; + println!("v7-niji — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + Ok(()) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v8.rs b/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v8.rs new file mode 100644 index 0000000000..53cfab2015 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/image/generate_midjourney_v8.rs @@ -0,0 +1,406 @@ +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::generate_image::generate_image::{ + generate_image, GenerateImageArgs, KinoviGenerateImageRequest, KinoviMidjourneyModel, +}; +use crate::requests::kinovi_host::KinoviHost; + +// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we +// re-export it from the base. Aspect ratio and quality, however, are pinned +// to model-specific enums on this wrapper to keep their valid values typed +// at the call site. +pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount; + +// ── Args ── + +pub struct GenerateMidjourneyV8Args<'a> { + pub request: GenerateMidjourneyV8Request, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateMidjourneyV8Request { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV8AspectRatio, + pub negative_prompt: Option, + pub stylize: Option, + pub weird: Option, + pub chaos: Option, + pub quality: Option, + pub raw_mode: bool, + pub batch_count: KinoviMidjourneyBatchCount, + pub reference_image_urls: Option>, +} + +impl GenerateMidjourneyV8Request { + /// Calculate the cost of this request, in Kinovi credits and USD cents + /// (rounded up). + pub fn calculate_costs(&self) -> KinoviGenerationCost { + self.to_inner_request().calculate_costs() + } + + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_credits(&self) -> u32 { + self.calculate_costs().kinovi_credits as u32 + } + + /// NB: Rounds UP fractional cents (the historical behavior rounded to nearest). + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_cost_in_usd_cents(&self) -> u64 { + self.calculate_costs().usd_cents_rounded_up + } + + pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest { + KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V8, + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio.as_api_str().to_string(), + negative_prompt: self.negative_prompt.clone(), + stylize: self.stylize, + weird: self.weird, + chaos: self.chaos, + quality: self.quality.map(|q| q.as_api_str().to_string()), + raw_mode: self.raw_mode, + batch_count: self.batch_count, + reference_image_urls: self.reference_image_urls.clone(), + } + } +} + +// ── Model-specific enums ── + +/// Aspect ratios supported by Midjourney v8. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GenerateMidjourneyV8AspectRatio { + Square1x1, + Landscape16x9, + Portrait9x16, + UltraWide21x9, + UltraTall9x21, + Standard4x3, + Portrait3x4, + Wide5x4, + Tall4x5, + Wide3x2, + Tall2x3, +} + +impl GenerateMidjourneyV8AspectRatio { + fn as_api_str(&self) -> &'static str { + match self { + Self::Square1x1 => "1:1", + Self::Landscape16x9 => "16:9", + Self::Portrait9x16 => "9:16", + Self::UltraWide21x9 => "21:9", + Self::UltraTall9x21 => "9:21", + Self::Standard4x3 => "4:3", + Self::Portrait3x4 => "3:4", + Self::Wide5x4 => "5:4", + Self::Tall4x5 => "4:5", + Self::Wide3x2 => "3:2", + Self::Tall2x3 => "2:3", + } + } +} + +/// Quality presets supported by Midjourney v8. Higher = more compute and +/// slower; Kinovi credit pricing is flat regardless. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GenerateMidjourneyV8Quality { + /// 0.25 + Quarter, + /// 0.5 + Half, + /// 1 (default) + Full, +} + +impl GenerateMidjourneyV8Quality { + fn as_api_str(&self) -> &'static str { + match self { + Self::Quarter => "0.25", + Self::Half => "0.5", + Self::Full => "1", + } + } +} + +// ── Response ── + +pub struct GenerateMidjourneyV8Response { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_midjourney_v8( + args: GenerateMidjourneyV8Args<'_>, +) -> Result { + let inner_request = args.request.to_inner_request(); + let result = generate_image(GenerateImageArgs { + request: inner_request, + session: args.session, + host_override: args.host_override, + }).await?; + Ok(GenerateMidjourneyV8Response { + task_id: result.task_id, + order_id: result.order_id, + task_ids: result.task_ids, + order_ids: result.order_ids, + }) +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV8Request { + GenerateMidjourneyV8Request { + prompt: "test".to_string(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count, + reference_image_urls: None, + } + } + + // ── Inner-request mapping ── + + mod inner_request_tests { + use super::*; + + #[test] + fn inner_request_pins_model_to_v8() { + let inner = make_request(KinoviMidjourneyBatchCount::One).to_inner_request(); + assert_eq!(inner.model, KinoviMidjourneyModel::V8); + } + + #[test] + fn inner_request_preserves_all_fields() { + let req = GenerateMidjourneyV8Request { + prompt: "abandoned k-mart, overgrown vines".to_string(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16, + negative_prompt: Some("dark, gloomy".to_string()), + stylize: Some(730), + weird: Some(2050), + chaos: Some(70), + quality: Some(GenerateMidjourneyV8Quality::Half), + raw_mode: true, + batch_count: KinoviMidjourneyBatchCount::Four, + reference_image_urls: Some(vec!["https://example.com/ref.jpg".to_string()]), + }; + let inner = req.to_inner_request(); + assert_eq!(inner.model, KinoviMidjourneyModel::V8); + assert_eq!(inner.prompt, "abandoned k-mart, overgrown vines"); + assert_eq!(inner.aspect_ratio, "9:16"); + assert_eq!(inner.negative_prompt.as_deref(), Some("dark, gloomy")); + assert_eq!(inner.stylize, Some(730)); + assert_eq!(inner.weird, Some(2050)); + assert_eq!(inner.chaos, Some(70)); + assert_eq!(inner.quality.as_deref(), Some("0.5")); + assert!(inner.raw_mode); + assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Four); + assert_eq!(inner.reference_image_urls.as_ref().map(|v| v.len()), Some(1)); + } + + /// Every wrapper-side aspect ratio must serialize to the canonical wire string. + #[test] + fn every_aspect_ratio_maps_to_canonical_wire_string() { + let cases = [ + (GenerateMidjourneyV8AspectRatio::Square1x1, "1:1"), + (GenerateMidjourneyV8AspectRatio::Landscape16x9, "16:9"), + (GenerateMidjourneyV8AspectRatio::Portrait9x16, "9:16"), + (GenerateMidjourneyV8AspectRatio::UltraWide21x9, "21:9"), + (GenerateMidjourneyV8AspectRatio::UltraTall9x21, "9:21"), + (GenerateMidjourneyV8AspectRatio::Standard4x3, "4:3"), + (GenerateMidjourneyV8AspectRatio::Portrait3x4, "3:4"), + (GenerateMidjourneyV8AspectRatio::Wide5x4, "5:4"), + (GenerateMidjourneyV8AspectRatio::Tall4x5, "4:5"), + (GenerateMidjourneyV8AspectRatio::Wide3x2, "3:2"), + (GenerateMidjourneyV8AspectRatio::Tall2x3, "2:3"), + ]; + for (variant, expected) in cases { + let req = GenerateMidjourneyV8Request { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant); + } + } + + #[test] + fn every_quality_maps_to_canonical_wire_string() { + let cases = [ + (GenerateMidjourneyV8Quality::Quarter, "0.25"), + (GenerateMidjourneyV8Quality::Half, "0.5"), + (GenerateMidjourneyV8Quality::Full, "1"), + ]; + for (variant, expected) in cases { + let req = GenerateMidjourneyV8Request { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant); + } + } + } + + // ── Pricing ── + + mod pricing_tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12); + } + + #[test] + fn batch_two_is_twentyfour_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24); + } + + #[test] + fn batch_three_is_thirtysix_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Three).calculate_costs().kinovi_credits, 36); + } + + #[test] + fn batch_four_is_fortyeight_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48); + } + + #[test] + fn usd_cents_batch_one_is_five() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4); + assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_batch_four_is_twenty() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19); + assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9); + } + + /// Pricing must match the inner module exactly. + #[test] + fn matches_inner_pricing_exactly() { + for batch in [ + KinoviMidjourneyBatchCount::One, + KinoviMidjourneyBatchCount::Two, + KinoviMidjourneyBatchCount::Three, + KinoviMidjourneyBatchCount::Four, + ] { + let outer = make_request(batch); + let inner = outer.to_inner_request(); + assert_eq!(outer.calculate_costs().kinovi_credits, inner.calculate_costs().kinovi_credits, "batch={:?}", batch); + assert_eq!(outer.calculate_costs().usd_cents_rounded_up, inner.calculate_costs().usd_cents_rounded_up, "batch={:?}", batch); + } + } + } + + // ── Real requests ── + + mod real_requests { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v8_minimal() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_midjourney_v8(GenerateMidjourneyV8Args { + session: &session, + host_override: None, + request: GenerateMidjourneyV8Request { + prompt: "spooky horror scene, ghosts".to_string(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: Some(GenerateMidjourneyV8Quality::Full), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }).await?; + println!("v8 minimal — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs 48 credits + async fn test_generate_v8_batch_4() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_midjourney_v8(GenerateMidjourneyV8Args { + session: &session, + host_override: None, + request: GenerateMidjourneyV8Request { + prompt: "desolate cliff overlooking the ocean".to_string(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: Some(GenerateMidjourneyV8Quality::Full), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::Four, + reference_image_urls: None, + }, + }).await?; + println!("v8 batch-4 — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(result.task_ids.as_ref().map(|v| v.len()), Some(4)); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v8_with_reference_image() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_midjourney_v8(GenerateMidjourneyV8Args { + session: &session, + host_override: None, + request: GenerateMidjourneyV8Request { + prompt: "spooky horror scene, ghosts".to_string(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: Some(GenerateMidjourneyV8Quality::Full), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260602/1780380104765-10cec276.jpg".to_string(), + ]), + }, + }).await?; + println!("v8 with ref image — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + Ok(()) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/image/generate_seedream_5p0_pro.rs b/crates/api_clients/kinovi_web_client/src/generate/image/generate_seedream_5p0_pro.rs new file mode 100644 index 0000000000..d0316ac791 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/image/generate_seedream_5p0_pro.rs @@ -0,0 +1,620 @@ +use serde_derive::Serialize; + +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse, +}; + +const BUSINESS_TYPE: &str = "seedream-5-pro-image-generation"; +const MODEL: &str = "seedream-5.0-pro"; + +/// Kinovi allows at most this many reference images per request. +pub const MAX_REFERENCE_IMAGES: usize = 14; + +/// Credits per generated image at 1k resolution. +const CREDITS_PER_IMAGE_1K: u64 = 14; + +/// Credits per generated image at 2k resolution. +const CREDITS_PER_IMAGE_2K: u64 = 24; + +// ── Args ── + +pub struct GenerateSeedream5p0ProArgs<'a> { + pub request: GenerateSeedream5p0ProRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Seedream 5.0 Pro: text-to-image (optionally with reference images). +#[derive(Clone, Debug)] +pub struct GenerateSeedream5p0ProRequest { + /// Reference images are cited in the prompt as @Image1, @Image2, etc. + pub prompt: String, + + pub aspect_ratio: KinoviSeedream5p0ProAspectRatio, + + pub resolution: KinoviSeedream5p0ProResolution, + + /// Number of images to generate in a single request. + pub batch_count: KinoviSeedream5p0ProBatchCount, + + /// Optional reference image URLs, up to [`MAX_REFERENCE_IMAGES`]. + /// Referenced in prompts as @Image1, @Image2, etc. + pub reference_image_urls: Option>, +} + +// ── Enums ── + +/// Aspect ratios supported by Seedream 5.0 Pro. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum KinoviSeedream5p0ProAspectRatio { + /// Let the model pick (useful with reference images). + Auto, + Square1x1, + Standard4x3, + Portrait3x4, + Landscape16x9, + Portrait9x16, + Wide3x2, + Tall2x3, + UltraWide21x9, +} + +/// Output resolution. Determines the per-image credit price. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum KinoviSeedream5p0ProResolution { + /// 1k (14 credits per image) + OneK, + /// 2k (24 credits per image) + TwoK, +} + +/// Number of images to generate in a single request. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum KinoviSeedream5p0ProBatchCount { + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, +} + +// ── Pricing ── +// +// Seedream 5.0 Pro credit pricing (per image; batch count multiplies): +// +// | Resolution | Credits/image | +// |------------|---------------| +// | 1k | 14 | +// | 2k | 24 | +// +// Reference images do not affect cost. +// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243). + +impl GenerateSeedream5p0ProRequest { + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents. + pub fn calculate_costs(&self) -> KinoviGenerationCost { + let credits_per_image = match self.resolution { + KinoviSeedream5p0ProResolution::OneK => CREDITS_PER_IMAGE_1K, + KinoviSeedream5p0ProResolution::TwoK => CREDITS_PER_IMAGE_2K, + }; + let total_credits = credits_per_image * u64::from(self.batch_count.as_u8()); + KinoviGenerationCost::from_kinovi_credits(total_credits) + } +} + +impl KinoviSeedream5p0ProBatchCount { + fn as_u8(&self) -> u8 { + match self { + Self::One => 1, + Self::Two => 2, + Self::Three => 3, + Self::Four => 4, + Self::Five => 5, + Self::Six => 6, + Self::Seven => 7, + Self::Eight => 8, + } + } +} + +impl KinoviSeedream5p0ProAspectRatio { + fn as_api_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Square1x1 => "1:1", + Self::Standard4x3 => "4:3", + Self::Portrait3x4 => "3:4", + Self::Landscape16x9 => "16:9", + Self::Portrait9x16 => "9:16", + Self::Wide3x2 => "3:2", + Self::Tall2x3 => "2:3", + Self::UltraWide21x9 => "21:9", + } + } +} + +impl KinoviSeedream5p0ProResolution { + fn as_api_str(&self) -> &'static str { + match self { + Self::OneK => "1k", + Self::TwoK => "2k", + } + } +} + +// ── Response ── + +pub struct GenerateSeedream5p0ProResponse { + pub task_id: String, + pub order_id: String, + + /// Present when batch_count > 1. + pub task_ids: Option>, + + /// Present when batch_count > 1. + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedream_5p0_pro( + args: GenerateSeedream5p0ProArgs<'_>, +) -> Result { + let api_params = build_api_params(&args.request)?; + + let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs { + business_type: BUSINESS_TYPE, + api_params, + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSeedream5p0ProResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Wire payload ── + +/// The `apiParams` shape for `seedream-5-pro-image-generation`. Field order +/// matches the captured browser traffic. +#[derive(Serialize, Debug)] +struct Seedream5p0ProApiParams { + prompt: String, + #[serde(rename = "aspectRatio")] + aspect_ratio: &'static str, + resolution: &'static str, + model: &'static str, + /// Omitted when generating a single image. + #[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")] + batch_count: Option, + /// Omitted when no reference images are attached. + #[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")] + uploaded_urls: Option>, +} + +fn build_api_params( + request: &GenerateSeedream5p0ProRequest, +) -> Result { + let uploaded_urls = match &request.reference_image_urls { + Some(urls) if urls.len() > MAX_REFERENCE_IMAGES => { + return Err(KinoviWebClientError::InvalidRequestField { + field: "reference_image_urls", + raw_value: format!("{} urls", urls.len()), + reason: format!("At most {} reference images are allowed", MAX_REFERENCE_IMAGES), + }); + } + Some(urls) if !urls.is_empty() => Some(urls.clone()), + _ => None, + }; + + let batch_count_value = request.batch_count.as_u8(); + let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None }; + + Ok(Seedream5p0ProApiParams { + prompt: request.prompt.clone(), + aspect_ratio: request.aspect_ratio.as_api_str(), + resolution: request.resolution.as_api_str(), + model: MODEL, + batch_count, + uploaded_urls, + }) +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + + fn base_request() -> GenerateSeedream5p0ProRequest { + GenerateSeedream5p0ProRequest { + prompt: "An anime girl riding a dinosaur".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto, + resolution: KinoviSeedream5p0ProResolution::TwoK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + reference_image_urls: None, + } + } + + mod request_shape_tests { + use super::*; + + /// Mirrors capture 1_seedream_5.txt (auto aspect ratio, 2k, single image, + /// no references). + #[test] + fn auto_2k_single_image() { + let params = build_api_params(&base_request()).unwrap(); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"prompt":"An anime girl riding a dinosaur","aspectRatio":"auto","resolution":"2k","model":"seedream-5.0-pro"}"#, + ); + } + + /// Mirrors capture 2_seedream_5_21x9.txt (21:9, 2k, single image). + #[test] + fn ultra_wide_21x9_2k() { + let params = build_api_params(&GenerateSeedream5p0ProRequest { + prompt: "a pirate sailing a pirate ship".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9, + resolution: KinoviSeedream5p0ProResolution::TwoK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + reference_image_urls: None, + }).unwrap(); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"prompt":"a pirate sailing a pirate ship","aspectRatio":"21:9","resolution":"2k","model":"seedream-5.0-pro"}"#, + ); + } + + /// Mirrors capture 3_seedream_5_9x16_image_refs.txt (9:16, 1k, single + /// image, two reference images). + #[test] + fn portrait_9x16_1k_with_image_references() { + let params = build_api_params(&GenerateSeedream5p0ProRequest { + prompt: "The man in @Image1 is staring out the door in @Image2 ".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16, + resolution: KinoviSeedream5p0ProResolution::OneK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(), + "https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(), + ]), + }).unwrap(); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"prompt":"The man in @Image1 is staring out the door in @Image2 ","aspectRatio":"9:16","resolution":"1k","model":"seedream-5.0-pro","uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg","https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png"]}"#, + ); + } + + /// Mirrors capture 4_seedream_5_misc.txt (2:3, 2k, batch of 4, two + /// reference images). + #[test] + fn tall_2x3_2k_batch_four_with_image_references() { + let params = build_api_params(&GenerateSeedream5p0ProRequest { + prompt: "An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Tall2x3, + resolution: KinoviSeedream5p0ProResolution::TwoK, + batch_count: KinoviSeedream5p0ProBatchCount::Four, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(), + "https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg".to_string(), + ]), + }).unwrap(); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"prompt":"An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ","aspectRatio":"2:3","resolution":"2k","model":"seedream-5.0-pro","batchCount":4,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png","https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg"]}"#, + ); + } + + /// Mirrors capture 5_seedream_5_misc2.txt (1:1, 1k, batch of 8, two + /// reference images). + #[test] + fn square_1x1_1k_batch_eight_with_image_references() { + let params = build_api_params(&GenerateSeedream5p0ProRequest { + prompt: "The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Square1x1, + resolution: KinoviSeedream5p0ProResolution::OneK, + batch_count: KinoviSeedream5p0ProBatchCount::Eight, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg".to_string(), + "https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png".to_string(), + ]), + }).unwrap(); + assert_eq!( + serde_json::to_string(¶ms).unwrap(), + r#"{"prompt":"The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ","aspectRatio":"1:1","resolution":"1k","model":"seedream-5.0-pro","batchCount":8,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg","https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png"]}"#, + ); + } + + #[test] + fn every_aspect_ratio_wire_value() { + let cases = [ + (KinoviSeedream5p0ProAspectRatio::Auto, "auto"), + (KinoviSeedream5p0ProAspectRatio::Square1x1, "1:1"), + (KinoviSeedream5p0ProAspectRatio::Standard4x3, "4:3"), + (KinoviSeedream5p0ProAspectRatio::Portrait3x4, "3:4"), + (KinoviSeedream5p0ProAspectRatio::Landscape16x9, "16:9"), + (KinoviSeedream5p0ProAspectRatio::Portrait9x16, "9:16"), + (KinoviSeedream5p0ProAspectRatio::Wide3x2, "3:2"), + (KinoviSeedream5p0ProAspectRatio::Tall2x3, "2:3"), + (KinoviSeedream5p0ProAspectRatio::UltraWide21x9, "21:9"), + ]; + for (variant, expected) in cases { + assert_eq!(variant.as_api_str(), expected, "wire value for {variant:?}"); + } + } + + #[test] + fn every_resolution_wire_value() { + assert_eq!(KinoviSeedream5p0ProResolution::OneK.as_api_str(), "1k"); + assert_eq!(KinoviSeedream5p0ProResolution::TwoK.as_api_str(), "2k"); + } + + #[test] + fn every_batch_count_value() { + let cases = [ + (KinoviSeedream5p0ProBatchCount::One, 1), + (KinoviSeedream5p0ProBatchCount::Two, 2), + (KinoviSeedream5p0ProBatchCount::Three, 3), + (KinoviSeedream5p0ProBatchCount::Four, 4), + (KinoviSeedream5p0ProBatchCount::Five, 5), + (KinoviSeedream5p0ProBatchCount::Six, 6), + (KinoviSeedream5p0ProBatchCount::Seven, 7), + (KinoviSeedream5p0ProBatchCount::Eight, 8), + ]; + for (variant, expected) in cases { + assert_eq!(variant.as_u8(), expected, "value for {variant:?}"); + } + } + + #[test] + fn batch_count_one_omits_field() { + let params = build_api_params(&base_request()).unwrap(); + assert_eq!(params.batch_count, None); + } + + #[test] + fn empty_reference_list_omits_uploaded_urls() { + let params = build_api_params(&GenerateSeedream5p0ProRequest { + reference_image_urls: Some(vec![]), + ..base_request() + }).unwrap(); + let json = serde_json::to_string(¶ms).unwrap(); + assert!(!json.contains("uploadedUrls"), "expected no uploadedUrls in {json}"); + } + + #[test] + fn business_type() { + assert_eq!(BUSINESS_TYPE, "seedream-5-pro-image-generation"); + } + } + + mod reference_image_limit_tests { + use super::*; + + #[test] + fn fourteen_references_is_allowed() { + let params = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES)).unwrap(); + assert_eq!(params.uploaded_urls.map(|urls| urls.len()), Some(MAX_REFERENCE_IMAGES)); + } + + #[test] + fn fifteen_references_is_rejected() { + let result = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES + 1)); + match result { + Err(KinoviWebClientError::InvalidRequestField { field, .. }) => { + assert_eq!(field, "reference_image_urls"); + } + other => panic!("expected InvalidRequestField, got {other:?}"), + } + } + + fn request_with_n_references(count: usize) -> GenerateSeedream5p0ProRequest { + GenerateSeedream5p0ProRequest { + reference_image_urls: Some( + (0..count).map(|i| format!("https://example.com/ref{i}.jpg")).collect(), + ), + ..base_request() + } + } + } + + // ── Pricing ── + // + // 1k = 14 credits/image, 2k = 24 credits/image; batch count multiplies. + // Verified against captured traffic: 2k x 4 images = 96 credits + // (4_seedream_5_misc.txt), 1k x 8 images = 112 credits + // (5_seedream_5_misc2.txt). + + mod pricing_tests { + use super::*; + + #[test] + fn one_1k_image_is_fourteen_credits() { + let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs(); + assert_eq!(costs.kinovi_credits, 14); + } + + #[test] + fn one_2k_image_is_twentyfour_credits() { + let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs(); + assert_eq!(costs.kinovi_credits, 24); + } + + /// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits. + #[test] + fn four_2k_images_is_ninetysix_credits() { + let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs(); + assert_eq!(costs.kinovi_credits, 96); + } + + /// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits. + #[test] + fn eight_1k_images_is_one_hundred_twelve_credits() { + let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs(); + assert_eq!(costs.kinovi_credits, 112); + } + + #[test] + fn usd_cents_one_1k_image() { + // 14 credits; 1400/243 = 5.7613… → up 6¢, down 5¢. + let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 6); + assert_eq!(costs.usd_cents_rounded_down, 5); + assert!((costs.usd_cents_fractional - (1400.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_one_2k_image() { + // 24 credits; 2400/243 = 9.8765… → up 10¢, down 9¢. + let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 10); + assert_eq!(costs.usd_cents_rounded_down, 9); + assert!((costs.usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_eight_1k_images() { + // 112 credits; 11200/243 = 46.0905… → up 47¢, down 46¢. + let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs(); + assert_eq!(costs.usd_cents_rounded_up, 47); + assert_eq!(costs.usd_cents_rounded_down, 46); + assert!((costs.usd_cents_fractional - (11200.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn reference_images_do_not_affect_cost() { + let without_refs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs(); + let with_refs = GenerateSeedream5p0ProRequest { + reference_image_urls: Some(vec![ + "https://example.com/ref1.jpg".to_string(), + "https://example.com/ref2.jpg".to_string(), + ]), + ..request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four) + }.calculate_costs(); + assert_eq!(without_refs, with_refs); + } + + #[test] + fn aspect_ratio_does_not_affect_cost() { + let auto = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs(); + let ultra_wide = GenerateSeedream5p0ProRequest { + aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9, + ..request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One) + }.calculate_costs(); + assert_eq!(auto, ultra_wide); + } + + fn request( + resolution: KinoviSeedream5p0ProResolution, + batch_count: KinoviSeedream5p0ProBatchCount, + ) -> GenerateSeedream5p0ProRequest { + GenerateSeedream5p0ProRequest { + resolution, + batch_count, + ..base_request() + } + } + } + + // ── Live usage tests ── + + mod live_tests { + use super::*; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs 24 credits. + async fn test_text_to_image_2k() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs { + session: &session, + host_override: None, + request: GenerateSeedream5p0ProRequest { + prompt: "A corgi wearing a wizard hat, studio lighting".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto, + resolution: KinoviSeedream5p0ProResolution::TwoK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + reference_image_urls: None, + }, + }).await?; + println!("seedream 5.0 pro 2k — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs 14 credits. + async fn test_text_to_image_1k_with_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs { + session: &session, + host_override: None, + request: GenerateSeedream5p0ProRequest { + prompt: "The man in @Image1 is staring out the door in @Image2".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16, + resolution: KinoviSeedream5p0ProResolution::OneK, + batch_count: KinoviSeedream5p0ProBatchCount::One, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(), + "https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(), + ]), + }, + }).await?; + println!("seedream 5.0 pro 1k refs — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] // Sends a real generation to Kinovi; costs 56 credits. + async fn test_batch_four_1k() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs { + session: &session, + host_override: None, + request: GenerateSeedream5p0ProRequest { + prompt: "A lighthouse on a cliff at golden hour".to_string(), + aspect_ratio: KinoviSeedream5p0ProAspectRatio::Landscape16x9, + resolution: KinoviSeedream5p0ProResolution::OneK, + batch_count: KinoviSeedream5p0ProBatchCount::Four, + reference_image_urls: None, + }, + }).await?; + println!("seedream 5.0 pro batch-4 — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(result.task_ids.as_ref().map(|ids| ids.len()), Some(4)); + assert_eq!(result.order_ids.as_ref().map(|ids| ids.len()), Some(4)); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/image/mod.rs b/crates/api_clients/kinovi_web_client/src/generate/image/mod.rs new file mode 100644 index 0000000000..0360d40366 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/image/mod.rs @@ -0,0 +1,4 @@ +pub mod generate_midjourney_v7; +pub mod generate_midjourney_v7_niji; +pub mod generate_midjourney_v8; +pub mod generate_seedream_5p0_pro; diff --git a/crates/api_clients/kinovi_web_client/src/generate/mod.rs b/crates/api_clients/kinovi_web_client/src/generate/mod.rs new file mode 100644 index 0000000000..53475895eb --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/mod.rs @@ -0,0 +1,3 @@ +pub mod audio; +pub mod image; +pub mod video; diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_happy_horse_1p0.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_happy_horse_1p0.rs new file mode 100644 index 0000000000..f77a3221da --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_happy_horse_1p0.rs @@ -0,0 +1,668 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, + KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs + , WorkflowRunTaskRequest, +}; + +// ── Args ── + +pub struct GenerateHappyHorse1p0Args<'a> { + pub request: GenerateHappyHorse1p0Request, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateHappyHorse1p0Request { + pub prompt: String, + pub aspect_ratio: Option, + pub output_resolution: Option, + pub batch_count: Option, + pub duration_seconds: u8, + pub start_frame_url: Option, +} + +// ── Enums ── + +#[derive(Debug, Clone, Copy)] +pub enum KinoviHappyHorse1p0AspectRatio { + Portrait9x16, + Portrait3x4, + Square1x1, + Landscape4x3, + Landscape16x9, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviHappyHorse1p0OutputResolution { + SevenTwentyP, + TenEightyP, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviHappyHorse1p0BatchCount { + One, + Two, + Four, +} + +// ── Pricing ── +// +// Happy Horse 1.0 credit pricing: +// +// | Resolution | Credits/sec | +// |------------|-------------| +// | 720p | 33 | +// | 1080p | 66 | +// +// Default resolution (None) is 720p. +// Batch count multiplies the total cost. +// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243). + +impl GenerateHappyHorse1p0Request { + /// Estimate the credit cost for this generation request. + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents (rounded up). + pub fn calculate_costs(&self) -> KinoviGenerationCost { + let credits_per_second: u64 = match self.output_resolution { + Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => 66, + Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => 33, + }; + + let per_video = u64::from(self.duration_seconds) * credits_per_second; + let batch_multiplier: u64 = match self.batch_count { + None | Some(KinoviHappyHorse1p0BatchCount::One) => 1, + Some(KinoviHappyHorse1p0BatchCount::Two) => 2, + Some(KinoviHappyHorse1p0BatchCount::Four) => 4, + }; + + KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier) + } + + /// Estimate the credit cost for this generation request. + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_credits(&self) -> u32 { + self.calculate_costs().kinovi_credits as u32 + } + + /// Estimate the USD cost in cents for this generation request. + /// NB: Rounds UP fractional cents (the historical behavior rounded to nearest). + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_cost_in_usd_cents(&self) -> u64 { + self.calculate_costs().usd_cents_rounded_up + } +} + +// ── Response ── + +pub struct GenerateHappyHorse1p0Response { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_happy_horse_1p0( + args: GenerateHappyHorse1p0Args<'_>, +) -> Result { + let req = args.request; + + let raw_request = WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::HappyHorse1p0, + prompt: req.prompt, + aspect_ratio: map_aspect_ratio(req.aspect_ratio), + output_resolution: Some(map_output_resolution(req.output_resolution)), + batch_count: map_batch_count(req.batch_count), + duration_seconds: req.duration_seconds, + start_frame_url: req.start_frame_url, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: Some(false), + bitrate: None, + }; + + let raw_response = workflow_run_task(WorkflowRunTaskArgs { + request: raw_request, + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateHappyHorse1p0Response { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Mapping helpers ── + +fn map_aspect_ratio(ar: Option) -> KinoviAspectRatioRaw { + match ar { + Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9, + Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16, + Some(KinoviHappyHorse1p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1, + Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3) => KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviHappyHorse1p0AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4, + None => KinoviAspectRatioRaw::Landscape16x9, + } +} + +fn map_output_resolution(res: Option) -> KinoviOutputResolutionRaw { + match res { + // Unset resolves to 720p — MUST stay in lockstep with calculate_costs(), + // which prices None as 720p. + Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP, + Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP, + } +} + +fn map_batch_count(bc: Option) -> KinoviBatchCountRaw { + match bc { + Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One, + Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two, + Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod pricing_tests { + use super::*; + + fn make_request( + duration_seconds: u8, + output_resolution: Option, + batch_count: Option, + ) -> GenerateHappyHorse1p0Request { + GenerateHappyHorse1p0Request { + prompt: String::new(), + aspect_ratio: None, + output_resolution, + batch_count, + duration_seconds, + start_frame_url: None, + } + } + + fn r720(dur: u8) -> GenerateHappyHorse1p0Request { + make_request(dur, None, None) + } + + fn r1080(dur: u8) -> GenerateHappyHorse1p0Request { + make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None) + } + + // ── 720p credits (33 credits/sec) ── + + mod credits_720p { + use super::*; + + #[test] + fn every_duration() { + assert_eq!(r720(3).calculate_costs().kinovi_credits, 99); + assert_eq!(r720(4).calculate_costs().kinovi_credits, 132); + assert_eq!(r720(5).calculate_costs().kinovi_credits, 165); + assert_eq!(r720(6).calculate_costs().kinovi_credits, 198); + assert_eq!(r720(7).calculate_costs().kinovi_credits, 231); + assert_eq!(r720(8).calculate_costs().kinovi_credits, 264); + assert_eq!(r720(9).calculate_costs().kinovi_credits, 297); + assert_eq!(r720(10).calculate_costs().kinovi_credits, 330); + assert_eq!(r720(11).calculate_costs().kinovi_credits, 363); + assert_eq!(r720(12).calculate_costs().kinovi_credits, 396); + assert_eq!(r720(13).calculate_costs().kinovi_credits, 429); + assert_eq!(r720(14).calculate_costs().kinovi_credits, 462); + assert_eq!(r720(15).calculate_costs().kinovi_credits, 495); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(5).calculate_costs().kinovi_credits; + let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits; + assert_eq!(default, explicit); + } + } + + // ── 1080p credits (66 credits/sec) ── + + mod credits_1080p { + use super::*; + + #[test] + fn every_duration() { + assert_eq!(r1080(3).calculate_costs().kinovi_credits, 198); + assert_eq!(r1080(4).calculate_costs().kinovi_credits, 264); + assert_eq!(r1080(5).calculate_costs().kinovi_credits, 330); + assert_eq!(r1080(6).calculate_costs().kinovi_credits, 396); + assert_eq!(r1080(7).calculate_costs().kinovi_credits, 462); + assert_eq!(r1080(8).calculate_costs().kinovi_credits, 528); + assert_eq!(r1080(9).calculate_costs().kinovi_credits, 594); + assert_eq!(r1080(10).calculate_costs().kinovi_credits, 660); + assert_eq!(r1080(11).calculate_costs().kinovi_credits, 726); + assert_eq!(r1080(12).calculate_costs().kinovi_credits, 792); + assert_eq!(r1080(13).calculate_costs().kinovi_credits, 858); + assert_eq!(r1080(14).calculate_costs().kinovi_credits, 924); + assert_eq!(r1080(15).calculate_costs().kinovi_credits, 990); + } + } + + // ── Batch multiplier ── + + mod batch_tests { + use super::*; + + #[test] + fn batch_1_is_base() { + let base = r720(5).calculate_costs().kinovi_credits; + let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits; + assert_eq!(base, explicit); + } + + #[test] + fn batch_2_doubles() { + let base = r720(5).calculate_costs().kinovi_credits; + let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs().kinovi_credits; + assert_eq!(batch2, base * 2); + } + + #[test] + fn batch_4_quadruples() { + let base = r720(5).calculate_costs().kinovi_credits; + let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).calculate_costs().kinovi_credits; + assert_eq!(batch4, base * 4); + } + + #[test] + fn batch_multiplier_applies_to_1080p() { + let base = r1080(5).calculate_costs().kinovi_credits; + let batch2 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs().kinovi_credits; + let batch4 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Four)).calculate_costs().kinovi_credits; + assert_eq!(batch2, base * 2); + assert_eq!(batch4, base * 4); + } + } + + // ── Relative pricing ── + + mod relative_tests { + use super::*; + + #[test] + fn r1080p_is_exactly_double_720p() { + for dur in 3..=15u8 { + let c720 = make_request(dur, None, None).calculate_costs().kinovi_credits; + let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).calculate_costs().kinovi_credits; + assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur); + } + } + + #[test] + fn cost_scales_with_duration() { + let c3 = r720(3).calculate_costs().kinovi_credits; + let c10 = r720(10).calculate_costs().kinovi_credits; + let c15 = r720(15).calculate_costs().kinovi_credits; + assert!(c3 < c10); + assert!(c10 < c15); + } + } + + // ── USD cents ── + + mod calculate_costs_tests { + use super::*; + + /// Both fields together: credits and ceil-rounded USD cents. + /// (USD cents are always rounded UP when fractional.) + #[test] + fn costs_720p_5s() { + // 33 credits/s × 5s = 165 credits; 16500/243 = 67.90 → rounds UP to 68¢ + let costs = r720(5).calculate_costs(); + assert_eq!(costs.kinovi_credits, 165); + assert_eq!(costs.usd_cents_rounded_up, 68); + assert_eq!(costs.usd_cents_rounded_down, 67); + assert!((costs.usd_cents_fractional - (16500.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn costs_1080p_5s() { + // 66 credits/s × 5s = 330 credits; 33000/243 = 135.80 → 136¢ + let costs = r1080(5).calculate_costs(); + assert_eq!(costs.kinovi_credits, 330); + assert_eq!(costs.usd_cents_rounded_up, 136); + assert_eq!(costs.usd_cents_rounded_down, 135); + assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn costs_720p_15s() { + // 33 credits/s × 15s = 495 credits; 49500/243 = 203.70 → rounds UP to 204¢ + let costs = r720(15).calculate_costs(); + assert_eq!(costs.kinovi_credits, 495); + assert_eq!(costs.usd_cents_rounded_up, 204); + assert_eq!(costs.usd_cents_rounded_down, 203); + assert!((costs.usd_cents_fractional - (49500.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn costs_1080p_15s() { + // 66 credits/s × 15s = 990 credits; 99000/243 = 407.41 → 408¢ + let costs = r1080(15).calculate_costs(); + assert_eq!(costs.kinovi_credits, 990); + assert_eq!(costs.usd_cents_rounded_up, 408); + assert_eq!(costs.usd_cents_rounded_down, 407); + assert!((costs.usd_cents_fractional - (99000.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn costs_batch_2_720p_5s() { + // 165 credits × 2 = 330 credits; 33000/243 = 135.80 → 136¢ + let costs = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs(); + assert_eq!(costs.kinovi_credits, 330); + assert_eq!(costs.usd_cents_rounded_up, 136); + assert_eq!(costs.usd_cents_rounded_down, 135); + assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9); + } + + /// The deprecated shims return the corresponding struct fields. + #[test] + #[allow(deprecated)] + fn deprecated_methods_delegate() { + let request = r720(5); + let costs = request.calculate_costs(); + assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits); + assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up); + } + } + + // ── Aspect ratio doesn't affect cost ── + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = r720(5).calculate_costs().kinovi_credits; + + let ratios = [ + KinoviHappyHorse1p0AspectRatio::Landscape16x9, + KinoviHappyHorse1p0AspectRatio::Portrait9x16, + KinoviHappyHorse1p0AspectRatio::Square1x1, + KinoviHappyHorse1p0AspectRatio::Landscape4x3, + KinoviHappyHorse1p0AspectRatio::Portrait3x4, + ]; + + for ar in &ratios { + let req = GenerateHappyHorse1p0Request { + prompt: String::new(), + aspect_ratio: Some(*ar), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + }; + assert_eq!( + req.calculate_costs().kinovi_credits, baseline, + "Aspect ratio {:?} should not change credits from baseline {}", ar, baseline, + ); + } + } + } + + mod text_to_video { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_default() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: "A corgi and a shiba are playing chess against one another".to_string(), + aspect_ratio: None, + output_resolution: None, + batch_count: None, + duration_seconds: 4, + start_frame_url: None, + }, + }).await?; + println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: "A golden retriever running through a field of sunflowers".to_string(), + aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9), + output_resolution: Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + }, + }).await?; + println!("t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: "A dragon soaring over a medieval castle at sunset".to_string(), + aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9), + output_resolution: Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), + batch_count: None, + duration_seconds: 4, + start_frame_url: None, + }, + }).await?; + println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod aspect_ratios { + use super::*; + + async fn test_aspect_ratio(ar: KinoviHappyHorse1p0AspectRatio, label: &str) -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: format!("A cat sitting in a sunbeam ({})", label), + aspect_ratio: Some(ar), + output_resolution: None, + batch_count: None, + duration_seconds: 4, + start_frame_url: None, + }, + }).await?; + println!("{} — task_id={}, order_id={}", label, result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_landscape_16x9() -> AnyhowResult<()> { + test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Landscape16x9, "16:9").await + } + + #[tokio::test] + #[ignore] + async fn test_portrait_9x16() -> AnyhowResult<()> { + test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Portrait9x16, "9:16").await + } + + #[tokio::test] + #[ignore] + async fn test_square_1x1() -> AnyhowResult<()> { + test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Square1x1, "1:1").await + } + + #[tokio::test] + #[ignore] + async fn test_standard_4x3() -> AnyhowResult<()> { + test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Landscape4x3, "4:3").await + } + + #[tokio::test] + #[ignore] + async fn test_portrait_3x4() -> AnyhowResult<()> { + test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Portrait3x4, "3:4").await + } + } + + mod keyframe { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_keyframe_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session).await?; + println!("Uploaded start frame: {}", start_frame_url); + + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: "The corgi dog watches the lake as the sun sets.".to_string(), + aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16), + output_resolution: Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), + batch_count: None, + duration_seconds: 8, + start_frame_url: Some(start_frame_url), + }, + }).await?; + println!("keyframe 720p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_1080p_square() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session).await?; + println!("Uploaded start frame: {}", start_frame_url); + + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: "A dragon and a raptor fighting on the beach.".to_string(), + aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Square1x1), + output_resolution: Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), + batch_count: None, + duration_seconds: 15, + start_frame_url: Some(start_frame_url), + }, + }).await?; + println!("keyframe 1080p square — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_landscape_default_resolution() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session).await?; + println!("Uploaded start frame: {}", start_frame_url); + + let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args { + session: &session, + host_override: None, + request: GenerateHappyHorse1p0Request { + prompt: "The dog runs along the shore, kicking up sand.".to_string(), + aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: Some(start_frame_url), + }, + }).await?; + println!("keyframe landscape default res — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + async fn upload_test_image(session: &KinoviWebSession) -> AnyhowResult { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + Ok(upload_result.public_url) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0.rs new file mode 100644 index 0000000000..85b0e1ed4c --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0.rs @@ -0,0 +1,1178 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::pricing::cost::kinovi_seedance_fractional_generation_cost::KinoviSeedanceFractionalGenerationCost; +use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use crate::pricing::kinovi_pricing_rate::KinoviPricingRate; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, + KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, + WorkflowRunTaskRequest, +}; + +// ── Args ── + +pub struct GenerateSeedance2p0Args<'a> { + pub request: GenerateSeedance2p0Request, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateSeedance2p0Request { + pub prompt: String, + pub aspect_ratio: Option, + pub output_resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + pub start_frame_url: Option, + pub end_frame_url: Option, + pub reference_image_urls: Option>, + pub reference_video_urls: Option>, + pub reference_audio_urls: Option>, + pub character_ids: Option>, + pub use_face_blur_hack: Option, + /// Output video bitrate. None defaults to "standard"; `High` requests a + /// higher bitrate. Does not affect cost. + pub bitrate: Option, +} + +// ── Enums ── + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0AspectRatio { + Landscape16x9, + UltraWide21x9, + Portrait9x16, + Square1x1, + Standard4x3, + Portrait3x4, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0OutputResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, + FourK, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0BatchCount { + One, + Two, + Three, + Four, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0Bitrate { + High, +} + +// ── Pricing ── +// +// Seedance 2.0 Pro credit pricing (per second of output duration): +// +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 15 | — | +// | 720p | 40 | 37.9 | +// | 1080p | 90 | — | +// | 4K | 200 | — | +// +// Attaching reference VIDEOS adds a per-output-second surcharge, billed per +// second of OUTPUT duration (not the reference video's duration). Reference +// images and audio are free. +// +// | Resolution | Consumer surcharge/sec | Enterprise surcharge/sec | +// |------------|------------------------|--------------------------| +// | 480p | 4 | — | +// | 720p | 8 | 7.58 | +// | 1080p | 18 | — | +// | 4K | 40 | — | +// +// The enterprise 720p rates come from the negotiated combined prices: +// 40 → 37.9 credits/sec without a reference video, 48 → 45.48 with one; the +// surcharge is the difference (45.48 − 37.9 = 7.58). "—" = no negotiated +// discount; enterprise pricing falls back to the consumer credit rate. +// +// Default resolution (None) is 720p. Batch count multiplies the total cost. + +/// Per-second base credit rates by resolution. +const SEEDANCE_2P0_480P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 15.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P0_720P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 40.0, + maybe_enterprise_credits: Some(37.9), +}; +const SEEDANCE_2P0_1080P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 90.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P0_4K: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 200.0, + maybe_enterprise_credits: None, +}; + +/// Per-second video-reference surcharge rates by resolution. Flat per +/// generation regardless of how many reference videos are attached +/// (assumption — Kinovi's pricing page only shows one). +const SEEDANCE_2P0_480P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 4.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P0_720P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 8.0, + maybe_enterprise_credits: Some(7.58), +}; +const SEEDANCE_2P0_1080P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 18.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P0_4K_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 40.0, + maybe_enterprise_credits: None, +}; + +impl KinoviCostCalculatorTrait for GenerateSeedance2p0Request { + type Cost = KinoviSeedanceFractionalGenerationCost; + + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents, at the given pricing tier. + fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviSeedanceFractionalGenerationCost { + let base_rate = match self.output_resolution { + Some(KinoviSeedance2p0OutputResolution::FourEightyP) => SEEDANCE_2P0_480P, + Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_720P, + Some(KinoviSeedance2p0OutputResolution::TenEightyP) => SEEDANCE_2P0_1080P, + Some(KinoviSeedance2p0OutputResolution::FourK) => SEEDANCE_2P0_4K, + }; + let surcharge_rate = match self.output_resolution { + Some(KinoviSeedance2p0OutputResolution::FourEightyP) => SEEDANCE_2P0_480P_VIDEO_REF_SURCHARGE, + Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_720P_VIDEO_REF_SURCHARGE, + Some(KinoviSeedance2p0OutputResolution::TenEightyP) => SEEDANCE_2P0_1080P_VIDEO_REF_SURCHARGE, + Some(KinoviSeedance2p0OutputResolution::FourK) => SEEDANCE_2P0_4K_VIDEO_REF_SURCHARGE, + }; + + let batch_multiplier: f64 = match self.batch_count { + None | Some(KinoviSeedance2p0BatchCount::One) => 1.0, + Some(KinoviSeedance2p0BatchCount::Two) => 2.0, + Some(KinoviSeedance2p0BatchCount::Three) => 3.0, + Some(KinoviSeedance2p0BatchCount::Four) => 4.0, + }; + + let output_seconds = f64::from(self.duration_seconds) * batch_multiplier; + let base_credits = base_rate.credits(tier) * output_seconds; + let maybe_video_reference_surcharge_credits = if self.has_video_reference() { + Some(surcharge_rate.credits(tier) * output_seconds) + } else { + None + }; + + KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + tier, + base_credits, + maybe_video_reference_surcharge_credits, + ) + } +} + +impl GenerateSeedance2p0Request { + fn has_video_reference(&self) -> bool { + self.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()) + } +} + +// ── Response ── + +pub struct GenerateSeedance2p0Response { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedance_2p0( + args: GenerateSeedance2p0Args<'_>, +) -> Result { + let req = args.request; + + let raw_request = WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: req.prompt, + aspect_ratio: map_aspect_ratio(req.aspect_ratio), + output_resolution: Some(map_output_resolution(req.output_resolution)), + batch_count: map_batch_count(req.batch_count), + duration_seconds: req.duration_seconds, + start_frame_url: req.start_frame_url, + end_frame_url: req.end_frame_url, + reference_image_urls: req.reference_image_urls, + reference_video_urls: req.reference_video_urls, + reference_audio_urls: req.reference_audio_urls, + character_ids: req.character_ids, + use_face_blur_hack: req.use_face_blur_hack, + bitrate: map_bitrate(req.bitrate), + }; + + let raw_response = workflow_run_task(WorkflowRunTaskArgs { + request: raw_request, + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSeedance2p0Response { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Mapping helpers ── + +fn map_aspect_ratio(ar: Option) -> KinoviAspectRatioRaw { + match ar { + Some(KinoviSeedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9, + Some(KinoviSeedance2p0AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9, + Some(KinoviSeedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16, + Some(KinoviSeedance2p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1, + Some(KinoviSeedance2p0AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviSeedance2p0AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4, + None => KinoviAspectRatioRaw::Landscape16x9, + } +} + +fn map_output_resolution(res: Option) -> KinoviOutputResolutionRaw { + match res { + Some(KinoviSeedance2p0OutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP, + // Unset resolves to 720p — MUST stay in lockstep with calculate_costs(), + // which prices None as 720p. Never leave the resolution to the Kinovi + // server-side default; a silent upstream change would make supplier cost + // diverge from what we billed. + Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP, + Some(KinoviSeedance2p0OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP, + Some(KinoviSeedance2p0OutputResolution::FourK) => KinoviOutputResolutionRaw::FourK, + } +} + +fn map_batch_count(bc: Option) -> KinoviBatchCountRaw { + match bc { + Some(KinoviSeedance2p0BatchCount::One) | None => KinoviBatchCountRaw::One, + Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two, + Some(KinoviSeedance2p0BatchCount::Three) => KinoviBatchCountRaw::Three, + Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four, + } +} + +fn map_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(KinoviSeedance2p0Bitrate::High) => Some(KinoviBitrateRaw::High), + None => None, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod pricing_tests { + use super::*; + + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + // ── Consumer credit tables ── + // + // The standard published rates: 480p 15/s, 720p 40/s, 1080p 90/s, + // 4K 200/s. Batch count multiplies the total. + + mod consumer_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(consumer_credits(&r480(4)), 60.0); + assert_eq!(consumer_credits(&r480(5)), 75.0); + assert_eq!(consumer_credits(&r480(10)), 150.0); + assert_eq!(consumer_credits(&r480(15)), 225.0); + + assert_eq!(consumer_cents(&r480(4)), 32); + assert_eq!(consumer_cents(&r480(5)), 39); + assert_eq!(consumer_cents(&r480(10)), 78); + assert_eq!(consumer_cents(&r480(15)), 117); + } + + #[test] + fn table_720p() { + assert_eq!(consumer_credits(&r720(4)), 160.0); + assert_eq!(consumer_credits(&r720(5)), 200.0); + assert_eq!(consumer_credits(&r720(10)), 400.0); + assert_eq!(consumer_credits(&r720(15)), 600.0); + + assert_eq!(consumer_cents(&r720(4)), 83); + assert_eq!(consumer_cents(&r720(5)), 104); + assert_eq!(consumer_cents(&r720(10)), 208); + assert_eq!(consumer_cents(&r720(15)), 311); + } + + #[test] + fn table_720p_with_video_reference() { + // 48 credits/sec (40 base + 8 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r720(4))), 192.0); + assert_eq!(consumer_credits(&with_video_ref(r720(5))), 240.0); + assert_eq!(consumer_credits(&with_video_ref(r720(10))), 480.0); + assert_eq!(consumer_credits(&with_video_ref(r720(15))), 720.0); + + assert_eq!(consumer_cents(&with_video_ref(r720(4))), 100); + assert_eq!(consumer_cents(&with_video_ref(r720(5))), 125); + assert_eq!(consumer_cents(&with_video_ref(r720(10))), 249); + assert_eq!(consumer_cents(&with_video_ref(r720(15))), 374); + } + + #[test] + fn table_1080p() { + assert_eq!(consumer_credits(&r1080(4)), 360.0); + assert_eq!(consumer_credits(&r1080(5)), 450.0); + assert_eq!(consumer_credits(&r1080(10)), 900.0); + assert_eq!(consumer_credits(&r1080(15)), 1350.0); + + assert_eq!(consumer_cents(&r1080(4)), 187); + assert_eq!(consumer_cents(&r1080(5)), 234); + assert_eq!(consumer_cents(&r1080(10)), 467); + assert_eq!(consumer_cents(&r1080(15)), 700); + } + + #[test] + fn table_4k() { + assert_eq!(consumer_credits(&r4k(4)), 800.0); + assert_eq!(consumer_credits(&r4k(5)), 1000.0); + assert_eq!(consumer_credits(&r4k(10)), 2000.0); + assert_eq!(consumer_credits(&r4k(15)), 3000.0); + + assert_eq!(consumer_cents(&r4k(4)), 415); + assert_eq!(consumer_cents(&r4k(5)), 519); + assert_eq!(consumer_cents(&r4k(10)), 1037); + assert_eq!(consumer_cents(&r4k(15)), 1555); + } + + #[test] + fn table_4k_with_video_reference() { + // 240 credits/sec (200 base + 40 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r4k(4))), 960.0); + assert_eq!(consumer_credits(&with_video_ref(r4k(5))), 1200.0); + assert_eq!(consumer_credits(&with_video_ref(r4k(10))), 2400.0); + assert_eq!(consumer_credits(&with_video_ref(r4k(15))), 3600.0); + + assert_eq!(consumer_cents(&with_video_ref(r4k(4))), 498); + assert_eq!(consumer_cents(&with_video_ref(r4k(5))), 622); + assert_eq!(consumer_cents(&with_video_ref(r4k(10))), 1244); + assert_eq!(consumer_cents(&with_video_ref(r4k(15))), 1866); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(5).calculate_consumer_costs(); + let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_consumer_costs(); + assert_eq!(default, explicit); + } + } + + // ── Enterprise 720p (the only negotiated discount) ── + // + // Base 40 → 37.9 credits/sec; with a reference video 48 → 45.48 + // credits/sec (surcharge 8 → 7.58). + + mod enterprise_720p { + use super::*; + + #[test] + fn table_base() { + assert_eq!(enterprise_credits(&r720(4)), 151.6); + assert_eq!(enterprise_credits(&r720(5)), 189.5); + assert_eq!(enterprise_credits(&r720(10)), 379.0); + assert_eq!(enterprise_credits(&r720(15)), 568.5); + + assert_eq!(enterprise_cents(&r720(4)), 63); + assert_eq!(enterprise_cents(&r720(5)), 78); + assert_eq!(enterprise_cents(&r720(10)), 156); + assert_eq!(enterprise_cents(&r720(15)), 234); + } + + #[test] + fn table_with_video_reference() { + // 45.48 credits/sec combined. + assert_eq!(enterprise_credits(&with_video_ref(r720(4))), 181.92); + assert_eq!(enterprise_credits(&with_video_ref(r720(5))), 227.4); + assert_eq!(enterprise_credits(&with_video_ref(r720(10))), 454.8); + assert_eq!(enterprise_credits(&with_video_ref(r720(15))), 682.2); + + assert_eq!(enterprise_cents(&with_video_ref(r720(4))), 75); + assert_eq!(enterprise_cents(&with_video_ref(r720(5))), 94); + assert_eq!(enterprise_cents(&with_video_ref(r720(10))), 188); + assert_eq!(enterprise_cents(&with_video_ref(r720(15))), 281); + } + + #[test] + fn base_and_surcharge_breakdown_5s() { + let costs = with_video_ref(r720(5)).calculate_enterprise_costs(); + assert_eq!(costs.base_cost.kinovi_credits, 189.5); // 5s × 37.9 + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(37.9)); // 5s × 7.58 + assert_eq!(costs.total_cost.kinovi_credits, 227.4); // 5s × 45.48 + + assert_eq!(costs.total_cost.usd_cents_rounded_up, 94); + assert_eq!(costs.total_cost.usd_cents_rounded_down, 93); + assert!((costs.total_cost.usd_cents_fractional - 93.5187).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn batch_multiplies_discounted_credits() { + let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_enterprise_costs(); + assert_eq!(batch4.total_cost.kinovi_credits, 758.0); // 189.5 × 4 + } + } + + // ── Enterprise fallback (no negotiated discount at 480p/1080p/4K) ── + // + // Credits fall back to the consumer rate; only the credit purchase rate + // (243.16 vs 192.98 credits/$1) makes enterprise cheaper in USD. + + mod enterprise_fallback { + use super::*; + + #[test] + fn credits_match_consumer_rates() { + for request in [r480(5), r1080(5), r4k(5), with_video_ref(r4k(5))] { + assert_eq!(enterprise_credits(&request), consumer_credits(&request)); + } + } + + #[test] + fn table_480p() { + assert_eq!(enterprise_credits(&r480(4)), 60.0); + assert_eq!(enterprise_credits(&r480(5)), 75.0); + assert_eq!(enterprise_credits(&r480(10)), 150.0); + assert_eq!(enterprise_credits(&r480(15)), 225.0); + + assert_eq!(enterprise_cents(&r480(4)), 25); + assert_eq!(enterprise_cents(&r480(5)), 31); + assert_eq!(enterprise_cents(&r480(10)), 62); + assert_eq!(enterprise_cents(&r480(15)), 93); + } + + #[test] + fn table_1080p() { + assert_eq!(enterprise_credits(&r1080(4)), 360.0); + assert_eq!(enterprise_credits(&r1080(5)), 450.0); + assert_eq!(enterprise_credits(&r1080(10)), 900.0); + assert_eq!(enterprise_credits(&r1080(15)), 1350.0); + + assert_eq!(enterprise_cents(&r1080(4)), 149); + assert_eq!(enterprise_cents(&r1080(5)), 186); + assert_eq!(enterprise_cents(&r1080(10)), 371); + assert_eq!(enterprise_cents(&r1080(15)), 556); + } + + #[test] + fn table_4k() { + assert_eq!(enterprise_credits(&r4k(4)), 800.0); + assert_eq!(enterprise_credits(&r4k(5)), 1000.0); + assert_eq!(enterprise_credits(&r4k(10)), 2000.0); + assert_eq!(enterprise_credits(&r4k(15)), 3000.0); + + assert_eq!(enterprise_cents(&r4k(4)), 330); + assert_eq!(enterprise_cents(&r4k(5)), 412); + assert_eq!(enterprise_cents(&r4k(10)), 823); + assert_eq!(enterprise_cents(&r4k(15)), 1234); + } + + #[test] + fn usd_is_still_cheaper_at_the_enterprise_purchase_rate() { + for request in [r480(5), r1080(5), r4k(5)] { + let consumer = request.calculate_consumer_costs().total_cost.usd_cents_fractional; + let enterprise = request.calculate_enterprise_costs().total_cost.usd_cents_fractional; + assert!(enterprise < consumer); + } + } + } + + // ── Video-reference surcharge ── + // + // Attaching a reference video adds a per-output-second surcharge + // (consumer: 480p +4/s, 720p +8/s, 1080p +18/s, 4K +40/s). Flat per + // generation regardless of how many reference videos are attached. + + mod video_reference_surcharge_tests { + use super::*; + + /// The full consumer base + surcharge table. Asserts every part: base, + /// surcharge, and the derived total. + #[test] + fn consumer_base_and_surcharge_table() { + // (request, duration, base credits, surcharge credits) + let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, f64, f64)] = &[ + // 480p: base 15/s + 4/s + (r480, 5, 75.0, 20.0), + (r480, 10, 150.0, 40.0), + // 720p: base 40/s + 8/s + (r720, 5, 200.0, 40.0), + (r720, 10, 400.0, 80.0), + // 1080p: base 90/s + 18/s + (r1080, 5, 450.0, 90.0), + (r1080, 10, 900.0, 180.0), + // 4K: base 200/s + 40/s + (r4k, 5, 1000.0, 200.0), + (r4k, 10, 2000.0, 400.0), + ]; + + for (make, duration, base, surcharge) in cases { + let costs = with_video_ref(make(*duration)).calculate_consumer_costs(); + assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s"); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s"); + assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s"); + } + } + + /// The base and surcharge parts each carry their own USD conversions. + #[test] + fn parts_have_their_own_usd_conversions() { + // Consumer 720p 5s + video ref: base 200, surcharge 40, total 240. + let costs = with_video_ref(r720(5)).calculate_consumer_costs(); + + assert_eq!(costs.base_cost.usd_cents_rounded_up, 104); + assert_eq!(costs.base_cost.usd_cents_rounded_down, 103); + assert!((costs.base_cost.usd_cents_fractional - 103.6377).abs() < FLOAT_TOLERANCE); + + let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge"); + assert_eq!(surcharge.kinovi_credits, 40.0); + assert_eq!(surcharge.usd_cents_rounded_up, 21); + assert_eq!(surcharge.usd_cents_rounded_down, 20); + assert!((surcharge.usd_cents_fractional - 20.7275).abs() < FLOAT_TOLERANCE); + + assert_eq!(costs.total_cost.kinovi_credits, 240.0); + assert_eq!(costs.total_cost.usd_cents_rounded_up, 125); + assert_eq!(costs.total_cost.usd_cents_rounded_down, 124); + assert!((costs.total_cost.usd_cents_fractional - 124.3652).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn empty_video_reference_list_has_no_surcharge() { + let mut request = r720(5); + request.reference_video_urls = Some(vec![]); + for costs in [request.calculate_consumer_costs(), request.calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + } + } + + /// Surcharge is flat per generation regardless of how many reference + /// videos are attached (assumption — Kinovi's page only shows one). + #[test] + fn multiple_video_references_charge_once() { + let mut request = r720(5); + request.reference_video_urls = Some(vec![ + "https://example.com/a.mp4".to_string(), + "https://example.com/b.mp4".to_string(), + ]); + assert_eq!(consumer_credits(&request), 240.0); + assert_eq!(enterprise_credits(&request), 227.4); + } + + /// The surcharge applies per generated video, so batches multiply it. + #[test] + fn batch_multiplies_surcharge() { + let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two))); + // (200 base + 40 surcharge) × 2 = 480 credits. + let costs = request.calculate_consumer_costs(); + assert_eq!(costs.base_cost.kinovi_credits, 400.0); + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(80.0)); + assert_eq!(costs.total_cost.kinovi_credits, 480.0); + } + } + + // ── Batch multiplier ── + + mod batch_tests { + use super::*; + + #[test] + fn batch_multiplies_at_both_tiers() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits; + for (batch, multiplier) in [ + (KinoviSeedance2p0BatchCount::Two, 2.0), + (KinoviSeedance2p0BatchCount::Three, 3.0), + (KinoviSeedance2p0BatchCount::Four, 4.0), + ] { + let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits; + assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}"); + } + } + } + + #[test] + fn batch_1_is_base() { + let base = consumer_credits(&r720(5)); + let explicit = consumer_credits(&build_request(5, None, Some(KinoviSeedance2p0BatchCount::One))); + assert_eq!(base, explicit); + } + } + + // ── Relative pricing ── + + mod relative_tests { + use super::*; + + #[test] + fn resolution_ordering_at_both_tiers() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + let c480 = r480(5).calculate_costs(tier).total_cost.kinovi_credits; + let c720 = r720(5).calculate_costs(tier).total_cost.kinovi_credits; + let c1080 = r1080(5).calculate_costs(tier).total_cost.kinovi_credits; + let c4k = r4k(5).calculate_costs(tier).total_cost.kinovi_credits; + assert!(c480 < c720 && c720 < c1080 && c1080 < c4k, "{tier:?}"); + } + } + + #[test] + fn cost_scales_with_duration() { + let c3 = enterprise_credits(&r720(3)); + let c10 = enterprise_credits(&r720(10)); + let c15 = enterprise_credits(&r720(15)); + assert!(c3 < c10); + assert!(c10 < c15); + } + } + + // ── Tier dispatch ── + + #[test] + fn convenience_methods_match_explicit_tier() { + let request = with_video_ref(r720(5)); + assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer)); + assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise)); + } + + // ── Aspect ratio doesn't affect cost ── + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + + let ratios = [ + KinoviSeedance2p0AspectRatio::Landscape16x9, + KinoviSeedance2p0AspectRatio::UltraWide21x9, + KinoviSeedance2p0AspectRatio::Portrait9x16, + KinoviSeedance2p0AspectRatio::Square1x1, + KinoviSeedance2p0AspectRatio::Standard4x3, + KinoviSeedance2p0AspectRatio::Portrait3x4, + ]; + + for ar in &ratios { + let mut request = r720(5); + request.aspect_ratio = Some(*ar); + assert_eq!( + consumer_credits(&request), baseline, + "Aspect ratio {:?} should not change credits from baseline {}", ar, baseline, + ); + } + } + + // ── Bitrate doesn't affect cost ── + + #[test] + fn high_bitrate_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + + let mut high = r720(5); + high.bitrate = Some(KinoviSeedance2p0Bitrate::High); + + assert_eq!( + consumer_credits(&high), baseline, + "High bitrate should not change credits from baseline {}", baseline, + ); + } + + // ── Helpers ── + + fn build_request( + duration_seconds: u8, + output_resolution: Option, + batch_count: Option, + ) -> GenerateSeedance2p0Request { + GenerateSeedance2p0Request { + prompt: String::new(), + aspect_ratio: None, + output_resolution, + batch_count, + duration_seconds, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + fn r480(dur: u8) -> GenerateSeedance2p0Request { + build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None) + } + + fn r720(dur: u8) -> GenerateSeedance2p0Request { + build_request(dur, None, None) + } + + fn r1080(dur: u8) -> GenerateSeedance2p0Request { + build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None) + } + + fn r4k(dur: u8) -> GenerateSeedance2p0Request { + build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourK), None) + } + + fn with_video_ref(mut request: GenerateSeedance2p0Request) -> GenerateSeedance2p0Request { + request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]); + request + } + + fn consumer_credits(request: &GenerateSeedance2p0Request) -> f64 { + request.calculate_consumer_costs().total_cost.kinovi_credits + } + + fn consumer_cents(request: &GenerateSeedance2p0Request) -> u64 { + request.calculate_consumer_costs().total_cost.usd_cents_rounded_up + } + + fn enterprise_credits(request: &GenerateSeedance2p0Request) -> f64 { + request.calculate_enterprise_costs().total_cost.kinovi_credits + } + + fn enterprise_cents(request: &GenerateSeedance2p0Request) -> u64 { + request.calculate_enterprise_costs().total_cost.usd_cents_rounded_up + } + } + + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + + const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te"; + const MOCHI_ID: &str = "char_1775177718294_g2pitx"; + + mod text_to_video { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_default() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "A corgi and a shiba are playing chess against one another".to_string(), + aspect_ratio: None, + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "A dragon soaring over a medieval castle at sunset".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: Some(KinoviSeedance2p0OutputResolution::TenEightyP), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod ultra_wide { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_21x9() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "A corgi is riding on the back of a sauropod dinosaur".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_21x9() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "The dogs sail across the ocean on a treasure ship.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: Some(start_frame_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod keyframe { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_keyframe_start_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "The corgi dog watches the lake as the sun sets.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: Some(start_frame_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_start_and_end_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?; + let end_frame_url = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "The dog walks from the lake toward the camera.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: Some(start_frame_url), + end_frame_url: Some(end_frame_url), + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod image_reference { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?; + let img2 = upload_test_image(&session, test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL).await?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "The dog in @1 runs through the scenery in @2.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![img1, img2]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + /// 4K with image references (Seedance 2.0 only), 5-second clip. + #[tokio::test] + #[ignore] + async fn test_4k_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?; + let img2 = upload_test_image(&session, test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL).await?; + let img3 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "The dog in @1 explores the scenery in @3 and meets the friendly man in @2. Cinematic 4K detail.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: Some(KinoviSeedance2p0OutputResolution::FourK), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![img1, img2, img3]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + } + + mod video_reference { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_video_reference() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "Change @video1 to a nighttime scene with moonlight.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod character_reference { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_single_character() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "@Steampunk Clown is juggling flaming torches in a circus tent.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_two_characters() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = generate_seedance_2p0(GenerateSeedance2p0Args { + session: &session, + host_override: None, + request: GenerateSeedance2p0Request { + prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + async fn upload_test_image(session: &KinoviWebSession, image_url: &str) -> AnyhowResult { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + image_url, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + Ok(upload_result.public_url) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_fast.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_fast.rs new file mode 100644 index 0000000000..1d9a7caf8c --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_fast.rs @@ -0,0 +1,1021 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::pricing::cost::kinovi_seedance_fractional_generation_cost::KinoviSeedanceFractionalGenerationCost; +use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use crate::pricing::kinovi_pricing_rate::KinoviPricingRate; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, + KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, + WorkflowRunTaskRequest, +}; + +// ── Args ── + +pub struct GenerateSeedance2p0FastArgs<'a> { + pub request: GenerateSeedance2p0FastRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateSeedance2p0FastRequest { + pub prompt: String, + pub aspect_ratio: Option, + pub output_resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + pub start_frame_url: Option, + pub end_frame_url: Option, + pub reference_image_urls: Option>, + pub reference_video_urls: Option>, + pub reference_audio_urls: Option>, + pub character_ids: Option>, + pub use_face_blur_hack: Option, + /// Output video bitrate. None defaults to "standard"; `High` requests a + /// higher bitrate. Does not affect cost. + pub bitrate: Option, +} + +// ── Enums ── + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0FastAspectRatio { + Landscape16x9, + UltraWide21x9, + Portrait9x16, + Square1x1, + Standard4x3, + Portrait3x4, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0FastOutputResolution { + FourEightyP, + SevenTwentyP, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0FastBatchCount { + One, + Two, + Three, + Four, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0FastBitrate { + High, +} + +// ── Pricing ── +// +// Seedance 2.0 Fast credit pricing (per second of output duration): +// +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 14 | 10.5 | +// | 720p | 28 | 21 | +// +// Attaching reference VIDEOS adds a per-output-second surcharge, billed per +// second of OUTPUT duration (not the reference video's duration). Reference +// images and audio are free. +// +// | Resolution | Consumer surcharge/sec | Enterprise surcharge/sec | +// |------------|------------------------|--------------------------| +// | 480p | 4 | 3 | +// | 720p | 6 | 4.5 | +// +// The enterprise rates come from the negotiated combined prices: 480p +// 14 → 10.5 credits/sec without a reference video and 18 → 13.5 with one; +// 720p 28 → 21 and 34 → 25.5. The surcharge is the difference +// (13.5 − 10.5 = 3; 25.5 − 21 = 4.5). +// +// Default resolution (None) is 720p. Batch count multiplies the total cost. + +/// Per-second base credit rates by resolution. +const SEEDANCE_2P0_FAST_480P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 14.0, + maybe_enterprise_credits: Some(10.5), +}; +const SEEDANCE_2P0_FAST_720P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 28.0, + maybe_enterprise_credits: Some(21.0), +}; + +/// Per-second video-reference surcharge rates by resolution. Flat per +/// generation regardless of how many reference videos are attached +/// (assumption — Kinovi's pricing page only shows one). +const SEEDANCE_2P0_FAST_480P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 4.0, + maybe_enterprise_credits: Some(3.0), +}; +const SEEDANCE_2P0_FAST_720P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 6.0, + maybe_enterprise_credits: Some(4.5), +}; + +impl KinoviCostCalculatorTrait for GenerateSeedance2p0FastRequest { + type Cost = KinoviSeedanceFractionalGenerationCost; + + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents, at the given pricing tier. + fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviSeedanceFractionalGenerationCost { + let base_rate = match self.output_resolution { + Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => SEEDANCE_2P0_FAST_480P, + Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_FAST_720P, + }; + let surcharge_rate = match self.output_resolution { + Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => SEEDANCE_2P0_FAST_480P_VIDEO_REF_SURCHARGE, + Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_FAST_720P_VIDEO_REF_SURCHARGE, + }; + + let batch_multiplier: f64 = match self.batch_count { + None | Some(KinoviSeedance2p0FastBatchCount::One) => 1.0, + Some(KinoviSeedance2p0FastBatchCount::Two) => 2.0, + Some(KinoviSeedance2p0FastBatchCount::Three) => 3.0, + Some(KinoviSeedance2p0FastBatchCount::Four) => 4.0, + }; + + let output_seconds = f64::from(self.duration_seconds) * batch_multiplier; + let base_credits = base_rate.credits(tier) * output_seconds; + let maybe_video_reference_surcharge_credits = if self.has_video_reference() { + Some(surcharge_rate.credits(tier) * output_seconds) + } else { + None + }; + + KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + tier, + base_credits, + maybe_video_reference_surcharge_credits, + ) + } +} + +impl GenerateSeedance2p0FastRequest { + fn has_video_reference(&self) -> bool { + self.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()) + } +} + +// ── Response ── + +pub struct GenerateSeedance2p0FastResponse { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedance_2p0_fast( + args: GenerateSeedance2p0FastArgs<'_>, +) -> Result { + let req = args.request; + + let raw_request = WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Fast, + prompt: req.prompt, + aspect_ratio: map_aspect_ratio(req.aspect_ratio), + output_resolution: Some(map_output_resolution(req.output_resolution)), + batch_count: map_batch_count(req.batch_count), + duration_seconds: req.duration_seconds, + start_frame_url: req.start_frame_url, + end_frame_url: req.end_frame_url, + reference_image_urls: req.reference_image_urls, + reference_video_urls: req.reference_video_urls, + reference_audio_urls: req.reference_audio_urls, + character_ids: req.character_ids, + use_face_blur_hack: req.use_face_blur_hack, + bitrate: map_bitrate(req.bitrate), + }; + + let raw_response = workflow_run_task(WorkflowRunTaskArgs { + request: raw_request, + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSeedance2p0FastResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Mapping helpers ── + +fn map_aspect_ratio(ar: Option) -> KinoviAspectRatioRaw { + match ar { + Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9, + Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9, + Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16, + Some(KinoviSeedance2p0FastAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1, + Some(KinoviSeedance2p0FastAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviSeedance2p0FastAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4, + None => KinoviAspectRatioRaw::Landscape16x9, + } +} + +fn map_output_resolution(res: Option) -> KinoviOutputResolutionRaw { + match res { + Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP, + // Unset resolves to 720p — MUST stay in lockstep with calculate_costs(), + // which prices None as 720p. + Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP, + } +} + +fn map_batch_count(bc: Option) -> KinoviBatchCountRaw { + match bc { + Some(KinoviSeedance2p0FastBatchCount::One) | None => KinoviBatchCountRaw::One, + Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two, + Some(KinoviSeedance2p0FastBatchCount::Three) => KinoviBatchCountRaw::Three, + Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four, + } +} + +fn map_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(KinoviSeedance2p0FastBitrate::High) => Some(KinoviBitrateRaw::High), + None => None, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod pricing_tests { + use super::*; + + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + // ── Consumer pricing tables ── + // + // The standard published rates: 480p 14/s (+4/s video ref), 720p 28/s + // (+6/s). Cents convert at 192.98 credits/$1, rounded up. + + mod consumer_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(consumer_credits(&r480(4)), 56.0); + assert_eq!(consumer_credits(&r480(5)), 70.0); + assert_eq!(consumer_credits(&r480(10)), 140.0); + assert_eq!(consumer_credits(&r480(15)), 210.0); + + assert_eq!(consumer_cents(&r480(4)), 30); + assert_eq!(consumer_cents(&r480(5)), 37); + assert_eq!(consumer_cents(&r480(10)), 73); + assert_eq!(consumer_cents(&r480(15)), 109); + } + + #[test] + fn table_480p_with_video_reference() { + // 18 credits/sec combined (14 base + 4 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r480(4))), 72.0); + assert_eq!(consumer_credits(&with_video_ref(r480(5))), 90.0); + assert_eq!(consumer_credits(&with_video_ref(r480(10))), 180.0); + assert_eq!(consumer_credits(&with_video_ref(r480(15))), 270.0); + + assert_eq!(consumer_cents(&with_video_ref(r480(4))), 38); + assert_eq!(consumer_cents(&with_video_ref(r480(5))), 47); + assert_eq!(consumer_cents(&with_video_ref(r480(10))), 94); + assert_eq!(consumer_cents(&with_video_ref(r480(15))), 140); + } + + #[test] + fn table_720p() { + assert_eq!(consumer_credits(&r720(4)), 112.0); + assert_eq!(consumer_credits(&r720(5)), 140.0); + assert_eq!(consumer_credits(&r720(10)), 280.0); + assert_eq!(consumer_credits(&r720(15)), 420.0); + + assert_eq!(consumer_cents(&r720(4)), 59); + assert_eq!(consumer_cents(&r720(5)), 73); + assert_eq!(consumer_cents(&r720(10)), 146); + assert_eq!(consumer_cents(&r720(15)), 218); + } + + #[test] + fn table_720p_with_video_reference() { + // 34 credits/sec combined (28 base + 6 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r720(4))), 136.0); + assert_eq!(consumer_credits(&with_video_ref(r720(5))), 170.0); + assert_eq!(consumer_credits(&with_video_ref(r720(10))), 340.0); + assert_eq!(consumer_credits(&with_video_ref(r720(15))), 510.0); + + assert_eq!(consumer_cents(&with_video_ref(r720(4))), 71); + assert_eq!(consumer_cents(&with_video_ref(r720(5))), 89); + assert_eq!(consumer_cents(&with_video_ref(r720(10))), 177); + assert_eq!(consumer_cents(&with_video_ref(r720(15))), 265); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(5).calculate_consumer_costs(); + let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_consumer_costs(); + assert_eq!(default, explicit); + } + } + + // ── Enterprise pricing tables (25% discount on every rate) ── + // + // 480p 10.5/s (+3/s video ref), 720p 21/s (+4.5/s). Cents convert at the + // bulk rate of 243.16 credits/$1, rounded up. + + mod enterprise_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(enterprise_credits(&r480(4)), 42.0); + assert_eq!(enterprise_credits(&r480(5)), 52.5); + assert_eq!(enterprise_credits(&r480(10)), 105.0); + assert_eq!(enterprise_credits(&r480(15)), 157.5); + + assert_eq!(enterprise_cents(&r480(4)), 18); + assert_eq!(enterprise_cents(&r480(5)), 22); + assert_eq!(enterprise_cents(&r480(10)), 44); + assert_eq!(enterprise_cents(&r480(15)), 65); + } + + #[test] + fn table_480p_with_video_reference() { + // 13.5 credits/sec combined (10.5 base + 3 surcharge). + assert_eq!(enterprise_credits(&with_video_ref(r480(4))), 54.0); + assert_eq!(enterprise_credits(&with_video_ref(r480(5))), 67.5); + assert_eq!(enterprise_credits(&with_video_ref(r480(10))), 135.0); + assert_eq!(enterprise_credits(&with_video_ref(r480(15))), 202.5); + + assert_eq!(enterprise_cents(&with_video_ref(r480(4))), 23); + assert_eq!(enterprise_cents(&with_video_ref(r480(5))), 28); + assert_eq!(enterprise_cents(&with_video_ref(r480(10))), 56); + assert_eq!(enterprise_cents(&with_video_ref(r480(15))), 84); + } + + #[test] + fn table_720p() { + assert_eq!(enterprise_credits(&r720(4)), 84.0); + assert_eq!(enterprise_credits(&r720(5)), 105.0); + assert_eq!(enterprise_credits(&r720(10)), 210.0); + assert_eq!(enterprise_credits(&r720(15)), 315.0); + + assert_eq!(enterprise_cents(&r720(4)), 35); + assert_eq!(enterprise_cents(&r720(5)), 44); + assert_eq!(enterprise_cents(&r720(10)), 87); + assert_eq!(enterprise_cents(&r720(15)), 130); + } + + #[test] + fn table_720p_with_video_reference() { + // 25.5 credits/sec combined (21 base + 4.5 surcharge). + assert_eq!(enterprise_credits(&with_video_ref(r720(4))), 102.0); + assert_eq!(enterprise_credits(&with_video_ref(r720(5))), 127.5); + assert_eq!(enterprise_credits(&with_video_ref(r720(10))), 255.0); + assert_eq!(enterprise_credits(&with_video_ref(r720(15))), 382.5); + + assert_eq!(enterprise_cents(&with_video_ref(r720(4))), 42); + assert_eq!(enterprise_cents(&with_video_ref(r720(5))), 53); + assert_eq!(enterprise_cents(&with_video_ref(r720(10))), 105); + assert_eq!(enterprise_cents(&with_video_ref(r720(15))), 158); + } + + #[test] + fn base_and_surcharge_breakdown_5s() { + let costs = with_video_ref(r720(5)).calculate_enterprise_costs(); + assert_eq!(costs.base_cost.kinovi_credits, 105.0); // 5s × 21 + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(22.5)); // 5s × 4.5 + assert_eq!(costs.total_cost.kinovi_credits, 127.5); // 5s × 25.5 + + assert_eq!(costs.total_cost.usd_cents_rounded_up, 53); + assert_eq!(costs.total_cost.usd_cents_rounded_down, 52); + assert!((costs.total_cost.usd_cents_fractional - 52.4346).abs() < FLOAT_TOLERANCE); + } + } + + // ── Video-reference surcharge ── + + mod video_reference_surcharge_tests { + use super::*; + + /// The full base + surcharge breakdown at both tiers. Asserts every + /// part: base, surcharge, and the derived total. + #[test] + fn base_and_surcharge_tables() { + // (tier, request builder, duration, base credits, surcharge credits) + let cases: &[(KinoviPricingTier, fn(u8) -> GenerateSeedance2p0FastRequest, u8, f64, f64)] = &[ + // Consumer 480p: 14/s base, +4/s surcharge + (KinoviPricingTier::Consumer, r480, 5, 70.0, 20.0), + (KinoviPricingTier::Consumer, r480, 10, 140.0, 40.0), + // Consumer 720p: 28/s base, +6/s surcharge + (KinoviPricingTier::Consumer, r720, 5, 140.0, 30.0), + (KinoviPricingTier::Consumer, r720, 10, 280.0, 60.0), + // Enterprise 480p: 10.5/s base, +3/s surcharge + (KinoviPricingTier::Enterprise, r480, 5, 52.5, 15.0), + (KinoviPricingTier::Enterprise, r480, 10, 105.0, 30.0), + // Enterprise 720p: 21/s base, +4.5/s surcharge + (KinoviPricingTier::Enterprise, r720, 5, 105.0, 22.5), + (KinoviPricingTier::Enterprise, r720, 10, 210.0, 45.0), + ]; + + for (tier, make, duration, base, surcharge) in cases { + let costs = with_video_ref(make(*duration)).calculate_costs(*tier); + assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s ({tier:?})"); + assert_eq!( + costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), + Some(*surcharge), + "surcharge at {duration}s ({tier:?})", + ); + assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s ({tier:?})"); + } + } + + /// The base and surcharge parts each carry their own USD conversions. + #[test] + fn parts_have_their_own_usd_conversions() { + // Consumer 720p 5s + video ref: base 140, surcharge 30, total 170. + let costs = with_video_ref(r720(5)).calculate_consumer_costs(); + + assert_eq!(costs.base_cost.usd_cents_rounded_up, 73); + assert_eq!(costs.base_cost.usd_cents_rounded_down, 72); + assert!((costs.base_cost.usd_cents_fractional - 72.5464).abs() < FLOAT_TOLERANCE); + + let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge"); + assert_eq!(surcharge.kinovi_credits, 30.0); + assert_eq!(surcharge.usd_cents_rounded_up, 16); + assert_eq!(surcharge.usd_cents_rounded_down, 15); + assert!((surcharge.usd_cents_fractional - 15.5457).abs() < FLOAT_TOLERANCE); + + assert_eq!(costs.total_cost.kinovi_credits, 170.0); + assert_eq!(costs.total_cost.usd_cents_rounded_up, 89); + assert_eq!(costs.total_cost.usd_cents_rounded_down, 88); + assert!((costs.total_cost.usd_cents_fractional - 88.0920).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn no_video_reference_has_no_surcharge() { + for costs in [r720(5).calculate_consumer_costs(), r720(5).calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + assert_eq!(costs.total_cost, costs.base_cost); + } + } + + #[test] + fn empty_video_reference_list_has_no_surcharge() { + let mut request = r720(5); + request.reference_video_urls = Some(vec![]); + for costs in [request.calculate_consumer_costs(), request.calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + } + } + + /// Surcharge is flat per generation regardless of how many reference + /// videos are attached (assumption — Kinovi's page only shows one). + #[test] + fn multiple_video_references_charge_once() { + let mut request = r720(5); + request.reference_video_urls = Some(vec![ + "https://example.com/a.mp4".to_string(), + "https://example.com/b.mp4".to_string(), + ]); + assert_eq!(consumer_credits(&request), 170.0); + assert_eq!(enterprise_credits(&request), 127.5); + } + + /// The surcharge applies per generated video, so batches multiply it. + #[test] + fn batch_multiplies_surcharge() { + let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two))); + + // Consumer: (140 base + 30 surcharge) × 2. + let consumer = request.calculate_consumer_costs(); + assert_eq!(consumer.base_cost.kinovi_credits, 280.0); + assert_eq!(consumer.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(60.0)); + assert_eq!(consumer.total_cost.kinovi_credits, 340.0); + + // Enterprise: (105 base + 22.5 surcharge) × 2. + let enterprise = request.calculate_enterprise_costs(); + assert_eq!(enterprise.base_cost.kinovi_credits, 210.0); + assert_eq!(enterprise.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(45.0)); + assert_eq!(enterprise.total_cost.kinovi_credits, 255.0); + } + } + + // ── Batch multiplier ── + + mod batch_tests { + use super::*; + + #[test] + fn batch_multiplies_at_both_tiers() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits; + for (batch, multiplier) in [ + (KinoviSeedance2p0FastBatchCount::Two, 2.0), + (KinoviSeedance2p0FastBatchCount::Three, 3.0), + (KinoviSeedance2p0FastBatchCount::Four, 4.0), + ] { + let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits; + assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}"); + } + } + } + + #[test] + fn batch_1_is_base() { + let base = consumer_credits(&r720(5)); + let explicit = consumer_credits(&build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One))); + assert_eq!(base, explicit); + } + } + + // ── Tier dispatch ── + + #[test] + fn convenience_methods_match_explicit_tier() { + let request = with_video_ref(r720(5)); + assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer)); + assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise)); + } + + // ── Defaults & invariants ── + + #[test] + fn cost_scales_with_duration() { + let c3 = enterprise_credits(&r720(3)); + let c10 = enterprise_credits(&r720(10)); + let c15 = enterprise_credits(&r720(15)); + assert!(c3 < c10); + assert!(c10 < c15); + } + + #[test] + fn resolution_480p_cheaper_than_720p_at_both_tiers() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + for dur in 3..=15u8 { + let c480 = r480(dur).calculate_costs(tier).total_cost.kinovi_credits; + let c720 = r720(dur).calculate_costs(tier).total_cost.kinovi_credits; + assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s ({tier:?})"); + } + } + } + + #[test] + fn enterprise_is_cheaper_than_consumer() { + for request in [r480(5), r720(5), with_video_ref(r480(5)), with_video_ref(r720(5))] { + let consumer = request.calculate_consumer_costs().total_cost; + let enterprise = request.calculate_enterprise_costs().total_cost; + assert!(enterprise.kinovi_credits < consumer.kinovi_credits); + assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional); + } + } + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + + let ratios = [ + KinoviSeedance2p0FastAspectRatio::Landscape16x9, + KinoviSeedance2p0FastAspectRatio::UltraWide21x9, + KinoviSeedance2p0FastAspectRatio::Portrait9x16, + KinoviSeedance2p0FastAspectRatio::Square1x1, + KinoviSeedance2p0FastAspectRatio::Standard4x3, + KinoviSeedance2p0FastAspectRatio::Portrait3x4, + ]; + + for ar in &ratios { + let mut request = r720(5); + request.aspect_ratio = Some(*ar); + assert_eq!( + consumer_credits(&request), baseline, + "Aspect ratio {:?} should not change credits from baseline {}", ar, baseline, + ); + } + } + + // ── Bitrate doesn't affect cost ── + + #[test] + fn high_bitrate_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + + let mut high = r720(5); + high.bitrate = Some(KinoviSeedance2p0FastBitrate::High); + + assert_eq!( + consumer_credits(&high), baseline, + "High bitrate should not change credits from baseline {}", baseline, + ); + } + + // ── Helpers ── + + fn build_request( + duration_seconds: u8, + output_resolution: Option, + batch_count: Option, + ) -> GenerateSeedance2p0FastRequest { + GenerateSeedance2p0FastRequest { + prompt: String::new(), + aspect_ratio: None, + output_resolution, + batch_count, + duration_seconds, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + fn r480(dur: u8) -> GenerateSeedance2p0FastRequest { + build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None) + } + + fn r720(dur: u8) -> GenerateSeedance2p0FastRequest { + build_request(dur, None, None) + } + + fn with_video_ref(mut request: GenerateSeedance2p0FastRequest) -> GenerateSeedance2p0FastRequest { + request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]); + request + } + + fn consumer_credits(request: &GenerateSeedance2p0FastRequest) -> f64 { + request.calculate_consumer_costs().total_cost.kinovi_credits + } + + fn consumer_cents(request: &GenerateSeedance2p0FastRequest) -> u64 { + request.calculate_consumer_costs().total_cost.usd_cents_rounded_up + } + + fn enterprise_credits(request: &GenerateSeedance2p0FastRequest) -> f64 { + request.calculate_enterprise_costs().total_cost.kinovi_credits + } + + fn enterprise_cents(request: &GenerateSeedance2p0FastRequest) -> u64 { + request.calculate_enterprise_costs().total_cost.usd_cents_rounded_up + } + } + + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + + const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te"; + const MOCHI_ID: &str = "char_1775177718294_g2pitx"; + + mod text_to_video { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_default() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "A corgi and a shiba are playing chess against one another".to_string(), + aspect_ratio: None, + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "A golden retriever running through a field of sunflowers".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9), + output_resolution: Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod ultra_wide { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_21x9() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "A shiba is riding on the back of a sauropod dinosaur".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9), + output_resolution: Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_21x9() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?; + + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "The dogs in @1 set sail across the ocean on a treasure ship.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![start_frame_url]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod keyframe { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_keyframe_start_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?; + + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "A corgi dog runs along the lake shore, splashing water.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: Some(start_frame_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod image_reference { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let img1 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?; + let img2 = upload_test_image(&session, test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL).await?; + + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "The dog in @1 runs through the scenery in @2. Golden hour.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![img1, img2]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod video_reference { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_video_reference() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "Change @video1 to night time.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod character_reference { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_single_character() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "@Mochi the shiba inu is eating a cheese pizza on the table.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![MOCHI_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_two_characters() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0FastRequest { + prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + async fn upload_test_image(session: &KinoviWebSession, image_url: &str) -> AnyhowResult { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + image_url, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + Ok(upload_result.public_url) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs new file mode 100644 index 0000000000..e35dfd3e3b --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p0_mini.rs @@ -0,0 +1,911 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::pricing::cost::kinovi_seedance_fractional_generation_cost::KinoviSeedanceFractionalGenerationCost; +use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use crate::pricing::kinovi_pricing_rate::KinoviPricingRate; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, + KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, + WorkflowRunTaskRequest, +}; + +// ── Args ── + +pub struct GenerateSeedance2p0MiniArgs<'a> { + pub request: GenerateSeedance2p0MiniRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +#[derive(Clone, Debug)] +pub struct GenerateSeedance2p0MiniRequest { + pub prompt: String, + pub aspect_ratio: Option, + pub output_resolution: Option, + pub duration_seconds: u8, + pub batch_count: Option, + pub start_frame_url: Option, + pub end_frame_url: Option, + pub reference_image_urls: Option>, + pub reference_video_urls: Option>, + pub reference_audio_urls: Option>, + pub character_ids: Option>, + pub use_face_blur_hack: Option, + /// Output video bitrate. None defaults to "normal"; `High` requests a + /// higher bitrate. Does not affect cost. + pub bitrate: Option, +} + +// ── Enums ── + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0MiniAspectRatio { + Landscape16x9, + UltraWide21x9, + Portrait9x16, + Square1x1, + Standard4x3, + Portrait3x4, +} + +/// Output resolution. Mini supports only 480p and 720p (no 1080p / 4K). +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0MiniOutputResolution { + FourEightyP, + SevenTwentyP, +} + +/// Number of videos to generate in one request. Mini allows 1–8. +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0MiniBatchCount { + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, +} + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p0MiniBitrate { + High, +} + +// ── Pricing ── +// +// Seedance 2.0 Mini credit pricing (per second of OUTPUT duration): +// +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 7.5 | 3 | +// | 720p | 20 | 8 | +// +// Attaching reference VIDEOS adds a per-output-second surcharge; reference +// images and audio are free. +// +// | Resolution | Consumer surcharge/sec | Enterprise surcharge/sec | +// |------------|------------------------|--------------------------| +// | 480p | 2 | 0.8 | +// | 720p | 4 | 1.6 | +// +// The enterprise rates come from the negotiated combined prices: 480p +// 7.5 → 3 credits/sec without a reference video and 9.5 → 3.8 with one; +// 720p 20 → 8 and 24 → 9.6. The surcharge is the difference +// (3.8 − 3 = 0.8; 9.6 − 8 = 1.6). +// +// 480p consumer credits can be FRACTIONAL (7.5/sec lands on half-credits at +// odd durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch +// count multiplies the total. + +/// Per-second base credit rates by resolution. +const SEEDANCE_2P0_MINI_480P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 7.5, + maybe_enterprise_credits: Some(3.0), +}; +const SEEDANCE_2P0_MINI_720P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 20.0, + maybe_enterprise_credits: Some(8.0), +}; + +/// Per-second video-reference surcharge rates by resolution. Flat per +/// generation regardless of how many reference videos are attached. +const SEEDANCE_2P0_MINI_480P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 2.0, + maybe_enterprise_credits: Some(0.8), +}; +const SEEDANCE_2P0_MINI_720P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 4.0, + maybe_enterprise_credits: Some(1.6), +}; + +impl KinoviCostCalculatorTrait for GenerateSeedance2p0MiniRequest { + type Cost = KinoviSeedanceFractionalGenerationCost; + + /// Calculate the cost of this generation request, in Kinovi credits + /// (possibly fractional) and USD cents, at the given pricing tier. + fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviSeedanceFractionalGenerationCost { + let base_rate = match self.output_resolution { + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => SEEDANCE_2P0_MINI_480P, + Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_MINI_720P, + }; + let surcharge_rate = match self.output_resolution { + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => SEEDANCE_2P0_MINI_480P_VIDEO_REF_SURCHARGE, + Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_MINI_720P_VIDEO_REF_SURCHARGE, + }; + + let output_seconds = f64::from(self.duration_seconds) * f64::from(self.batch_multiplier()); + let base_credits = base_rate.credits(tier) * output_seconds; + let maybe_video_reference_surcharge_credits = if self.has_video_reference() { + Some(surcharge_rate.credits(tier) * output_seconds) + } else { + None + }; + + KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + tier, + base_credits, + maybe_video_reference_surcharge_credits, + ) + } +} + +impl GenerateSeedance2p0MiniRequest { + fn has_video_reference(&self) -> bool { + self.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()) + } + + fn batch_multiplier(&self) -> u8 { + match self.batch_count { + None | Some(KinoviSeedance2p0MiniBatchCount::One) => 1, + Some(KinoviSeedance2p0MiniBatchCount::Two) => 2, + Some(KinoviSeedance2p0MiniBatchCount::Three) => 3, + Some(KinoviSeedance2p0MiniBatchCount::Four) => 4, + Some(KinoviSeedance2p0MiniBatchCount::Five) => 5, + Some(KinoviSeedance2p0MiniBatchCount::Six) => 6, + Some(KinoviSeedance2p0MiniBatchCount::Seven) => 7, + Some(KinoviSeedance2p0MiniBatchCount::Eight) => 8, + } + } +} + +// ── Response ── + +pub struct GenerateSeedance2p0MiniResponse { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedance_2p0_mini( + args: GenerateSeedance2p0MiniArgs<'_>, +) -> Result { + let raw_response = workflow_run_task(WorkflowRunTaskArgs { + request: to_raw_request(args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSeedance2p0MiniResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Mapping helpers ── + +fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest { + WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Mini, + prompt: req.prompt, + aspect_ratio: map_aspect_ratio(req.aspect_ratio), + output_resolution: Some(map_output_resolution(req.output_resolution)), + batch_count: map_batch_count(req.batch_count), + duration_seconds: req.duration_seconds, + start_frame_url: req.start_frame_url, + end_frame_url: req.end_frame_url, + reference_image_urls: req.reference_image_urls, + reference_video_urls: req.reference_video_urls, + reference_audio_urls: req.reference_audio_urls, + character_ids: req.character_ids, + use_face_blur_hack: req.use_face_blur_hack, + bitrate: map_bitrate(req.bitrate), + } +} + +fn map_aspect_ratio(ar: Option) -> KinoviAspectRatioRaw { + match ar { + Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9, + Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9, + Some(KinoviSeedance2p0MiniAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16, + Some(KinoviSeedance2p0MiniAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1, + Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviSeedance2p0MiniAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4, + None => KinoviAspectRatioRaw::Landscape16x9, + } +} + +fn map_output_resolution(res: Option) -> KinoviOutputResolutionRaw { + match res { + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP, + // Unset resolves to 720p — MUST stay in lockstep with calculate_costs(), + // which prices None as 720p. + Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP, + } +} + +fn map_batch_count(bc: Option) -> KinoviBatchCountRaw { + match bc { + Some(KinoviSeedance2p0MiniBatchCount::One) | None => KinoviBatchCountRaw::One, + Some(KinoviSeedance2p0MiniBatchCount::Two) => KinoviBatchCountRaw::Two, + Some(KinoviSeedance2p0MiniBatchCount::Three) => KinoviBatchCountRaw::Three, + Some(KinoviSeedance2p0MiniBatchCount::Four) => KinoviBatchCountRaw::Four, + Some(KinoviSeedance2p0MiniBatchCount::Five) => KinoviBatchCountRaw::Five, + Some(KinoviSeedance2p0MiniBatchCount::Six) => KinoviBatchCountRaw::Six, + Some(KinoviSeedance2p0MiniBatchCount::Seven) => KinoviBatchCountRaw::Seven, + Some(KinoviSeedance2p0MiniBatchCount::Eight) => KinoviBatchCountRaw::Eight, + } +} + +fn map_bitrate(bitrate: Option) -> Option { + match bitrate { + Some(KinoviSeedance2p0MiniBitrate::High) => Some(KinoviBitrateRaw::High), + None => None, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod pricing_tests { + use super::*; + + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + // ── Consumer pricing tables ── + // + // The standard published rates: 480p 7.5/s (+2/s video ref), 720p 20/s + // (+4/s). 480p produces fractional credits at odd durations (37.5, + // 112.5, …). Cents convert at 192.98 credits/$1, rounded up. + + mod consumer_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(consumer_credits(&r480(4)), 30.0); + assert_eq!(consumer_credits(&r480(5)), 37.5); + assert_eq!(consumer_credits(&r480(10)), 75.0); + assert_eq!(consumer_credits(&r480(15)), 112.5); + + assert_eq!(consumer_cents(&r480(4)), 16); + assert_eq!(consumer_cents(&r480(5)), 20); + assert_eq!(consumer_cents(&r480(10)), 39); + assert_eq!(consumer_cents(&r480(15)), 59); + } + + #[test] + fn table_480p_with_video_reference() { + // 9.5 credits/sec combined (7.5 base + 2 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r480(4))), 38.0); + assert_eq!(consumer_credits(&with_video_ref(r480(5))), 47.5); + assert_eq!(consumer_credits(&with_video_ref(r480(10))), 95.0); + assert_eq!(consumer_credits(&with_video_ref(r480(15))), 142.5); + + assert_eq!(consumer_cents(&with_video_ref(r480(4))), 20); + assert_eq!(consumer_cents(&with_video_ref(r480(5))), 25); + assert_eq!(consumer_cents(&with_video_ref(r480(10))), 50); + assert_eq!(consumer_cents(&with_video_ref(r480(15))), 74); + } + + #[test] + fn table_720p() { + assert_eq!(consumer_credits(&r720(4)), 80.0); + assert_eq!(consumer_credits(&r720(5)), 100.0); + assert_eq!(consumer_credits(&r720(10)), 200.0); + assert_eq!(consumer_credits(&r720(15)), 300.0); + + assert_eq!(consumer_cents(&r720(4)), 42); + assert_eq!(consumer_cents(&r720(5)), 52); + assert_eq!(consumer_cents(&r720(10)), 104); + assert_eq!(consumer_cents(&r720(15)), 156); + } + + #[test] + fn table_720p_with_video_reference() { + // 24 credits/sec combined (20 base + 4 surcharge). + assert_eq!(consumer_credits(&with_video_ref(r720(4))), 96.0); + assert_eq!(consumer_credits(&with_video_ref(r720(5))), 120.0); + assert_eq!(consumer_credits(&with_video_ref(r720(10))), 240.0); + assert_eq!(consumer_credits(&with_video_ref(r720(15))), 360.0); + + assert_eq!(consumer_cents(&with_video_ref(r720(4))), 50); + assert_eq!(consumer_cents(&with_video_ref(r720(5))), 63); + assert_eq!(consumer_cents(&with_video_ref(r720(10))), 125); + assert_eq!(consumer_cents(&with_video_ref(r720(15))), 187); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(5).calculate_consumer_costs(); + let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None).calculate_consumer_costs(); + assert_eq!(default, explicit); + } + } + + // ── Enterprise pricing tables (60% discount on every rate) ── + // + // 480p 3/s (+0.8/s video ref), 720p 8/s (+1.6/s). Cents convert at the + // bulk rate of 243.16 credits/$1, rounded up. + + mod enterprise_pricing_tables { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(enterprise_credits(&r480(4)), 12.0); + assert_eq!(enterprise_credits(&r480(5)), 15.0); + assert_eq!(enterprise_credits(&r480(10)), 30.0); + assert_eq!(enterprise_credits(&r480(15)), 45.0); + + assert_eq!(enterprise_cents(&r480(4)), 5); + assert_eq!(enterprise_cents(&r480(5)), 7); + assert_eq!(enterprise_cents(&r480(10)), 13); + assert_eq!(enterprise_cents(&r480(15)), 19); + } + + #[test] + fn table_480p_with_video_reference() { + // 3.8 credits/sec combined (3 base + 0.8 surcharge). + assert_eq!(enterprise_credits(&with_video_ref(r480(4))), 15.2); + assert_eq!(enterprise_credits(&with_video_ref(r480(5))), 19.0); + assert_eq!(enterprise_credits(&with_video_ref(r480(10))), 38.0); + assert_eq!(enterprise_credits(&with_video_ref(r480(15))), 57.0); + + assert_eq!(enterprise_cents(&with_video_ref(r480(4))), 7); + assert_eq!(enterprise_cents(&with_video_ref(r480(5))), 8); + assert_eq!(enterprise_cents(&with_video_ref(r480(10))), 16); + assert_eq!(enterprise_cents(&with_video_ref(r480(15))), 24); + } + + #[test] + fn table_720p() { + assert_eq!(enterprise_credits(&r720(4)), 32.0); + assert_eq!(enterprise_credits(&r720(5)), 40.0); + assert_eq!(enterprise_credits(&r720(10)), 80.0); + assert_eq!(enterprise_credits(&r720(15)), 120.0); + + assert_eq!(enterprise_cents(&r720(4)), 14); + assert_eq!(enterprise_cents(&r720(5)), 17); + assert_eq!(enterprise_cents(&r720(10)), 33); + assert_eq!(enterprise_cents(&r720(15)), 50); + } + + #[test] + fn table_720p_with_video_reference() { + // 9.6 credits/sec combined (8 base + 1.6 surcharge). + assert_eq!(enterprise_credits(&with_video_ref(r720(4))), 38.4); + assert_eq!(enterprise_credits(&with_video_ref(r720(5))), 48.0); + assert_eq!(enterprise_credits(&with_video_ref(r720(10))), 96.0); + assert_eq!(enterprise_credits(&with_video_ref(r720(15))), 144.0); + + assert_eq!(enterprise_cents(&with_video_ref(r720(4))), 16); + assert_eq!(enterprise_cents(&with_video_ref(r720(5))), 20); + assert_eq!(enterprise_cents(&with_video_ref(r720(10))), 40); + assert_eq!(enterprise_cents(&with_video_ref(r720(15))), 60); + } + + #[test] + fn base_and_surcharge_breakdown_5s() { + let costs = with_video_ref(r720(5)).calculate_enterprise_costs(); + assert_eq!(costs.base_cost.kinovi_credits, 40.0); // 5s × 8 + assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(8.0)); // 5s × 1.6 + assert_eq!(costs.total_cost.kinovi_credits, 48.0); // 5s × 9.6 + + assert_eq!(costs.total_cost.usd_cents_rounded_up, 20); + assert_eq!(costs.total_cost.usd_cents_rounded_down, 19); + assert!((costs.total_cost.usd_cents_fractional - 19.7401).abs() < FLOAT_TOLERANCE); + } + } + + // ── Video-reference surcharge ── + + mod video_reference_surcharge_tests { + use super::*; + + /// The full base + surcharge breakdown at both tiers. Asserts every + /// part: base, surcharge, and the derived total. + #[test] + fn base_and_surcharge_tables() { + // (tier, request builder, duration, base credits, surcharge credits) + let cases: &[(KinoviPricingTier, fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[ + // Consumer 480p: 7.5/s base, +2/s surcharge + (KinoviPricingTier::Consumer, r480, 5, 37.5, 10.0), + (KinoviPricingTier::Consumer, r480, 10, 75.0, 20.0), + // Consumer 720p: 20/s base, +4/s surcharge + (KinoviPricingTier::Consumer, r720, 5, 100.0, 20.0), + (KinoviPricingTier::Consumer, r720, 10, 200.0, 40.0), + // Enterprise 480p: 3/s base, +0.8/s surcharge + (KinoviPricingTier::Enterprise, r480, 5, 15.0, 4.0), + (KinoviPricingTier::Enterprise, r480, 10, 30.0, 8.0), + // Enterprise 720p: 8/s base, +1.6/s surcharge + (KinoviPricingTier::Enterprise, r720, 5, 40.0, 8.0), + (KinoviPricingTier::Enterprise, r720, 10, 80.0, 16.0), + ]; + + for (tier, make, duration, base, surcharge) in cases { + let costs = with_video_ref(make(*duration)).calculate_costs(*tier); + assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s ({tier:?})"); + assert_eq!( + costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), + Some(*surcharge), + "surcharge at {duration}s ({tier:?})", + ); + assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s ({tier:?})"); + } + } + + #[test] + fn no_video_reference_has_no_surcharge() { + for costs in [r480(5).calculate_consumer_costs(), r480(5).calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + assert_eq!(costs.total_cost, costs.base_cost); + } + } + + #[test] + fn empty_video_reference_list_has_no_surcharge() { + let mut request = r720(5); + request.reference_video_urls = Some(vec![]); + for costs in [request.calculate_consumer_costs(), request.calculate_enterprise_costs()] { + assert!(costs.video_reference_surcharge_cost.is_none()); + } + } + + /// The surcharge applies per generated video, so batches multiply it. + #[test] + fn batch_multiplies_surcharge() { + let request = with_video_ref(build_request( + 5, + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + Some(KinoviSeedance2p0MiniBatchCount::Two), + )); + + // Consumer: (37.5 base + 10 surcharge) × 2. + let consumer = request.calculate_consumer_costs(); + assert_eq!(consumer.base_cost.kinovi_credits, 75.0); + assert_eq!(consumer.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0)); + assert_eq!(consumer.total_cost.kinovi_credits, 95.0); + + // Enterprise: (15 base + 4 surcharge) × 2. + let enterprise = request.calculate_enterprise_costs(); + assert_eq!(enterprise.base_cost.kinovi_credits, 30.0); + assert_eq!(enterprise.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(8.0)); + assert_eq!(enterprise.total_cost.kinovi_credits, 38.0); + } + } + + // ── Batch multiplier ── + + mod batch_tests { + use super::*; + + #[test] + fn batch_scales_total_linearly_at_both_tiers() { + let by_count = [ + (KinoviSeedance2p0MiniBatchCount::One, 1.0), + (KinoviSeedance2p0MiniBatchCount::Two, 2.0), + (KinoviSeedance2p0MiniBatchCount::Three, 3.0), + (KinoviSeedance2p0MiniBatchCount::Four, 4.0), + (KinoviSeedance2p0MiniBatchCount::Five, 5.0), + (KinoviSeedance2p0MiniBatchCount::Six, 6.0), + (KinoviSeedance2p0MiniBatchCount::Seven, 7.0), + (KinoviSeedance2p0MiniBatchCount::Eight, 8.0), + ]; + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits; + for (count, multiplier) in by_count { + let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits; + assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}"); + } + } + } + + #[test] + fn batch_eight_480p_with_video_reference() { + let request = with_video_ref(build_request( + 5, + Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + Some(KinoviSeedance2p0MiniBatchCount::Eight), + )); + // Consumer: (37.5 + 10) × 8 = 380. Enterprise: (15 + 4) × 8 = 152. + assert_eq!(consumer_credits(&request), 380.0); + assert_eq!(enterprise_credits(&request), 152.0); + assert_eq!(enterprise_cents(&request), 63); + } + } + + // ── Tier dispatch ── + + #[test] + fn convenience_methods_match_explicit_tier() { + let request = with_video_ref(r480(5)); + assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer)); + assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise)); + } + + // ── Defaults & invariants ── + + #[test] + fn default_resolution_is_720p() { + let default = consumer_credits(&r720(5)); + let explicit = consumer_credits(&build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None)); + assert_eq!(default, explicit); + assert_eq!(default, 100.0); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = enterprise_credits(&r720(4)); + let c10 = enterprise_credits(&r720(10)); + let c15 = enterprise_credits(&r720(15)); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn resolution_480p_cheaper_than_720p_at_both_tiers() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + for dur in 4..=15u8 { + let c480 = r480(dur).calculate_costs(tier).total_cost.kinovi_credits; + let c720 = r720(dur).calculate_costs(tier).total_cost.kinovi_credits; + assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s ({tier:?})"); + } + } + } + + #[test] + fn enterprise_is_cheaper_than_consumer() { + for request in [r480(5), r720(5), with_video_ref(r480(5)), with_video_ref(r720(5))] { + let consumer = request.calculate_consumer_costs().total_cost; + let enterprise = request.calculate_enterprise_costs().total_cost; + assert!(enterprise.kinovi_credits < consumer.kinovi_credits); + assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional); + } + } + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + let ratios = [ + KinoviSeedance2p0MiniAspectRatio::Landscape16x9, + KinoviSeedance2p0MiniAspectRatio::UltraWide21x9, + KinoviSeedance2p0MiniAspectRatio::Portrait9x16, + KinoviSeedance2p0MiniAspectRatio::Square1x1, + KinoviSeedance2p0MiniAspectRatio::Standard4x3, + KinoviSeedance2p0MiniAspectRatio::Portrait3x4, + ]; + for ar in ratios { + let mut request = r720(5); + request.aspect_ratio = Some(ar); + assert_eq!(consumer_credits(&request), baseline, "{ar:?}"); + } + } + + #[test] + fn high_bitrate_does_not_affect_credits() { + let baseline = consumer_credits(&r720(5)); + let mut high = r720(5); + high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High); + assert_eq!(consumer_credits(&high), baseline); + } + + // ── Helpers ── + + fn build_request( + duration_seconds: u8, + output_resolution: Option, + batch_count: Option, + ) -> GenerateSeedance2p0MiniRequest { + GenerateSeedance2p0MiniRequest { + prompt: String::new(), + aspect_ratio: None, + output_resolution, + batch_count, + duration_seconds, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + fn r480(dur: u8) -> GenerateSeedance2p0MiniRequest { + build_request(dur, Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), None) + } + + fn r720(dur: u8) -> GenerateSeedance2p0MiniRequest { + build_request(dur, None, None) + } + + fn with_video_ref(mut request: GenerateSeedance2p0MiniRequest) -> GenerateSeedance2p0MiniRequest { + request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]); + request + } + + fn consumer_credits(request: &GenerateSeedance2p0MiniRequest) -> f64 { + request.calculate_consumer_costs().total_cost.kinovi_credits + } + + fn consumer_cents(request: &GenerateSeedance2p0MiniRequest) -> u64 { + request.calculate_consumer_costs().total_cost.usd_cents_rounded_up + } + + fn enterprise_credits(request: &GenerateSeedance2p0MiniRequest) -> f64 { + request.calculate_enterprise_costs().total_cost.kinovi_credits + } + + fn enterprise_cents(request: &GenerateSeedance2p0MiniRequest) -> u64 { + request.calculate_enterprise_costs().total_cost.usd_cents_rounded_up + } + } + + // ── Request shape (mapping to the raw Kinovi request) ── + + mod request_shape_tests { + use super::*; + + fn sample() -> GenerateSeedance2p0MiniRequest { + GenerateSeedance2p0MiniRequest { + prompt: "a corgi".to_string(), + aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3), + output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + duration_seconds: 10, + batch_count: Some(KinoviSeedance2p0MiniBatchCount::Eight), + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: Some(KinoviSeedance2p0MiniBitrate::High), + } + } + + #[test] + fn maps_to_mini_model_type() { + let raw = to_raw_request(sample()); + assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Mini)); + } + + #[test] + fn maps_resolution_aspect_and_batch() { + let raw = to_raw_request(sample()); + assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape4x3)); + assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::FourEightyP))); + assert!(matches!(raw.batch_count, KinoviBatchCountRaw::Eight)); + assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High))); + } + + #[test] + fn passes_through_prompt_duration_and_references() { + let raw = to_raw_request(sample()); + assert_eq!(raw.prompt, "a corgi"); + assert_eq!(raw.duration_seconds, 10); + assert_eq!(raw.reference_image_urls, Some(vec!["https://example.com/a.png".to_string()])); + } + + #[test] + fn default_resolution_maps_to_720p() { + let mut req = sample(); + req.output_resolution = None; + let raw = to_raw_request(req); + assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::SevenTwentyP))); + } + + #[test] + fn all_batch_counts_map() { + let expected: &[(KinoviSeedance2p0MiniBatchCount, u8)] = &[ + (KinoviSeedance2p0MiniBatchCount::One, 1), + (KinoviSeedance2p0MiniBatchCount::Two, 2), + (KinoviSeedance2p0MiniBatchCount::Three, 3), + (KinoviSeedance2p0MiniBatchCount::Four, 4), + (KinoviSeedance2p0MiniBatchCount::Five, 5), + (KinoviSeedance2p0MiniBatchCount::Six, 6), + (KinoviSeedance2p0MiniBatchCount::Seven, 7), + (KinoviSeedance2p0MiniBatchCount::Eight, 8), + ]; + for (count, n) in expected { + let mut req = sample(); + req.batch_count = Some(*count); + let raw = to_raw_request(req); + // The mapped raw batch matches the requested count's multiplier. + let mut probe = build_probe(); + probe.batch_count = Some(*count); + assert_eq!(probe.batch_multiplier(), *n, "{count:?}"); + // And the raw enum is the corresponding variant. + assert!(!matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Pro)); + } + } + + fn build_probe() -> GenerateSeedance2p0MiniRequest { + GenerateSeedance2p0MiniRequest { + prompt: String::new(), + aspect_ratio: None, + output_resolution: None, + duration_seconds: 5, + batch_count: None, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + } + + // ── Live API tests (manual; require Kinovi cookies) ── + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + mod live { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0MiniRequest { + prompt: "A giant evil teddy bear steps on people in the city.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9), + output_resolution: None, + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0MiniRequest { + prompt: "A snowman fights a bear".to_string(), + aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3), + output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + batch_count: None, + duration_seconds: 10, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_reference_to_video_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0MiniRequest { + prompt: "Girl walks around in the city, giving a historical tour".to_string(), + aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9), + output_resolution: None, + batch_count: None, + duration_seconds: 15, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260624/1782342102687-53fe5496.png".to_string(), + ]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_video_reference_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p0MiniRequest { + prompt: "Change @video1 to night time.".to_string(), + aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9), + output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), + batch_count: None, + duration_seconds: 5, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + }, + }).await?; + println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs new file mode 100644 index 0000000000..f89bd26d8d --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs @@ -0,0 +1,1063 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost; +use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use crate::pricing::kinovi_pricing_rate::KinoviPricingRate; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw, + KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest, +}; + +// ── Constants ── + +/// Seedance 2.5 supports at most 30 seconds of video: reference-video input +/// seconds beyond this are clamped for billing. +pub const MAX_BILLED_INPUT_SECONDS: u8 = 30; + +/// The minimum TOTAL billed input seconds when video references are +/// attached: however short the input videos are, the total bills at least 4 +/// seconds. (Three 1-second videos sum to 3 and clamp to 4; three 3-second +/// videos sum to 9 and bill 9.) +pub const MIN_BILLED_INPUT_SECONDS: u8 = 4; + +// ── Args ── + +pub struct GenerateSeedance2p5Args<'a> { + pub request: GenerateSeedance2p5Request, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Seedance 2.5 supports keyframe (image-to-video) and reference +/// (text-to-video / reference-to-video) modalities, and 480p/720p/1080p +/// output. +/// +/// On the wire it reuses the 2.5 *Preview* business type +/// (`seedance25-preview-video-generation`) with model `seedance2-5`; +/// text-to-video is sent as mode "reference". +#[derive(Clone, Debug)] +pub struct GenerateSeedance2p5Request { + pub prompt: String, + + pub modality: KinoviSeedance2p5Modality, + + pub output_resolution: Option, + + pub duration_seconds: u8, + + /// CALCULATION-ONLY (never sent on the wire): the total seconds of + /// reference video input, summed across all reference videos. When video + /// references are attached, Kinovi bills the input seconds on top of the + /// output duration — see the pricing notes on [`calculate_costs`]. + /// + /// [`calculate_costs`]: GenerateSeedance2p5Request::calculate_costs + pub total_input_seconds: Option, + + /// Controls `faceBlurMode`: true sends "on"; false or None sends "off" + /// (the model always sends the field, like 2.5 Preview). + pub use_face_blur_hack: Option, +} + +// ── Modality ── + +/// The generation modality. The aspect ratio choice lives here because the +/// two modalities support disjoint aspect ratio sets. +#[derive(Clone, Debug)] +pub enum KinoviSeedance2p5Modality { + /// Image-to-video (start frame + optional end frame). + /// + /// The aspect ratio is always "Adaptive" — the only choice the API allows + /// in this modality, expressed on the wire by omitting the `aspectRatio` + /// field entirely — so there is nothing to configure. + Keyframe { + start_frame_url: String, + end_frame_url: Option, + }, + + /// Text-to-video / reference-to-video. "Adaptive" is NOT supported here; + /// `None` defaults to 16:9. + Reference { + aspect_ratio: Option, + /// Reference images, referenced in prompts as @image1, @image2, etc. + reference_image_urls: Option>, + /// Reference videos, referenced in prompts as @video1, @video2, etc. + reference_video_urls: Option>, + /// Reference audio, referenced in prompts as @audio1, @audio2, etc. + reference_audio_urls: Option>, + }, +} + +// ── Enums ── + +/// Aspect ratios for the reference (text/reference-to-video) modality. +/// There is deliberately no "Adaptive" variant: Adaptive is keyframe-only +/// (and there it is the only choice). +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p5AspectRatio { + Landscape16x9, + UltraWide21x9, + Portrait9x16, + Square1x1, + Standard4x3, + Portrait3x4, +} + +/// Output resolution. Seedance 2.5 supports 480p, 720p, and (since +/// 2026-08-17) 1080p. +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p5OutputResolution { + FourEightyP, + SevenTwentyP, + TenEightyP, +} + +// ── Pricing ── +// +// Seedance 2.5 credit pricing (credits per billed second). Without video +// references (text-to-video, keyframe, and reference mode with only +// image/audio references), billed seconds = output duration: +// +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 26 | (no discount) | +// | 720p | 59 | (no discount) | +// | 1080p | 107.55 | 103.25 | +// +// With video references, the rate drops but the billed seconds are the +// output duration PLUS the total seconds of reference video input +// (`total_input_seconds`, summed across all reference videos and clamped to +// the [`MIN_BILLED_INPUT_SECONDS`]..=[`MAX_BILLED_INPUT_SECONDS`] range). +// E.g. a 30s output with a 10s video reference bills as 40 seconds; a 30s +// output with two 7s references (14s total input) bills as 44 seconds. +// +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 16 | (no discount) | +// | 720p | 35 | (no discount) | +// | 1080p | 64.26 | 61.69 | +// +// The 1080p enterprise rates were spot-checked against the Kinovi site +// (2026-08-17): 4s / 10s / 30s without a reference video showed 413 / +// 1,032.5 / 3,097.5 credits. 480p and 720p have no separately negotiated +// enterprise credit rate, so enterprise bills the same credits as consumer +// (the tiers still convert to USD at their own purchase rates). Default +// resolution (None) is 720p. + +/// Per-second credit rates without video references (billed over the output +/// duration only). +const SEEDANCE_2P5_480P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 26.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P5_720P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 59.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P5_1080P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 107.55, + maybe_enterprise_credits: Some(103.25), +}; + +/// Per-second credit rates when video references are attached. These REPLACE +/// the base rates (they are not surcharges) and bill over output + input +/// seconds. +const SEEDANCE_2P5_480P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 16.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P5_720P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 35.0, + maybe_enterprise_credits: None, +}; +const SEEDANCE_2P5_1080P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 64.26, + maybe_enterprise_credits: Some(61.69), +}; + +impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request { + type Cost = KinoviFractionalGenerationCost; + + /// Calculate the cost of this generation request, in Kinovi credits and + /// USD cents, at the given pricing tier. + fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviFractionalGenerationCost { + let has_video_references = matches!( + &self.modality, + KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty() + ); + + let (rate, billed_seconds) = if has_video_references { + let rate = match self.output_resolution { + Some(KinoviSeedance2p5OutputResolution::FourEightyP) => SEEDANCE_2P5_480P_VIDEO_REF, + Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_720P_VIDEO_REF, + Some(KinoviSeedance2p5OutputResolution::TenEightyP) => SEEDANCE_2P5_1080P_VIDEO_REF, + }; + // An unknown (None) or zero input duration bills the worst-case + // maximum — never default toward the minimum. + let input_seconds = match self.total_input_seconds { + None | Some(0) => MAX_BILLED_INPUT_SECONDS, + Some(seconds) => seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS), + }; + let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds); + (rate, seconds) + } else { + let rate = match self.output_resolution { + Some(KinoviSeedance2p5OutputResolution::FourEightyP) => SEEDANCE_2P5_480P, + Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_720P, + Some(KinoviSeedance2p5OutputResolution::TenEightyP) => SEEDANCE_2P5_1080P, + }; + (rate, u64::from(self.duration_seconds)) + }; + + let total_credits = rate.credits(tier) * billed_seconds as f64; + tier.cost_from_credits(total_credits) + } +} + +// ── Response ── + +pub struct GenerateSeedance2p5Response { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedance_2p5( + args: GenerateSeedance2p5Args<'_>, +) -> Result { + let raw_response = workflow_run_task(WorkflowRunTaskArgs { + request: to_raw_request(args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSeedance2p5Response { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Mapping helpers ── + +fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest { + let ( + aspect_ratio, + start_frame_url, + end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + ) = match req.modality { + KinoviSeedance2p5Modality::Keyframe { start_frame_url, end_frame_url } => ( + // Keyframe is always "Adaptive"; the raw layer omits the aspect ratio + // for 2.5 keyframe requests, so this placeholder never hits the wire. + KinoviAspectRatioRaw::Landscape16x9, + Some(start_frame_url), + end_frame_url, + None, + None, + None, + ), + KinoviSeedance2p5Modality::Reference { + aspect_ratio, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + } => ( + map_aspect_ratio(aspect_ratio), + None, + None, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + ), + }; + + WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2p5, + prompt: req.prompt, + aspect_ratio, + output_resolution: Some(map_output_resolution(req.output_resolution)), + duration_seconds: req.duration_seconds, + batch_count: KinoviBatchCountRaw::One, + start_frame_url, + end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + character_ids: None, + use_face_blur_hack: req.use_face_blur_hack, + bitrate: None, + } +} + +fn map_aspect_ratio(ar: Option) -> KinoviAspectRatioRaw { + match ar { + Some(KinoviSeedance2p5AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9, + Some(KinoviSeedance2p5AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9, + Some(KinoviSeedance2p5AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16, + Some(KinoviSeedance2p5AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1, + Some(KinoviSeedance2p5AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviSeedance2p5AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4, + None => KinoviAspectRatioRaw::Landscape16x9, + } +} + +fn map_output_resolution(res: Option) -> KinoviOutputResolutionRaw { + match res { + Some(KinoviSeedance2p5OutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP, + // Unset resolves to 720p — MUST stay in lockstep with calculate_costs(), + // which prices None as 720p. + Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP, + Some(KinoviSeedance2p5OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod pricing_tests { + use super::*; + + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + // ── No video references: output-duration billing ── + // + // 480p = 26 credits/sec and 720p = 59 credits/sec at both tiers (no + // negotiated enterprise discount); 1080p = 107.55 consumer / 103.25 + // enterprise credits/sec. + + mod without_video_references { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(t2v_480(1).calculate_consumer_costs().kinovi_credits, 26.0); + assert_eq!(t2v_480(5).calculate_consumer_costs().kinovi_credits, 130.0); + assert_eq!(t2v_480(10).calculate_consumer_costs().kinovi_credits, 260.0); + assert_eq!(t2v_480(15).calculate_consumer_costs().kinovi_credits, 390.0); + assert_eq!(t2v_480(30).calculate_consumer_costs().kinovi_credits, 780.0); + } + + #[test] + fn table_720p() { + assert_eq!(t2v_720(1).calculate_consumer_costs().kinovi_credits, 59.0); + assert_eq!(t2v_720(5).calculate_consumer_costs().kinovi_credits, 295.0); + assert_eq!(t2v_720(10).calculate_consumer_costs().kinovi_credits, 590.0); + assert_eq!(t2v_720(15).calculate_consumer_costs().kinovi_credits, 885.0); + assert_eq!(t2v_720(30).calculate_consumer_costs().kinovi_credits, 1770.0); + } + + #[test] + fn table_1080p() { + // Enterprise 103.25 credits/sec — the 4s / 10s / 30s values were + // verified against the Kinovi site (413 / 1,032.5 / 3,097.5). + assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 413.0); + assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 516.25); + assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1032.5); + assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 1548.75); + assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2065.0); + assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 2581.25); + assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 3097.5); + + // Consumer 107.55 credits/sec. + assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 430.2); + assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 537.75); + assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1075.5); + assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 1613.25); + assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2151.0); + assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 2688.75); + assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 3226.5); + } + + #[test] + fn no_enterprise_credit_discount_at_480p_and_720p() { + // 480p and 720p have no negotiated enterprise credit rate: both + // tiers bill the same credits (USD conversion still differs). + for duration in [4u8, 10, 30] { + assert_eq!( + t2v_480(duration).calculate_consumer_costs().kinovi_credits, + t2v_480(duration).calculate_enterprise_costs().kinovi_credits, + ); + assert_eq!( + t2v_720(duration).calculate_consumer_costs().kinovi_credits, + t2v_720(duration).calculate_enterprise_costs().kinovi_credits, + ); + } + } + + #[test] + fn default_resolution_is_720p() { + let default = t2v_720(10).calculate_enterprise_costs(); + let mut explicit = t2v_720(10); + explicit.output_resolution = Some(KinoviSeedance2p5OutputResolution::SevenTwentyP); + assert_eq!(default, explicit.calculate_enterprise_costs()); + } + + #[test] + fn keyframe_modality_uses_no_reference_rates() { + let mut request = keyframe_request(10); + request.output_resolution = Some(KinoviSeedance2p5OutputResolution::FourEightyP); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0); + + let request_720 = keyframe_request(10); + assert_eq!(request_720.calculate_consumer_costs().kinovi_credits, 590.0); + + let mut request_1080 = keyframe_request(10); + request_1080.output_resolution = Some(KinoviSeedance2p5OutputResolution::TenEightyP); + assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1032.5); + } + + #[test] + fn image_and_audio_references_do_not_change_the_rate() { + let mut request = t2v_480(10); + set_reference_urls( + &mut request, + Some(vec!["https://example.com/a.png".to_string(), "https://example.com/b.jpg".to_string()]), + None, + Some(vec!["https://example.com/ref.wav".to_string()]), + ); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0); + } + + #[test] + fn empty_video_reference_list_counts_as_no_references() { + let mut request = t2v_480(10); + set_reference_urls(&mut request, None, Some(Vec::new()), None); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0); + } + + #[test] + fn total_input_seconds_is_ignored_without_video_references() { + let mut request = t2v_480(10); + request.total_input_seconds = Some(60); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0); + } + } + + // ── With video references: input seconds are billed too ── + // + // 480p = 16 credits/sec and 720p = 35 credits/sec at both tiers; 1080p + // = 64.26 consumer / 61.69 enterprise credits/sec. All over + // (output duration + total_input_seconds) billed seconds. + + mod with_video_references { + use super::*; + + #[test] + fn thirty_second_output_with_ten_second_reference_bills_forty_seconds() { + // The canonical example: 30s output + 10s of input video = 40 billed seconds. + let request = video_ref_480(30, Some(10)); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 640.0); + + let request = video_ref_720(30, Some(10)); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1400.0); + } + + #[test] + fn two_seven_second_references_bill_forty_four_seconds() { + // Two 7s references (14s total input) + 30s output = 44 billed seconds. + let mut request = video_ref_480(30, Some(14)); + set_reference_urls( + &mut request, + None, + Some(vec![ + "https://example.com/ref1.mp4".to_string(), + "https://example.com/ref2.mp4".to_string(), + ]), + None, + ); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 704.0); + } + + #[test] + fn table_480p_with_ten_input_seconds() { + assert_eq!(video_ref_480(5, Some(10)).calculate_consumer_costs().kinovi_credits, 240.0); + assert_eq!(video_ref_480(10, Some(10)).calculate_consumer_costs().kinovi_credits, 320.0); + assert_eq!(video_ref_480(30, Some(10)).calculate_consumer_costs().kinovi_credits, 640.0); + } + + #[test] + fn table_720p_with_ten_input_seconds() { + assert_eq!(video_ref_720(5, Some(10)).calculate_consumer_costs().kinovi_credits, 525.0); + assert_eq!(video_ref_720(10, Some(10)).calculate_consumer_costs().kinovi_credits, 700.0); + assert_eq!(video_ref_720(30, Some(10)).calculate_consumer_costs().kinovi_credits, 1400.0); + } + + #[test] + fn table_1080p() { + // A 20-second output with reference videos totaling 1–30 input + // seconds: billed seconds = 20 + clamp(input, 4, 30). E.g. a 10s + // input video + 20s generation = 30 billed seconds. + // + // Enterprise 61.69 credits/sec. + assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1480.56); + assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 1542.25); + assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 1850.7); + assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2159.15); + assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 2467.6); + assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 3084.5); + + // Consumer 64.26 credits/sec. + assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 1542.24); + assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 1606.5); + assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 1927.8); + assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2249.1); + assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 2570.4); + assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 3213.0); + } + + #[test] + fn input_totals_under_four_seconds_clamp_to_four() { + // Total input clamps to a 4-second minimum: 1s, 3s, and 4s totals + // all bill 4; 5s bills 5. + assert_eq!(video_ref_480(30, Some(1)).calculate_consumer_costs().kinovi_credits, 544.0); + assert_eq!(video_ref_480(30, Some(3)).calculate_consumer_costs().kinovi_credits, 544.0); + assert_eq!(video_ref_480(30, Some(4)).calculate_consumer_costs().kinovi_credits, 544.0); + assert_eq!(video_ref_480(30, Some(5)).calculate_consumer_costs().kinovi_credits, 560.0); + } + + #[test] + fn missing_total_input_seconds_bills_the_worst_case_maximum() { + // Unknown input duration with video references attached: assume the + // 30-second maximum so the estimate never undershoots the charge. + assert_eq!(video_ref_480(10, None).calculate_consumer_costs().kinovi_credits, 640.0); + assert_eq!(video_ref_720(10, None).calculate_consumer_costs().kinovi_credits, 1400.0); + // 1080p: 40 billed seconds at 61.69 enterprise / 64.26 consumer. + assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 2467.6); + assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 2570.4); + } + + #[test] + fn input_seconds_clamp_to_max_billed_input_seconds() { + // 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds. + assert_eq!(video_ref_480(30, Some(200)).calculate_consumer_costs().kinovi_credits, 960.0); + assert_eq!( + video_ref_480(30, Some(200)).calculate_consumer_costs(), + video_ref_480(30, Some(MAX_BILLED_INPUT_SECONDS)).calculate_consumer_costs(), + ); + } + + #[test] + fn mixed_references_still_use_video_reference_rates() { + let mut request = video_ref_480(10, Some(5)); + set_reference_urls( + &mut request, + Some(vec!["https://example.com/a.png".to_string()]), + Some(vec!["https://example.com/ref.mp4".to_string()]), + Some(vec!["https://example.com/ref.wav".to_string()]), + ); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 240.0); + } + } + + // ── USD cents conversion ── + // + // usd_cents = credits × 100 / credits_per_dollar, computed on integer + // hundredths. Consumer converts at 192.98 credits/$1; enterprise + // converts at the bulk rate of 243.16 credits/$1. + + mod usd_cents { + use super::*; + + #[test] + fn enterprise_cents_480p_5s() { + // 130 credits; 13000/243.16 = 53.4627 cents. + let cost = t2v_480(5).calculate_enterprise_costs(); + assert_eq!(cost.usd_cents_rounded_up, 54); + assert_eq!(cost.usd_cents_rounded_down, 53); + assert!((cost.usd_cents_fractional - 53.4627).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_480p_5s() { + // 130 credits; 13000/192.98 = 67.3645 cents. + let cost = t2v_480(5).calculate_consumer_costs(); + assert_eq!(cost.usd_cents_rounded_up, 68); + assert_eq!(cost.usd_cents_rounded_down, 67); + assert!((cost.usd_cents_fractional - 67.3645).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_720p_30s() { + // 1770 credits; 177000/243.16 = 727.9158 cents. + let cost = t2v_720(30).calculate_enterprise_costs(); + assert_eq!(cost.usd_cents_rounded_up, 728); + assert_eq!(cost.usd_cents_rounded_down, 727); + assert!((cost.usd_cents_fractional - 727.9158).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_720p_video_ref_40_billed_seconds() { + // 35 × 40 = 1400 credits; 140000/243.16 = 575.7526 cents. + let cost = video_ref_720(30, Some(10)).calculate_enterprise_costs(); + assert_eq!(cost.usd_cents_rounded_up, 576); + assert_eq!(cost.usd_cents_rounded_down, 575); + assert!((cost.usd_cents_fractional - 575.7526).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_1080p_4s() { + // 413 credits; 41300/243.16 = 169.8470 cents. + let cost = t2v_1080(4).calculate_enterprise_costs(); + assert_eq!(cost.usd_cents_rounded_up, 170); + assert_eq!(cost.usd_cents_rounded_down, 169); + assert!((cost.usd_cents_fractional - 169.8470).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_1080p_4s() { + // 430.2 credits; 43020/192.98 = 222.9247 cents. + let cost = t2v_1080(4).calculate_consumer_costs(); + assert_eq!(cost.usd_cents_rounded_up, 223); + assert_eq!(cost.usd_cents_rounded_down, 222); + assert!((cost.usd_cents_fractional - 222.9247).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_1080p_30s() { + // 3097.5 credits; 309750/243.16 = 1273.8526 cents. + let cost = t2v_1080(30).calculate_enterprise_costs(); + assert_eq!(cost.usd_cents_rounded_up, 1274); + assert_eq!(cost.usd_cents_rounded_down, 1273); + assert!((cost.usd_cents_fractional - 1273.8526).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_1080p_video_ref_30_billed_seconds() { + // 61.69 × 30 = 1850.7 credits; 185070/243.16 = 761.1038 cents. + let cost = video_ref_1080(20, Some(10)).calculate_enterprise_costs(); + assert_eq!(cost.usd_cents_rounded_up, 762); + assert_eq!(cost.usd_cents_rounded_down, 761); + assert!((cost.usd_cents_fractional - 761.1038).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_1080p_video_ref_30_billed_seconds() { + // 64.26 × 30 = 1927.8 credits; 192780/192.98 = 998.9636 cents. + let cost = video_ref_1080(20, Some(10)).calculate_consumer_costs(); + assert_eq!(cost.usd_cents_rounded_up, 999); + assert_eq!(cost.usd_cents_rounded_down, 998); + assert!((cost.usd_cents_fractional - 998.9636).abs() < FLOAT_TOLERANCE); + } + } + + // ── Relative pricing ── + + mod relative_tests { + use super::*; + + #[test] + fn video_reference_rate_is_cheaper_per_second() { + // 16 < 26, 35 < 59, and 61.69 < 103.25: the with-references rate is + // lower per billed second (the input seconds are where the money + // goes). Compare at the minimum billed input (1s clamps to 4) — + // unknown or zero input assumes the 30-second maximum, which would + // swamp the rate comparison. + assert!(video_ref_480(10, Some(1)).calculate_consumer_costs().kinovi_credits + < t2v_480(10).calculate_consumer_costs().kinovi_credits); + assert!(video_ref_720(10, Some(1)).calculate_consumer_costs().kinovi_credits + < t2v_720(10).calculate_consumer_costs().kinovi_credits); + assert!(video_ref_1080(10, Some(1)).calculate_enterprise_costs().kinovi_credits + < t2v_1080(10).calculate_enterprise_costs().kinovi_credits); + } + + #[test] + fn enterprise_1080p_is_cheaper_than_consumer() { + for request in [t2v_1080(10), video_ref_1080(10, Some(10))] { + let consumer = request.calculate_consumer_costs(); + let enterprise = request.calculate_enterprise_costs(); + assert!(enterprise.kinovi_credits < consumer.kinovi_credits); + assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional); + } + } + + #[test] + fn cost_scales_linearly_with_duration() { + for duration in [1u8, 5, 10, 30] { + assert_eq!( + t2v_480(duration).calculate_consumer_costs().kinovi_credits, + (26 * u64::from(duration)) as f64, + ); + } + } + } + + // ── Aspect ratio doesn't affect cost ── + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = t2v_720(10).calculate_consumer_costs().kinovi_credits; + + let ratios = [ + KinoviSeedance2p5AspectRatio::Landscape16x9, + KinoviSeedance2p5AspectRatio::UltraWide21x9, + KinoviSeedance2p5AspectRatio::Portrait9x16, + KinoviSeedance2p5AspectRatio::Square1x1, + KinoviSeedance2p5AspectRatio::Standard4x3, + KinoviSeedance2p5AspectRatio::Portrait3x4, + ]; + + for ar in &ratios { + let mut request = t2v_720(10); + request.modality = KinoviSeedance2p5Modality::Reference { + aspect_ratio: Some(*ar), + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + }; + assert_eq!( + request.calculate_consumer_costs().kinovi_credits, baseline, + "Aspect ratio {:?} should not change credits from baseline {}", ar, baseline, + ); + } + } + + // ── Helpers ── + + fn text_to_video_request( + duration_seconds: u8, + output_resolution: Option, + ) -> GenerateSeedance2p5Request { + GenerateSeedance2p5Request { + prompt: String::new(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + }, + output_resolution, + duration_seconds, + total_input_seconds: None, + use_face_blur_hack: None, + } + } + + fn t2v_480(duration: u8) -> GenerateSeedance2p5Request { + text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::FourEightyP)) + } + + fn t2v_720(duration: u8) -> GenerateSeedance2p5Request { + text_to_video_request(duration, None) + } + + fn t2v_1080(duration: u8) -> GenerateSeedance2p5Request { + text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::TenEightyP)) + } + + fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request { + GenerateSeedance2p5Request { + prompt: String::new(), + modality: KinoviSeedance2p5Modality::Keyframe { + start_frame_url: "https://example.com/start.png".to_string(), + end_frame_url: Some("https://example.com/end.png".to_string()), + }, + output_resolution: None, + duration_seconds: duration, + total_input_seconds: None, + use_face_blur_hack: None, + } + } + + fn video_ref_480(duration: u8, total_input_seconds: Option) -> GenerateSeedance2p5Request { + let mut request = t2v_480(duration); + set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None); + request.total_input_seconds = total_input_seconds; + request + } + + fn video_ref_720(duration: u8, total_input_seconds: Option) -> GenerateSeedance2p5Request { + let mut request = t2v_720(duration); + set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None); + request.total_input_seconds = total_input_seconds; + request + } + + fn video_ref_1080(duration: u8, total_input_seconds: Option) -> GenerateSeedance2p5Request { + let mut request = t2v_1080(duration); + set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None); + request.total_input_seconds = total_input_seconds; + request + } + + fn set_reference_urls( + request: &mut GenerateSeedance2p5Request, + images: Option>, + videos: Option>, + audio: Option>, + ) { + let KinoviSeedance2p5Modality::Reference { + reference_image_urls, + reference_video_urls, + reference_audio_urls, + .. + } = &mut request.modality else { + panic!("set_reference_urls requires the Reference modality"); + }; + *reference_image_urls = images; + *reference_video_urls = videos; + *reference_audio_urls = audio; + } + } + + mod raw_mapping_tests { + use super::*; + + #[test] + fn keyframe_maps_frames_and_no_references() { + let raw = to_raw_request(GenerateSeedance2p5Request { + prompt: "Car drives into the sunset".to_string(), + modality: KinoviSeedance2p5Modality::Keyframe { + start_frame_url: "https://example.com/start.png".to_string(), + end_frame_url: Some("https://example.com/end.png".to_string()), + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP), + duration_seconds: 8, + total_input_seconds: None, + use_face_blur_hack: None, + }); + + assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5)); + assert_eq!(raw.start_frame_url.as_deref(), Some("https://example.com/start.png")); + assert_eq!(raw.end_frame_url.as_deref(), Some("https://example.com/end.png")); + assert!(raw.reference_image_urls.is_none()); + assert!(raw.reference_video_urls.is_none()); + assert!(raw.reference_audio_urls.is_none()); + } + + #[test] + fn reference_maps_references_and_no_frames() { + let raw = to_raw_request(GenerateSeedance2p5Request { + prompt: "The t-rex @image1 eats the banana".to_string(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9), + reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]), + reference_video_urls: Some(vec!["https://example.com/ref.mp4".to_string()]), + reference_audio_urls: Some(vec!["https://example.com/ref.wav".to_string()]), + }, + output_resolution: None, + duration_seconds: 8, + total_input_seconds: Some(7), + use_face_blur_hack: None, + }); + + assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9)); + assert!(raw.start_frame_url.is_none()); + assert!(raw.end_frame_url.is_none()); + assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1)); + assert_eq!(raw.reference_video_urls.as_deref().map(|urls| urls.len()), Some(1)); + assert_eq!(raw.reference_audio_urls.as_deref().map(|urls| urls.len()), Some(1)); + } + + #[test] + fn resolution_1080p_maps_to_raw_1080p() { + let raw = to_raw_request(GenerateSeedance2p5Request { + prompt: "A car driving on the beach".to_string(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::TenEightyP), + duration_seconds: 4, + total_input_seconds: None, + use_face_blur_hack: None, + }); + + assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::TenEightyP))); + } + + #[test] + fn reference_aspect_ratio_defaults_to_16x9() { + let raw = to_raw_request(GenerateSeedance2p5Request { + prompt: "Lightning hits a building".to_string(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + }, + output_resolution: None, + duration_seconds: 5, + total_input_seconds: None, + use_face_blur_hack: None, + }); + + assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9)); + } + } + + mod real_requests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5(GenerateSeedance2p5Args { + session: &session, + host_override: None, + request: GenerateSeedance2p5Request { + prompt: "Lightning hits a building".to_string(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9), + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP), + duration_seconds: 5, + total_input_seconds: None, + use_face_blur_hack: None, + }, + }).await?; + println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_1080p() -> AnyhowResult<()> { + // Mirrors external/requests/sites/kinovi.ai/2026-08-17-seedance2p5-1080p/ + // 1_seedance2p5_1080p.txt. + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5(GenerateSeedance2p5Args { + session: &session, + host_override: None, + request: GenerateSeedance2p5Request { + prompt: "A car driving on the beach".to_string(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9), + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::TenEightyP), + duration_seconds: 4, + total_input_seconds: None, + use_face_blur_hack: None, + }, + }).await?; + println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_to_video_adaptive_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5(GenerateSeedance2p5Args { + session: &session, + host_override: None, + request: GenerateSeedance2p5Request { + prompt: "Car drives into the sunset".to_string(), + modality: KinoviSeedance2p5Modality::Keyframe { + start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(), + end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()), + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::TenEightyP), + duration_seconds: 4, + total_input_seconds: None, + use_face_blur_hack: None, + }, + }).await?; + println!("keyframe 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_keyframe_to_video_adaptive_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5(GenerateSeedance2p5Args { + session: &session, + host_override: None, + request: GenerateSeedance2p5Request { + prompt: "Car drives into the sunset".to_string(), + modality: KinoviSeedance2p5Modality::Keyframe { + start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(), + end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()), + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP), + duration_seconds: 8, + total_input_seconds: None, + use_face_blur_hack: None, + }, + }).await?; + println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_reference_to_video_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5(GenerateSeedance2p5Args { + session: &session, + host_override: None, + request: GenerateSeedance2p5Request { + prompt: "The t-rex @image1 eats the banana @image2".to_string(), + modality: KinoviSeedance2p5Modality::Reference { + aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9), + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260807/1786128628611-d6ca7afd.png".to_string(), + "https://static.seedance2-pro.com/materials/20260807/1786128638331-5395ff1c.jpg".to_string(), + ]), + reference_video_urls: None, + reference_audio_urls: None, + }, + output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP), + duration_seconds: 8, + total_input_seconds: None, + use_face_blur_hack: None, + }, + }).await?; + println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5_preview.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5_preview.rs new file mode 100644 index 0000000000..a07e3cbcfc --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5_preview.rs @@ -0,0 +1,605 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait; +use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost; +use crate::pricing::kinovi_pricing_rate::KinoviPricingRate; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; +use crate::requests::kinovi_host::KinoviHost; +use crate::requests::workflow_run_task::workflow_run_task::{ + workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw, + KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest, +}; + +// ── Args ── + +pub struct GenerateSeedance2p5PreviewArgs<'a> { + pub request: GenerateSeedance2p5PreviewRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// ── Request ── + +/// Seedance 2.5 Preview only supports reference mode (there is no keyframe +/// start/end frame mode), 480p/720p output, and durations of 4–30 seconds. +#[derive(Clone, Debug)] +pub struct GenerateSeedance2p5PreviewRequest { + pub prompt: String, + pub aspect_ratio: Option, + pub output_resolution: Option, + pub duration_seconds: u8, + /// Reference images, referenced in prompts as @image1, @image2, etc. + pub reference_image_urls: Option>, + /// Reference videos, referenced in prompts as @video1, @video2, etc. + pub reference_video_urls: Option>, + /// Reference audio, referenced in prompts as @audio1, @audio2, etc. + pub reference_audio_urls: Option>, + /// Controls `faceBlurMode`: true sends "on"; false or None sends "off" + /// (the model always sends the field, unlike older Seedance models). + pub use_face_blur_hack: Option, +} + +// ── Enums ── + +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p5PreviewAspectRatio { + Landscape16x9, + UltraWide21x9, + Portrait9x16, + Square1x1, + Standard4x3, + Portrait3x4, +} + +/// Output resolution. 2.5 Preview supports only 480p and 720p. +#[derive(Debug, Clone, Copy)] +pub enum KinoviSeedance2p5PreviewOutputResolution { + FourEightyP, + SevenTwentyP, +} + +// ── Pricing ── +// +// Seedance 2.5 Preview credit pricing (per second of output duration): +// +// | Resolution | Consumer credits/sec | Enterprise credits/sec | +// |------------|----------------------|------------------------| +// | 480p | 46.15 | 42.13 | +// | 720p | 92.03 | 84.26 | +// +// References (images, videos, and audio) do NOT affect the cost — unlike the +// Seedance 2.0 models, there is no video-reference surcharge. Default +// resolution (None) is 720p. + +/// Per-second credit rates for Seedance 2.5 Preview at 480p. +const SEEDANCE_2P5_PREVIEW_480P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 46.15, + maybe_enterprise_credits: Some(42.13), +}; + +/// Per-second credit rates for Seedance 2.5 Preview at 720p. +const SEEDANCE_2P5_PREVIEW_720P: KinoviPricingRate = KinoviPricingRate { + consumer_credits: 92.03, + maybe_enterprise_credits: Some(84.26), +}; + +impl KinoviCostCalculatorTrait for GenerateSeedance2p5PreviewRequest { + type Cost = KinoviFractionalGenerationCost; + + /// Calculate the cost of this generation request, in Kinovi credits + /// (fractional) and USD cents, at the given pricing tier. + /// + /// `cost_from_credits` snaps to the nearest hundredth of a credit, so the + /// totals land exactly on the observed values (e.g. consumer 480p × 15s = + /// 692.25, not a 692.250000000001 float artifact). + fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviFractionalGenerationCost { + let rate = match self.output_resolution { + Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => SEEDANCE_2P5_PREVIEW_480P, + Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_PREVIEW_720P, + }; + let total_credits = rate.credits(tier) * f64::from(self.duration_seconds); + tier.cost_from_credits(total_credits) + } +} + +// ── Response ── + +pub struct GenerateSeedance2p5PreviewResponse { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, +} + +// ── Entry point ── + +pub async fn generate_seedance_2p5_preview( + args: GenerateSeedance2p5PreviewArgs<'_>, +) -> Result { + let raw_response = workflow_run_task(WorkflowRunTaskArgs { + request: to_raw_request(args.request), + session: args.session, + host_override: args.host_override, + }).await?; + + Ok(GenerateSeedance2p5PreviewResponse { + task_id: raw_response.task_id, + order_id: raw_response.order_id, + task_ids: raw_response.task_ids, + order_ids: raw_response.order_ids, + }) +} + +// ── Mapping helpers ── + +fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequest { + WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2p5Preview, + prompt: req.prompt, + aspect_ratio: map_aspect_ratio(req.aspect_ratio), + output_resolution: Some(map_output_resolution(req.output_resolution)), + duration_seconds: req.duration_seconds, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: req.reference_image_urls, + reference_video_urls: req.reference_video_urls, + reference_audio_urls: req.reference_audio_urls, + character_ids: None, + use_face_blur_hack: req.use_face_blur_hack, + bitrate: None, + } +} + +fn map_aspect_ratio(ar: Option) -> KinoviAspectRatioRaw { + match ar { + Some(KinoviSeedance2p5PreviewAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9, + Some(KinoviSeedance2p5PreviewAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9, + Some(KinoviSeedance2p5PreviewAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16, + Some(KinoviSeedance2p5PreviewAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1, + Some(KinoviSeedance2p5PreviewAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviSeedance2p5PreviewAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4, + None => KinoviAspectRatioRaw::Landscape16x9, + } +} + +fn map_output_resolution(res: Option) -> KinoviOutputResolutionRaw { + match res { + Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP, + // Unset resolves to 720p — MUST stay in lockstep with calculate_costs(), + // which prices None as 720p. + Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP, + } +} + +// ── Tests ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod pricing_tests { + use super::*; + + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + // ── Full pricing tables (credits) ── + // + // The exact per-duration tables for Seedance 2.5 Preview at each tier: + // consumer 480p = 46.15 credits/sec, 720p = 92.03 credits/sec; + // enterprise 480p = 42.13 credits/sec, 720p = 84.26 credits/sec. + + mod consumer_pricing_table { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(r480(4).calculate_consumer_costs().kinovi_credits, 184.6); + assert_eq!(r480(10).calculate_consumer_costs().kinovi_credits, 461.5); + assert_eq!(r480(15).calculate_consumer_costs().kinovi_credits, 692.25); + assert_eq!(r480(20).calculate_consumer_costs().kinovi_credits, 923.0); + assert_eq!(r480(25).calculate_consumer_costs().kinovi_credits, 1153.75); + assert_eq!(r480(30).calculate_consumer_costs().kinovi_credits, 1384.5); + } + + #[test] + fn table_720p() { + assert_eq!(r720(4).calculate_consumer_costs().kinovi_credits, 368.12); + assert_eq!(r720(10).calculate_consumer_costs().kinovi_credits, 920.3); + assert_eq!(r720(15).calculate_consumer_costs().kinovi_credits, 1380.45); + assert_eq!(r720(20).calculate_consumer_costs().kinovi_credits, 1840.6); + assert_eq!(r720(25).calculate_consumer_costs().kinovi_credits, 2300.75); + assert_eq!(r720(30).calculate_consumer_costs().kinovi_credits, 2760.9); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(10).calculate_consumer_costs(); + let explicit = build_request(10, Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP)).calculate_consumer_costs(); + assert_eq!(default, explicit); + } + } + + mod enterprise_pricing_table { + use super::*; + + #[test] + fn table_480p() { + assert_eq!(r480(4).calculate_enterprise_costs().kinovi_credits, 168.52); + assert_eq!(r480(10).calculate_enterprise_costs().kinovi_credits, 421.3); + assert_eq!(r480(15).calculate_enterprise_costs().kinovi_credits, 631.95); + assert_eq!(r480(20).calculate_enterprise_costs().kinovi_credits, 842.6); + assert_eq!(r480(25).calculate_enterprise_costs().kinovi_credits, 1053.25); + assert_eq!(r480(30).calculate_enterprise_costs().kinovi_credits, 1263.9); + } + + #[test] + fn table_720p() { + assert_eq!(r720(4).calculate_enterprise_costs().kinovi_credits, 337.04); + assert_eq!(r720(10).calculate_enterprise_costs().kinovi_credits, 842.6); + assert_eq!(r720(15).calculate_enterprise_costs().kinovi_credits, 1263.9); + assert_eq!(r720(20).calculate_enterprise_costs().kinovi_credits, 1685.2); + assert_eq!(r720(25).calculate_enterprise_costs().kinovi_credits, 2106.5); + assert_eq!(r720(30).calculate_enterprise_costs().kinovi_credits, 2527.8); + } + + #[test] + fn explicit_720p_same_as_default() { + let default = r720(10).calculate_enterprise_costs(); + let explicit = build_request(10, Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP)).calculate_enterprise_costs(); + assert_eq!(default, explicit); + } + } + + // ── Tier dispatch ── + + #[test] + fn convenience_methods_match_explicit_tier() { + let request = r480(10); + assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer)); + assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise)); + } + + // ── References don't affect cost ── + // + // Unlike the Seedance 2.0 models, 2.5 Preview has no video-reference + // surcharge: adding or removing references of any type leaves the cost + // unchanged at either tier. + + mod references_do_not_affect_cost { + use super::*; + + #[test] + fn table_480p_with_references_is_identical() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + for duration in [4u8, 10, 15, 20, 25, 30] { + assert_eq!( + with_all_reference_types(r480(duration)).calculate_costs(tier), + r480(duration).calculate_costs(tier), + "480p at {duration}s ({tier:?})", + ); + } + } + } + + #[test] + fn table_720p_with_references_is_identical() { + for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] { + for duration in [4u8, 10, 15, 20, 25, 30] { + assert_eq!( + with_all_reference_types(r720(duration)).calculate_costs(tier), + r720(duration).calculate_costs(tier), + "720p at {duration}s ({tier:?})", + ); + } + } + } + + #[test] + fn video_reference_alone_adds_no_surcharge() { + let mut request = r720(10); + request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]); + assert_eq!(request.calculate_consumer_costs().kinovi_credits, 920.3); + assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 842.6); + } + } + + // ── USD cents conversion ── + // + // usd_cents = credits × 100 / credits_per_dollar. Consumer converts at + // 192.98 credits/$1; enterprise converts at the bulk rate of 243.16 + // credits/$1. + + mod usd_cents { + use super::*; + + #[test] + fn consumer_cents_480p_4s() { + let cost = r480(4).calculate_consumer_costs(); + assert_eq!(cost.kinovi_credits, 184.6); + assert_eq!(cost.usd_cents_rounded_up, 96); + assert_eq!(cost.usd_cents_rounded_down, 95); + assert!((cost.usd_cents_fractional - 95.6576).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_480p_10s() { + let cost = r480(10).calculate_consumer_costs(); + assert_eq!(cost.kinovi_credits, 461.5); + assert_eq!(cost.usd_cents_rounded_up, 240); + assert_eq!(cost.usd_cents_rounded_down, 239); + assert!((cost.usd_cents_fractional - 239.144).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_480p_15s() { + let cost = r480(15).calculate_consumer_costs(); + assert_eq!(cost.kinovi_credits, 692.25); + assert_eq!(cost.usd_cents_rounded_up, 359); + assert_eq!(cost.usd_cents_rounded_down, 358); + assert!((cost.usd_cents_fractional - 358.7159).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_720p_4s() { + let cost = r720(4).calculate_consumer_costs(); + assert_eq!(cost.kinovi_credits, 368.12); + assert_eq!(cost.usd_cents_rounded_up, 191); + assert_eq!(cost.usd_cents_rounded_down, 190); + assert!((cost.usd_cents_fractional - 190.7555).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_720p_10s() { + let cost = r720(10).calculate_consumer_costs(); + assert_eq!(cost.kinovi_credits, 920.3); + assert_eq!(cost.usd_cents_rounded_up, 477); + assert_eq!(cost.usd_cents_rounded_down, 476); + assert!((cost.usd_cents_fractional - 476.8888).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cents_720p_30s() { + let cost = r720(30).calculate_consumer_costs(); + assert_eq!(cost.kinovi_credits, 2760.9); + assert_eq!(cost.usd_cents_rounded_up, 1431); + assert_eq!(cost.usd_cents_rounded_down, 1430); + assert!((cost.usd_cents_fractional - 1430.6664).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_480p_4s() { + let cost = r480(4).calculate_enterprise_costs(); + assert_eq!(cost.kinovi_credits, 168.52); + assert_eq!(cost.usd_cents_rounded_up, 70); + assert_eq!(cost.usd_cents_rounded_down, 69); + assert!((cost.usd_cents_fractional - 69.3042).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_480p_10s() { + let cost = r480(10).calculate_enterprise_costs(); + assert_eq!(cost.kinovi_credits, 421.3); + assert_eq!(cost.usd_cents_rounded_up, 174); + assert_eq!(cost.usd_cents_rounded_down, 173); + assert!((cost.usd_cents_fractional - 173.2604).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_480p_15s() { + let cost = r480(15).calculate_enterprise_costs(); + assert_eq!(cost.kinovi_credits, 631.95); + assert_eq!(cost.usd_cents_rounded_up, 260); + assert_eq!(cost.usd_cents_rounded_down, 259); + assert!((cost.usd_cents_fractional - 259.8906).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_720p_4s() { + let cost = r720(4).calculate_enterprise_costs(); + assert_eq!(cost.kinovi_credits, 337.04); + assert_eq!(cost.usd_cents_rounded_up, 139); + assert_eq!(cost.usd_cents_rounded_down, 138); + assert!((cost.usd_cents_fractional - 138.6083).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_720p_10s() { + let cost = r720(10).calculate_enterprise_costs(); + assert_eq!(cost.kinovi_credits, 842.6); + assert_eq!(cost.usd_cents_rounded_up, 347); + assert_eq!(cost.usd_cents_rounded_down, 346); + assert!((cost.usd_cents_fractional - 346.5208).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn enterprise_cents_720p_30s() { + let cost = r720(30).calculate_enterprise_costs(); + assert_eq!(cost.kinovi_credits, 2527.8); + assert_eq!(cost.usd_cents_rounded_up, 1040); + assert_eq!(cost.usd_cents_rounded_down, 1039); + assert!((cost.usd_cents_fractional - 1039.5624).abs() < FLOAT_TOLERANCE); + } + } + + // ── Relative pricing ── + + mod relative_tests { + use super::*; + + #[test] + fn enterprise_720p_is_exactly_double_480p() { + // 84.26 = 2 × 42.13. + for duration in [4u8, 10, 15, 20, 25, 30] { + let c480 = r480(duration).calculate_enterprise_costs().kinovi_credits; + let c720 = r720(duration).calculate_enterprise_costs().kinovi_credits; + assert_eq!(c720, c480 * 2.0, "enterprise 720p should be 2× 480p at {duration}s"); + } + } + + #[test] + fn consumer_720p_is_slightly_less_than_double_480p() { + // 92.03 < 2 × 46.15 = 92.30, so consumer 720p is just under double. + for duration in [4u8, 10, 15, 20, 25, 30] { + let c480 = r480(duration).calculate_consumer_costs().kinovi_credits; + let c720 = r720(duration).calculate_consumer_costs().kinovi_credits; + assert!(c720 < c480 * 2.0, "consumer 720p should be under 2× 480p at {duration}s"); + } + } + + #[test] + fn enterprise_is_cheaper_than_consumer() { + for resolution in [ + Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP), + Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP), + ] { + let request = build_request(10, resolution); + let consumer = request.calculate_consumer_costs(); + let enterprise = request.calculate_enterprise_costs(); + assert!(enterprise.kinovi_credits < consumer.kinovi_credits); + assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional); + } + } + + #[test] + fn cost_scales_linearly_with_duration() { + assert_eq!(r480(1).calculate_consumer_costs().kinovi_credits, 46.15); + assert_eq!(r480(1).calculate_enterprise_costs().kinovi_credits, 42.13); + for duration in [4u8, 10, 15, 20, 25, 30] { + let expected_consumer = (4_615 * u64::from(duration)) as f64 / 100.0; + let expected_enterprise = (4_213 * u64::from(duration)) as f64 / 100.0; + assert_eq!(r480(duration).calculate_consumer_costs().kinovi_credits, expected_consumer); + assert_eq!(r480(duration).calculate_enterprise_costs().kinovi_credits, expected_enterprise); + } + } + } + + // ── Aspect ratio doesn't affect cost ── + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let baseline = r720(10).calculate_consumer_costs().kinovi_credits; + + let ratios = [ + KinoviSeedance2p5PreviewAspectRatio::Landscape16x9, + KinoviSeedance2p5PreviewAspectRatio::UltraWide21x9, + KinoviSeedance2p5PreviewAspectRatio::Portrait9x16, + KinoviSeedance2p5PreviewAspectRatio::Square1x1, + KinoviSeedance2p5PreviewAspectRatio::Standard4x3, + KinoviSeedance2p5PreviewAspectRatio::Portrait3x4, + ]; + + for ar in &ratios { + let mut request = r720(10); + request.aspect_ratio = Some(*ar); + assert_eq!( + request.calculate_consumer_costs().kinovi_credits, baseline, + "Aspect ratio {:?} should not change credits from baseline {}", ar, baseline, + ); + } + } + + // ── Helpers ── + + fn build_request( + duration_seconds: u8, + output_resolution: Option, + ) -> GenerateSeedance2p5PreviewRequest { + GenerateSeedance2p5PreviewRequest { + prompt: String::new(), + aspect_ratio: None, + output_resolution, + duration_seconds, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + use_face_blur_hack: None, + } + } + + fn r480(dur: u8) -> GenerateSeedance2p5PreviewRequest { + build_request(dur, Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP)) + } + + fn r720(dur: u8) -> GenerateSeedance2p5PreviewRequest { + build_request(dur, None) + } + + fn with_all_reference_types(mut request: GenerateSeedance2p5PreviewRequest) -> GenerateSeedance2p5PreviewRequest { + request.reference_image_urls = Some(vec![ + "https://example.com/a.jpg".to_string(), + "https://example.com/b.png".to_string(), + ]); + request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]); + request.reference_audio_urls = Some(vec!["https://example.com/ref.wav".to_string()]); + request + } + } + + mod real_requests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_text_to_video_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5_preview(GenerateSeedance2p5PreviewArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p5PreviewRequest { + prompt: "A man is running from a t-rex".to_string(), + aspect_ratio: Some(KinoviSeedance2p5PreviewAspectRatio::Landscape16x9), + output_resolution: Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP), + duration_seconds: 4, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + use_face_blur_hack: None, + }, + }).await?; + println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_to_video_720p_default() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = generate_seedance_2p5_preview(GenerateSeedance2p5PreviewArgs { + session: &session, + host_override: None, + request: GenerateSeedance2p5PreviewRequest { + prompt: "A corgi and a shiba are playing chess against one another".to_string(), + aspect_ratio: None, + output_resolution: None, + duration_seconds: 4, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + use_face_blur_hack: None, + }, + }).await?; + println!("t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs b/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs new file mode 100644 index 0000000000..025c602431 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/generate/video/mod.rs @@ -0,0 +1,6 @@ +pub mod generate_happy_horse_1p0; +pub mod generate_seedance_2p0; +pub mod generate_seedance_2p0_fast; +pub mod generate_seedance_2p0_mini; +pub mod generate_seedance_2p5; +pub mod generate_seedance_2p5_preview; diff --git a/crates/api_clients/kinovi_web_client/src/lib.rs b/crates/api_clients/kinovi_web_client/src/lib.rs new file mode 100644 index 0000000000..a1e36a47df --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/lib.rs @@ -0,0 +1,11 @@ +#[cfg(test)] +pub (crate) mod test_utils; + +// User API +pub mod billing; +pub mod creds; +pub mod error; +pub mod generate; +pub mod pricing; +pub mod requests; +pub mod utils; diff --git a/crates/api_clients/kinovi_web_client/src/pricing/constants.rs b/crates/api_clients/kinovi_web_client/src/pricing/constants.rs new file mode 100644 index 0000000000..8983ea07b7 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/constants.rs @@ -0,0 +1,21 @@ +/// Kinovi credits per US dollar at the enterprise bulk purchase rate (us). +/// +/// Current package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, +/// rounded down to 243). +/// +/// Historical packages, for reference: +/// - 500,000 credits for $2,159.09 (~231.58 credits/$1, rounded down to 231) +/// - 22,000 credits for $114 (~192.98 credits/$1, rounded to 193) +pub const ENTERPRISE_CREDITS_PER_DOLLAR: u64 = 243; + +/// Kinovi credits per US dollar at the ordinary consumer purchase rate +/// (22,000 credits for $114, ~192.98 credits/$1). +pub const CONSUMER_CREDITS_PER_DOLLAR: u64 = 193; + +/// [`ENTERPRISE_CREDITS_PER_DOLLAR`] as a float. Prefer this in new code; +/// the u64 constants will migrate here later. +pub const ENTERPRISE_CREDITS_PER_DOLLAR_FLOAT: f64 = 243.16f64; + +/// [`CONSUMER_CREDITS_PER_DOLLAR`] as a float. Prefer this in new code; +/// the u64 constants will migrate here later. +pub const CONSUMER_CREDITS_PER_DOLLAR_FLOAT: f64 = 192.98f64; diff --git a/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_fractional_generation_cost.rs b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_fractional_generation_cost.rs new file mode 100644 index 0000000000..abdc76d735 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_fractional_generation_cost.rs @@ -0,0 +1,85 @@ +use crate::pricing::constants::ENTERPRISE_CREDITS_PER_DOLLAR; + +/// The cost of a Kinovi generation that bills FRACTIONAL credits, in both +/// native credits and USD cents. +/// +/// This is the fractional-credit counterpart of +/// [`crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses +/// `u64`). Some Kinovi models bill non-integer credits — e.g. Seedance 2.0 +/// Mini at 480p bills 7.5 credits/sec, landing on half-credits like 37.5 at +/// odd durations. Any model with a fractional credit rate uses this type; +/// whole-credit models use the cheaper `u64` `KinoviGenerationCost`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KinoviFractionalGenerationCost { + /// The cost of the generation in Kinovi credits. May be fractional (e.g. + /// 37.5 for Seedance 2.0 Mini 480p at 5 seconds). + pub kinovi_credits: f64, + + /// Estimated cost in cents, rounded up to the nearest whole cent. + /// This does not account for discounts, prorations, etc. + pub usd_cents_rounded_up: u64, + + /// Estimated cost in cents, rounded down to the nearest whole cent. + pub usd_cents_rounded_down: u64, + + /// Estimated cost in cents without rounding (the exact fractional value). + pub usd_cents_fractional: f64, +} + +impl KinoviFractionalGenerationCost { + /// Build a cost from a (possibly fractional) Kinovi credit amount, deriving + /// the USD cents via the credit-package rate. + /// + /// The conversion is done on integer hundredths-of-a-credit, so the integer + /// cent results are exact (no floating-point drift). Credit amounts are + /// rounded to the nearest hundredth of a credit, which covers every Kinovi + /// rate seen so far (e.g. Mini's half-credits). + pub fn from_kinovi_credits(kinovi_credits: f64) -> Self { + // usd_cents == credits × 100 / ENTERPRISE_CREDITS_PER_DOLLAR == hundredths / ENTERPRISE_CREDITS_PER_DOLLAR. + let hundredths_of_credit = (kinovi_credits * 100.0).round() as u64; + Self { + kinovi_credits, + usd_cents_rounded_up: hundredths_of_credit.div_ceil(ENTERPRISE_CREDITS_PER_DOLLAR), + usd_cents_rounded_down: hundredths_of_credit / ENTERPRISE_CREDITS_PER_DOLLAR, + usd_cents_fractional: hundredths_of_credit as f64 / ENTERPRISE_CREDITS_PER_DOLLAR as f64, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FLOAT_TOLERANCE: f64 = 1e-9; + + #[test] + fn fractional_credits_convert_to_usd() { + // 37.5 credits; 3750/243 = 15.4320 cents. + let cost = KinoviFractionalGenerationCost::from_kinovi_credits(37.5); + assert_eq!(cost.kinovi_credits, 37.5); + assert_eq!(cost.usd_cents_rounded_up, 16); + assert_eq!(cost.usd_cents_rounded_down, 15); + assert!((cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn whole_credits_convert_to_usd() { + // 100 credits; 10000/243 = 41.1522 cents. + let cost = KinoviFractionalGenerationCost::from_kinovi_credits(100.0); + assert_eq!(cost.kinovi_credits, 100.0); + assert_eq!(cost.usd_cents_rounded_up, 42); + assert_eq!(cost.usd_cents_rounded_down, 41); + assert!((cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn rounded_bounds_bracket_the_fractional_value() { + for half_credits in [0u64, 1, 15, 75, 95, 200, 225, 600, 720] { + let credits = half_credits as f64 / 2.0; + let cost = KinoviFractionalGenerationCost::from_kinovi_credits(credits); + assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional); + assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64); + assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1); + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_generation_cost.rs b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_generation_cost.rs new file mode 100644 index 0000000000..f92a23ea1f --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_generation_cost.rs @@ -0,0 +1,90 @@ +use crate::pricing::constants::ENTERPRISE_CREDITS_PER_DOLLAR; + +/// The cost of a Kinovi generation, in both native credits and USD cents. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KinoviGenerationCost { + /// The cost of the generation in Kinovi credits. + pub kinovi_credits: u64, + + /// Estimated cost in cents, rounded up to the nearest whole cent. + /// This does not account for discounts, prorations, etc. + /// This is always rounded up when fractional. + pub usd_cents_rounded_up: u64, + + /// Estimated cost in cents, rounded down to the nearest whole cent. + pub usd_cents_rounded_down: u64, + + /// Estimated cost in cents without rounding (the exact fractional value). + pub usd_cents_fractional: f64, +} + +impl KinoviGenerationCost { + /// Build a cost from a Kinovi credit amount, deriving the USD cents via + /// the credit-package rate. + pub fn from_kinovi_credits(kinovi_credits: u64) -> Self { + let total_hundredths = kinovi_credits * 100; + Self { + kinovi_credits, + // Integer ceiling / floor of (credits * 100 / ENTERPRISE_CREDITS_PER_DOLLAR). + usd_cents_rounded_up: total_hundredths.div_ceil(ENTERPRISE_CREDITS_PER_DOLLAR), + usd_cents_rounded_down: total_hundredths / ENTERPRISE_CREDITS_PER_DOLLAR, + usd_cents_fractional: total_hundredths as f64 / ENTERPRISE_CREDITS_PER_DOLLAR as f64, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FLOAT_TOLERANCE: f64 = 1e-9; + + #[test] + fn zero_credits_is_zero_cents() { + let cost = KinoviGenerationCost::from_kinovi_credits(0); + assert_eq!(cost.kinovi_credits, 0); + assert_eq!(cost.usd_cents_rounded_up, 0); + assert_eq!(cost.usd_cents_rounded_down, 0); + assert!((cost.usd_cents_fractional - 0.0).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn exact_dollar_boundaries_do_not_round() { + // 243 credits = exactly $1.00 — all three representations agree. + let cost = KinoviGenerationCost::from_kinovi_credits(243); + assert_eq!(cost.kinovi_credits, 243); + assert_eq!(cost.usd_cents_rounded_up, 100); + assert_eq!(cost.usd_cents_rounded_down, 100); + assert!((cost.usd_cents_fractional - 100.0).abs() < FLOAT_TOLERANCE); + + let cost = KinoviGenerationCost::from_kinovi_credits(486); + assert_eq!(cost.usd_cents_rounded_up, 200); + assert_eq!(cost.usd_cents_rounded_down, 200); + assert!((cost.usd_cents_fractional - 200.0).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn fractional_cents_round_in_both_directions() { + // 244 credits = 100.411... cents. + let cost = KinoviGenerationCost::from_kinovi_credits(244); + assert_eq!(cost.usd_cents_rounded_up, 101); + assert_eq!(cost.usd_cents_rounded_down, 100); + assert!((cost.usd_cents_fractional - (24400.0 / 243.0)).abs() < FLOAT_TOLERANCE); + + // 1 credit = 0.432... cents. + let cost = KinoviGenerationCost::from_kinovi_credits(1); + assert_eq!(cost.usd_cents_rounded_up, 1); + assert_eq!(cost.usd_cents_rounded_down, 0); + assert!((cost.usd_cents_fractional - (100.0 / 243.0)).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn rounded_bounds_bracket_the_fractional_value() { + for credits in [0, 1, 12, 75, 200, 243, 244, 450, 1350] { + let cost = KinoviGenerationCost::from_kinovi_credits(credits); + assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional); + assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64); + assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1); + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs new file mode 100644 index 0000000000..1f19cf560a --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/cost/kinovi_seedance_fractional_generation_cost.rs @@ -0,0 +1,87 @@ +use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost; +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; + +/// The cost of a Kinovi Seedance generation (Seedance 2.0, Fast, and Mini), +/// with the video-reference surcharge broken out from the base price. +/// +/// Credits are fractional (`f64`): Mini's consumer 480p rate is 7.5/sec, and +/// the enterprise discount rates land on fractional credits at most +/// durations (e.g. Seedance 2.0 at 37.9/sec). +/// +/// `total_cost` covers base + surcharge. NB: the total's USD conversions are +/// computed from the SUMMED credits (rounded once), so they may differ by a +/// cent from adding the parts' rounded USD values together. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KinoviSeedanceFractionalGenerationCost { + /// The full cost (base + any surcharge), in credits and USD cents. + pub total_cost: KinoviFractionalGenerationCost, + + /// The base generation cost (resolution rate × output duration × batch + /// count), excluding any surcharges. + pub base_cost: KinoviFractionalGenerationCost, + + /// The video-reference surcharge, when one or more reference videos are + /// attached. `None` when no reference videos are attached. + pub video_reference_surcharge_cost: Option, +} + +impl KinoviSeedanceFractionalGenerationCost { + /// Build a cost from base credits plus an optional video-reference + /// surcharge (both in Kinovi credits, possibly fractional), converting to + /// USD at the given pricing tier's credit purchase rate. + pub fn from_base_and_surcharge_at_tier( + tier: KinoviPricingTier, + base_credits: f64, + maybe_video_reference_surcharge_credits: Option, + ) -> Self { + let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0); + Self { + total_cost: tier.cost_from_credits(total_credits), + base_cost: tier.cost_from_credits(base_credits), + video_reference_surcharge_cost: maybe_video_reference_surcharge_credits + .map(|credits| tier.cost_from_credits(credits)), + } + } + +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn without_surcharge_total_equals_base() { + let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + KinoviPricingTier::Enterprise, 37.5, None, + ); + assert!(cost.video_reference_surcharge_cost.is_none()); + assert_eq!(cost.base_cost.kinovi_credits, 37.5); + assert_eq!(cost.total_cost, cost.base_cost); + } + + #[test] + fn with_surcharge_sums_into_total_at_the_enterprise_rate() { + // 37.5 base + 10 surcharge = 47.5 total; 4750/243.16 = 19.5344 → 20¢. + let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + KinoviPricingTier::Enterprise, 37.5, Some(10.0), + ); + assert_eq!(cost.base_cost.kinovi_credits, 37.5); + assert_eq!( + cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits), + Some(10.0), + ); + assert_eq!(cost.total_cost.kinovi_credits, 47.5); + assert_eq!(cost.total_cost.usd_cents_rounded_up, 20); + assert_eq!(cost.total_cost.usd_cents_rounded_down, 19); + } + + #[test] + fn consumer_tier_converts_at_the_consumer_rate() { + // 47.5 credits; 4750/192.98 = 24.6139 → 25¢. + let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier( + KinoviPricingTier::Consumer, 37.5, Some(10.0), + ); + assert_eq!(cost.total_cost.usd_cents_rounded_up, 25); + assert_eq!(cost.total_cost.usd_cents_rounded_down, 24); + } +} diff --git a/crates/api_clients/kinovi_web_client/src/pricing/cost/mod.rs b/crates/api_clients/kinovi_web_client/src/pricing/cost/mod.rs new file mode 100644 index 0000000000..f28bb9f489 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/cost/mod.rs @@ -0,0 +1,3 @@ +pub mod kinovi_fractional_generation_cost; +pub mod kinovi_generation_cost; +pub mod kinovi_seedance_fractional_generation_cost; diff --git a/crates/api_clients/kinovi_web_client/src/pricing/kinovi_cost_calculator_trait.rs b/crates/api_clients/kinovi_web_client/src/pricing/kinovi_cost_calculator_trait.rs new file mode 100644 index 0000000000..156e2a022f --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/kinovi_cost_calculator_trait.rs @@ -0,0 +1,20 @@ +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; + +/// Prices a generation request at either pricing tier. +/// +/// NOTE: the Seedance video models implement this; the other models still +/// expose single-rate inherent `calculate_costs()` methods and will migrate +/// here. +pub trait KinoviCostCalculatorTrait { + type Cost; + + fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost; + + fn calculate_consumer_costs(&self) -> Self::Cost { + self.calculate_costs(KinoviPricingTier::Consumer) + } + + fn calculate_enterprise_costs(&self) -> Self::Cost { + self.calculate_costs(KinoviPricingTier::Enterprise) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/pricing/kinovi_pricing_rate.rs b/crates/api_clients/kinovi_web_client/src/pricing/kinovi_pricing_rate.rs new file mode 100644 index 0000000000..dec0a698ac --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/kinovi_pricing_rate.rs @@ -0,0 +1,42 @@ +use crate::pricing::kinovi_pricing_tier::KinoviPricingTier; + +/// The credit cost of one billable unit (e.g. one second of video for the +/// Seedance models) at each pricing tier. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KinoviPricingRate { + /// The standard rate every Kinovi customer pays. + pub consumer_credits: f64, + /// A negotiated enterprise discount rate, when one exists. Enterprise + /// pricing falls back to `consumer_credits` when absent. + pub maybe_enterprise_credits: Option, +} + +impl KinoviPricingRate { + /// The credit rate at the given tier. Enterprise falls back to the + /// consumer rate when no discount exists. + pub fn credits(&self, tier: KinoviPricingTier) -> f64 { + match tier { + KinoviPricingTier::Enterprise => self.maybe_enterprise_credits.unwrap_or(self.consumer_credits), + KinoviPricingTier::Consumer => self.consumer_credits, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discounted_rate_applies_to_enterprise_only() { + let rate = KinoviPricingRate { consumer_credits: 46.15, maybe_enterprise_credits: Some(42.13) }; + assert_eq!(rate.credits(KinoviPricingTier::Consumer), 46.15); + assert_eq!(rate.credits(KinoviPricingTier::Enterprise), 42.13); + } + + #[test] + fn enterprise_falls_back_to_consumer_rate_when_no_discount() { + let rate = KinoviPricingRate { consumer_credits: 10.0, maybe_enterprise_credits: None }; + assert_eq!(rate.credits(KinoviPricingTier::Enterprise), 10.0); + assert_eq!(rate.credits(KinoviPricingTier::Consumer), 10.0); + } +} diff --git a/crates/api_clients/kinovi_web_client/src/pricing/kinovi_pricing_tier.rs b/crates/api_clients/kinovi_web_client/src/pricing/kinovi_pricing_tier.rs new file mode 100644 index 0000000000..31efd1097c --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/kinovi_pricing_tier.rs @@ -0,0 +1,103 @@ +use crate::pricing::constants::{CONSUMER_CREDITS_PER_DOLLAR_FLOAT, ENTERPRISE_CREDITS_PER_DOLLAR_FLOAT}; +use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost; + +/// Which credit purchase rate and per-model credit rate to price against. +/// +/// Enterprise is our own bulk pricing: the enterprise credit purchase rate +/// plus any negotiated per-model/per-modality credit discount. Consumer is +/// what an ordinary Kinovi customer pays: the consumer credit purchase rate +/// and the standard per-model credit rate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KinoviPricingTier { + Enterprise, + Consumer, +} + +impl KinoviPricingTier { + /// The credit purchase rate for this tier, in credits per US dollar. + pub const fn credits_per_dollar(self) -> f64 { + match self { + KinoviPricingTier::Enterprise => ENTERPRISE_CREDITS_PER_DOLLAR_FLOAT, + KinoviPricingTier::Consumer => CONSUMER_CREDITS_PER_DOLLAR_FLOAT, + } + } + + /// Convert a (possibly fractional) credit amount into a + /// [`KinoviFractionalGenerationCost`] at this tier's purchase rate. + /// + /// The credit amount is snapped to the nearest hundredth of a credit, so + /// totals land exactly on the observed values (e.g. 46.15 × 15 = 692.25, + /// not the 692.250000000001 float artifact) and the integer cent results + /// are exact. + pub fn cost_from_credits(self, kinovi_credits: f64) -> KinoviFractionalGenerationCost { + let credit_hundredths = (kinovi_credits * 100.0).round(); + // usd_cents == credits × 100 / credits_per_dollar == hundredths / credits_per_dollar. + let usd_cents_fractional = credit_hundredths / self.credits_per_dollar(); + KinoviFractionalGenerationCost { + kinovi_credits: credit_hundredths / 100.0, + usd_cents_rounded_up: usd_cents_fractional.ceil() as u64, + usd_cents_rounded_down: usd_cents_fractional.floor() as u64, + usd_cents_fractional, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Expected fractional cents are written to 4 decimal places. + const FLOAT_TOLERANCE: f64 = 0.0001; + + #[test] + fn credits_per_dollar_rates() { + assert_eq!(KinoviPricingTier::Enterprise.credits_per_dollar(), 243.16); + assert_eq!(KinoviPricingTier::Consumer.credits_per_dollar(), 192.98); + } + + #[test] + fn spot_check_enterprise() { + // One dollar's worth of credits at the enterprise rate = exactly 100¢. + // Breaks if the rate changes. + assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).kinovi_credits, 243.16); + assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).usd_cents_rounded_up, 100); + assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).usd_cents_rounded_down, 100); + assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).usd_cents_fractional, 100.0); + } + + #[test] + fn spot_check_consumer() { + // One dollar's worth of credits at the consumer rate = exactly 100¢. + // Breaks if the rate changes. + assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).kinovi_credits, 192.98); + assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).usd_cents_rounded_up, 100); + assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).usd_cents_rounded_down, 100); + assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).usd_cents_fractional, 100.0); + } + + #[test] + fn enterprise_cost_from_credits() { + let cost = KinoviPricingTier::Enterprise.cost_from_credits(37.5); + assert_eq!(cost.kinovi_credits, 37.5); + assert_eq!(cost.usd_cents_rounded_up, 16); + assert_eq!(cost.usd_cents_rounded_down, 15); + assert!((cost.usd_cents_fractional - 15.4219).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn consumer_cost_from_credits() { + let cost = KinoviPricingTier::Consumer.cost_from_credits(37.5); + assert_eq!(cost.kinovi_credits, 37.5); + assert_eq!(cost.usd_cents_rounded_up, 20); + assert_eq!(cost.usd_cents_rounded_down, 19); + assert!((cost.usd_cents_fractional - 19.4321).abs() < FLOAT_TOLERANCE); + } + + #[test] + fn credits_snap_to_the_nearest_hundredth() { + // 46.15 × 15 accumulates float drift (692.250000000001); the conversion + // must land exactly on 692.25. + let cost = KinoviPricingTier::Enterprise.cost_from_credits(46.15 * 15.0); + assert_eq!(cost.kinovi_credits, 692.25); + } +} diff --git a/crates/api_clients/kinovi_web_client/src/pricing/mod.rs b/crates/api_clients/kinovi_web_client/src/pricing/mod.rs new file mode 100644 index 0000000000..3f4fb66f70 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/pricing/mod.rs @@ -0,0 +1,5 @@ +pub mod constants; +pub mod cost; +pub mod kinovi_cost_calculator_trait; +pub mod kinovi_pricing_rate; +pub mod kinovi_pricing_tier; diff --git a/crates/api_clients/kinovi_web_client/src/requests/create_material/create_material.rs b/crates/api_clients/kinovi_web_client/src/requests/create_material/create_material.rs new file mode 100644 index 0000000000..8828e231e4 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/create_material/create_material.rs @@ -0,0 +1,496 @@ +use serde_derive::{Deserialize, Serialize}; + +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::create_material::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Request args --- + +/// Register an uploaded file as a Kinovi "material" (`material.createMaterial`). +/// +/// The site calls this once per uploaded reference file, after the signed-URL +/// PUT (see `prepare_file_upload` / `upload_file`) and before the file's URL +/// is used in `workflow.runTask`. The response includes a `material_id` and a +/// content-moderation `detect_status`, but NONE of that state is carried into +/// the generate request — `workflow.runTask` references materials purely by +/// their CDN URL. Registration appears to exist so Kinovi's backend can run +/// content detection on the file; an unregistered URL is presumed to be why +/// video-reference generations stall or fail. +pub struct CreateMaterialArgs<'a> { + pub request: CreateMaterialRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +// --- Request --- + +#[derive(Clone, Debug)] +pub struct CreateMaterialRequest { + /// The public CDN URL of the uploaded file, as returned by `upload_file` + /// (e.g. `https://static.seedance2-pro.com/materials/20260801/...mp4`). + pub url: String, + + pub format: KinoviMaterialFormat, + + /// Pixel width of the video or image. + pub width: u32, + + /// Pixel height of the video or image. + pub height: u32, + + /// Duration in whole seconds. Required for videos; must be None for + /// photos (the site sends `duration: null` annotated as `undefined`). + pub maybe_duration_seconds: Option, + + /// File size in bytes. + pub size_bytes: u64, +} + +/// The material type. Only videos and photos have been observed on the wire; +/// audio uploads have not been captured going through `createMaterial`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KinoviMaterialFormat { + /// `"video"` + Video, + /// `"photo"` + Photo, +} + +impl KinoviMaterialFormat { + pub fn as_api_str(&self) -> &'static str { + match self { + Self::Video => "video", + Self::Photo => "photo", + } + } +} + +// --- Response --- + +pub struct CreateMaterialResponse { + pub material: KinoviMaterial, +} + +/// A material record as returned by `material.createMaterial`. +/// +/// Most fields are nullable server-side; everything nonessential is optional +/// and defaulted so new/omitted fields never break parsing. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KinoviMaterial { + /// Numeric row id (e.g. 83483). + pub id: u64, + + /// Owning user id (e.g. "cmlrbvj2a000buo91m14ejhw0"). + pub user_id: String, + + /// Material token (e.g. "mat_v1k48s02um7vow9q0rx38jlc"). + pub material_id: String, + + /// The public CDN URL the material was registered under. + pub url: String, + + /// "video" or "photo". + pub format: String, + + /// File size in bytes. + pub size: u64, + + pub width: u32, + + pub height: u32, + + /// Duration in seconds (videos only). + #[serde(default)] + pub duration: Option, + + /// Content-moderation status. Videos start as `Processing`; photos have + /// been observed returning `Succeeded` immediately. + pub detect_status: KinoviMaterialDetectStatus, + + #[serde(default)] + pub name: Option, + + #[serde(default)] + pub frame_rate: Option, + + #[serde(default)] + pub detect_task_id: Option, + + #[serde(default)] + pub poster: Option, + + #[serde(default)] + pub detect_url: Option, + + #[serde(default)] + pub detect_info: Option, + + /// Unix-millis string (superjson bigint), e.g. "1785592620587". + #[serde(default)] + pub detect_start_at: Option, + + #[serde(default)] + pub detect_finish_at: Option, + + #[serde(default)] + pub detect_finish_from: Option, + + #[serde(default)] + pub used_times: Option, + + #[serde(default)] + pub user_agent: Option, + + /// Perceptual thumbnail hash — a byte array for photos, null for videos. + #[serde(default)] + pub thumb_hash: Option, + + #[serde(default)] + pub status: Option, + + #[serde(default)] + pub preview_image_url: Option, + + #[serde(default)] + pub expire_del_at: Option, + + #[serde(default)] + pub created_at: Option, + + #[serde(default)] + pub updated_at: Option, + + #[serde(default)] + pub remove_status: Option, + + #[serde(default)] + pub remove_at: Option, + + #[serde(default)] + pub feature_type: Option, + + #[serde(default)] + pub mux_playback_id: Option, + + #[serde(default)] + pub mux_stream_url: Option, + + #[serde(default)] + pub mux_thumbnail_url: Option, +} + +/// Content-moderation status of a material. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum KinoviMaterialDetectStatus { + Processing, + Succeeded, + Failed, + /// Forward-compatibility catch-all for statuses we haven't observed. + #[serde(other)] + Unknown, +} + +// --- Implementation --- + +pub async fn create_material(args: CreateMaterialArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let create_material_url = format!("{}/api/trpc/material.createMaterial?batch=1", base_url); + + info!("Creating material: {:?}", args.request); + + let request_body = build_batch_request(args.request); + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/", base_url); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.post(&create_material_url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .json(&request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let material = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + Ok(CreateMaterialResponse { material }) +} + +/// Build the tRPC request body. Videos send an integer `duration` and no +/// `meta` block; photos send `duration: null` with a superjson `meta` block +/// marking the field as `undefined` (mirroring the site's requests exactly). +fn build_batch_request(req: CreateMaterialRequest) -> BatchRequest { + let meta = match req.format { + KinoviMaterialFormat::Photo => Some(MaterialMeta { + values: MaterialMetaValues { duration: ["undefined"] }, + v: 1, + }), + KinoviMaterialFormat::Video => None, + }; + + BatchRequest { + zero: BatchRequestInner { + json: MaterialJson { + url: req.url, + format: req.format.as_api_str(), + width: req.width, + height: req.height, + duration: req.maybe_duration_seconds, + size: req.size_bytes, + }, + meta, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + // ── Request serialization ── + // + // Mirrors the captured requests in + // external/requests/sites/kinovi.ai/2026-08-01-seedance2p5-fixes/ + // (4_upload_material.txt and 11_create_image_material.txt). + + mod request_serialization_tests { + use super::*; + + #[test] + fn video_request_matches_observed_wire_format() { + let body = build_batch_request(CreateMaterialRequest { + url: "https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4".to_string(), + format: KinoviMaterialFormat::Video, + width: 1280, + height: 720, + maybe_duration_seconds: Some(8), + size_bytes: 4773897, + }); + let json = serde_json::to_string(&body).unwrap(); + assert_eq!( + json, + r#"{"0":{"json":{"url":"https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4","format":"video","width":1280,"height":720,"duration":8,"size":4773897}}}"#, + ); + } + + #[test] + fn photo_request_matches_observed_wire_format() { + let body = build_batch_request(CreateMaterialRequest { + url: "https://static.seedance2-pro.com/materials/20260801/1785593115274-52bcb691.png".to_string(), + format: KinoviMaterialFormat::Photo, + width: 1024, + height: 1024, + maybe_duration_seconds: None, + size_bytes: 2158735, + }); + let json = serde_json::to_string(&body).unwrap(); + assert_eq!( + json, + r#"{"0":{"json":{"url":"https://static.seedance2-pro.com/materials/20260801/1785593115274-52bcb691.png","format":"photo","width":1024,"height":1024,"duration":null,"size":2158735},"meta":{"values":{"duration":["undefined"]},"v":1}}}"#, + ); + } + + #[test] + fn format_api_strings() { + assert_eq!(KinoviMaterialFormat::Video.as_api_str(), "video"); + assert_eq!(KinoviMaterialFormat::Photo.as_api_str(), "photo"); + } + } + + // ── Response parsing ── + + mod response_parsing_tests { + use super::*; + + #[test] + fn parses_real_video_material_response() { + let body = read_response_body("create_material_video.json"); + let batch: Vec = serde_json::from_str(&body).unwrap(); + let material = batch.into_iter().next().unwrap().result.data.json; + + assert_eq!(material.id, 83483); + assert_eq!(material.material_id, "mat_v1k48s02um7vow9q0rx38jlc"); + assert_eq!(material.user_id, "cmlrbvj2a000buo91m14ejhw0"); + assert_eq!(material.url, "https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4"); + assert_eq!(material.format, "video"); + assert_eq!(material.size, 4773897); + assert_eq!(material.width, 1280); + assert_eq!(material.height, 720); + assert_eq!(material.duration, Some(8.0)); + assert_eq!(material.detect_status, KinoviMaterialDetectStatus::Processing); + assert_eq!(material.detect_start_at.as_deref(), Some("1785592620587")); + assert!(material.thumb_hash.is_none()); + } + + #[test] + fn parses_real_photo_material_response() { + let body = read_response_body("create_material_photo.json"); + let batch: Vec = serde_json::from_str(&body).unwrap(); + let material = batch.into_iter().next().unwrap().result.data.json; + + assert_eq!(material.id, 83519); + assert_eq!(material.material_id, "mat_iqs01mlyw5f2ph8g2dk55k1s"); + assert_eq!(material.format, "photo"); + assert_eq!(material.width, 1024); + assert_eq!(material.height, 1024); + assert_eq!(material.duration, None); + assert_eq!(material.detect_status, KinoviMaterialDetectStatus::Succeeded); + // Photos carry a thumb hash byte array. + assert!(material.thumb_hash.as_ref().is_some_and(|v| v.is_array())); + } + } + + // ── Detect-status deserialization ── + + mod detect_status_tests { + use super::*; + + #[test] + fn known_statuses_deserialize() { + assert_eq!( + serde_json::from_str::(r#""PROCESSING""#).unwrap(), + KinoviMaterialDetectStatus::Processing, + ); + assert_eq!( + serde_json::from_str::(r#""SUCCEEDED""#).unwrap(), + KinoviMaterialDetectStatus::Succeeded, + ); + assert_eq!( + serde_json::from_str::(r#""FAILED""#).unwrap(), + KinoviMaterialDetectStatus::Failed, + ); + } + + #[test] + fn unknown_status_falls_back_to_unknown() { + assert_eq!( + serde_json::from_str::(r#""SOME_NEW_STATUS""#).unwrap(), + KinoviMaterialDetectStatus::Unknown, + ); + } + } + + // ── Live tests ── + + mod real_requests { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_upload_and_create_image_material() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + let size_bytes = image_bytes.len() as u64; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + println!("Uploaded: {}", upload_result.public_url); + + let result = create_material(CreateMaterialArgs { + session: &session, + host_override: None, + request: CreateMaterialRequest { + url: upload_result.public_url, + format: KinoviMaterialFormat::Photo, + width: 1365, + height: 2048, + maybe_duration_seconds: None, + size_bytes, + }, + }).await?; + + println!("Material: {:#?}", result.material); + assert!(result.material.material_id.starts_with("mat_")); + assert_eq!(result.material.format, "photo"); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + } + + // ── Helpers ── + + fn read_response_body(filename: &str) -> String { + std::fs::read_to_string(format!("test_data/responses/{}", filename)) + .expect("Failed to read test data file") + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/create_material/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/create_material/mod.rs new file mode 100644 index 0000000000..5a58c56c0e --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/create_material/mod.rs @@ -0,0 +1,2 @@ +pub mod create_material; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/create_material/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/create_material/request_types.rs new file mode 100644 index 0000000000..69b268758e --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/create_material/request_types.rs @@ -0,0 +1,57 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestInner { + pub json: MaterialJson, + /// superjson type annotations. Only present for photos, where the site + /// marks the null `duration` as `undefined`: + /// `{"values":{"duration":["undefined"]},"v":1}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[derive(Serialize, Debug)] +pub (super) struct MaterialJson { + pub url: String, + /// "video" or "photo". + pub format: &'static str, + pub width: u32, + pub height: u32, + /// Duration in whole seconds for videos; null for photos (annotated as + /// `undefined` via the meta block). + pub duration: Option, + /// File size in bytes. + pub size: u64, +} + +#[derive(Serialize, Debug)] +pub (super) struct MaterialMeta { + pub values: MaterialMetaValues, + pub v: u8, +} + +#[derive(Serialize, Debug)] +pub (super) struct MaterialMetaValues { + pub duration: [&'static str; 1], +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseData { + pub json: super::create_material::KinoviMaterial, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_character/generate_character.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_character/generate_character.rs new file mode 100644 index 0000000000..85afdd1719 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_character/generate_character.rs @@ -0,0 +1,237 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::generate_character::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Public args --- + +pub struct GenerateCharacterArgs<'a> { + pub session: &'a KinoviWebSession, + + /// The name for this character. + pub name: String, + + /// A description of the character. + pub description: String, + + /// URL of the uploaded reference image (from `upload_file`). + /// NB: The Kinovi API accepts multiple images, but the UI only supports one for now. + /// We may expand this to Vec in the future. + pub reference_image_url: String, + + /// Whether the character should be publicly visible. + pub is_public: bool, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +// --- Public response --- + +#[derive(Debug)] +pub struct GenerateCharacterResponse { + /// Internal numeric ID. + pub id: u64, + + /// The character identifier (e.g. "char_1774752056469_2wlxoq"). + pub character_id: String, + + /// The name that was assigned. + pub name: String, + + /// ISO 8601 timestamp of creation. + pub created_at: String, +} + +// --- Implementation --- + +pub async fn generate_character(args: GenerateCharacterArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/character.createCharacter?batch=1", base_url); + + info!("Creating character '{}'", args.name); + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + name: args.name, + description: args.description, + reference_image_urls: vec![args.reference_image_url], + mode: "upload", + is_public: args.is_public, + }, + }, + }; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/characters", base_url); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.post(&url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(&request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Create character response: status={}, body={}", status, response_body); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let data = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + Ok(GenerateCharacterResponse { + id: data.id, + character_id: data.character_id, + name: data.name, + created_at: data.created_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + async fn upload_image_from_url(session: &KinoviWebSession, image_url: &str, extension: &str) -> AnyhowResult { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes(image_url).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session, + extension: extension.to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + Ok(upload_result.public_url) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_create_character_juno() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let public_url = upload_image_from_url( + &session, + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + "jpg", + ).await?; + + println!("Uploaded image: {}", public_url); + + let result = generate_character(GenerateCharacterArgs { + session: &session, + name: "Juno".to_string(), + description: "Juno the shiba inu at the lake".to_string(), + reference_image_url: public_url, + is_public: false, + host_override: None, + }).await?; + + println!("Character ID: {}", result.character_id); + println!("Name: {}", result.name); + println!("Created at: {}", result.created_at); + assert!(!result.character_id.is_empty()); + assert_eq!(result.name, "Juno"); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_create_character_ernest() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let public_url = upload_image_from_url( + &session, + test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL, + "jpg", + ).await?; + + println!("Uploaded image: {}", public_url); + + let result = generate_character(GenerateCharacterArgs { + session: &session, + name: "Ernest".to_string(), + description: "Ernest".to_string(), + reference_image_url: public_url, + is_public: false, + host_override: None, + }).await?; + + println!("Character ID: {}", result.character_id); + println!("Name: {}", result.name); + println!("Created at: {}", result.created_at); + assert!(!result.character_id.is_empty()); + assert_eq!(result.name, "Ernest"); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_character/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_character/mod.rs new file mode 100644 index 0000000000..e4a02cf1ff --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_character/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_character; +pub(crate) mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_character/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_character/request_types.rs new file mode 100644 index 0000000000..a0685d658e --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_character/request_types.rs @@ -0,0 +1,51 @@ +use serde_derive::{Deserialize, Serialize}; + +// ======================== Request ======================== + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequestJson { + pub name: String, + pub description: String, + #[serde(rename = "referenceImageUrls")] + pub reference_image_urls: Vec, + pub mode: &'static str, + #[serde(rename = "isPublic")] + pub is_public: bool, +} + +// ======================== Response ======================== + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: CharacterResponseJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct CharacterResponseJson { + pub id: u64, + pub character_id: String, + pub name: String, + pub created_at: String, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_image/generate_image.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_image/generate_image.rs new file mode 100644 index 0000000000..e814531676 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_image/generate_image.rs @@ -0,0 +1,953 @@ +use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost; +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_bad_request_api_error::KinoviWebBadRequestApiError; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::generate_image::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::categorize_kinovi_web_error::categorize_kinovi_web_error; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// ── Constants ── + +/// All Midjourney generations on Kinovi cost 12 credits per image-task, +/// regardless of model (v7, v7-niji, v8) or aspect ratio. Batches are billed +/// at this rate × `batch_count`. +const CREDITS_PER_MIDJOURNEY_TASK: u32 = 12; + +/// Newer Kinovi credit-package rate. Used to convert credits to USD for the +/// Midjourney image generation flow. +/// +/// 500,000 credits / $2,159.09 = 231.58 ≈ 243 credits per dollar (rounded down). + +/// Kinovi only currently exposes a single Midjourney resolution preset. +/// Kinovi moved these Midjourney models to `2k`; `1k` is no longer accepted. +const MIDJOURNEY_RESOLUTION: &str = "2k"; + +const BUSINESS_TYPE: &str = "midjourney-image-generation"; + +// ── Request args ── + +/// Wrapper that bundles a [`KinoviGenerateImageRequest`] with session and host info. +pub struct GenerateImageArgs<'a> { + pub request: KinoviGenerateImageRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +/// Image generation parameters (no session/host info). +/// +/// `aspect_ratio` and `quality` are loosely-typed strings here so this +/// function can serve image-generation flows that aren't Midjourney without +/// being constrained to Midjourney's particular set of values. The thin +/// per-model wrappers (`generate_midjourney_v7`, etc.) own strongly-typed +/// enums and convert them to strings before calling in. +#[derive(Clone)] +pub struct KinoviGenerateImageRequest { + /// Which Midjourney variant to run. + pub model: KinoviMidjourneyModel, + + /// User prompt. + pub prompt: String, + + /// Output aspect ratio, e.g. `"16:9"`, `"1:1"`, `"9:16"`. Forwarded + /// verbatim into the request's `aspectRatio` field. + pub aspect_ratio: String, + + /// Optional negative prompt. Sent as the `"no"` field on the wire. + pub negative_prompt: Option, + + /// "0 = literal, 1000 = artistic" + /// Midjourney "stylize" parameter. Valid range 0–1000. `None` omits the + /// field and lets Kinovi pick its default. + pub stylize: Option, + + /// "Unconventional aesthetics" + /// Midjourney "weird" parameter. Valid range 0–3000. + pub weird: Option, + + /// "Variation between images" + /// Midjourney "chaos" parameter. Valid range 0–100. + pub chaos: Option, + + /// Quality preset, e.g. `"0.25"`, `"0.5"`, `"1"`. Parsed as an `f32` and + /// emitted as a JSON number on the wire. `None` omits the field. + /// + /// If the string isn't a valid `f32`, `generate_image()` returns a + /// `KinoviWebClientError::InvalidRequestField` error rather than + /// panicking. The typed wrappers always supply well-formed values, so + /// errors here only occur for callers that bypass them. + pub quality: Option, + + /// Enables Midjourney "raw" style mode when true. Sent as `"style":"raw"`. + pub raw_mode: bool, + + /// Number of distinct generations to enqueue in a single request. + pub batch_count: KinoviMidjourneyBatchCount, + + /// Optional reference image URLs. Sent on the wire as `uploadedUrls`. + /// Midjourney uses these as visual inspiration alongside the prompt. + pub reference_image_urls: Option>, +} + +impl std::fmt::Debug for KinoviGenerateImageRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GenerateImageRequest") + .field("model", &self.model) + .field("prompt", &self.prompt) + .field("aspect_ratio", &self.aspect_ratio) + .field("negative_prompt", &self.negative_prompt) + .field("stylize", &self.stylize) + .field("weird", &self.weird) + .field("chaos", &self.chaos) + .field("quality", &self.quality) + .field("raw_mode", &self.raw_mode) + .field("batch_count", &self.batch_count) + .field("reference_image_urls", &self.reference_image_urls) + .finish() + } +} + +impl std::fmt::Debug for GenerateImageArgs<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GenerateImageArgs") + .field("request", &self.request) + .field("host_override", &self.host_override) + .finish() + } +} + +impl KinoviGenerateImageRequest { + /// Calculate the cost of this request, in Kinovi credits and USD cents + /// (rounded up). + /// + /// Pricing for Midjourney is flat: 12 credits per task, regardless of + /// model or aspect ratio. Batches multiply the cost by `batch_count`. + pub fn calculate_costs(&self) -> KinoviGenerationCost { + let credits = u64::from(CREDITS_PER_MIDJOURNEY_TASK) * u64::from(self.batch_count.as_u8()); + KinoviGenerationCost::from_kinovi_credits(credits) + } + + /// Estimates the Kinovi credit cost for this request. + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_credits(&self) -> u32 { + self.calculate_costs().kinovi_credits as u32 + } + + /// Estimates the dollar cost in USD cents. + /// NB: Rounds UP fractional cents (the historical behavior rounded to nearest). + #[deprecated(note = "Use calculate_costs() instead")] + pub fn estimate_cost_in_usd_cents(&self) -> u64 { + self.calculate_costs().usd_cents_rounded_up + } +} + +// ── Public enums ── + +/// Midjourney model variant. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum KinoviMidjourneyModel { + /// Midjourney v7 — the current general-purpose model. + V7, + /// Midjourney v7 Niji — v7 trained for anime/illustration. + V7Niji, + /// Midjourney v8 — the newest model. + V8, +} + +impl KinoviMidjourneyModel { + fn as_api_str(&self) -> &'static str { + match self { + Self::V7 => "midjourney-v7", + Self::V7Niji => "midjourney-v7-niji", + Self::V8 => "midjourney-v8", + } + } +} + +/// Number of distinct Midjourney generations to enqueue. +/// Each task itself returns a 4-image grid; this knob just enqueues N tasks. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum KinoviMidjourneyBatchCount { + One, + Two, + Three, + Four, +} + +impl KinoviMidjourneyBatchCount { + fn as_u8(&self) -> u8 { + match self { + Self::One => 1, + Self::Two => 2, + Self::Three => 3, + Self::Four => 4, + } + } +} + +// ── Response ── + +pub struct GenerateImageResponse { + pub task_id: String, + pub order_id: String, + + /// Present when batch_count > 1. + pub task_ids: Option>, + + /// Present when batch_count > 1. + pub order_ids: Option>, +} + +// ── Implementation ── + +pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url); + + let req = args.request; + + info!("Requesting Midjourney image from Kinovi: {:?}", req); + + let batch_count_value = req.batch_count.as_u8(); + let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None }; + + let style = if req.raw_mode { Some("raw") } else { None }; + + // Empty Vec is treated as "no references" so the field is omitted from the + // wire — Midjourney rejects empty arrays. + let uploaded_urls = req.reference_image_urls.filter(|urls| !urls.is_empty()); + + let quality = req.quality.as_deref().map(parse_quality).transpose()?; + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + business_type: BUSINESS_TYPE, + api_params: ApiParams { + prompt: req.prompt, + aspect_ratio: req.aspect_ratio, + resolution: MIDJOURNEY_RESOLUTION, + model: req.model.as_api_str(), + stylize: req.stylize, + chaos: req.chaos, + weird: req.weird, + quality, + style, + negative_prompt: req.negative_prompt, + batch_count, + uploaded_urls, + }, + }, + }, + }; + + info!("Kinovi Midjourney request: {:?}", request_body); + + let cookie = args.session.cookies.as_str(); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let referer = format!("{}/", base_url); + + let response = client.post(&run_task_url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(&request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(categorize_kinovi_web_error(status, response_body)); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let task_data = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + if task_data.violation_warning { + return Err(KinoviWebBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into()); + } + + Ok(GenerateImageResponse { + task_id: task_data.task_id, + order_id: task_data.order_id, + task_ids: task_data.task_ids, + order_ids: task_data.order_ids, + }) +} + +/// The wire format for `quality` is a JSON number, but the public API +/// accepts a string so future models with different quality semantics can +/// hand in whatever they like. Callers funnel through strongly-typed +/// wrapper enums whose string output is always parseable; arbitrary +/// callers might not, so we surface a structured client error instead of +/// panicking. +fn parse_quality(raw: &str) -> Result { + raw.parse::().map_err(|err| KinoviWebClientError::InvalidRequestField { + field: "quality", + raw_value: raw.to_string(), + reason: format!("not a valid f32: {}", err), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Pricing ── + + mod pricing_tests { + use super::*; + + fn make_request(batch_count: KinoviMidjourneyBatchCount) -> KinoviGenerateImageRequest { + KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7, + prompt: String::new(), + aspect_ratio: "1:1".to_string(), + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count, + reference_image_urls: None, + } + } + + // ── Headline cost rule: 12 credits × batch_count ── + + #[test] + fn batch_one_is_twelve_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12); + } + + #[test] + fn batch_two_is_twentyfour_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24); + } + + #[test] + fn batch_three_is_thirtysix_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Three).calculate_costs().kinovi_credits, 36); + } + + #[test] + fn batch_four_is_fortyeight_credits() { + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48); + } + + /// Pricing is identical across all three Midjourney models. + #[test] + fn pricing_is_model_agnostic() { + let baseline = make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits; + for model in [ + KinoviMidjourneyModel::V7, + KinoviMidjourneyModel::V7Niji, + KinoviMidjourneyModel::V8, + ] { + let req = KinoviGenerateImageRequest { model, ..make_request(KinoviMidjourneyBatchCount::One) }; + assert_eq!(req.calculate_costs().kinovi_credits, baseline, "model={:?}", model); + } + } + + /// Pricing does not vary by aspect ratio. + #[test] + fn pricing_is_aspect_ratio_agnostic() { + let baseline = make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits; + for ar in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] { + let req = KinoviGenerateImageRequest { + aspect_ratio: ar.to_string(), + ..make_request(KinoviMidjourneyBatchCount::One) + }; + assert_eq!(req.calculate_costs().kinovi_credits, baseline, "aspect_ratio={:?}", ar); + } + } + + /// Pricing does not vary with the Midjourney style knobs. + #[test] + fn pricing_is_independent_of_style_knobs() { + let baseline = make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits; + let req = KinoviGenerateImageRequest { + stylize: Some(1000), + weird: Some(3000), + chaos: Some(100), + quality: Some("1".to_string()), + raw_mode: true, + negative_prompt: Some("ugly, blurry".to_string()), + ..make_request(KinoviMidjourneyBatchCount::One) + }; + assert_eq!(req.calculate_costs().kinovi_credits, baseline); + } + + // ── USD cents conversion ── + + #[test] + fn usd_cents_batch_one() { + // 12/243 × 100 = 4.94¢ → 5¢ + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4); + assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_batch_two() { + // 24/243 × 100 = 9.88¢ → 10¢ + assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 10); // 2400/243 = 9.88 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_down, 9); + assert!((make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9); + } + + #[test] + fn usd_cents_batch_four() { + // 48/243 × 100 = 19.75¢ → 20¢ + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP + assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19); + assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9); + } + + /// Batch 4 should be exactly 4× the credit cost of batch 1, and the USD + /// cents conversion should track within rounding (1¢ slack). + #[test] + fn batch_4_costs_4x_credits_and_approximately_4x_dollars() { + let one = make_request(KinoviMidjourneyBatchCount::One); + let four = make_request(KinoviMidjourneyBatchCount::Four); + + assert_eq!(four.calculate_costs().kinovi_credits, one.calculate_costs().kinovi_credits * 4); + + // With round-up pricing, a batch rounds up ONCE while four singles + // round up four times — so the batch price is at most 4× the single + // price, and within 4¢ of it. + let one_cents = one.calculate_costs().usd_cents_rounded_up; + let four_cents = four.calculate_costs().usd_cents_rounded_up; + let four_singles = one_cents * 4; + assert!(four_cents <= four_singles && four_cents + 4 > four_singles, + "expected within 4¢ under {}¢, got {}¢", four_singles, four_cents); + } + } + + // ── Wire-shape ── + + mod wire_shape_tests { + use super::*; + use serde_json::Value; + + fn build_wire_json(req: KinoviGenerateImageRequest) -> Value { + try_build_wire_json(req).expect("test helper: malformed quality string") + } + + fn try_build_wire_json(req: KinoviGenerateImageRequest) -> Result { + // Mirrors the body assembly in `generate_image()` minus the actual + // network call, so we can introspect the JSON the server would see. + let batch_count_value = req.batch_count.as_u8(); + let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None }; + let style = if req.raw_mode { Some("raw") } else { None }; + let uploaded_urls = req.reference_image_urls.filter(|urls| !urls.is_empty()); + let quality = req.quality.as_deref().map(parse_quality).transpose()?; + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + business_type: BUSINESS_TYPE, + api_params: ApiParams { + prompt: req.prompt, + aspect_ratio: req.aspect_ratio, + resolution: MIDJOURNEY_RESOLUTION, + model: req.model.as_api_str(), + stylize: req.stylize, + chaos: req.chaos, + weird: req.weird, + quality, + style, + negative_prompt: req.negative_prompt, + batch_count, + uploaded_urls, + }, + }, + }, + }; + Ok(serde_json::to_value(&request_body).unwrap()) + } + + fn api_params(req: KinoviGenerateImageRequest) -> Value { + build_wire_json(req)["0"]["json"]["apiParams"].clone() + } + + fn minimal_request(model: KinoviMidjourneyModel, prompt: &str) -> KinoviGenerateImageRequest { + KinoviGenerateImageRequest { + model, + prompt: prompt.to_string(), + aspect_ratio: "1:1".to_string(), + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + } + } + + // ── Minimal: matches captured sample #1 ── + + #[test] + fn minimal_v7_matches_captured_sample() { + // Sample #1: `{"prompt":"A corgi walking in the park","aspectRatio":"1:1","resolution":"2k","model":"midjourney-v7"}` + let params = api_params(minimal_request(KinoviMidjourneyModel::V7, "A corgi walking in the park")); + assert_eq!(params["prompt"], "A corgi walking in the park"); + assert_eq!(params["aspectRatio"], "1:1"); + assert_eq!(params["resolution"], "2k"); + assert_eq!(params["model"], "midjourney-v7"); + + let obj = params.as_object().unwrap(); + let unexpected: Vec<&str> = obj.keys() + .filter(|k| !["prompt", "aspectRatio", "resolution", "model"].contains(&k.as_str())) + .map(|s| s.as_str()) + .collect(); + assert!(unexpected.is_empty(), "minimal request should omit optional fields, found: {:?}", unexpected); + } + + // ── Model strings ── + + #[test] + fn model_v7_maps_to_midjourney_v7() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p")); + assert_eq!(p["model"], "midjourney-v7"); + } + + #[test] + fn model_v7_niji_maps_to_midjourney_v7_niji() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V7Niji, "p")); + assert_eq!(p["model"], "midjourney-v7-niji"); + } + + #[test] + fn model_v8_maps_to_midjourney_v8() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V8, "p")); + assert_eq!(p["model"], "midjourney-v8"); + } + + // ── Aspect ratio strings ── + + #[test] + fn aspect_ratio_passes_through_verbatim() { + // The base API is stringly-typed for aspect_ratio — whatever the caller + // (typically a per-model wrapper) hands in must appear on the wire as-is. + for input in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] { + let req = KinoviGenerateImageRequest { + aspect_ratio: input.to_string(), + ..minimal_request(KinoviMidjourneyModel::V7, "p") + }; + let p = api_params(req); + assert_eq!(p["aspectRatio"], input, "aspect_ratio={:?}", input); + } + } + + // ── Optional fields omit when None ── + + #[test] + fn optionals_omitted_when_none() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p")); + let obj = p.as_object().unwrap(); + for key in ["stylize", "chaos", "weird", "quality", "style", "no", "batchCount", "uploadedUrls"] { + assert!(!obj.contains_key(key), "expected `{}` to be omitted in minimal request", key); + } + } + + // ── Style knobs ── + + #[test] + fn stylize_serializes_when_set() { + let req = KinoviGenerateImageRequest { stylize: Some(730), ..minimal_request(KinoviMidjourneyModel::V8, "p") }; + assert_eq!(api_params(req)["stylize"], 730); + } + + #[test] + fn chaos_serializes_when_set() { + let req = KinoviGenerateImageRequest { chaos: Some(70), ..minimal_request(KinoviMidjourneyModel::V8, "p") }; + assert_eq!(api_params(req)["chaos"], 70); + } + + #[test] + fn weird_serializes_when_set() { + let req = KinoviGenerateImageRequest { weird: Some(2050), ..minimal_request(KinoviMidjourneyModel::V8, "p") }; + assert_eq!(api_params(req)["weird"], 2050); + } + + #[test] + fn stylize_zero_is_explicitly_sent_not_omitted() { + // Captured sample #9 sends explicit zeros — `None` ≠ `Some(0)` on the wire. + let req = KinoviGenerateImageRequest { + stylize: Some(0), + chaos: Some(0), + weird: Some(0), + ..minimal_request(KinoviMidjourneyModel::V7, "p") + }; + let p = api_params(req); + assert_eq!(p["stylize"], 0); + assert_eq!(p["chaos"], 0); + assert_eq!(p["weird"], 0); + } + + #[test] + fn quality_quarter_serializes_as_0_25() { + let req = KinoviGenerateImageRequest { quality: Some("0.25".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") }; + let q = api_params(req)["quality"].as_f64().unwrap(); + assert!((q - 0.25).abs() < 1e-6, "got {}", q); + } + + #[test] + fn quality_half_serializes_as_0_5() { + let req = KinoviGenerateImageRequest { quality: Some("0.5".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") }; + let q = api_params(req)["quality"].as_f64().unwrap(); + assert!((q - 0.5).abs() < 1e-6, "got {}", q); + } + + #[test] + fn quality_full_serializes_as_1_0() { + let req = KinoviGenerateImageRequest { quality: Some("1".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") }; + let q = api_params(req)["quality"].as_f64().unwrap(); + assert!((q - 1.0).abs() < 1e-6, "got {}", q); + } + + #[test] + fn quality_with_non_numeric_string_returns_client_error() { + // The base function accepts strings; malformed input surfaces as a + // structured `KinoviWebClientError::InvalidRequestField` rather + // than panicking. + let req = KinoviGenerateImageRequest { + quality: Some("not-a-number".to_string()), + ..minimal_request(KinoviMidjourneyModel::V7, "p") + }; + let err = try_build_wire_json(req).expect_err("expected an error"); + match err { + KinoviWebClientError::InvalidRequestField { field, raw_value, .. } => { + assert_eq!(field, "quality"); + assert_eq!(raw_value, "not-a-number"); + } + other => panic!("expected InvalidRequestField, got {:?}", other), + } + } + + // ── Raw mode ── + + #[test] + fn raw_mode_true_sends_style_raw() { + let req = KinoviGenerateImageRequest { raw_mode: true, ..minimal_request(KinoviMidjourneyModel::V7, "p") }; + assert_eq!(api_params(req)["style"], "raw"); + } + + #[test] + fn raw_mode_false_omits_style() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p")); + assert!(p.as_object().unwrap().get("style").is_none()); + } + + // ── Negative prompt → `"no"` field ── + + #[test] + fn negative_prompt_serializes_as_no_field() { + let req = KinoviGenerateImageRequest { + negative_prompt: Some("dark, gloomy, night".to_string()), + ..minimal_request(KinoviMidjourneyModel::V7, "p") + }; + let p = api_params(req); + assert_eq!(p["no"], "dark, gloomy, night"); + assert!(p.as_object().unwrap().get("negative_prompt").is_none(), "must not leak field name"); + assert!(p.as_object().unwrap().get("negativePrompt").is_none(), "must not leak camelCase variant"); + } + + // ── Batch count ── + + #[test] + fn batch_one_omits_batch_count() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p")); + assert!(p.as_object().unwrap().get("batchCount").is_none()); + } + + #[test] + fn batch_two_sends_batch_count_2() { + let req = KinoviGenerateImageRequest { batch_count: KinoviMidjourneyBatchCount::Two, ..minimal_request(KinoviMidjourneyModel::V7, "p") }; + assert_eq!(api_params(req)["batchCount"], 2); + } + + #[test] + fn batch_three_sends_batch_count_3() { + let req = KinoviGenerateImageRequest { batch_count: KinoviMidjourneyBatchCount::Three, ..minimal_request(KinoviMidjourneyModel::V7Niji, "p") }; + assert_eq!(api_params(req)["batchCount"], 3); + } + + #[test] + fn batch_four_sends_batch_count_4() { + let req = KinoviGenerateImageRequest { batch_count: KinoviMidjourneyBatchCount::Four, ..minimal_request(KinoviMidjourneyModel::V7, "p") }; + assert_eq!(api_params(req)["batchCount"], 4); + } + + // ── businessType and trpc envelope ── + + #[test] + fn business_type_is_midjourney_image_generation() { + let body = build_wire_json(minimal_request(KinoviMidjourneyModel::V7, "p")); + assert_eq!(body["0"]["json"]["businessType"], "midjourney-image-generation"); + } + + #[test] + fn resolution_is_2k() { + let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p")); + assert_eq!(p["resolution"], "2k"); + } + + // ── Captured-sample replay (#9: kitchen sink) ── + + /// Reconstructs the captured sample #9 (V7 + raw + all knobs + negative + /// prompt) and asserts the wire matches byte-for-byte. + #[test] + fn captured_sample_9_matches_byte_for_byte() { + let req = KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7, + prompt: "abandoned skyscrapers".to_string(), + aspect_ratio: "1:1".to_string(), + negative_prompt: Some("dark, gloomy, night".to_string()), + stylize: Some(1000), + weird: Some(3000), + chaos: Some(100), + quality: Some("0.5".to_string()), + raw_mode: true, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }; + let got = api_params(req); + let expected: Value = serde_json::from_str( + r#"{"prompt":"abandoned skyscrapers","aspectRatio":"1:1","resolution":"2k","model":"midjourney-v7","stylize":1000,"chaos":100,"weird":3000,"quality":0.5,"style":"raw","no":"dark, gloomy, night"}"#, + ).unwrap(); + assert_eq!(got, expected); + } + + /// Captured sample #12 (V8 + batch 4). + #[test] + fn captured_sample_12_batch_4() { + let req = KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V8, + prompt: "desolate cliff overlooking the ocean".to_string(), + aspect_ratio: "9:16".to_string(), + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: Some("1".to_string()), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::Four, + reference_image_urls: None, + }; + let got = api_params(req); + let expected: Value = serde_json::from_str( + r#"{"prompt":"desolate cliff overlooking the ocean","aspectRatio":"9:16","resolution":"2k","model":"midjourney-v8","batchCount":4,"quality":1.0}"#, + ).unwrap(); + assert_eq!(got, expected); + } + + /// Real capture (2026-07-01): V7-niji + batch 3 + all knobs. `seed` is + /// present in the real request but not yet supported by the client, so + /// it is excluded from the expected shape here. + #[test] + fn captured_v7_niji_batch_3_matches_except_seed() { + let req = KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7Niji, + prompt: "A shiba walking in the park".to_string(), + aspect_ratio: "16:9".to_string(), + negative_prompt: Some("bad quality, green".to_string()), + stylize: Some(80), + weird: Some(700), + chaos: Some(15), + quality: Some("0.25".to_string()), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::Three, + reference_image_urls: None, + }; + let got = api_params(req); + let expected: Value = serde_json::from_str( + r#"{"prompt":"A shiba walking in the park","aspectRatio":"16:9","resolution":"2k","model":"midjourney-v7-niji","batchCount":3,"stylize":80,"chaos":15,"weird":700,"quality":0.25,"no":"bad quality, green"}"#, + ).unwrap(); + assert_eq!(got, expected); + } + } + + // ── Real requests (manually run; require live cookies and cost credits) ── + + mod real_requests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v7_minimal() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateImageArgs { + session: &session, + host_override: None, + request: KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7, + prompt: "A corgi astronaut floating among stars".to_string(), + aspect_ratio: "1:1".to_string(), + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }; + let result = generate_image(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v7_niji_anime() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateImageArgs { + session: &session, + host_override: None, + request: KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V7Niji, + prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(), + aspect_ratio: "21:9".to_string(), + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }; + let result = generate_image(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs credits + async fn test_generate_v8_all_knobs() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateImageArgs { + session: &session, + host_override: None, + request: KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V8, + prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(), + aspect_ratio: "1:1".to_string(), + negative_prompt: Some("dark, gloomy".to_string()), + stylize: Some(730), + weird: Some(2050), + chaos: Some(70), + quality: Some("0.5".to_string()), + raw_mode: true, + batch_count: KinoviMidjourneyBatchCount::One, + reference_image_urls: None, + }, + }; + let result = generate_image(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs 48 credits + async fn test_generate_v8_batch_4() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateImageArgs { + session: &session, + host_override: None, + request: KinoviGenerateImageRequest { + model: KinoviMidjourneyModel::V8, + prompt: "desolate cliff overlooking the ocean".to_string(), + aspect_ratio: "9:16".to_string(), + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: Some("1".to_string()), + raw_mode: false, + batch_count: KinoviMidjourneyBatchCount::Four, + reference_image_urls: None, + }, + }; + let result = generate_image(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + println!("All task IDs: {:?}", result.task_ids); + println!("All order IDs: {:?}", result.order_ids); + assert!(!result.task_id.is_empty()); + assert_eq!(result.task_ids.as_ref().map(|v| v.len()), Some(4), "batch 4 should return 4 task IDs"); + Ok(()) + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_image/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_image/mod.rs new file mode 100644 index 0000000000..a4af23dea0 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_image/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_image; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_image/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_image/request_types.rs new file mode 100644 index 0000000000..5c426c82b8 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_image/request_types.rs @@ -0,0 +1,83 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestJson { + #[serde(rename = "businessType")] + pub business_type: &'static str, + #[serde(rename = "apiParams")] + pub api_params: ApiParams, +} + +#[derive(Serialize, Debug)] +pub (super) struct ApiParams { + pub prompt: String, + #[serde(rename = "aspectRatio")] + pub aspect_ratio: String, + pub resolution: &'static str, + pub model: &'static str, + + #[serde(skip_serializing_if = "Option::is_none")] + pub stylize: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chaos: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub weird: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// Sent as `"raw"` for raw mode; omitted otherwise. Currently `"raw"` is + /// the only documented value. + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option<&'static str>, + + /// Negative prompt. Sent as the field `"no"` to match Midjourney's + /// CLI-style convention. + #[serde(rename = "no", skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// Number of distinct image generations to enqueue. Omitted when 1 + /// (Midjourney returns four images per task regardless). + #[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")] + pub batch_count: Option, + + /// Reference image URLs. When present, Midjourney uses them as visual + /// inspiration alongside the prompt. + #[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")] + pub uploaded_urls: Option>, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseData { + pub json: TaskResponseJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub (super) struct TaskResponseJson { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, + pub violation_warning: bool, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_video/generate_video.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_video/generate_video.rs new file mode 100644 index 0000000000..33509eea34 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_video/generate_video.rs @@ -0,0 +1,1476 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_bad_request_api_error::KinoviWebBadRequestApiError; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::generate_video::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::categorize_kinovi_web_error::categorize_kinovi_web_error; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Request args --- + +/// Wrapper that bundles a [`KinoviGenerateVideoRequest`] with session and host info. +pub struct GenerateVideoArgs<'a> { + pub request: KinoviGenerateVideoRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +/// Video generation parameters (no session/host info). +#[derive(Clone)] +pub struct KinoviGenerateVideoRequest { + /// Seedance 2.0 Pro vs Fast + pub model_type: KinoviModelType, + + pub prompt: String, + + /// The aspect ratio + /// (Kinovi terms this "resolution" in the API, confusingly.) + pub aspect_ratio: KinoviAspectRatio, + + /// The resolution + /// Output resolution quality (480p, 720p, 1080p). None resolves to an + /// explicit 720p on the wire, matching how estimate_credits() prices None. + /// (Kinovi terms this "outputResolution" in the API, which is confusingly named) + pub output_resolution: Option, + + /// Duration in seconds (4–15). + pub duration_seconds: u8, + + pub batch_count: KinoviBatchCount, + + /// Optional start frame image URL (keyframe mode). + pub start_frame_url: Option, + + /// Optional end frame image URL (keyframe mode). + pub end_frame_url: Option, + + /// Optional reference image URLs (reference mode). + /// When present, takes priority over start/end frames. + pub reference_image_urls: Option>, + + /// Optional reference video URLs (reference mode). + /// Can be combined with reference_image_urls. + /// Videos are referenced in prompts as @video1, @video2, etc. + /// When present, takes priority over start/end frames. + pub reference_video_urls: Option>, + + /// Optional reference audio URLs (reference mode). + /// Audio is referenced in prompts as @audio1, @audio2, etc. + /// Sent in a separate `audioUrls` field (not in `uploadedUrls`). + pub reference_audio_urls: Option>, + + /// Optional Kinovi character IDs to reference in the prompt. + /// Characters are referenced in prompts as @CharacterName. + pub character_ids: Option>, + + /// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it. + pub use_face_blur_hack: Option, +} + +impl std::fmt::Debug for KinoviGenerateVideoRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GenerateVideoRequest") + .field("model_type", &self.model_type) + .field("prompt", &self.prompt) + .field("aspect_ratio", &self.aspect_ratio) + .field("duration_seconds", &self.duration_seconds) + .field("batch_count", &self.batch_count) + .field("start_frame_url", &self.start_frame_url) + .field("end_frame_url", &self.end_frame_url) + .field("reference_image_urls", &self.reference_image_urls) + .field("reference_video_urls", &self.reference_video_urls) + .field("reference_audio_urls", &self.reference_audio_urls) + .field("character_ids", &self.character_ids) + .field("output_resolution", &self.output_resolution) + .field("use_face_blur_hack", &self.use_face_blur_hack) + .finish() + } +} + +impl std::fmt::Debug for GenerateVideoArgs<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GenerateVideoArgs") + .field("request", &self.request) + .field("host_override", &self.host_override) + .finish() + } +} + +impl KinoviGenerateVideoRequest { + /// Estimates the credit cost for this generation request. + /// + /// Pricing is per-second × batch count, with the per-second rate + /// depending on model type and output resolution: + /// + /// | Model | 480p | 720p | 1080p | + /// |-----------|------|------|-------| + /// | Pro | 15 | 40 | 90 | + /// | Fast | 10 | 28 | n/a | + /// + /// Input mode (text, keyframe, reference) has no effect on cost. + /// Aspect ratio (`resolution` field) has no effect on cost. + pub fn estimate_credits(&self) -> u32 { + let credits_per_second: u32 = match (self.model_type, self.output_resolution) { + // Seedance 2.0 Pro + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::FourEightyP)) => 15, + (KinoviModelType::Seedance2Pro, None) + | (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::SevenTwentyP)) => 40, + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::TenEightyP)) => 90, + + // Seedance 2.0 Fast + (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::FourEightyP)) => 10, + (KinoviModelType::Seedance2Fast, None) + | (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::SevenTwentyP)) => 28, + // NB: 1080p not officially supported for Fast, but price as 720p if requested + (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::TenEightyP)) => 28, + }; + + let per_video = u32::from(self.duration_seconds) * credits_per_second; + let batch_multiplier: u32 = match self.batch_count { + KinoviBatchCount::One => 1, + KinoviBatchCount::Two => 2, + KinoviBatchCount::Four => 4, + }; + per_video * batch_multiplier + } + + /// Credits per dollar for billing conversion. + /// + /// Legacy 720p pricing uses the original Kinovi credit package rates. + /// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909. + fn credits_per_dollar(&self) -> f64 { + match (self.model_type, self.output_resolution) { + // Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99 + (KinoviModelType::Seedance2Pro, None) + | (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::SevenTwentyP)) => 250.0, + + // Legacy: Seedance 2.0 Fast @ 720p — 22,000 credits for $99.99 + (KinoviModelType::Seedance2Fast, None) + | (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::SevenTwentyP)) => 220.0, + + // New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1) + _ => 243.0, + } + } + + pub fn estimate_cost_in_usd_cents(&self) -> u64 { + let credits = self.estimate_credits() as f64; + let credits_per_dollar = self.credits_per_dollar(); + let cost = credits / credits_per_dollar * 100.0; + cost.round() as u64 + } +} + +// --- Public enums --- + +/// Video resolution / aspect ratio. +#[derive(Debug, Clone, Copy)] +pub enum KinoviAspectRatio { + /// 16:9 landscape (1280x720) + Landscape16x9, + /// 21:9 ultra-wide (1280x540) + UltraWide21x9, + /// 9:16 portrait (720x1280) + Portrait9x16, + /// 1:1 square (720x720) + Square1x1, + /// 4:3 standard (960x720) + Standard4x3, + /// 3:4 portrait (720x960) + Portrait3x4, +} + +impl KinoviAspectRatio { + fn as_str(&self) -> &'static str { + match self { + Self::Landscape16x9 => "1280x720", + Self::UltraWide21x9 => "1280x540", + Self::Portrait9x16 => "720x1280", + Self::Square1x1 => "720x720", + Self::Standard4x3 => "960x720", + Self::Portrait3x4 => "720x960", + } + } +} + +/// Output resolution quality. Always sent explicitly on the wire — we never +/// omit the field and rely on the Kinovi server-side default, because the +/// billed price assumes a specific resolution and the two must not diverge. +#[derive(Debug, Clone, Copy)] +pub enum KinoviOutputResolution { + /// 480p + FourEightyP, + /// 720p (what unset requests resolve to) + SevenTwentyP, + /// 1080p + TenEightyP, +} + +impl KinoviOutputResolution { + /// The API string to send. + pub fn as_api_str(&self) -> &'static str { + match self { + Self::FourEightyP => "480p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + } + } +} + +/// Number of videos to generate in a single request. +#[derive(Debug, Clone, Copy)] +pub enum KinoviBatchCount { + One, + Two, + Four, +} + +impl KinoviBatchCount { + fn as_u8(&self) -> u8 { + match self { + Self::One => 1, + Self::Two => 2, + Self::Four => 4, + } + } +} + +/// The Seedance model variant to use. +#[derive(Debug, Clone, Copy)] +pub enum KinoviModelType { + /// Seedance 2.0 Pro (higher quality, slower). + Seedance2Pro, + /// Seedance 2.0 Fast (lower quality, faster). + Seedance2Fast, +} + +impl KinoviModelType { + fn as_api_str(&self) -> &'static str { + match self { + Self::Seedance2Pro => "seedance-20", + Self::Seedance2Fast => "seedance2-fast", + } + } +} + +// --- Response --- + +pub struct GenerateVideoResponse { + pub task_id: String, + + pub order_id: String, + + /// Present when batch_count > 1. + pub task_ids: Option>, + + /// Present when batch_count > 1. + pub order_ids: Option>, +} + +// --- Implementation --- + +pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url); + + let req = args.request; + + info!("Requesting video from Seedance2Pro: {:?}", req); + + let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty()); + let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty()); + let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty()); + let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty()); + + let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters; + + let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" }; + + let uploaded_urls: Option> = if is_reference_mode { + let mut urls = Vec::new(); + if let Some(video_urls) = req.reference_video_urls { + urls.extend(video_urls); + } + if let Some(image_urls) = req.reference_image_urls { + urls.extend(image_urls); + } + if urls.is_empty() { None } else { Some(urls) } + } else { + let mut urls = Vec::new(); + if let Some(url) = req.start_frame_url { + urls.push(url); + } + if let Some(url) = req.end_frame_url { + urls.push(url); + } + if urls.is_empty() { None } else { Some(urls) } + }; + + let audio_urls: Option> = if has_reference_audio { + req.reference_audio_urls + } else { + None + }; + + let face_blur_mode = match req.use_face_blur_hack { + Some(true) => Some("on"), + Some(false) => Some("off"), + None => None, + }; + + let batch_count_value = req.batch_count.as_u8(); + let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None }; + + let duration = format!("{}s", req.duration_seconds); + + info!( + "Generating video: mode={}, resolution={}, duration={}, batch={}", + video_input_mode, req.aspect_ratio.as_str(), duration, batch_count_value + ); + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + business_type: "wan22-video-generation", + api_params: ApiParams { + prompt: req.prompt, + resolution: req.aspect_ratio.as_str().to_string(), + content_mode: "normal", + model: req.model_type.as_api_str(), + duration, + mode: video_input_mode, + // ALWAYS sent explicitly, defaulting unset to 720p — must match + // estimate_credits(), which prices None as 720p. + output_resolution: Some(req.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP).as_api_str()), + face_blur_mode, + character_ids: req.character_ids, + uploaded_urls, + audio_urls, + batch_count, + }, + }, + }, + }; + + info!("KinoviWeb request : {:?}", request_body); + + let cookie = args.session.cookies.as_str(); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let referer = format!("{}/", base_url); + + let response = client.post(&run_task_url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(&request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(categorize_kinovi_web_error(status, response_body)); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let task_data = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + if task_data.violation_warning { + return Err(KinoviWebBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into()); + } + + Ok(GenerateVideoResponse { + task_id: task_data.task_id, + order_id: task_data.order_id, + task_ids: task_data.task_ids, + order_ids: task_data.order_ids, + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + + mod pricing_tests { + use super::*; + + fn make_args( + model_type: KinoviModelType, + duration_seconds: u8, + batch_count: KinoviBatchCount, + output_resolution: Option, + ) -> KinoviGenerateVideoRequest { + KinoviGenerateVideoRequest { + model_type, + prompt: String::new(), + aspect_ratio: KinoviAspectRatio::Square1x1, + duration_seconds, + batch_count, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + output_resolution, + use_face_blur_hack: None, + } + } + + fn pro(dur: u8, batch: KinoviBatchCount) -> KinoviGenerateVideoRequest { + make_args(KinoviModelType::Seedance2Pro, dur, batch, None) + } + + fn pro_res(dur: u8, batch: KinoviBatchCount, res: KinoviOutputResolution) -> KinoviGenerateVideoRequest { + make_args(KinoviModelType::Seedance2Pro, dur, batch, Some(res)) + } + + fn fast(dur: u8, batch: KinoviBatchCount) -> KinoviGenerateVideoRequest { + make_args(KinoviModelType::Seedance2Fast, dur, batch, None) + } + + fn fast_res(dur: u8, batch: KinoviBatchCount, res: KinoviOutputResolution) -> KinoviGenerateVideoRequest { + make_args(KinoviModelType::Seedance2Fast, dur, batch, Some(res)) + } + + // ── Spot checks: exact values from the pricing table ── + + #[test] + fn spot_check_pro_720p() { + // Pro 720p = 40 credits/sec (default when output_resolution is None) + assert_eq!(pro(5, KinoviBatchCount::One).estimate_credits(), 200); + assert_eq!(pro(10, KinoviBatchCount::One).estimate_credits(), 400); + assert_eq!(pro(15, KinoviBatchCount::One).estimate_credits(), 600); + } + + #[test] + fn spot_check_pro_480p() { + // Pro 480p = 15 credits/sec + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(), 75); + assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(), 150); + assert_eq!(pro_res(15, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(), 225); + } + + #[test] + fn spot_check_pro_1080p() { + // Pro 1080p = 90 credits/sec + assert_eq!(pro_res(4, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 360); + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 450); + assert_eq!(pro_res(6, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 540); + assert_eq!(pro_res(7, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 630); + assert_eq!(pro_res(8, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 720); + assert_eq!(pro_res(9, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 810); + assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 900); + assert_eq!(pro_res(11, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 990); + assert_eq!(pro_res(12, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 1080); + assert_eq!(pro_res(13, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 1170); + assert_eq!(pro_res(14, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 1260); + assert_eq!(pro_res(15, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(), 1350); + } + + #[test] + fn spot_check_fast_720p() { + // Fast 720p = 28 credits/sec + assert_eq!(fast(5, KinoviBatchCount::One).estimate_credits(), 140); + assert_eq!(fast(10, KinoviBatchCount::One).estimate_credits(), 280); + } + + #[test] + fn spot_check_fast_480p() { + // Fast 480p = 10 credits/sec + assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(), 50); + assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(), 100); + } + + // ── Pro is more expensive than Fast at the same resolution ── + + #[test] + fn pro_more_expensive_than_fast_720p() { + let p = pro(5, KinoviBatchCount::One).estimate_credits(); + let f = fast(5, KinoviBatchCount::One).estimate_credits(); + assert!(p > f, "Pro 720p ({}) should be more than Fast 720p ({})", p, f); + } + + #[test] + fn pro_more_expensive_than_fast_480p() { + let p = pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(); + let f = fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(); + assert!(p > f, "Pro 480p ({}) should be more than Fast 480p ({})", p, f); + } + + // ── Higher resolution is more expensive ── + + #[test] + fn pro_1080p_more_than_720p_more_than_480p() { + let c480 = pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(); + let c720 = pro(5, KinoviBatchCount::One).estimate_credits(); + let c1080 = pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_credits(); + assert!(c480 < c720, "480p ({}) should be less than 720p ({})", c480, c720); + assert!(c720 < c1080, "720p ({}) should be less than 1080p ({})", c720, c1080); + } + + #[test] + fn fast_720p_more_than_480p() { + let c480 = fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_credits(); + let c720 = fast(5, KinoviBatchCount::One).estimate_credits(); + assert!(c480 < c720, "Fast 480p ({}) should be less than Fast 720p ({})", c480, c720); + } + + // ── None output_resolution same as explicit 720p ── + + #[test] + fn pro_none_same_as_720p() { + let none = pro(5, KinoviBatchCount::One).estimate_credits(); + let explicit = pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::SevenTwentyP).estimate_credits(); + assert_eq!(none, explicit); + } + + #[test] + fn fast_none_same_as_720p() { + let none = fast(5, KinoviBatchCount::One).estimate_credits(); + let explicit = fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::SevenTwentyP).estimate_credits(); + assert_eq!(none, explicit); + } + + // ── Table-driven: batch × duration × model × resolution spot checks ── + + #[test] + fn table_driven_credits() { + // (model, output_res, duration, batch, expected_credits) + let cases: Vec<(KinoviModelType, Option, u8, KinoviBatchCount, u32)> = vec![ + // Pro 720p (40/sec) + (KinoviModelType::Seedance2Pro, None, 4, KinoviBatchCount::One, 160), + (KinoviModelType::Seedance2Pro, None, 5, KinoviBatchCount::Two, 400), + (KinoviModelType::Seedance2Pro, None, 10, KinoviBatchCount::Four, 1600), + // Pro 480p (15/sec) + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, 75), + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, 300), + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::FourEightyP), 15, KinoviBatchCount::Four, 900), + // Pro 1080p (90/sec) + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::One, 450), + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::TenEightyP), 10, KinoviBatchCount::Two, 1800), + (KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::TenEightyP), 15, KinoviBatchCount::Four, 5400), + // Fast 720p (28/sec) + (KinoviModelType::Seedance2Fast, None, 4, KinoviBatchCount::One, 112), + (KinoviModelType::Seedance2Fast, None, 5, KinoviBatchCount::Two, 280), + (KinoviModelType::Seedance2Fast, None, 15, KinoviBatchCount::Four, 1680), + // Fast 480p (10/sec) + (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, 50), + (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, 200), + (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::FourEightyP), 15, KinoviBatchCount::Four, 600), + ]; + + for (i, (model, res, dur, batch, expected)) in cases.iter().enumerate() { + let args = make_args(*model, *dur, *batch, *res); + let actual = args.estimate_credits(); + assert_eq!( + actual, *expected, + "Case {}: {:?} {:?} {}s batch {:?} — expected {} credits, got {}", + i, model, res, dur, batch, expected, actual, + ); + } + } + + // ── Aspect ratio doesn't affect cost ── + + #[test] + fn aspect_ratio_does_not_affect_credits() { + let resolutions = [ + KinoviAspectRatio::Landscape16x9, + KinoviAspectRatio::Portrait9x16, + KinoviAspectRatio::Square1x1, + KinoviAspectRatio::Standard4x3, + KinoviAspectRatio::Portrait3x4, + ]; + + let baseline = pro(5, KinoviBatchCount::One).estimate_credits(); + + for res in &resolutions { + let req = KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: String::new(), + aspect_ratio: *res, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + output_resolution: None, + use_face_blur_hack: None, + }; + assert_eq!( + req.estimate_credits(), baseline, + "Aspect ratio {:?} should not change credits from baseline {}", + res, baseline, + ); + } + } + + // ── USD cents: legacy 720p pricing ── + + #[test] + fn usd_cents_legacy_pro_720p() { + // 250 credits/$1: 200 credits = 80¢ + assert_eq!(pro(5, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 80); + assert_eq!(pro(10, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 160); + assert_eq!(pro(15, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 240); + // Batch 2 + assert_eq!(pro(5, KinoviBatchCount::Two).estimate_cost_in_usd_cents(), 160); + // Batch 4 + assert_eq!(pro(5, KinoviBatchCount::Four).estimate_cost_in_usd_cents(), 320); + } + + #[test] + fn usd_cents_legacy_fast_720p() { + // 220 credits/$1: 140 credits = 57.61 → 58¢ + assert_eq!(fast(5, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 64); + assert_eq!(fast(10, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 127); + } + + // ── USD cents: new pricing (22000 credits / $114) ── + + #[test] + fn usd_cents_new_pro_480p() { + // 243 credits/$1: 75 credits = 30.86 → 31¢ + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 31); + assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 62); + } + + #[test] + fn usd_cents_new_pro_1080p() { + // 243 credits/$1: 450 credits = 185.19 → 185¢ + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 185); + // 900 credits = 370.37 → 370¢ + assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 370); + } + + #[test] + fn usd_cents_new_fast_480p() { + // 243 credits/$1: 50 credits = 20.58 → 21¢ + assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 21); + assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 41); + } + + // ── credits_per_dollar: verify the rates directly ── + + #[test] + fn credits_per_dollar_legacy_rates() { + assert_eq!(pro(5, KinoviBatchCount::One).credits_per_dollar(), 250.0); + assert_eq!(fast(5, KinoviBatchCount::One).credits_per_dollar(), 220.0); + // Explicit 720p = same as None + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::SevenTwentyP).credits_per_dollar(), 250.0); + assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::SevenTwentyP).credits_per_dollar(), 220.0); + } + + #[test] + fn credits_per_dollar_new_rate() { + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0); + assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0); + assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0); + // Fast 1080p (not officially supported) also uses new rate + assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0); + } + } + + mod real_requests { + use super::*; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_generate_text_to_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "A corgi eating a cake in a fancy kitchen.".to_string(), + aspect_ratio: KinoviAspectRatio::Square1x1, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_generate_keyframe_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_generate_reference_image_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps walks in front of the desk and barks.".to_string(), + aspect_ratio: KinoviAspectRatio::Standard4x3, + duration_seconds: 10, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(), + "https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(), + ]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_generate_reference_video_only() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "Change the Video @video1 to night time.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_generate_reference_video_and_image() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "Put the robot in @video1 next to the house in @image1".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773595053724-07a1d500.png".to_string(), + ]), + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and a test image + async fn test_video_ref_file_that_is_too_long() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + + // Step 1: Get a signed upload URL + let cookies = get_test_cookies()?; + let session = KinoviWebSession::from_cookies_string(cookies); + let prepare_args = PrepareFileUploadArgs { + session: &session, + extension: "mp4".to_string(), + host_override: None, + }; + let prepare_result = prepare_file_upload(prepare_args).await?; + println!("Upload URL: {}", prepare_result.upload_url); + + // Step 2: Read a test image + let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?; + println!("File size: {} bytes", file_bytes.len()); + + // Step 3: Upload + let upload_args = UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes, + host_override: None, + }; + let result = upload_file(upload_args).await?; + println!("Public URL: {}", result.public_url); + + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "Change @video1 to night time".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![ + result.public_url, + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_pro_keyframe_with_start_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Upload a start frame image from our CDN + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded start frame: {}", upload_result.public_url); + + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "The corgi dog watches the lake.".to_string(), + aspect_ratio: KinoviAspectRatio::Portrait9x16, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: Some(upload_result.public_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + // --- Seedance 2 Fast tests --- + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_fast_keyframe_with_start_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Upload a start frame image from our CDN + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded start frame: {}", upload_result.public_url); + + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: "A corgi dog runs along the lake shore, splashing water. Camera follows.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: Some(upload_result.public_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_fast_three_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Upload three reference images from our CDN + let image_urls_to_upload = [ + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL, + test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL, + ]; + + let mut uploaded_urls = Vec::new(); + for (i, source_url) in image_urls_to_upload.iter().enumerate() { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes(source_url).await?; + let ext = if source_url.ends_with(".png") { "png" } else { "jpg" }; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: ext.to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded ref image {}: {}", i + 1, upload_result.public_url); + uploaded_urls.push(upload_result.public_url); + } + + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: "The dog in @1 is running through the scenery in @3 towards the building in @2. Golden hour lighting.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(uploaded_urls), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_fast_audio_reference_with_text() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Upload a test audio file + let audio_path = test_utils::test_file_path::test_file_path( + "test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3", + )?; + let audio_bytes = fs::read(&audio_path)?; + println!("Audio file size: {} bytes", audio_bytes.len()); + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "mp3".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: audio_bytes, + host_override: None, + }).await?; + + println!("Uploaded audio: {}", upload_result.public_url); + + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: "A fantasy forest with mushrooms glowing in the dark. Fireflies dance between the trees. A small character walks along a winding path.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: Some(vec![upload_result.public_url]), + character_ids: None, + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + mod character_tests { + use super::*; + + const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te"; + const MOCHI_ID: &str = "char_1775177718294_g2pitx"; + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs money + async fn test_text_prompt_with_character_pro() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "@Steampunk Clown is juggling flaming torches in a circus tent.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs money + async fn test_text_prompt_with_character_fast() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: "@Mochi the female shiba inu is eating a cheese pizza while standing on the table".to_string(), + aspect_ratio: KinoviAspectRatio::Portrait9x16, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![MOCHI_ID.to_string()]), + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs money + async fn test_character_with_image_ref_pro() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Pro, + prompt: "@Steampunk Clown is walking up to pet a dog on the couch.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260329/1774752385699-1ff44886.jpeg".to_string(), + ]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, costs money + async fn test_two_characters_fast() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = GenerateVideoArgs { + session: &session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![ + STEAMPUNK_CLOWN_ID.to_string(), + MOCHI_ID.to_string(), + ]), + use_face_blur_hack: None, + output_resolution: None, + }, + }; + let result = generate_video(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + } + } + + mod output_resolution_tests { + use super::*; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + fn make_args_with_prompt<'a>( + prompt: &'a str, + session: &'a KinoviWebSession, + model_type: KinoviModelType, + output_resolution: Option, + ) -> GenerateVideoArgs<'a> { + GenerateVideoArgs { + session, + host_override: None, + request: KinoviGenerateVideoRequest { + model_type, + prompt: prompt.to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + duration_seconds: 4, + batch_count: KinoviBatchCount::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + output_resolution, + use_face_blur_hack: None, + }, + } + } + + fn make_args<'a>( + session: &'a KinoviWebSession, + model_type: KinoviModelType, + output_resolution: Option, + ) -> GenerateVideoArgs<'a> { + make_args_with_prompt("A corgi running through a field of flowers", session, model_type, output_resolution) + } + + mod seedance_2 { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real cookies and costs money + async fn test_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = make_args(&session, KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::FourEightyP)); + let result = generate_video(args).await?; + println!("Seedance 2.0 @ 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + // Output resolution was: "864 × 496" + // Cost: 60 credits (4 x 15) + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and costs money + async fn test_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + // 720p = None (unset; now sent explicitly as "720p" on the wire) + let prompt = "A corgi running through a field of stars"; + let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Pro, None); + let result = generate_video(args).await?; + println!("Seedance 2.0 @ 720p (default) — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + // Output resolution was: "1280 × 720 + // Cost: 160 credits (4 x 40) + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and costs money + async fn test_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A shiba running through a field of stars"; + let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Pro, Some(KinoviOutputResolution::TenEightyP)); + let result = generate_video(args).await?; + println!("Seedance 2.0 @ 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + // Output resolution was: "1920 × 1080" + // Cost: 360 credits (4 x 90) + Ok(()) + } + } + + mod seedance_2_fast { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real cookies and costs money + async fn test_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A corgi running through a foggy meadow at dawn"; + let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::FourEightyP)); + let result = generate_video(args).await?; + println!("Seedance 2.0 Fast @ 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + // Output resolution was: "???" + // Cost: 40 credits (4 x 10) + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and costs money + async fn test_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A shiba running through a foggy meadow at dawn"; + let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Fast, None); + let result = generate_video(args).await?; + println!("Seedance 2.0 Fast @ 720p (default) — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + // Output resolution was: "???" + // Cost: 112 credits (4 x 28) + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and costs money + async fn test_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A small klee kai dog running through a foggy meadow at dawn"; + let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::TenEightyP)); + let result = generate_video(args).await?; + println!("Seedance 2.0 Fast @ 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + // Output resolution was: "???" + // Cost: 112 credits (4 x 28) ??? - it enqueued as 720p I think + Ok(()) + } + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_video/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_video/mod.rs new file mode 100644 index 0000000000..278eed12c0 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_video/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_video; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/generate_video/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/generate_video/request_types.rs new file mode 100644 index 0000000000..3ae8a16c55 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/generate_video/request_types.rs @@ -0,0 +1,68 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestJson { + #[serde(rename = "businessType")] + pub business_type: &'static str, + #[serde(rename = "apiParams")] + pub api_params: ApiParams, +} + +#[derive(Serialize, Debug)] +pub (super) struct ApiParams { + pub prompt: String, + pub resolution: String, + #[serde(rename = "contentMode")] + pub content_mode: &'static str, + pub model: &'static str, + pub duration: String, + pub mode: &'static str, + #[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")] + pub output_resolution: Option<&'static str>, + #[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")] + pub face_blur_mode: Option<&'static str>, + #[serde(rename = "characterIds", skip_serializing_if = "Option::is_none")] + pub character_ids: Option>, + #[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")] + pub uploaded_urls: Option>, + #[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")] + pub audio_urls: Option>, + #[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")] + pub batch_count: Option, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseData { + pub json: TaskResponseJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub (super) struct TaskResponseJson { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, + pub violation_warning: bool, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/get_user_auth_details.rs b/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/get_user_auth_details.rs new file mode 100644 index 0000000000..bb5cfaf7fc --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/get_user_auth_details.rs @@ -0,0 +1,162 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::get_user_auth_details::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Args & response --- + +pub struct GetUserAuthDetailsArgs<'a> { + pub session: &'a KinoviWebSession, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +#[derive(Debug)] +pub struct GetUserAuthDetailsResponse { + pub email: String, + pub credits: u64, + pub available_credits: u64, +} + +// --- Implementation --- + +/// The tRPC `input` query param for `auth.user` — a static null payload. +const AUTH_USER_INPUT: &str = + r#"{"0":{"json":null,"meta":{"values":["undefined"],"v":1}}}"#; + +pub async fn get_user_auth_details( + args: GetUserAuthDetailsArgs<'_>, +) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/auth.user", base_url); + + info!("Fetching user auth details..."); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/pricing", base_url); + + let request = client.get(&url) + .query(&[("batch", "1"), ("input", AUTH_USER_INPUT)]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.execute(request) + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Get user auth details response status: {}", status); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody( + err, + response_body.clone(), + ))?; + + let json = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + info!("User: {}, credits: {}, available: {}", json.email, json.credits, json.available_credits); + + Ok(GetUserAuthDetailsResponse { + email: json.email, + credits: json.credits, + available_credits: json.available_credits, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + /// Regression: the Kinovi API started returning `credits` as a float + /// (e.g. `372215.18`) while `availableCredits` stays an int. Both must parse + /// and coerce to `u64` instead of failing with + /// "invalid type: floating point ..., expected u64". + #[test] + fn float_credits_are_coerced() { + let body = r#"[{"result":{"data":{"json":{ + "email":"hello@storyteller.ai", + "credits":372215.18, + "availableCredits":372215 + }}}}]"#; + let batch: Vec = serde_json::from_str(body).expect("parse user auth"); + let json = batch.into_iter().next().expect("non-empty batch").result.data.json; + assert_eq!(json.credits, 372215); + assert_eq!(json.available_credits, 372215); + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_get_user_auth_details() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = get_user_auth_details(GetUserAuthDetailsArgs { + session: &session, + host_override: None, + }).await?; + println!("Email: {}", result.email); + println!("Credits: {}", result.credits); + println!("Available credits: {}", result.available_credits); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/mod.rs new file mode 100644 index 0000000000..6277520a72 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/mod.rs @@ -0,0 +1,2 @@ +pub mod get_user_auth_details; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/request_types.rs new file mode 100644 index 0000000000..c5caccd599 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/get_user_auth_details/request_types.rs @@ -0,0 +1,28 @@ +use serde_derive::Deserialize; + +use crate::utils::number_coercion::de_u64_int_or_float; + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: RawUserAuthDetails, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawUserAuthDetails { + pub email: String, + #[serde(deserialize_with = "de_u64_int_or_float")] + pub credits: u64, + #[serde(deserialize_with = "de_u64_int_or_float")] + pub available_credits: u64, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/kinovi_host.rs b/crates/api_clients/kinovi_web_client/src/requests/kinovi_host.rs new file mode 100644 index 0000000000..bed1b972e9 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/kinovi_host.rs @@ -0,0 +1,75 @@ +/// The host to target for API requests. +/// +/// The service was originally at `seedance2-pro.com` but migrated to `kinovi.ai`. +/// This enum allows configuring which host to use. +#[derive(Debug, Clone)] +pub enum KinoviHost { + /// https://kinovi.ai (current default) + Kinovi, + + /// https://seedance2-pro.com (legacy) + Seedance2Pro, + + /// Custom hosts for API and CDN. + /// Must include the URL scheme but no trailing slash. + /// e.g. api_host: "https://example.com", cdn_host: "http://static.example.com:1234" + CustomHost { api_host: String, cdn_host: String }, +} + +impl Default for KinoviHost { + fn default() -> Self { + Self::Kinovi + } +} + +impl KinoviHost { + /// Returns the API base URL (scheme + domain, no trailing slash). + pub fn api_base_url(&self) -> &str { + match self { + Self::Kinovi => "https://kinovi.ai", + Self::Seedance2Pro => "https://seedance2-pro.com", + Self::CustomHost { api_host, .. } => api_host.as_str(), + } + } + + /// Returns the CDN base URL for uploaded/static files (no trailing slash). + pub fn cdn_base_url(&self) -> &str { + match self { + // NB: The API endpoint moved to kinovi.ai but the CDN for uploaded materials + // still uses the legacy seedance2-pro.com domain. The generate_video API + // expects URLs on this domain. + Self::Kinovi => "https://static.seedance2-pro.com", + // Self::Kinovi => "https://static.kinovi.ai", + Self::Seedance2Pro => "https://static.seedance2-pro.com", + Self::CustomHost { cdn_host, .. } => cdn_host.as_str(), + } + } +} + +/// Env var pair for a process-wide custom host (both must be set and +/// non-empty to take effect). Intended for tests and local development +/// against a stub server; leave unset in production. +pub const ENV_KINOVI_CUSTOM_API_HOST: &str = "KINOVI_CUSTOM_API_HOST"; +pub const ENV_KINOVI_CUSTOM_CDN_HOST: &str = "KINOVI_CUSTOM_CDN_HOST"; + +/// Resolves an optional host override to the effective host. +/// +/// Precedence: explicit `host_override` argument, then the +/// [`ENV_KINOVI_CUSTOM_API_HOST`] / [`ENV_KINOVI_CUSTOM_CDN_HOST`] env var +/// pair, then the default host. +pub fn resolve_host(host_override: Option<&KinoviHost>) -> KinoviHost { + if let Some(host) = host_override { + return host.clone(); + } + + if let (Ok(api_host), Ok(cdn_host)) = ( + std::env::var(ENV_KINOVI_CUSTOM_API_HOST), + std::env::var(ENV_KINOVI_CUSTOM_CDN_HOST), + ) { + if !api_host.is_empty() && !cdn_host.is_empty() { + return KinoviHost::CustomHost { api_host, cdn_host }; + } + } + + KinoviHost::default() +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/mod.rs new file mode 100644 index 0000000000..dc1a0cae33 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/mod.rs @@ -0,0 +1,12 @@ +pub mod generate_character; +pub mod generate_image; +pub mod generate_video; +pub mod workflow_run_task; +pub mod get_user_auth_details; +pub mod kinovi_host; +pub mod create_material; +pub mod poll_characters; +pub mod poll_orders; +pub mod prepare_file_upload; +pub mod update_character; +pub mod upload_file; diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_characters/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_characters/mod.rs new file mode 100644 index 0000000000..119ad6ada5 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_characters/mod.rs @@ -0,0 +1,2 @@ +pub mod poll_characters; +pub(crate) mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_characters/poll_characters.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_characters/poll_characters.rs new file mode 100644 index 0000000000..d1bfcf2acf --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_characters/poll_characters.rs @@ -0,0 +1,375 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::requests::poll_characters::request_types::*; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Public args --- + +pub struct PollCharactersArgs<'a> { + pub session: &'a KinoviWebSession, + + /// Cursor from a previous `PollCharactersResponse::next_cursor`. + /// When `None`, the most recent characters are returned. + pub cursor: Option, + + /// Maximum number of characters to return per page. Defaults to 50. + pub limit: Option, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +// --- Public response --- + +pub struct PollCharactersResponse { + pub characters: Vec, + + /// Present when there are more characters to fetch. + /// Pass this value as `PollCharactersArgs::cursor` in the next call. + pub next_cursor: Option, +} + +// --- Public types --- + +/// Our resolved status for a character creation job. +/// +/// This is NOT 1:1 with Kinovi's API. A character can have `taskStatus: "COMPLETED"` +/// but `assetStatus: "Failed"`, which we treat as `Failed` on our end. +#[derive(Debug, Clone, PartialEq)] +pub enum CharacterCreationStatus { + /// The character was successfully created and the asset is active. + Success, + /// The character creation failed (either the task failed or the asset failed). + Failed, + /// The character is still being created (pending or processing). + Pending, +} + +impl CharacterCreationStatus { + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Success | Self::Failed) + } +} + +/// A single result image attached to a character. +#[derive(Debug, Clone)] +pub struct CharacterResultImage { + pub url: String, + pub image_type: Option, +} + +/// The status of one character. +#[derive(Debug, Clone)] +pub struct CharacterStatus { + /// Internal numeric ID. + pub id: u64, + + /// The character identifier (e.g. "char_1774752056469_2wlxoq"). + pub character_id: String, + + /// The name of the character. + pub name: String, + + /// The description of the character. + pub description: Option, + + /// The avatar URL (typically the uploaded reference image). + pub avatar_url: Option, + + /// Result images generated during character creation. + pub result_images: Vec, + + /// Our resolved status (Success, Failed, or Pending). + pub status: CharacterCreationStatus, + + /// If the task failed, the reason from the API. + pub fail_reason: Option, + + /// The asset ID, present when the character completed successfully. + pub asset_id: Option, + + /// Raw task status from the Kinovi API (for debugging). + pub raw_task_status: String, + + /// Raw asset status from the Kinovi API (for debugging). + pub raw_asset_status: Option, + + /// ISO 8601 timestamp of creation. + pub created_at: String, +} + +// --- Implementation --- + +pub async fn poll_characters(args: PollCharactersArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + + let limit = args.limit.unwrap_or(50); + + // The query param is URL-encoded JSON: {"0":{"json":{"limit":N}}} or {"0":{"json":{"limit":N,"cursor":C}}} + let input = match args.cursor { + Some(cursor) => format!( + r#"{{"0":{{"json":{{"limit":{},"cursor":{}}}}}}}"#, + limit, cursor + ), + None => format!( + r#"{{"0":{{"json":{{"limit":{}}}}}}}"#, + limit + ), + }; + + let encoded_input: String = url::form_urlencoded::byte_serialize(input.as_bytes()).collect(); + + let url = format!( + "{}/api/trpc/character.getCharacters?batch=1&input={}", + base_url, + encoded_input, + ); + + info!("Polling characters (limit={}, cursor={:?})...", limit, args.cursor); + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/characters", base_url); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.get(&url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Poll characters response: status={}", status); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let data = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + let characters: Vec = data.items.into_iter().map(|item| { + let result_images = item.result_images + .unwrap_or_default() + .into_iter() + .map(|img| CharacterResultImage { + url: img.url, + image_type: img.image_type, + }) + .collect(); + + // Derive our status from the raw Kinovi API fields. + // Kinovi reports "COMPLETED" even when the character actually failed: + // - assetStatus "Failed" => the asset generation failed + // - assetStatus null (no assetId) => the task completed but produced no asset + // Only taskStatus "COMPLETED" + assetStatus "Active" is a true success. + let status = match item.task_status.as_str() { + "FAILED" => CharacterCreationStatus::Failed, + "COMPLETED" => match item.asset_status.as_deref() { + Some("Active") => CharacterCreationStatus::Success, + _ => CharacterCreationStatus::Failed, // "Failed", null, or any other value + }, + _ => CharacterCreationStatus::Pending, + }; + + CharacterStatus { + id: item.id, + character_id: item.character_id, + name: item.name, + description: item.description, + avatar_url: item.avatar_url, + result_images, + status, + fail_reason: item.fail_reason, + asset_id: item.asset_id, + raw_task_status: item.task_status, + raw_asset_status: item.asset_status, + created_at: item.created_at, + } + }).collect(); + + // Parse next_cursor: it's either a JSON number or null. + let next_cursor = data.next_cursor + .and_then(|v| v.as_u64()); + + info!("Polled {} character(s), next_cursor={:?}", characters.len(), next_cursor); + + Ok(PollCharactersResponse { characters, next_cursor }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_poll_characters() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = poll_characters(PollCharactersArgs { + session: &session, + limit: None, + cursor: None, + host_override: None, + }).await?; + + println!("Characters returned: {}", result.characters.len()); + println!("Next cursor: {:?}", result.next_cursor); + for ch in &result.characters { + println!( + " {} | {} | {:?} | asset_id={:?} | avatar={:?}", + ch.character_id, ch.name, ch.status, ch.asset_id, ch.avatar_url, + ); + } + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_poll_characters_small_limit() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = poll_characters(PollCharactersArgs { + session: &session, + limit: Some(2), + cursor: None, + host_override: None, + }).await?; + + println!("Characters returned (limit=2): {}", result.characters.len()); + println!("Next cursor: {:?}", result.next_cursor); + for ch in &result.characters { + println!( + " {} | {} | {:?} | asset_id={:?}", + ch.character_id, ch.name, ch.status, ch.asset_id, + ); + } + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; exhausts all pages + async fn test_poll_all_pages() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let mut cursor: Option = None; + let mut page = 0usize; + let mut total_characters = 0usize; + + loop { + page += 1; + let result = poll_characters(PollCharactersArgs { + session: &session, + limit: Some(2), + cursor, + host_override: None, + }).await?; + + let page_count = result.characters.len(); + total_characters += page_count; + + println!("Page {}: {} characters, next_cursor: {:?}", page, page_count, result.next_cursor); + for ch in &result.characters { + println!(" {} | {} | {:?} | asset_id={:?}", ch.character_id, ch.name, ch.status, ch.asset_id); + } + + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + + println!("Total characters across {} pages: {}", page, total_characters); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; logs all character details + async fn test_poll_all_characters_verbose() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = poll_characters(PollCharactersArgs { + session: &session, + limit: None, + cursor: None, + host_override: None, + }).await?; + + println!("=== All Characters ({} total) ===", result.characters.len()); + for ch in &result.characters { + println!("Character: {} ({})", ch.name, ch.character_id); + println!(" Description: {:?}", ch.description); + println!(" Status: {:?}", ch.status); + println!(" Avatar URL: {:?}", ch.avatar_url); + println!(" Asset ID: {:?}", ch.asset_id); + println!(" Raw Task Status: {}", ch.raw_task_status); + println!(" Raw Asset Status: {:?}", ch.raw_asset_status); + println!(" Fail Reason: {:?}", ch.fail_reason); + println!(" Created At: {}", ch.created_at); + println!(" Result Images:"); + for img in &ch.result_images { + println!(" {} (type={:?})", img.url, img.image_type); + } + println!(); + } + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_characters/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_characters/request_types.rs new file mode 100644 index 0000000000..85c23f2fc3 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_characters/request_types.rs @@ -0,0 +1,50 @@ +use serde_derive::Deserialize; + +// ======================== Response ======================== + +// The request is a GET with query params, no request body. + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: CharacterListJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct CharacterListJson { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct CharacterItemJson { + pub id: u64, + pub character_id: String, + pub name: String, + pub description: Option, + pub avatar_url: Option, + pub result_images: Option>, + pub task_status: String, + pub fail_reason: Option, + pub asset_id: Option, + pub asset_status: Option, + pub created_at: String, +} + +#[derive(Deserialize, Debug)] +pub(super) struct ResultImageJson { + pub url: String, + #[serde(rename = "type")] + pub image_type: Option, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/failure_reason.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/failure_reason.rs new file mode 100644 index 0000000000..fce92498ba --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/failure_reason.rs @@ -0,0 +1,26 @@ +use super::failure_type::FailureType; + +/// A structured failure reason parsed from the raw Seedance2 Pro API response. +#[derive(Debug, Clone)] +pub struct FailureReason { + /// The original reason string from the API. + pub reason: String, + + /// The categorized failure type. + pub failure_type: FailureType, +} + +impl FailureReason { + /// Parse a raw failure reason string into a structured `FailureReason`. + /// + /// First checks for exact string matches against known reasons, + /// then falls back to case-insensitive substring matching. + pub fn from_reason(reason: &str) -> Self { + let failure_type = FailureType::classify_text(reason); + FailureReason { + reason: reason.to_string(), + failure_type, + } + } +} + diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/failure_type.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/failure_type.rs new file mode 100644 index 0000000000..8817ef7eb2 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/failure_type.rs @@ -0,0 +1,84 @@ + +/// Categorized failure type for a Seedance2 Pro order. +#[derive(Debug, Clone, PartialEq)] +pub enum FailureType { + /// User's uploaded image was rejected by platform content rules. + RuleBansUserImage, + /// User's uploaded image was rejected because it contains faces. + RuleBansUserImageWithFaces, + /// User's text prompt was rejected by platform content rules. + RuleBansUserTextPrompt, + /// User's content (image or text) was rejected by platform content rules (generic). + RuleBansUserContent, + /// The generated video was rejected by platform content review. + RuleBansGeneratedVideo, + /// The generated audio was rejected by platform content rules. + RuleBansGeneratedAudio, + /// The generated content (video/audio/other) was rejected by platform content rules (generic). + RuleBansGeneratedContent, + /// Video generation failed (timeout, server error, processing error, etc.) + GenerationFailed, + /// An unrecognized or absent failure reason. + OtherUnknownReason, +} + +impl FailureType { + pub fn classify_text(reason: &str) -> Self { + // --- Exact matches first --- + match reason { + "Your uploaded image violates platform rules. Please modify and try again." => + return Self::RuleBansUserImage, + "Face detected in uploaded media. Please adjust your media and try again." => + return Self::RuleBansUserImageWithFaces, + "Your input text violates platform rules. Please modify and try again." => + return Self::RuleBansUserTextPrompt, + "Content violates platform rules. Please modify and try again." => + return Self::RuleBansUserContent, + "The generated video did not pass review. Credits will not be deducted." => + return Self::RuleBansGeneratedVideo, + "The generated audio violates platform rules. Please adjust your prompt or images and try again." => + return Self::RuleBansGeneratedAudio, + "The generated content violates platform rules. Please adjust your prompt or images and try again." => + return Self::RuleBansGeneratedContent, + "Video generation failed. Please try again." => + return Self::GenerationFailed, + "Generation timed out. Please try again." => + return Self::GenerationFailed, + "Server error. Please try again later." => + return Self::GenerationFailed, + "Your content could not be processed. Please try different images or adjust your prompt." => + return Self::GenerationFailed, + _ => {} + } + + // --- Substring matches (case-insensitive) --- + let lower = reason.to_lowercase(); + + if lower.contains("face detected") || lower.contains("ensure no faces") { + return Self::RuleBansUserImageWithFaces; + } + if lower.contains("uploaded image violates") { + return Self::RuleBansUserImage; + } + if lower.contains("input text violates") { + return Self::RuleBansUserTextPrompt; + } + if lower.contains("generated video") && lower.contains("not pass review") { + return Self::RuleBansGeneratedVideo; + } + if lower.contains("generated audio") && lower.contains("violates") { + return Self::RuleBansGeneratedAudio; + } + if lower.contains("generated content") && lower.contains("violates") { + return Self::RuleBansGeneratedContent; + } + if lower.contains("content violates") || lower.contains("platform rules") { + return Self::RuleBansUserContent; + } + if lower.contains("video generation failed") || lower.contains("timed out") || lower.contains("server error") { + return Self::GenerationFailed; + } + + Self::OtherUnknownReason + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/mod.rs new file mode 100644 index 0000000000..c3458c1fe2 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/mod.rs @@ -0,0 +1,4 @@ +pub mod failure_reason; +pub mod failure_type; +pub mod poll_orders; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs new file mode 100644 index 0000000000..283b8b63de --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs @@ -0,0 +1,759 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::requests::poll_orders::failure_reason::FailureReason; +use crate::requests::poll_orders::request_types::*; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use chrono::{DateTime, Utc}; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + + +// --- Args & response --- + +pub struct PollOrdersArgs<'a> { + pub session: &'a KinoviWebSession, + + /// Optional cursor from a previous `PollOrdersResponse::next_cursor`. + /// When `None`, the most recent orders are returned. + pub cursor: Option, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct PollOrdersResponse { + pub orders: Vec, + + /// Present when there are more orders to fetch. + /// Pass this value as `PollOrdersArgs::cursor` in the next call. + pub next_cursor: Option, +} + +// --- Public types --- + +/// The status of one order (one generation task — video or image). +#[derive(Debug, Clone)] +pub struct OrderStatus { + pub order_id: String, + + pub task_status: TaskStatus, + + /// Top-level result URL (video file for video orders, the first image of + /// the 4-image grid for Midjourney image orders). Populated when + /// `task_status` is `Completed`. + pub result_url: Option, + + /// Detailed result entries. One entry per video frame (video orders), or + /// four entries per Midjourney task (image orders). + pub results: Vec, + + /// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present. + pub fail_reason: Option, + + /// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`). + pub created_at: String, + + /// Parsed `created_at` as a `DateTime`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, + + /// Whether this order produced an image or a video. `None` for older + /// polling responses that didn't include the field — those came from the + /// video-only era and can be treated as video by callers that need to. + pub media_type: Option, + + /// The Kinovi credits charged for the order (the API's `totalCredits`). + /// `None` for older polling responses that didn't include the field. + pub total_credits: Option, +} + +/// The lifecycle status of a video generation task. +#[derive(Debug, Clone, PartialEq)] +pub enum TaskStatus { + /// The task is queued and has not started yet. + Pending, + /// The task is actively being processed. + Processing, + /// The task finished successfully. `result_url` and `results` will be populated. + Completed, + /// The task failed. `fail_reason` will contain the reason. + Failed, + /// An unrecognised status string was returned by the server. + Unknown(String), +} + +impl TaskStatus { + fn from_str(s: &str) -> Self { + match s { + "PENDING" => Self::Pending, + "PROCESSING" => Self::Processing, + "COMPLETED" => Self::Completed, + "FAILED" => Self::Failed, + other => Self::Unknown(other.to_string()), + } + } + + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed) + } +} + +/// A single generated result attached to an order — a video frame for video +/// orders, or one of Midjourney's 4 generated images for image orders. +/// (Originally named for the video-only days; the underlying shape is shared.) +#[derive(Debug, Clone)] +pub struct MediaResult { + pub url: String, + /// Frame width in pixels. `None` when the API returns `null`/omits it + /// (it does so intermittently); callers should persist NULL, not a sentinel. + pub maybe_width: Option, + /// Frame height in pixels. `None` when the API returns `null`/omits it. + pub maybe_height: Option, + // NB: We don't need these. + // /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null. + // pub ratio: Option, +} + +/// Media type of an order. Midjourney orders are `Image`; the various +/// Seedance/keyframe/reference flows are `Video`. +/// +/// `Unknown` covers response payloads that omit the field (older video +/// polling) or return an unrecognised value. +#[derive(Debug, Clone, PartialEq)] +pub enum OrderMediaType { + Image, + Video, + Unknown(String), +} + +impl OrderMediaType { + fn from_str(s: &str) -> Self { + match s { + "image" => Self::Image, + "video" => Self::Video, + other => Self::Unknown(other.to_string()), + } + } + + pub fn is_image(&self) -> bool { matches!(self, Self::Image) } + pub fn is_video(&self) -> bool { matches!(self, Self::Video) } +} + +// --- Implementation --- + +pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let get_orders_url = format!("{}/api/trpc/userOrder.getOrders", base_url); + + info!("Polling orders (cursor: {:?})...", args.cursor); + + let input_json = build_input_json(args.cursor); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/gallery", base_url); + + let request = client.get(&get_orders_url) + .query(&[("batch", "1"), ("input", input_json.as_str())]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.execute(request) + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Poll orders response status: {}", status); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let json = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + let next_cursor = json.next_cursor; + + let orders: Vec = json.orders + .into_iter() + .map(|o| { + let task_status = TaskStatus::from_str(&o.task_status); + let fail_reason = match (&o.fail_reason, &task_status) { + (Some(reason), _) => Some(FailureReason::from_reason(reason)), + (None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")), + _ => None, + }; + let created_at_utc = DateTime::parse_from_rfc3339(&o.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .ok(); + + let media_type = o.media_type.as_deref().map(OrderMediaType::from_str); + + OrderStatus { + order_id: o.order_id, + task_status, + result_url: o.result_url, + results: o.results.into_iter().map(|r| MediaResult { + url: r.url, + maybe_width: r.maybe_width, + maybe_height: r.maybe_height, + }).collect(), + fail_reason, + created_at: o.created_at, + created_at_utc, + media_type, + total_credits: o.total_credits, + } + }) + .collect(); + + info!("Polled {} orders, next_cursor: {:?}", orders.len(), next_cursor); + + Ok(PollOrdersResponse { orders, next_cursor }) +} + +/// Builds the tRPC `input` JSON for the getOrders endpoint. +/// When `cursor` is `Some`, it is included in the JSON payload. +fn build_input_json(cursor: Option) -> String { + match cursor { + None => r#"{"0":{"json":{"limit":30,"format":null,"toolId":null,"direction":"forward"},"meta":{"values":{"format":["undefined"],"toolId":["undefined"]},"v":1}}}"#.to_string(), + Some(c) => format!( + r#"{{"0":{{"json":{{"limit":30,"format":null,"toolId":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"],"toolId":["undefined"]}},"v":1}}}}}}"#, + cursor = c + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + /// Diagnostic: fetch the raw getOrders JSON (no typed parsing) and print + /// the complete field set per order, so schema additions can be checked + /// against reality. Requires real cookies. + /// cargo test -p kinovi_web_client live_dump_raw_orders_json -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn live_dump_raw_orders_json() -> AnyhowResult<()> { + let session = test_session()?; + let host = resolve_host(None); + let base_url = host.api_base_url(); + let get_orders_url = format!("{}/api/trpc/userOrder.getOrders", base_url); + let input_json = build_input_json(None); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build()?; + + let response = client.get(&get_orders_url) + .query(&[("batch", "1"), ("input", input_json.as_str())]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Referer", format!("{}/app/gallery", base_url)) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Cookie", session.cookies.as_str()) + .send() + .await?; + + let body = response.text().await?; + let parsed: serde_json::Value = serde_json::from_str(&body)?; + let orders = &parsed[0]["result"]["data"]["json"]["orders"]; + + if let Some(first) = orders.get(0) { + let keys: Vec<&str> = first.as_object().map(|o| o.keys().map(|k| k.as_str()).collect()).unwrap_or_default(); + println!("FIELDS ON AN ORDER: {:?}", keys); + println!("FIRST ORDER RAW: {}", serde_json::to_string_pretty(first)?); + } else { + println!("no orders; raw body head: {}", &body[..body.len().min(500)]); + } + Ok(()) + } + + // ── Offline parsing tests against captured responses ── + // + // These verify the deserializer + mapping handle the Midjourney image + // polling shape, including the new `mediaType` field, without breaking + // the older video-only callers. + + mod offline_parsing { + use super::*; + + /// Helper: take a captured response file with a `Response:` header and + /// extract the first complete JSON array body. Tolerant of whitespace + /// between `[` and `{` (the pretty-printed captures look like + /// `[\n {`). + fn extract_json_body(raw: &str) -> &str { + // The body always sits after the `Response:` header in the capture. + // Anchor the search there so we don't accidentally grab a `[` from + // a header value above. + let after_header = match raw.find("Response") { + Some(i) => &raw[i..], + None => raw, + }; + let local_start = after_header.find('[').expect("no `[` found in response body"); + let start = (raw.len() - after_header.len()) + local_start; + + let mut depth = 0i32; + let mut in_str = false; + let mut esc = false; + let bytes = raw.as_bytes(); + for i in start..bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { esc = false; } + else if c == '\\' { esc = true; } + else if c == '"' { in_str = false; } + } else { + if c == '"' { in_str = true; } + else if c == '[' || c == '{' { depth += 1; } + else if c == ']' || c == '}' { + depth -= 1; + if depth == 0 { + return &raw[start..=i]; + } + } + } + } + panic!("never found closing bracket") + } + + /// The pretty-printed captures occasionally contain trailing commas + /// before `]` or `}` because the curator excised neighbouring orders + /// by hand. serde_json is strict about JSON, so scrub those out + /// before parsing. + fn strip_trailing_commas(body: &str) -> String { + let mut out = String::with_capacity(body.len()); + let mut in_str = false; + let mut esc = false; + let bytes = body.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + out.push(c); + if esc { esc = false; } + else if c == '\\' { esc = true; } + else if c == '"' { in_str = false; } + i += 1; + continue; + } + if c == '"' { in_str = true; out.push(c); i += 1; continue; } + if c == ',' { + // Peek ahead past whitespace; if the next non-whitespace char is + // `]` or `}`, drop the comma. + let mut j = i + 1; + while j < bytes.len() && (bytes[j] as char).is_whitespace() { j += 1; } + if j < bytes.len() && (bytes[j] == b']' || bytes[j] == b'}') { + i += 1; + continue; + } + } + out.push(c); + i += 1; + } + out + } + + fn parse_orders(raw: &str) -> Vec { + let body = extract_json_body(raw); + let body = strip_trailing_commas(body); + let batch: Vec = serde_json::from_str(&body).expect("parse batch"); + let json = batch.into_iter().next().expect("non-empty batch").result.data.json; + json.orders.into_iter().map(|o| { + let task_status = TaskStatus::from_str(&o.task_status); + let fail_reason = match (&o.fail_reason, &task_status) { + (Some(r), _) => Some(FailureReason::from_reason(r)), + (None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")), + _ => None, + }; + let created_at_utc = DateTime::parse_from_rfc3339(&o.created_at) + .map(|dt| dt.with_timezone(&Utc)).ok(); + let media_type = o.media_type.as_deref().map(OrderMediaType::from_str); + OrderStatus { + order_id: o.order_id, + task_status, + result_url: o.result_url, + results: o.results.into_iter().map(|r| MediaResult { + url: r.url, maybe_width: r.maybe_width, maybe_height: r.maybe_height, + }).collect(), + fail_reason, + created_at: o.created_at, + created_at_utc, + media_type, + total_credits: o.total_credits, + } + }).collect() + } + + /// Back-compat: when the raw payload omits `mediaType` (older video-only + /// polling), `media_type` parses as `None` and the rest of the order + /// continues to deserialise unchanged. + #[test] + fn omitted_media_type_field_yields_none() { + // Hand-built minimal video-shaped order, no `mediaType` field. + let body = r#"[{"result":{"data":{"json":{"orders":[{ + "orderId":"ord_legacy_video", + "resultUrl":null, + "taskStatus":"PROCESSING", + "results":[], + "failReason":null, + "createdAt":"2026-02-19T01:20:50.398Z" + }],"nextCursor":null}}}}]"#; + let orders = parse_orders(body); + assert_eq!(orders.len(), 1); + assert!(orders[0].media_type.is_none(), "legacy responses without mediaType should map to None"); + assert_eq!(orders[0].task_status, TaskStatus::Processing); + } + + /// Regression: the Kinovi API started returning result `width`/`height` + /// (and `totalCredits`) as floats (e.g. `568.5`). They must parse and + /// coerce to unsigned ints instead of failing with + /// "invalid type: floating point ..., expected u32". + #[test] + fn float_dimensions_and_credits_are_coerced() { + let body = r#"[{"result":{"data":{"json":{"orders":[{ + "orderId":"ord_float", + "resultUrl":"https://example.com/v.mp4", + "taskStatus":"COMPLETED", + "results":[{"url":"https://example.com/v.mp4","width":568.5,"height":1023.4}], + "failReason":null, + "createdAt":"2026-02-19T01:20:50.398Z", + "mediaType":"video", + "totalCredits":12.9 + }],"nextCursor":394062.0}}}}]"#; + let orders = parse_orders(body); + assert_eq!(orders.len(), 1); + assert_eq!(orders[0].results.len(), 1); + assert_eq!(orders[0].results[0].maybe_width, Some(569)); + assert_eq!(orders[0].results[0].maybe_height, Some(1023)); + assert_eq!(orders[0].total_credits, Some(13)); + } + + /// Regression (paging outage): the Kinovi API started returning result + /// `width`/`height` as `null`, which blew up the whole poll with + /// "invalid type: null, expected a JSON number" and stalled the queue. + /// They must parse as `None` and let the rest of the response parse. + #[test] + fn null_dimensions_parse_as_none() { + let body = r#"[{"result":{"data":{"json":{"orders":[{ + "orderId":"ord_foo", + "resultUrl":"https://static.seedance2-pro.com/videos/seedance_video.mp4", + "taskStatus":"COMPLETED", + "results":[{"url":"https://static.seedance2-pro.com/videos/seedance_video.mp4","width":null,"height":null}], + "failReason":null, + "createdAt":"2026-02-19T01:20:50.398Z", + "mediaType":"video" + }],"nextCursor":null}}}}]"#; + let orders = parse_orders(body); + assert_eq!(orders.len(), 1); + assert_eq!(orders[0].results.len(), 1); + assert_eq!(orders[0].results[0].maybe_width, None); + assert_eq!(orders[0].results[0].maybe_height, None); + assert_eq!(orders[0].task_status, TaskStatus::Completed); + } + + /// Omitted `width`/`height` keys (not just `null`) also parse as `None`. + #[test] + fn missing_dimensions_parse_as_none() { + let body = r#"[{"result":{"data":{"json":{"orders":[{ + "orderId":"ord_no_dims", + "resultUrl":"https://example.com/v.mp4", + "taskStatus":"COMPLETED", + "results":[{"url":"https://example.com/v.mp4"}], + "failReason":null, + "createdAt":"2026-02-19T01:20:50.398Z", + "mediaType":"video" + }],"nextCursor":null}}}}]"#; + let orders = parse_orders(body); + assert_eq!(orders.len(), 1); + assert_eq!(orders[0].results[0].maybe_width, None); + assert_eq!(orders[0].results[0].maybe_height, None); + } + + /// Unrecognised mediaType values shouldn't crash deserialisation; + /// they end up as `Unknown(_)`. + #[test] + fn unknown_media_type_value_is_preserved() { + let body = r#"[{"result":{"data":{"json":{"orders":[{ + "orderId":"ord_x", + "resultUrl":null, + "taskStatus":"PROCESSING", + "results":[], + "failReason":null, + "createdAt":"2026-02-19T01:20:50.398Z", + "mediaType":"hologram" + }],"nextCursor":null}}}}]"#; + let orders = parse_orders(body); + assert_eq!(orders.len(), 1); + match &orders[0].media_type { + Some(OrderMediaType::Unknown(s)) => assert_eq!(s, "hologram"), + other => panic!("expected Unknown(\"hologram\"), got {:?}", other), + } + } + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_poll_all_orders() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let result = poll_orders(PollOrdersArgs { session: &session, cursor: None, host_override: None }).await?; + println!("Orders returned: {}", result.orders.len()); + println!("Next cursor: {:?}", result.next_cursor); + let mut images = 0; + let mut videos = 0; + let mut unknown = 0; + let mut missing = 0; + for order in &result.orders { + match &order.media_type { + Some(OrderMediaType::Image) => images += 1, + Some(OrderMediaType::Video) => videos += 1, + Some(OrderMediaType::Unknown(_)) => unknown += 1, + None => missing += 1, + } + println!(" {} | {:?} | media={:?} | results={} | result_url={:?} | fail={:?}", + order.order_id, order.task_status, order.media_type, + order.results.len(), order.result_url, order.fail_reason); + } + println!( + "media_type tally: image={}, video={}, unknown={}, missing={}", + images, videos, unknown, missing, + ); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and a known cursor value + async fn test_poll_with_cursor() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + // Use the cursor value returned from a prior call (e.g. 394062 from the example responses). + let cursor: u64 = 394062; + let result = poll_orders(PollOrdersArgs { session: &session, cursor: Some(cursor), host_override: None }).await?; + println!("Orders returned (with cursor {}): {}", cursor, result.orders.len()); + println!("Next cursor: {:?}", result.next_cursor); + for order in &result.orders { + println!(" {} | {:?} | media={:?} | results={} | result_url={:?} | fail={:?}", + order.order_id, order.task_status, order.media_type, + order.results.len(), order.result_url, order.fail_reason); + } + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + /// Page back from the most recent orders until we hit a Midjourney image + /// order, then assert the new parsing path detects it. Bounded to a few + /// pages so this doesn't accidentally exhaust the whole account. + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_poll_back_to_first_image_order() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + const MAX_PAGES: usize = 10; + let mut cursor: Option = None; + let mut found_image_orders: Vec = Vec::new(); + let mut pages_scanned = 0usize; + + for _ in 0..MAX_PAGES { + let result = poll_orders(PollOrdersArgs { + session: &session, cursor, host_override: None, + }).await?; + pages_scanned += 1; + println!("Page {}: {} orders, next_cursor={:?}", pages_scanned, result.orders.len(), result.next_cursor); + + for order in &result.orders { + if order.media_type == Some(OrderMediaType::Image) { + found_image_orders.push(order.clone()); + } + } + if !found_image_orders.is_empty() { break; } + cursor = result.next_cursor; + if cursor.is_none() { break; } + } + + println!("Scanned {} pages; found {} image order(s)", pages_scanned, found_image_orders.len()); + for o in &found_image_orders { + println!(" IMG {} | {:?} | results={} | result_url={:?}", + o.order_id, o.task_status, o.results.len(), o.result_url); + } + + assert!(!found_image_orders.is_empty(), + "expected to find at least one image order within {} pages — \ + create a Midjourney generation if needed and rerun", MAX_PAGES); + + // Each Midjourney task returns exactly 4 images when completed. + if let Some(completed) = found_image_orders.iter().find(|o| o.task_status == TaskStatus::Completed) { + assert_eq!(completed.results.len(), 4, + "completed Midjourney image order should have 4 results, got {}", + completed.results.len()); + } + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; exhausts all pages + async fn test_poll_all_pages() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let mut cursor: Option = None; + let mut page = 0usize; + let mut total_orders = 0usize; + + loop { + page += 1; + let result = poll_orders(PollOrdersArgs { session: &session, cursor, host_override: None }).await?; + let page_count = result.orders.len(); + total_orders += page_count; + + println!("Page {}: {} orders, next_cursor: {:?}", page, page_count, result.next_cursor); + for order in &result.orders { + println!(" {} | {:?}", order.order_id, order.task_status); + } + + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + + println!("Total orders across {} pages: {}", page, total_orders); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + /// Live diagnostic for the paging outage: try EACH Kinovi account's cookies + /// from the job's secrets env file and cursor across several pages, reporting + /// which account(s) parse cleanly and which blow up. Never prints cookies. + /// + /// cargo test -p kinovi_web_client live_cursor_all_accounts -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn live_cursor_all_accounts() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + + const SECRETS_ENV: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../crates/service/job/seedance2_pro_job/config/seedance2-pro-job.development-secrets.env", + ); + const ACCOUNTS: &[&str] = &[ + "SEEDANCE2PRO_VOLCENGINE_COOKIES", + "SEEDANCE2PRO_BYTEPLUS_COOKIES", + "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", + ]; + const MAX_PAGES: usize = 5; + + let env_contents = std::fs::read_to_string(SECRETS_ENV) + .unwrap_or_else(|e| panic!("could not read secrets env at {}: {}", SECRETS_ENV, e)); + + for account in ACCOUNTS { + let Some(cookies) = read_env_value(&env_contents, account) else { + println!("[{}] not present in secrets env — skipping", account); + continue; + }; + if cookies.is_empty() { + println!("[{}] empty — skipping", account); + continue; + } + + let session = KinoviWebSession::from_cookies_string(cookies); + let mut cursor: Option = None; + let mut pages = 0usize; + let mut total = 0usize; + let mut ok = true; + + for _ in 0..MAX_PAGES { + match poll_orders(PollOrdersArgs { session: &session, cursor, host_override: None }).await { + Ok(result) => { + pages += 1; + total += result.orders.len(); + cursor = result.next_cursor; + if cursor.is_none() { break; } + } + Err(err) => { + ok = false; + println!("[{}] FAILED on page {}: {:?}", account, pages + 1, err); + break; + } + } + } + + println!( + "[{}] {} — {} page(s), {} order(s) parsed cleanly", + account, if ok { "OK" } else { "ERROR" }, pages, total, + ); + } + + Ok(()) + } + + /// Minimal dotenv value reader: returns the value for `KEY=...` from raw env + /// file contents, trimming surrounding quotes. Used so the diagnostic can try + /// each account without depending on process-wide env being loaded. + fn read_env_value(contents: &str, key: &str) -> Option { + for line in contents.lines() { + let line = line.trim(); + if line.starts_with('#') { continue; } + let Some((k, v)) = line.split_once('=') else { continue; }; + if k.trim() != key { continue; } + let v = v.trim().trim_matches('"').trim_matches('\'').to_string(); + return Some(v); + } + None + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/request_types.rs new file mode 100644 index 0000000000..0e09b3968f --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/request_types.rs @@ -0,0 +1,61 @@ +use serde_derive::Deserialize; + +use crate::utils::number_coercion::{ + de_opt_u32_int_or_float, de_opt_u64_int_or_float, +}; + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: OrdersResponseJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct OrdersResponseJson { + pub orders: Vec, + #[serde(default, deserialize_with = "de_opt_u64_int_or_float")] + pub next_cursor: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawOrder { + pub order_id: String, + pub result_url: Option, + pub task_status: String, + pub results: Vec, + pub fail_reason: Option, + pub created_at: String, + + /// `"image"` for Midjourney image-generation orders, `"video"` for video + /// orders. `None` for older response shapes that didn't include the field. + #[serde(default)] + pub media_type: Option, + + /// The Kinovi credits charged for the order. `None` for older response + /// shapes that didn't include the field. + #[serde(default, deserialize_with = "de_opt_u32_int_or_float")] + pub total_credits: Option, +} + +#[derive(Deserialize, Debug)] +pub(super) struct RawVideoResult { + pub url: String, + /// The API intermittently returns `null` (or omits) these dimensions, so + /// they're optional rather than coerced to a sentinel. + #[serde(rename = "width", default, deserialize_with = "de_opt_u32_int_or_float")] + pub maybe_width: Option, + #[serde(rename = "height", default, deserialize_with = "de_opt_u32_int_or_float")] + pub maybe_height: Option, + // pub ratio: Option, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/mod.rs new file mode 100644 index 0000000000..6de086d735 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/mod.rs @@ -0,0 +1,2 @@ +pub mod prepare_file_upload; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/prepare_file_upload.rs b/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/prepare_file_upload.rs new file mode 100644 index 0000000000..4c64514e57 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/prepare_file_upload.rs @@ -0,0 +1,177 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::requests::prepare_file_upload::request_types::*; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use chrono::Utc; +use log::info; +use rand::Rng; +use wreq::Client; +use wreq_util::Emulation; + +/// Generates a material path based on the current time. +/// Format: `materials/YYYYMMDD/-.` +fn generate_material_path(extension: &str) -> String { + let now = Utc::now(); + let date_part = now.format("%Y%m%d").to_string(); + let timestamp_millis = now.timestamp_millis(); + let random_hex: u32 = rand::rng().random(); + let hex_part = format!("{:08x}", random_hex); + format!("materials/{}/{}-{}.{}", date_part, timestamp_millis, hex_part, extension) +} + +pub struct PrepareFileUploadArgs<'a> { + pub session: &'a KinoviWebSession, + + /// File extension without the dot, e.g. "png", "mp4", "mp3". + pub extension: String, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct PrepareFileUploadResponse { + /// The signed URL to upload the file to (Cloudflare R2 / S3-compatible). + pub upload_url: String, + + /// The material path that was generated for this upload. + pub material_path: String, +} + +pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let signed_upload_url = format!("{}/api/trpc/uploads.signedUploadUrl?batch=1", base_url); + + let material_path = generate_material_path(&args.extension); + + info!("Preparing file upload with path: {}", material_path); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + path: material_path.clone(), + }, + }, + }; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/", base_url); + + let response = client.post(&signed_upload_url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(&request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let upload_url = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + Ok(PrepareFileUploadResponse { + upload_url, + material_path, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_prepare_file_upload_png() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let cookies = get_test_cookies()?; + let session = KinoviWebSession::from_cookies_string(cookies); + let args = PrepareFileUploadArgs { + session: &session, + extension: "png".to_string(), + host_override: None, + }; + let result = prepare_file_upload(args).await?; + println!("Upload URL: {}", result.upload_url); + println!("Material path: {}", result.material_path); + assert!(!result.upload_url.is_empty()); + assert!(result.upload_url.contains("cloudflarestorage.com") || result.upload_url.contains("kinovi")); + assert!(result.material_path.starts_with("materials/")); + assert!(result.material_path.ends_with(".png")); + + assert_eq!(1, 2); // NB: Intentional failure to check the response. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies + async fn test_prepare_file_upload_mp4() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let cookies = get_test_cookies()?; + let session = KinoviWebSession::from_cookies_string(cookies); + let args = PrepareFileUploadArgs { + session: &session, + extension: "mp4".to_string(), + host_override: None, + }; + let result = prepare_file_upload(args).await?; + println!("Upload URL: {}", result.upload_url); + println!("Material path: {}", result.material_path); + assert!(!result.upload_url.is_empty()); + assert!(result.material_path.ends_with(".mp4")); + + assert_eq!(1, 2); // NB: Intentional failure to check the response. + Ok(()) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/request_types.rs new file mode 100644 index 0000000000..6736d58829 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/prepare_file_upload/request_types.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub(super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize)] +pub(super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize)] +pub(super) struct BatchRequestJson { + pub path: String, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: String, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/update_character/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/update_character/mod.rs new file mode 100644 index 0000000000..e7abf05796 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/update_character/mod.rs @@ -0,0 +1,2 @@ +pub mod update_character; +pub(crate) mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/update_character/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/update_character/request_types.rs new file mode 100644 index 0000000000..963523f3aa --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/update_character/request_types.rs @@ -0,0 +1,44 @@ +use serde_derive::{Deserialize, Serialize}; + +// ======================== Request ======================== + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct BatchRequestJson { + pub character_id: String, + pub name: String, + pub description: String, +} + +// ======================== Response ======================== + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: UpdateCharacterResponseJson, +} + +#[derive(Deserialize, Debug)] +pub(super) struct UpdateCharacterResponseJson { + pub success: bool, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/update_character/update_character.rs b/crates/api_clients/kinovi_web_client/src/requests/update_character/update_character.rs new file mode 100644 index 0000000000..164527f6f0 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/update_character/update_character.rs @@ -0,0 +1,206 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::update_character::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Public args --- + +pub struct UpdateCharacterArgs<'a> { + pub session: &'a KinoviWebSession, + + /// The Kinovi character ID (e.g. "char_1775184656440_vsy3eu"). + pub character_id: String, + + /// The new name for the character. + pub name: String, + + /// The new description (empty string clears it). + pub description: String, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +// --- Public response --- + +#[derive(Debug)] +pub struct UpdateCharacterResponse { + pub success: bool, +} + +// --- Implementation --- + +pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/character.updateCharacter?batch=1", base_url); + + info!("Updating character '{}' (name='{}', description='{}')", args.character_id, args.name, args.description); + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + character_id: args.character_id.clone(), + name: args.name, + description: args.description, + }, + }, + }; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/characters", base_url); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let response = client.post(&url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(&request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Update character response: status={}, body={}", status, response_body); + + if !status.is_success() { + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let data = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + Ok(UpdateCharacterResponse { + success: data.success, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + // Steampunk Clown character + const TEST_CHARACTER_ID: &str = "char_1775176566518_sik0te"; + + #[tokio::test] + #[ignore] // manually test — requires real cookies, modifies real data + async fn test_rename_character() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Steampunk Clown (renamed)".to_string(), + description: "A steampunk clown character for testing".to_string(), + host_override: None, + }).await?; + + println!("Success: {}", result.success); + assert!(result.success); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, modifies real data + async fn test_rename_character_clear_description() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Steampunk Clown".to_string(), + description: "".to_string(), + host_override: None, + }).await?; + + println!("Success: {}", result.success); + assert!(result.success); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, modifies real data + async fn test_rename_and_restore() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Rename + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Temporary Name".to_string(), + description: "Temporary description".to_string(), + host_override: None, + }).await?; + assert!(result.success); + + // Restore + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Steampunk Clown".to_string(), + description: "A steampunk clown".to_string(), + host_override: None, + }).await?; + assert!(result.success); + + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/upload_file/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/upload_file/mod.rs new file mode 100644 index 0000000000..e24b75ef1b --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/upload_file/mod.rs @@ -0,0 +1 @@ +pub mod upload_file; diff --git a/crates/api_clients/kinovi_web_client/src/requests/upload_file/upload_file.rs b/crates/api_clients/kinovi_web_client/src/requests/upload_file/upload_file.rs new file mode 100644 index 0000000000..76e98a7a7d --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/upload_file/upload_file.rs @@ -0,0 +1,174 @@ +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_UPLOAD_TIMEOUT; +use log::info; +use url::Url; +use wreq::Client; +use wreq_util::Emulation; + +pub struct UploadFileArgs { + /// The signed upload URL returned by `prepare_file_upload`. + pub upload_url: String, + + /// The raw file bytes to upload. + pub file_bytes: Vec, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct UploadFileResponse { + /// The public-facing URL for the uploaded file. + /// e.g. `https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png` + pub public_url: String, +} + +/// Extracts the path from the R2 upload URL and builds the static public URL. +/// e.g. `https://comm.….r2.cloudflarestorage.com/materials/20260219/…?X-Amz-…` +/// -> `https://static.kinovi.ai/materials/20260219/…` +fn build_public_url(upload_url: &str, host: &KinoviHost) -> Result { + let parsed = Url::parse(upload_url) + .map_err(|err| KinoviWebClientError::UrlParseError(err))?; + let path = parsed.path(); // e.g. "/materials/20260219/1771463564512-b14bfe90.png" + Ok(format!("{}{}", host.cdn_base_url(), path)) +} + +pub async fn upload_file(args: UploadFileArgs) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + + info!("Uploading file to: {}", args.upload_url); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_UPLOAD_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let referer = format!("{}/", base_url); + + let response = client.put(&args.upload_url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "cross-site") + .header("Priority", "u=4") + .body(args.file_bytes) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + + info!("Upload response status: {}", status); + + if !status.is_success() { + let body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body, + }.into()); + } + + let public_url = build_public_url(&args.upload_url, &host)?; + + info!("Public URL: {}", public_url); + + Ok(UploadFileResponse { public_url }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use crate::requests::prepare_file_upload::prepare_file_upload::{ + prepare_file_upload, PrepareFileUploadArgs, + }; + use errors::AnyhowResult; + use log::LevelFilter; + use std::fs; + + #[tokio::test] + #[ignore] // manually test — requires real cookies and a test image + async fn test_upload_image_file() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + + // Step 1: Get a signed upload URL + let cookies = get_test_cookies()?; + let session = KinoviWebSession::from_cookies_string(cookies); + let prepare_args = PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }; + let prepare_result = prepare_file_upload(prepare_args).await?; + println!("Upload URL: {}", prepare_result.upload_url); + + // Step 2: Read a test image + let file_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?; + println!("File size: {} bytes", file_bytes.len()); + + // Step 3: Upload + let upload_args = UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes, + host_override: None, + }; + let result = upload_file(upload_args).await?; + println!("Public URL: {}", result.public_url); + + assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/")); + assert_eq!(1, 2); // NB: Intentional failure to check the response. + + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies and a test image + async fn test_upload_video_file_that_is_too_long() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + + // Step 1: Get a signed upload URL + let cookies = get_test_cookies()?; + let session = KinoviWebSession::from_cookies_string(cookies); + let prepare_args = PrepareFileUploadArgs { + session: &session, + extension: "mp4".to_string(), + host_override: None, + }; + let prepare_result = prepare_file_upload(prepare_args).await?; + println!("Upload URL: {}", prepare_result.upload_url); + + // Step 2: Read a test image + let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?; + println!("File size: {} bytes", file_bytes.len()); + + // Step 3: Upload + let upload_args = UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes, + host_override: None, + }; + let result = upload_file(upload_args).await?; + println!("Public URL: {}", result.public_url); + + assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/")); + assert_eq!(1, 2); // NB: Intentional failure to check the response. + + Ok(()) + } +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/mod.rs b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/mod.rs new file mode 100644 index 0000000000..01eb807c0d --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/mod.rs @@ -0,0 +1,2 @@ +pub mod workflow_run_task; +mod request_types; diff --git a/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/request_types.rs b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/request_types.rs new file mode 100644 index 0000000000..d75ec3f16e --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/request_types.rs @@ -0,0 +1,96 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize, Debug)] +pub (super) struct BatchRequestJson { + #[serde(rename = "businessType")] + pub business_type: &'static str, + #[serde(rename = "apiParams")] + pub api_params: ApiParams, +} + +#[derive(Serialize, Debug)] +pub (super) struct ApiParams { + pub prompt: String, + /// Aspect ratio as pixel dimensions (e.g. "1280x720"). Most Kinovi + /// models carry the aspect ratio here. None for models that use the + /// `aspectRatio` field instead (Seedance 2.0 Mini). + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + /// Aspect ratio as a ratio string (e.g. "16:9"). Used by Seedance 2.0 + /// Mini instead of `resolution`. None for models that use `resolution`. + #[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option<&'static str>, + #[serde(rename = "contentMode")] + pub content_mode: &'static str, + pub model: &'static str, + pub duration: String, + /// Generation mode ("keyframe" / "reference"). Omitted for models that use + /// `happyhorseMode` instead (Happy Horse). + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option<&'static str>, + /// Happy Horse generation mode ("t2v" / "i2v"). Used instead of `mode` by + /// Happy Horse; None for every other model. + #[serde(rename = "happyhorseMode", skip_serializing_if = "Option::is_none")] + pub happyhorse_mode: Option<&'static str>, + #[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")] + pub output_resolution: Option<&'static str>, + #[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")] + pub face_blur_mode: Option<&'static str>, + #[serde(rename = "characterIds", skip_serializing_if = "Option::is_none")] + pub character_ids: Option>, + /// Reference image URLs, for models that send references in split + /// `imageUrls` / `videoUrls` fields (Seedance 2.5 Preview). Those models + /// also mirror the image URLs into `uploadedUrls`. None for models that + /// send a single combined `uploadedUrls` list. + #[serde(rename = "imageUrls", skip_serializing_if = "Option::is_none")] + pub image_urls: Option>, + /// Reference video URLs, for models that send references in split + /// `imageUrls` / `videoUrls` fields (Seedance 2.5 Preview). None for + /// models that send videos in the combined `uploadedUrls` list. + #[serde(rename = "videoUrls", skip_serializing_if = "Option::is_none")] + pub video_urls: Option>, + #[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")] + pub uploaded_urls: Option>, + #[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")] + pub audio_urls: Option>, + #[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")] + pub batch_count: Option, + #[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")] + pub bitrate_mode: Option<&'static str>, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub (super) struct BatchResponseData { + pub json: TaskResponseJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub (super) struct TaskResponseJson { + pub task_id: String, + pub order_id: String, + pub task_ids: Option>, + pub order_ids: Option>, + pub violation_warning: bool, +} diff --git a/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs new file mode 100644 index 0000000000..d375de98f8 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/requests/workflow_run_task/workflow_run_task.rs @@ -0,0 +1,2120 @@ +use crate::creds::kinovi_web_session::KinoviWebSession; +use crate::error::kinovi_web_bad_request_api_error::KinoviWebBadRequestApiError; +use crate::error::kinovi_web_client_error::KinoviWebClientError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::requests::workflow_run_task::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::categorize_kinovi_web_error::categorize_kinovi_web_error; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Request args --- + +/// Wrapper that bundles a [`WorkflowRunTaskRequest`] with session and host info. +pub struct WorkflowRunTaskArgs<'a> { + pub request: WorkflowRunTaskRequest, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +/// Video generation parameters (no session/host info). +#[derive(Clone)] +pub struct WorkflowRunTaskRequest { + /// Seedance 2.0 Pro vs Fast + pub model_type: KinoviModelTypeRaw, + + pub prompt: String, + + /// The aspect ratio + /// (Kinovi terms this "resolution" in the API, confusingly.) + pub aspect_ratio: KinoviAspectRatioRaw, + + /// The resolution + /// Output resolution quality (480p, 720p, 1080p). None defaults to 720p. + /// (Kinovi terms this "outputResolution" in the API, which is confusingly named) + pub output_resolution: Option, + + /// Duration in seconds (4–15). + pub duration_seconds: u8, + + pub batch_count: KinoviBatchCountRaw, + + /// Optional start frame image URL (keyframe mode). + pub start_frame_url: Option, + + /// Optional end frame image URL (keyframe mode). + pub end_frame_url: Option, + + /// Optional reference image URLs (reference mode). + /// When present, takes priority over start/end frames. + pub reference_image_urls: Option>, + + /// Optional reference video URLs (reference mode). + /// Can be combined with reference_image_urls. + /// Videos are referenced in prompts as @video1, @video2, etc. + /// When present, takes priority over start/end frames. + pub reference_video_urls: Option>, + + /// Optional reference audio URLs (reference mode). + /// Audio is referenced in prompts as @audio1, @audio2, etc. + /// Sent in a separate `audioUrls` field (not in `uploadedUrls`). + pub reference_audio_urls: Option>, + + /// Optional Kinovi character IDs to reference in the prompt. + /// Characters are referenced in prompts as @CharacterName. + pub character_ids: Option>, + + /// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it. + pub use_face_blur_hack: Option, + + /// Output video bitrate. None defaults to "standard" (the field is omitted); + /// `High` sends `bitrate_mode: "high"`. Does not affect cost. + pub bitrate: Option, +} + +impl std::fmt::Debug for WorkflowRunTaskRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkflowRunTaskRequest") + .field("model_type", &self.model_type) + .field("prompt", &self.prompt) + .field("aspect_ratio", &self.aspect_ratio) + .field("duration_seconds", &self.duration_seconds) + .field("batch_count", &self.batch_count) + .field("start_frame_url", &self.start_frame_url) + .field("end_frame_url", &self.end_frame_url) + .field("reference_image_urls", &self.reference_image_urls) + .field("reference_video_urls", &self.reference_video_urls) + .field("reference_audio_urls", &self.reference_audio_urls) + .field("character_ids", &self.character_ids) + .field("output_resolution", &self.output_resolution) + .field("use_face_blur_hack", &self.use_face_blur_hack) + .field("bitrate", &self.bitrate) + .finish() + } +} + +impl std::fmt::Debug for WorkflowRunTaskArgs<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkflowRunTaskArgs") + .field("request", &self.request) + .field("host_override", &self.host_override) + .finish() + } +} + +// --- Public enums --- + +/// Video resolution / aspect ratio. +#[derive(Debug, Clone, Copy)] +pub enum KinoviAspectRatioRaw { + /// 16:9 landscape (1280x720) + Landscape16x9, + /// 21:9 ultra-wide (1280x540) + UltraWide21x9, + /// 9:16 portrait (720x1280) + Portrait9x16, + /// 1:1 square (720x720) + Square1x1, + /// 4:3 standard (960x720) + Landscape4x3, + /// 3:4 portrait (720x960) + Portrait3x4, +} + +impl KinoviAspectRatioRaw { + /// The aspect ratio as a ratio string (e.g. "16:9"), used by models that + /// send an `aspectRatio` field (Seedance 2.0 Mini) instead of `resolution`. + fn as_aspect_ratio_str(&self) -> &'static str { + match self { + Self::Landscape16x9 => "16:9", + Self::UltraWide21x9 => "21:9", + Self::Portrait9x16 => "9:16", + Self::Square1x1 => "1:1", + Self::Landscape4x3 => "4:3", + Self::Portrait3x4 => "3:4", + } + } +} + +/// Output resolution quality. Always sent explicitly on the wire — we never +/// omit the field and rely on the Kinovi server-side default, because the +/// billed price assumes a specific resolution and the two must not diverge. +#[derive(Debug, Clone, Copy)] +pub enum KinoviOutputResolutionRaw { + /// 480p + FourEightyP, + /// 720p (what unset requests resolve to) + SevenTwentyP, + /// 1080p + TenEightyP, + /// 4K. Only supported by Seedance 2.0 (Pro). Seedance 2.0 Fast does NOT + /// offer 4K — requesting it for Fast is not valid upstream. + FourK, +} + +impl KinoviOutputResolutionRaw { + /// The API string to send. + pub fn as_api_str(&self) -> &'static str { + match self { + Self::FourEightyP => "480p", + Self::SevenTwentyP => "720p", + Self::TenEightyP => "1080p", + Self::FourK => "4k", + } + } +} + +/// Number of videos to generate in a single request. +#[derive(Debug, Clone, Copy)] +pub enum KinoviBatchCountRaw { + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, +} + +impl KinoviBatchCountRaw { + fn as_u8(&self) -> u8 { + match self { + Self::One => 1, + Self::Two => 2, + Self::Three => 3, + Self::Four => 4, + Self::Five => 5, + Self::Six => 6, + Self::Seven => 7, + Self::Eight => 8, + } + } +} + +/// The model variant to use. +#[derive(Debug, Clone, Copy)] +pub enum KinoviModelTypeRaw { + /// Seedance 2.0 Pro (higher quality, slower). + Seedance2Pro, + /// Seedance 2.0 Fast (lower quality, faster). + Seedance2Fast, + /// Seedance 2.0 Mini (cheapest; 480p/720p only). + Seedance2Mini, + /// Seedance 2.5 Preview (480p/720p only). + Seedance2p5Preview, + /// Seedance 2.5 (480p/720p only). + Seedance2p5, + /// Happy Horse 1.0. + HappyHorse1p0, +} + +impl KinoviModelTypeRaw { + fn as_api_str(&self) -> &'static str { + match self { + Self::Seedance2Pro => "seedance-20", + Self::Seedance2Fast => "seedance2-fast", + Self::Seedance2Mini => "seedance2.0-mini", + Self::Seedance2p5Preview => "seedance2-5-preview", + Self::Seedance2p5 => "seedance2-5", + Self::HappyHorse1p0 => "happyhorse1.0", + } + } + + /// The tRPC `businessType` for this model. Seedance 2.0 Mini and the 2.5 + /// family use their own business types; every other model uses the shared + /// one. (Seedance 2.5 reuses the *preview* business type on the wire — + /// only the `model` string differs.) + fn business_type(&self) -> &'static str { + match self { + Self::Seedance2Mini => "seedance20-mini-video-generation", + Self::Seedance2p5Preview | Self::Seedance2p5 => "seedance25-preview-video-generation", + Self::HappyHorse1p0 => "happyhorse-video-generation", + Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation", + } + } + + /// Whether the aspect ratio is sent in an `aspectRatio` field (true) + /// rather than the `resolution` field. Mini, the 2.5 family, and Happy + /// Horse use `aspectRatio`. + fn uses_aspect_ratio_field(&self) -> bool { + matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::Seedance2p5 | Self::HappyHorse1p0) + } + + /// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v) + /// instead of the standard `mode` (keyframe/reference). + fn uses_happyhorse_mode(&self) -> bool { + matches!(self, Self::HappyHorse1p0) + } + + /// Whether reference URLs are sent in split `imageUrls` / `videoUrls` + /// fields — with `uploadedUrls` mirroring just the images — rather than + /// one combined `uploadedUrls` list. The 2.5 family uses the split shape. + /// (In 2.5 keyframe mode the start/end frames go in `imageUrls` with + /// `uploadedUrls` mirroring them.) + fn uses_split_reference_url_fields(&self) -> bool { + matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5) + } + + /// Whether `mode` is always "reference", even with no references attached. + /// Seedance 2.5 Preview has no keyframe mode. + fn always_uses_reference_mode(&self) -> bool { + matches!(self, Self::Seedance2p5Preview) + } + + /// Whether `mode` is "keyframe" only when a start/end frame is attached, + /// and "reference" otherwise (including plain text-to-video). Seedance 2.5 + /// sends text-to-video as mode "reference". + fn uses_reference_mode_unless_keyframes(&self) -> bool { + matches!(self, Self::Seedance2p5) + } + + /// Whether keyframe mode omits the aspect ratio entirely: Seedance 2.5 + /// keyframe (image-to-video) only supports "Adaptive", which is expressed + /// by sending no `aspectRatio` field at all. + fn omits_aspect_ratio_in_keyframe_mode(&self) -> bool { + matches!(self, Self::Seedance2p5) + } + + /// Whether the default `faceBlurMode: "off"` is always sent explicitly + /// rather than omitted. The 2.5 family always sends it. + /// (`outputResolution` is always sent explicitly for EVERY model — see + /// `build_batch_request` — so it is no longer governed by this flag.) + fn always_sends_default_fields(&self) -> bool { + matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5) + } +} + +/// Output video bitrate. When omitted, defaults to "standard". +#[derive(Debug, Clone, Copy)] +pub enum KinoviBitrateRaw { + /// High bitrate (`bitrate_mode: "high"`). + High, +} + +impl KinoviBitrateRaw { + /// Returns the API string to send, or None for "standard" (the default, + /// which is expressed by omitting the field entirely). + pub fn as_api_str(&self) -> Option<&'static str> { + match self { + Self::High => Some("high"), + } + } +} + +// --- Response --- + +pub struct WorkflowRunTaskResponse { + pub task_id: String, + + pub order_id: String, + + /// Present when batch_count > 1. + pub task_ids: Option>, + + /// Present when batch_count > 1. + pub order_ids: Option>, +} + +// --- Implementation --- + +pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result { + let req = args.request; + + info!("Requesting video from Seedance2Pro (v2): {:?}", req); + + let request_body = build_batch_request(req); + + info!("KinoviWeb request (v2): {:?}", request_body); + + send_run_task_request(args.session, args.host_override, &request_body).await +} + +/// Run a `workflow.runTask` call whose `apiParams` shape differs from the +/// standard video request — e.g. the Suno audio models, which carry their own +/// parameter sets. The caller provides the tRPC `businessType` and a +/// serializable `apiParams` payload; the HTTP plumbing and response handling +/// are shared with [`workflow_run_task`]. +pub async fn workflow_run_task_custom( + args: WorkflowRunTaskCustomArgs<'_, T>, +) -> Result { + info!( + "Requesting {} from Seedance2Pro (custom): {:?}", + args.business_type, args.api_params, + ); + + let request_body = serde_json::json!({ + "0": { + "json": { + "businessType": args.business_type, + "apiParams": args.api_params, + } + } + }); + + send_run_task_request(args.session, args.host_override, &request_body).await +} + +/// Bundle for [`workflow_run_task_custom`]. +pub struct WorkflowRunTaskCustomArgs<'a, T: serde::Serialize + std::fmt::Debug> { + /// The tRPC `businessType` discriminator (e.g. "suno-music-generation"). + pub business_type: &'static str, + pub api_params: T, + pub session: &'a KinoviWebSession, + pub host_override: Option, +} + +async fn send_run_task_request( + session: &KinoviWebSession, + host_override: Option, + request_body: &B, +) -> Result { + let host = resolve_host(host_override.as_ref()); + let base_url = host.api_base_url(); + let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url); + + let cookie = session.cookies.as_str(); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .timeout(KINOVI_REQUEST_TIMEOUT) + .build() + .map_err(|err| KinoviWebClientError::WreqClientError(err))?; + + let referer = format!("{}/", base_url); + + let response = client.post(&run_task_url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(request_body) + .send() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| KinoviWebGenericApiError::WreqError(err))?; + + info!("Response status: {}, body: {}", status, response_body); + + if !status.is_success() { + return Err(categorize_kinovi_web_error(status, response_body)); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let task_data = batch_response + .into_iter() + .next() + .ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.clone(), + })? + .result + .data + .json; + + if task_data.violation_warning { + return Err(KinoviWebBadRequestApiError::VideoGenerationViolation { raw_body: response_body }.into()); + } + + Ok(WorkflowRunTaskResponse { + task_id: task_data.task_id, + order_id: task_data.order_id, + task_ids: task_data.task_ids, + order_ids: task_data.order_ids, + }) +} + +/// Build the tRPC request body for a workflow run-task call. +/// +/// Most models carry the aspect ratio as pixel dimensions in `resolution`. +/// Seedance 2.0 Mini instead sends a ratio string in an `aspectRatio` field +/// and a different `businessType`. +fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest { + let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty()); + let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty()); + let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty()); + let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty()); + + let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters; + + let has_keyframes = req.start_frame_url.is_some() || req.end_frame_url.is_some(); + + let video_input_mode = if req.model_type.always_uses_reference_mode() { + "reference" + } else if req.model_type.uses_reference_mode_unless_keyframes() { + // Seedance 2.5: text-to-video is sent as "reference"; "keyframe" only + // when a start/end frame is attached. + if has_keyframes { "keyframe" } else { "reference" } + } else if is_reference_mode { + "reference" + } else { + "keyframe" + }; + + // Reference URLs. The 2.5 family sends split `imageUrls` / `videoUrls` + // fields with `uploadedUrls` mirroring just the images (in 2.5 keyframe + // mode the start/end frames ride in `imageUrls` + `uploadedUrls`); other + // models combine videos + images into `uploadedUrls` in reference mode, or + // send the start/end frames there in keyframe mode. + let (image_urls, video_urls, uploaded_urls): (Option>, Option>, Option>) = if req.model_type.uses_split_reference_url_fields() { + if video_input_mode == "keyframe" { + let mut urls = Vec::new(); + if let Some(url) = req.start_frame_url { + urls.push(url); + } + if let Some(url) = req.end_frame_url { + urls.push(url); + } + let urls = if urls.is_empty() { None } else { Some(urls) }; + (urls.clone(), None, urls) + } else { + let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty()); + let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty()); + (image_urls.clone(), video_urls, image_urls) + } + } else if is_reference_mode { + let mut urls = Vec::new(); + if let Some(video_urls) = req.reference_video_urls { + urls.extend(video_urls); + } + if let Some(image_urls) = req.reference_image_urls { + urls.extend(image_urls); + } + (None, None, if urls.is_empty() { None } else { Some(urls) }) + } else { + let mut urls = Vec::new(); + if let Some(url) = req.start_frame_url { + urls.push(url); + } + if let Some(url) = req.end_frame_url { + urls.push(url); + } + (None, None, if urls.is_empty() { None } else { Some(urls) }) + }; + + let audio_urls: Option> = if has_reference_audio { + req.reference_audio_urls + } else { + None + }; + + let face_blur_mode = match req.use_face_blur_hack { + Some(true) => Some("on"), + Some(false) => Some("off"), + None if req.model_type.always_sends_default_fields() => Some("off"), + None => None, + }; + + // ALWAYS send the resolution explicitly, defaulting unset to 720p. We used + // to omit the field for 720p (mimicking the Kinovi web UI) and let the + // server default apply, but that let the supplier-side resolution — and + // therefore the supplier cost — float free of what we billed. Pin it. + let output_resolution = { + let resolution = req.output_resolution.unwrap_or(KinoviOutputResolutionRaw::SevenTwentyP); + Some(resolution.as_api_str()) + }; + + let batch_count_value = req.batch_count.as_u8(); + let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None }; + + let duration = format!("{}s", req.duration_seconds); + + // The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast + // carry it in the `resolution` field; Mini, the 2.5 family, and Happy Horse + // use `aspectRatio`. Seedance 2.5 keyframe mode only supports "Adaptive", + // which is expressed by omitting the field entirely. + let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str(); + let (resolution, aspect_ratio) = if video_input_mode == "keyframe" && req.model_type.omits_aspect_ratio_in_keyframe_mode() { + (None, None) + } else if req.model_type.uses_aspect_ratio_field() { + (None, Some(aspect_ratio_value)) + } else { + (Some(aspect_ratio_value.to_string()), None) + }; + + // Happy Horse uses `happyhorseMode` (t2v/i2v) instead of the standard `mode` + // (keyframe/reference). i2v applies whenever an input image/video is attached. + let (mode, happyhorse_mode) = if req.model_type.uses_happyhorse_mode() { + let hh = if uploaded_urls.is_some() { "i2v" } else { "t2v" }; + (None, Some(hh)) + } else { + (Some(video_input_mode), None) + }; + + info!( + "Generating video (v2): mode={}, model={}, duration={}, batch={}", + video_input_mode, req.model_type.as_api_str(), duration, batch_count_value + ); + + BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + business_type: req.model_type.business_type(), + api_params: ApiParams { + prompt: req.prompt, + resolution, + aspect_ratio, + content_mode: "normal", + model: req.model_type.as_api_str(), + duration, + mode, + happyhorse_mode, + output_resolution, + face_blur_mode, + character_ids: req.character_ids, + image_urls, + video_urls, + uploaded_urls, + audio_urls, + batch_count, + bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()), + }, + }, + }, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use super::*; + use crate::creds::kinovi_web_session::KinoviWebSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; + use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; + + // ── Bitrate serialization ── + // + // The optional `bitrate_mode` field is sent only when `High` is requested; + // the standard (default) bitrate omits the field entirely. + + mod bitrate_tests { + use super::*; + + #[test] + fn high_bitrate_serializes_to_high() { + assert_eq!(KinoviBitrateRaw::High.as_api_str(), Some("high")); + + let api_params = base_api_params(Some("high")); + let json = serde_json::to_string(&api_params).unwrap(); + assert!(json.contains(r#""bitrate_mode":"high""#), "expected bitrate_mode in {json}"); + } + + #[test] + fn standard_bitrate_omits_field() { + let api_params = base_api_params(None); + let json = serde_json::to_string(&api_params).unwrap(); + assert!(!json.contains("bitrate_mode"), "expected no bitrate_mode in {json}"); + } + + fn base_api_params(bitrate_mode: Option<&'static str>) -> ApiParams { + ApiParams { + prompt: "a corgi".to_string(), + resolution: Some("16:9".to_string()), + aspect_ratio: None, + content_mode: "normal", + model: "seedance-20", + duration: "5s".to_string(), + mode: Some("keyframe"), + happyhorse_mode: None, + output_resolution: None, + face_blur_mode: None, + character_ids: None, + image_urls: None, + video_urls: None, + uploaded_urls: None, + audio_urls: None, + batch_count: None, + bitrate_mode, + } + } + } + + // ── Output-resolution serialization ── + // + // `outputResolution` is always sent explicitly; 720p serializes to the + // literal "720p" and 4K to the literal "4k". + + mod output_resolution_serialization_tests { + use super::*; + + #[test] + fn four_k_maps_to_4k() { + assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), "4k"); + } + + #[test] + fn four_k_serializes_on_the_wire() { + let api_params = base_api_params(Some(KinoviOutputResolutionRaw::FourK.as_api_str())); + let json = serde_json::to_string(&api_params).unwrap(); + assert!(json.contains(r#""outputResolution":"4k""#), "expected outputResolution in {json}"); + } + + #[test] + fn default_720p_serializes_explicitly() { + assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), "720p"); + let api_params = base_api_params(Some(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str())); + let json = serde_json::to_string(&api_params).unwrap(); + assert!(json.contains(r#""outputResolution":"720p""#), "expected outputResolution in {json}"); + } + + fn base_api_params(output_resolution: Option<&'static str>) -> ApiParams { + ApiParams { + prompt: "a corgi".to_string(), + resolution: Some("16:9".to_string()), + aspect_ratio: None, + content_mode: "normal", + model: "seedance-20", + duration: "5s".to_string(), + mode: Some("reference"), + happyhorse_mode: None, + output_resolution, + face_blur_mode: None, + character_ids: None, + image_urls: None, + video_urls: None, + uploaded_urls: None, + audio_urls: None, + batch_count: None, + bitrate_mode: None, + } + } + } + + // ── Seedance 2.0 Mini request shape ── + // + // Mini differs from the other models on the wire: a + // `seedance20-mini-video-generation` businessType, a `seedance2.0-mini` + // model, and an `aspectRatio` ratio string (e.g. "16:9") in place of the + // pixel-dimension `resolution` field. + + mod mini_request_shape_tests { + use super::*; + + fn mini_request( + aspect_ratio: KinoviAspectRatioRaw, + output_resolution: Option, + batch_count: KinoviBatchCountRaw, + ) -> WorkflowRunTaskRequest { + WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Mini, + prompt: "a corgi".to_string(), + aspect_ratio, + output_resolution, + duration_seconds: 5, + batch_count, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + #[test] + fn mini_uses_aspect_ratio_field_and_business_type() { + let body = build_batch_request(mini_request( + KinoviAspectRatioRaw::Landscape4x3, + Some(KinoviOutputResolutionRaw::FourEightyP), + KinoviBatchCountRaw::One, + )); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"seedance20-mini-video-generation""#), "{json}"); + assert!(json.contains(r#""model":"seedance2.0-mini""#), "{json}"); + assert!(json.contains(r#""aspectRatio":"4:3""#), "{json}"); + assert!(json.contains(r#""outputResolution":"480p""#), "{json}"); + // Mini does NOT send the pixel-dimension `resolution` field. + assert!(!json.contains(r#""resolution":"#), "{json}"); + } + + #[test] + fn mini_unset_resolution_sends_explicit_720p() { + let body = build_batch_request(mini_request( + KinoviAspectRatioRaw::Landscape16x9, + None, + KinoviBatchCountRaw::One, + )); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}"); + assert!(json.contains(r#""outputResolution":"720p""#), "{json}"); + } + + #[test] + fn mini_batch_count_eight_serializes() { + let body = build_batch_request(mini_request( + KinoviAspectRatioRaw::Landscape16x9, + None, + KinoviBatchCountRaw::Eight, + )); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""batchCount":8"#), "{json}"); + } + + #[test] + fn non_mini_still_uses_resolution_field() { + let mut req = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, KinoviBatchCountRaw::One); + req.model_type = KinoviModelTypeRaw::Seedance2Pro; + let body = build_batch_request(req); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"wan22-video-generation""#), "{json}"); + assert!(json.contains(r#""resolution":"16:9""#), "{json}"); + assert!(!json.contains("aspectRatio"), "{json}"); + } + + #[test] + fn model_strings_and_business_types() { + assert_eq!(KinoviModelTypeRaw::Seedance2Mini.as_api_str(), "seedance2.0-mini"); + assert_eq!(KinoviModelTypeRaw::Seedance2Mini.business_type(), "seedance20-mini-video-generation"); + assert_eq!(KinoviModelTypeRaw::Seedance2Pro.business_type(), "wan22-video-generation"); + assert_eq!(KinoviModelTypeRaw::Seedance2Fast.business_type(), "wan22-video-generation"); + assert_eq!(KinoviModelTypeRaw::HappyHorse1p0.business_type(), "happyhorse-video-generation"); + } + + #[test] + fn aspect_ratio_strings() { + assert_eq!(KinoviAspectRatioRaw::Landscape16x9.as_aspect_ratio_str(), "16:9"); + assert_eq!(KinoviAspectRatioRaw::UltraWide21x9.as_aspect_ratio_str(), "21:9"); + assert_eq!(KinoviAspectRatioRaw::Portrait9x16.as_aspect_ratio_str(), "9:16"); + assert_eq!(KinoviAspectRatioRaw::Square1x1.as_aspect_ratio_str(), "1:1"); + assert_eq!(KinoviAspectRatioRaw::Landscape4x3.as_aspect_ratio_str(), "4:3"); + assert_eq!(KinoviAspectRatioRaw::Portrait3x4.as_aspect_ratio_str(), "3:4"); + } + } + + // ── Seedance 2.5 Preview request shape ── + // + // 2.5 Preview differs from the other models on the wire: a + // `seedance25-preview-video-generation` businessType, a + // `seedance2-5-preview` model, an `aspectRatio` ratio string, `mode` always + // "reference" (even with no references), `outputResolution` and + // `faceBlurMode` always sent (including their "720p" / "off" defaults), and + // references split into `imageUrls` / `videoUrls` with `uploadedUrls` + // mirroring just the images. + + mod seedance_2p5_preview_shape_tests { + use super::*; + + fn preview_request( + output_resolution: Option, + reference_image_urls: Option>, + reference_video_urls: Option>, + reference_audio_urls: Option>, + ) -> WorkflowRunTaskRequest { + WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2p5Preview, + prompt: "A man is running from a t-rex".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + output_resolution, + duration_seconds: 4, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + #[test] + fn text_to_video_matches_observed_request() { + // Mirrors external/requests/sites/kinovi.ai/2026-07-31-seedance2p5/1_seedance2p5.txt. + let body = build_batch_request(preview_request( + Some(KinoviOutputResolutionRaw::FourEightyP), None, None, None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}"); + assert!(json.contains(r#""model":"seedance2-5-preview""#), "{json}"); + assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}"); + assert!(json.contains(r#""duration":"4s""#), "{json}"); + assert!(json.contains(r#""outputResolution":"480p""#), "{json}"); + assert!(json.contains(r#""contentMode":"normal""#), "{json}"); + // 2.5 Preview does NOT send the ratio-string `resolution` field. + assert!(!json.contains(r#""resolution":"#), "{json}"); + // No references attached: no URL fields at all. + assert!(!json.contains("imageUrls"), "{json}"); + assert!(!json.contains("videoUrls"), "{json}"); + assert!(!json.contains("uploadedUrls"), "{json}"); + assert!(!json.contains("audioUrls"), "{json}"); + } + + #[test] + fn mode_is_reference_even_without_references() { + let body = build_batch_request(preview_request(None, None, None, None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""mode":"reference""#), "{json}"); + assert!(!json.contains("keyframe"), "{json}"); + } + + #[test] + fn default_720p_and_face_blur_off_are_sent_explicitly() { + let body = build_batch_request(preview_request(None, None, None, None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""outputResolution":"720p""#), "{json}"); + assert!(json.contains(r#""faceBlurMode":"off""#), "{json}"); + } + + #[test] + fn image_references_fill_image_urls_and_uploaded_urls() { + let images = vec![ + "https://static.seedance2-pro.com/materials/a.jpg".to_string(), + "https://static.seedance2-pro.com/materials/b.png".to_string(), + ]; + let body = build_batch_request(preview_request( + Some(KinoviOutputResolutionRaw::SevenTwentyP), Some(images), None, None)); + let json = serde_json::to_string(&body).unwrap(); + let expected_urls = r#"["https://static.seedance2-pro.com/materials/a.jpg","https://static.seedance2-pro.com/materials/b.png"]"#; + assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}"); + // `uploadedUrls` mirrors the images. + assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}"); + assert!(!json.contains("videoUrls"), "{json}"); + } + + #[test] + fn video_and_audio_references_use_their_own_fields() { + // Mirrors 3_seedance2p5_third.txt: images + video + audio. The video + // and audio URLs are NOT mirrored into `uploadedUrls`. + let body = build_batch_request(preview_request( + Some(KinoviOutputResolutionRaw::FourEightyP), + Some(vec!["https://static.seedance2-pro.com/materials/img.jpg".to_string()]), + Some(vec!["https://static.seedance2-pro.com/materials/dog.mp4".to_string()]), + Some(vec!["https://static.seedance2-pro.com/materials/bark.wav".to_string()]), + )); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""imageUrls":["https://static.seedance2-pro.com/materials/img.jpg"]"#), "{json}"); + assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/dog.mp4"]"#), "{json}"); + assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/bark.wav"]"#), "{json}"); + assert!(json.contains(r#""uploadedUrls":["https://static.seedance2-pro.com/materials/img.jpg"]"#), "{json}"); + } + + #[test] + fn empty_reference_lists_send_no_url_fields() { + let body = build_batch_request(preview_request( + None, Some(vec![]), Some(vec![]), None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(!json.contains("imageUrls"), "{json}"); + assert!(!json.contains("videoUrls"), "{json}"); + assert!(!json.contains("uploadedUrls"), "{json}"); + } + + #[test] + fn model_string_and_business_type() { + assert_eq!(KinoviModelTypeRaw::Seedance2p5Preview.as_api_str(), "seedance2-5-preview"); + assert_eq!(KinoviModelTypeRaw::Seedance2p5Preview.business_type(), "seedance25-preview-video-generation"); + } + } + + // ── Seedance 2.5 request shape ── + // + // Seedance 2.5 reuses the *preview* businessType + // (`seedance25-preview-video-generation`) with model `seedance2-5`. Unlike + // Preview it has a keyframe (image-to-video) mode: `mode` is "keyframe" + // only when a start/end frame is attached (text-to-video goes out as + // "reference"), keyframe requests omit `aspectRatio` entirely (the only + // choice is "Adaptive"), and the frames ride in `imageUrls` with + // `uploadedUrls` mirroring them. Reference mode matches Preview's split + // URL shape. Mirrors + // external/requests/sites/kinovi.ai/2026-08-07-seedance2p5/. + + mod seedance_2p5_shape_tests { + use super::*; + + fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest { + WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2p5, + prompt: prompt.to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + output_resolution: Some(KinoviOutputResolutionRaw::FourEightyP), + duration_seconds, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + #[test] + fn text_to_video_matches_observed_request() { + // Mirrors 1_request_text_to_video.txt: mode "reference", aspectRatio + // "16:9", 480p, 5s, no URL fields. + let body = build_batch_request(base_request("Lightning hits a building", 5)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}"); + assert!(json.contains(r#""model":"seedance2-5""#), "{json}"); + assert!(json.contains(r#""mode":"reference""#), "{json}"); + assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}"); + assert!(json.contains(r#""duration":"5s""#), "{json}"); + assert!(json.contains(r#""outputResolution":"480p""#), "{json}"); + assert!(json.contains(r#""faceBlurMode":"off""#), "{json}"); + assert!(json.contains(r#""contentMode":"normal""#), "{json}"); + // No ratio-string `resolution` field, and no URL fields. + assert!(!json.contains(r#""resolution":"#), "{json}"); + assert!(!json.contains("imageUrls"), "{json}"); + assert!(!json.contains("videoUrls"), "{json}"); + assert!(!json.contains("uploadedUrls"), "{json}"); + assert!(!json.contains("audioUrls"), "{json}"); + } + + #[test] + fn keyframe_with_both_frames_matches_observed_request() { + // Mirrors 2_request_keyframe_to_video.txt: mode "keyframe", NO + // aspectRatio (Adaptive), frames in imageUrls + uploadedUrls. + let mut request = base_request("Car drives into the sunset", 8); + request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.png".to_string()); + request.end_frame_url = Some("https://static.seedance2-pro.com/materials/end.png".to_string()); + + let body = build_batch_request(request); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""mode":"keyframe""#), "{json}"); + // Adaptive: neither the `aspectRatio` nor `resolution` field is sent. + assert!(!json.contains("aspectRatio"), "{json}"); + assert!(!json.contains(r#""resolution":"#), "{json}"); + let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.png","https://static.seedance2-pro.com/materials/end.png"]"#; + assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}"); + assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}"); + assert!(!json.contains("videoUrls"), "{json}"); + } + + #[test] + fn keyframe_with_start_frame_only_matches_observed_request() { + // Mirrors 4_request_keyframe_to_video_2.txt: a single start frame. + let mut request = base_request("The cars drive by", 5); + request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.jpg".to_string()); + + let body = build_batch_request(request); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""mode":"keyframe""#), "{json}"); + assert!(!json.contains("aspectRatio"), "{json}"); + let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.jpg"]"#; + assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}"); + assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}"); + } + + #[test] + fn reference_with_all_reference_types_matches_observed_request() { + // Mirrors 3_request_reference_to_video.txt: mode "reference", + // aspectRatio "21:9", split imageUrls / videoUrls / audioUrls with + // uploadedUrls mirroring just the images. + let mut request = base_request("The t-rex @image1 eats the banana @image2", 8); + request.aspect_ratio = KinoviAspectRatioRaw::UltraWide21x9; + request.reference_image_urls = Some(vec![ + "https://static.seedance2-pro.com/materials/a.png".to_string(), + "https://static.seedance2-pro.com/materials/b.jpg".to_string(), + ]); + request.reference_video_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.mp4".to_string()]); + request.reference_audio_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.wav".to_string()]); + + let body = build_batch_request(request); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""mode":"reference""#), "{json}"); + assert!(json.contains(r#""aspectRatio":"21:9""#), "{json}"); + let expected_images = r#"["https://static.seedance2-pro.com/materials/a.png","https://static.seedance2-pro.com/materials/b.jpg"]"#; + assert!(json.contains(&format!(r#""imageUrls":{expected_images}"#)), "{json}"); + assert!(json.contains(&format!(r#""uploadedUrls":{expected_images}"#)), "{json}"); + assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/ref.mp4"]"#), "{json}"); + assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}"); + } + + #[test] + fn text_to_video_1080p_matches_observed_request() { + // Mirrors external/requests/sites/kinovi.ai/2026-08-17-seedance2p5-1080p/ + // 1_seedance2p5_1080p.txt: mode "reference", aspectRatio "16:9", 4s, + // outputResolution "1080p". + let mut request = base_request("A car driving on the beach", 4); + request.output_resolution = Some(KinoviOutputResolutionRaw::TenEightyP); + + let body = build_batch_request(request); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}"); + assert!(json.contains(r#""model":"seedance2-5""#), "{json}"); + assert!(json.contains(r#""mode":"reference""#), "{json}"); + assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}"); + assert!(json.contains(r#""duration":"4s""#), "{json}"); + assert!(json.contains(r#""outputResolution":"1080p""#), "{json}"); + assert!(json.contains(r#""faceBlurMode":"off""#), "{json}"); + assert!(json.contains(r#""contentMode":"normal""#), "{json}"); + } + + #[test] + fn default_720p_and_face_blur_off_are_sent_explicitly() { + let mut request = base_request("a corgi", 5); + request.output_resolution = None; + + let body = build_batch_request(request); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""outputResolution":"720p""#), "{json}"); + assert!(json.contains(r#""faceBlurMode":"off""#), "{json}"); + } + + #[test] + fn model_string_and_business_type() { + assert_eq!(KinoviModelTypeRaw::Seedance2p5.as_api_str(), "seedance2-5"); + assert_eq!(KinoviModelTypeRaw::Seedance2p5.business_type(), "seedance25-preview-video-generation"); + } + } + + // ── 2026-06-24 request-shape change ── + // + // The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it + // in `resolution`; Happy Horse moved to its own `businessType`, an + // `aspectRatio` field, and a `happyhorseMode` (t2v/i2v) in place of `mode`. + + mod wire_shape_change_tests { + use super::*; + + fn request( + model_type: KinoviModelTypeRaw, + aspect_ratio: KinoviAspectRatioRaw, + start_frame_url: Option, + ) -> WorkflowRunTaskRequest { + WorkflowRunTaskRequest { + model_type, + prompt: "a corgi".to_string(), + aspect_ratio, + output_resolution: None, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + } + } + + #[test] + fn seedance_pro_resolution_is_a_ratio_string() { + let body = build_batch_request(request( + KinoviModelTypeRaw::Seedance2Pro, KinoviAspectRatioRaw::Landscape16x9, None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"wan22-video-generation""#), "{json}"); + assert!(json.contains(r#""resolution":"16:9""#), "{json}"); + assert!(json.contains(r#""mode":"keyframe""#), "{json}"); + assert!(!json.contains("aspectRatio"), "{json}"); + assert!(!json.contains("1280x720"), "{json}"); + assert!(!json.contains("happyhorseMode"), "{json}"); + } + + #[test] + fn seedance_fast_resolution_is_a_ratio_string() { + let body = build_batch_request(request( + KinoviModelTypeRaw::Seedance2Fast, KinoviAspectRatioRaw::Portrait3x4, None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""model":"seedance2-fast""#), "{json}"); + assert!(json.contains(r#""resolution":"3:4""#), "{json}"); + } + + #[test] + fn happy_horse_text_to_video_shape() { + let body = build_batch_request(request( + KinoviModelTypeRaw::HappyHorse1p0, KinoviAspectRatioRaw::Portrait9x16, None)); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""businessType":"happyhorse-video-generation""#), "{json}"); + assert!(json.contains(r#""model":"happyhorse1.0""#), "{json}"); + assert!(json.contains(r#""happyhorseMode":"t2v""#), "{json}"); + assert!(json.contains(r#""aspectRatio":"9:16""#), "{json}"); + // Happy Horse omits the standard `mode` and the `resolution` field. + assert!(!json.contains(r#""mode":"#), "{json}"); + assert!(!json.contains(r#""resolution":"#), "{json}"); + } + + #[test] + fn happy_horse_image_to_video_uses_i2v() { + let body = build_batch_request(request( + KinoviModelTypeRaw::HappyHorse1p0, KinoviAspectRatioRaw::Landscape16x9, + Some("https://example.com/start.png".to_string()))); + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains(r#""happyhorseMode":"i2v""#), "{json}"); + assert!(json.contains(r#""uploadedUrls":["https://example.com/start.png"]"#), "{json}"); + assert!(!json.contains(r#""mode":"#), "{json}"); + } + + #[test] + fn business_types() { + assert_eq!(KinoviModelTypeRaw::Seedance2Pro.business_type(), "wan22-video-generation"); + assert_eq!(KinoviModelTypeRaw::Seedance2Fast.business_type(), "wan22-video-generation"); + assert_eq!(KinoviModelTypeRaw::Seedance2Mini.business_type(), "seedance20-mini-video-generation"); + assert_eq!(KinoviModelTypeRaw::HappyHorse1p0.business_type(), "happyhorse-video-generation"); + } + } + + mod real_requests { + use super::*; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + #[tokio::test] + #[ignore] + async fn test_generate_text_to_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "A corgi eating a cake in a fancy kitchen.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Square1x1, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_generate_keyframe_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_generate_reference_image_video() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps walks in front of the desk and barks.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape4x3, + duration_seconds: 10, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(), + "https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(), + ]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_generate_reference_video_only() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "Change the Video @video1 to night time.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_generate_reference_video_and_image() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "Put the robot in @video1 next to the house in @image1".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773595053724-07a1d500.png".to_string(), + ]), + reference_video_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(), + ]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_video_ref_file_that_is_too_long() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + + let cookies = get_test_cookies()?; + let session = KinoviWebSession::from_cookies_string(cookies); + let prepare_args = PrepareFileUploadArgs { + session: &session, + extension: "mp4".to_string(), + host_override: None, + }; + let prepare_result = prepare_file_upload(prepare_args).await?; + println!("Upload URL: {}", prepare_result.upload_url); + + let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?; + println!("File size: {} bytes", file_bytes.len()); + + let upload_args = UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes, + host_override: None, + }; + let result = upload_file(upload_args).await?; + println!("Public URL: {}", result.public_url); + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "Change @video1 to night time".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: Some(vec![result.public_url]), + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert_eq!(1, 2); + + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_pro_keyframe_with_start_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded start frame: {}", upload_result.public_url); + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "The corgi dog watches the lake.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Portrait9x16, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: Some(upload_result.public_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_fast_keyframe_with_start_frame() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded start frame: {}", upload_result.public_url); + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Fast, + prompt: "A corgi dog runs along the lake shore, splashing water. Camera follows.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: Some(upload_result.public_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_fast_three_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let image_urls_to_upload = [ + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL, + test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL, + ]; + + let mut uploaded_urls = Vec::new(); + for (i, source_url) in image_urls_to_upload.iter().enumerate() { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes(source_url).await?; + let ext = if source_url.ends_with(".png") { "png" } else { "jpg" }; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: ext.to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded ref image {}: {}", i + 1, upload_result.public_url); + uploaded_urls.push(upload_result.public_url); + } + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Fast, + prompt: "The dog in @1 is running through the scenery in @3 towards the building in @2. Golden hour lighting.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(uploaded_urls), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_fast_audio_reference_with_text() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let audio_path = test_utils::test_file_path::test_file_path( + "test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3", + )?; + let audio_bytes = fs::read(&audio_path)?; + println!("Audio file size: {} bytes", audio_bytes.len()); + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "mp3".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: audio_bytes, + host_override: None, + }).await?; + + println!("Uploaded audio: {}", upload_result.public_url); + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Fast, + prompt: "A fantasy forest with mushrooms glowing in the dark. Fireflies dance between the trees. A small character walks along a winding path.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: Some(vec![upload_result.public_url]), + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + mod character_tests { + use super::*; + + const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te"; + const MOCHI_ID: &str = "char_1775177718294_g2pitx"; + + #[tokio::test] + #[ignore] + async fn test_text_prompt_with_character_pro() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "@Steampunk Clown is juggling flaming torches in a circus tent.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_text_prompt_with_character_fast() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Fast, + prompt: "@Mochi the female shiba inu is eating a cheese pizza while standing on the table".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Portrait9x16, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![MOCHI_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_character_with_image_ref_pro() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "@Steampunk Clown is walking up to pet a dog on the couch.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(vec![ + "https://static.seedance2-pro.com/materials/20260329/1774752385699-1ff44886.jpeg".to_string(), + ]), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]), + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_two_characters_fast() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Fast, + prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: Some(vec![ + STEAMPUNK_CLOWN_ID.to_string(), + MOCHI_ID.to_string(), + ]), + use_face_blur_hack: None, + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Task ID: {}", result.task_id); + println!("Order ID: {}", result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + + mod happy_horse_tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_happy_horse_text_to_video_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::HappyHorse1p0, + prompt: "A corgi and shiba are in a bamboo forest. They are samurai battling one anotherplaying chess against one another".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 4, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: Some(false), + bitrate: None, + output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP), + }, + }; + let result = workflow_run_task(args).await?; + println!("Happy Horse t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_happy_horse_keyframe_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded start frame: {}", upload_result.public_url); + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::HappyHorse1p0, + prompt: "The corgi dog watches the lake as the sun sets.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Portrait9x16, + duration_seconds: 8, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: Some(upload_result.public_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: Some(false), + bitrate: None, + output_resolution: None, + }, + }; + let result = workflow_run_task(args).await?; + println!("Happy Horse keyframe 720p — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_happy_horse_keyframe_1080p_square() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let image_bytes = crate::test_utils::http_download::http_download_to_bytes( + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + ).await?; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: "jpg".to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded start frame: {}", upload_result.public_url); + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::HappyHorse1p0, + prompt: "A dragon and a raptor fighting on the beach.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Square1x1, + duration_seconds: 15, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: Some(upload_result.public_url), + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: Some(false), + bitrate: None, + output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP), + }, + }; + let result = workflow_run_task(args).await?; + println!("Happy Horse keyframe 1080p square — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2); + Ok(()) + } + } + } + + mod output_resolution_tests { + use super::*; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(KinoviWebSession::from_cookies_string(cookies)) + } + + fn make_args_with_prompt<'a>( + prompt: &'a str, + session: &'a KinoviWebSession, + model_type: KinoviModelTypeRaw, + output_resolution: Option, + ) -> WorkflowRunTaskArgs<'a> { + WorkflowRunTaskArgs { + session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type, + prompt: prompt.to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 4, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + output_resolution, + use_face_blur_hack: None, + bitrate: None, + }, + } + } + + fn make_args<'a>( + session: &'a KinoviWebSession, + model_type: KinoviModelTypeRaw, + output_resolution: Option, + ) -> WorkflowRunTaskArgs<'a> { + make_args_with_prompt("A corgi running through a field of flowers", session, model_type, output_resolution) + } + + mod seedance_2 { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let args = make_args(&session, KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::FourEightyP)); + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 @ 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A corgi running through a field of stars"; + let args = make_args_with_prompt(prompt, &session, KinoviModelTypeRaw::Seedance2Pro, None); + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 @ 720p (default) — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A shiba running through a field of stars"; + let args = make_args_with_prompt(prompt, &session, KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::TenEightyP)); + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 @ 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + /// 4K with image references (Seedance 2.0 Pro only), 5-second sample. + #[tokio::test] + #[ignore] + async fn test_4k_image_references() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Upload a few reference images first, then drive a 5-second 4K job. + let image_urls_to_upload = [ + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL, + test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL, + test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL, + ]; + + let mut uploaded_urls = Vec::new(); + for (i, source_url) in image_urls_to_upload.iter().enumerate() { + let image_bytes = crate::test_utils::http_download::http_download_to_bytes(source_url).await?; + let ext = if source_url.ends_with(".png") { "png" } else { "jpg" }; + + let prepare_result = prepare_file_upload(PrepareFileUploadArgs { + session: &session, + extension: ext.to_string(), + host_override: None, + }).await?; + + let upload_result = upload_file(UploadFileArgs { + upload_url: prepare_result.upload_url, + file_bytes: image_bytes, + host_override: None, + }).await?; + + println!("Uploaded ref image {}: {}", i + 1, upload_result.public_url); + uploaded_urls.push(upload_result.public_url); + } + + let args = WorkflowRunTaskArgs { + session: &session, + host_override: None, + request: WorkflowRunTaskRequest { + model_type: KinoviModelTypeRaw::Seedance2Pro, + prompt: "The dog in @1 explores the scenery in @3 near the building in @2. Cinematic 4K detail.".to_string(), + aspect_ratio: KinoviAspectRatioRaw::Landscape16x9, + duration_seconds: 5, + batch_count: KinoviBatchCountRaw::One, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: Some(uploaded_urls), + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + bitrate: None, + output_resolution: Some(KinoviOutputResolutionRaw::FourK), + }, + }; + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 @ 4K (image refs) — task_id={}, order_id={}", result.task_id, result.order_id); + assert!(!result.task_id.is_empty()); + assert!(!result.order_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + } + + mod seedance_2_fast { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_480p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A corgi running through a foggy meadow at dawn"; + let args = make_args_with_prompt(prompt, &session, KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourEightyP)); + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 Fast @ 480p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_720p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A shiba running through a foggy meadow at dawn"; + let args = make_args_with_prompt(prompt, &session, KinoviModelTypeRaw::Seedance2Fast, None); + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 Fast @ 720p (default) — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_1080p() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + let prompt = "A small klee kai dog running through a foggy meadow at dawn"; + let args = make_args_with_prompt(prompt, &session, KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::TenEightyP)); + let result = workflow_run_task(args).await?; + println!("Seedance 2.0 Fast @ 1080p — task_id={}, order_id={}", result.task_id, result.order_id); + assert_eq!(1, 2, "Inspect output above"); + Ok(()) + } + } + } +} diff --git a/crates/api_clients/kinovi_web_client/src/test_utils/get_test_cookies.rs b/crates/api_clients/kinovi_web_client/src/test_utils/get_test_cookies.rs new file mode 100644 index 0000000000..bbe163abae --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/test_utils/get_test_cookies.rs @@ -0,0 +1,9 @@ +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub fn get_test_cookies() -> AnyhowResult { + let cookies = read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")?; + let cookies = cookies.trim().to_string(); + Ok(cookies) +} diff --git a/crates/api_clients/kinovi_web_client/src/test_utils/http_download.rs b/crates/api_clients/kinovi_web_client/src/test_utils/http_download.rs new file mode 100644 index 0000000000..cd5fae5203 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/test_utils/http_download.rs @@ -0,0 +1,9 @@ +use errors::AnyhowResult; + +/// Download a URL to bytes. For use in tests only. +#[cfg(test)] +pub async fn http_download_to_bytes(url: &str) -> AnyhowResult> { + let response = wreq::get(url).send().await?; + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) +} diff --git a/crates/api_clients/kinovi_web_client/src/test_utils/mod.rs b/crates/api_clients/kinovi_web_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..84b8dd4f67 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/test_utils/mod.rs @@ -0,0 +1,8 @@ +#[cfg(test)] +pub (crate) mod get_test_cookies; + +#[cfg(test)] +pub (crate) mod http_download; + +#[cfg(test)] +pub (crate) mod setup_test_logging; diff --git a/crates/api_clients/kinovi_web_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/kinovi_web_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..cdb6c4e517 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,54 @@ +use chrono::Local; +use env_logger::Builder; +use log::{debug, error, info, trace, warn, LevelFilter}; +use std::io::Write; + +const RUST_LOG: &str = "RUST_LOG"; + +pub fn setup_test_logging(level: LevelFilter) { + let level_str = get_level_str(level); + + println!("Previous log level: {:?}", env::var(RUST_LOG)); + + let is_unsafe = env::set_var(RUST_LOG, level_str); + + if is_unsafe.is_none() { + println!("Unsafely setting Rust Log Level..."); + unsafe { std::env::set_var(RUST_LOG, level_str) } + } + + println!("Log level: {:?}", env::var(RUST_LOG)); + + // try_init: several #[ignore] live tests may run in one process, and only + // the first can install the logger. + let _ = Builder::new() + .is_test(true) + .format(|buf, record| { + writeln!(buf, + "{} [{}] - {}", + Local::now().format("%H:%M:%S%.6f"), + record.level(), + record.args() + ) + }) + .filter(None, level) + .filter_level(level) + .try_init(); + + trace!("Test trace log"); + debug!("Test debug log"); + info!("Test info log"); + warn!("Test warn log"); + error!("Test error log"); +} + +fn get_level_str(level: LevelFilter) -> &'static str { + match level { + LevelFilter::Error => "error", + LevelFilter::Warn => "warn", + LevelFilter::Info => "info", + LevelFilter::Debug => "debug", + LevelFilter::Trace => "trace", + LevelFilter::Off => "off", + } +} diff --git a/crates/api_clients/kinovi_web_client/src/utils/categorize_kinovi_web_error.rs b/crates/api_clients/kinovi_web_client/src/utils/categorize_kinovi_web_error.rs new file mode 100644 index 0000000000..c585f0aec8 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/utils/categorize_kinovi_web_error.rs @@ -0,0 +1,224 @@ +use wreq::StatusCode; + +use crate::error::kinovi_web_bad_request_api_error::KinoviWebBadRequestApiError; +use crate::error::kinovi_web_error::KinoviWebError; +use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError; +use crate::error::kinovi_web_specific_api_error::KinoviWebSpecificApiError; + +/// Known error message substrings that indicate a billing/credits error. +const BILLING_ERROR_MARKERS: &[&str] = &[ + "credits not enough", +]; + +/// Zod validation message for a string field exceeding its maximum length, +/// eg. "参数验证失败 - String must contain at most 10000 character(s)". +const STRING_TOO_LONG_MARKER: &str = "String must contain at most"; + +/// Zod validation message for an array field exceeding its maximum size, +/// eg. "参数验证失败 - Array must contain at most 9 element(s)". +const ARRAY_TOO_LARGE_MARKER: &str = "Array must contain at most"; + +/// Zod `fieldErrors` path entry for the prompt field. +const PROMPT_FIELD_MARKER: &str = "\"prompt\""; + +/// Zod `fieldErrors` path entry for the uploaded media urls field. +const UPLOADED_URLS_FIELD_MARKER: &str = "\"uploadedUrls\""; + +/// Categorize a non-success HTTP response into a specific or generic error. +/// +/// Checks the response body for known patterns (e.g. billing errors, request +/// validation failures) and returns a specific error variant when possible, +/// falling back to `UncategorizedBadResponseWithStatusAndBody` otherwise. +pub fn categorize_kinovi_web_error( + status_code: StatusCode, + body: String, +) -> KinoviWebError { + if is_billing_error(&body) { + return KinoviWebSpecificApiError::BillingError { + status_code, + body, + }.into(); + } + + if status_code == StatusCode::BAD_REQUEST { + if is_prompt_too_long_error(&body) { + return KinoviWebBadRequestApiError::PromptIsTooLong { raw_body: body }.into(); + } + if is_too_many_urls_error(&body) { + return KinoviWebBadRequestApiError::TooManyUrls { raw_body: body }.into(); + } + } + + KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body, + }.into() +} + +/// Check if the response body contains a known billing error marker. +fn is_billing_error(body: &str) -> bool { + let body_lower = body.to_lowercase(); + BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker)) +} + +/// Check if the response body is a zod validation failure for an over-long prompt. +fn is_prompt_too_long_error(body: &str) -> bool { + body.contains(STRING_TOO_LONG_MARKER) && body.contains(PROMPT_FIELD_MARKER) +} + +/// Check if the response body is a zod validation failure for too many uploaded urls. +fn is_too_many_urls_error(body: &str) -> bool { + body.contains(ARRAY_TOO_LARGE_MARKER) && body.contains(UPLOADED_URLS_FIELD_MARKER) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::kinovi_web_error::KinoviWebError; + + mod billing_tests { + use super::*; + + #[test] + fn real_not_enough_credits_response() { + let body = read_response_body("not_enough_credits.json"); + + let error = categorize_kinovi_web_error(StatusCode::BAD_REQUEST, body); + + match error { + KinoviWebError::ApiSpecific( + KinoviWebSpecificApiError::BillingError { status_code, body } + ) => { + assert_eq!(status_code, StatusCode::BAD_REQUEST); + assert!(body.contains("credits not enough")); + } + other => panic!("Expected BillingError, got: {:?}", other), + } + } + + #[test] + fn billing_error_case_insensitive() { + let body = r#"{"error":"Credits Not Enough"}"#.to_string(); + + let error = categorize_kinovi_web_error(StatusCode::BAD_REQUEST, body); + + match error { + KinoviWebError::ApiSpecific( + KinoviWebSpecificApiError::BillingError { .. } + ) => {} + other => panic!("Expected BillingError, got: {:?}", other), + } + } + } + + mod bad_request_tests { + use super::*; + + #[test] + fn real_prompt_too_long_response() { + let body = read_response_body("prompt_too_long.json"); + + let error = categorize_kinovi_web_error(StatusCode::BAD_REQUEST, body); + + match error { + KinoviWebError::ApiBadRequest( + KinoviWebBadRequestApiError::PromptIsTooLong { raw_body } + ) => { + assert!(raw_body.contains("String must contain at most 10000 character(s)")); + assert!(raw_body.contains("\"prompt\"")); + } + other => panic!("Expected PromptIsTooLong, got: {:?}", other), + } + } + + #[test] + fn real_too_many_urls_response() { + let body = read_response_body("too_many_urls.json"); + + let error = categorize_kinovi_web_error(StatusCode::BAD_REQUEST, body); + + match error { + KinoviWebError::ApiBadRequest( + KinoviWebBadRequestApiError::TooManyUrls { raw_body } + ) => { + assert!(raw_body.contains("Array must contain at most 9 element(s)")); + assert!(raw_body.contains("\"uploadedUrls\"")); + } + other => panic!("Expected TooManyUrls, got: {:?}", other), + } + } + + #[test] + fn bad_request_markers_require_400_status() { + let body = read_response_body("prompt_too_long.json"); + + let error = categorize_kinovi_web_error(StatusCode::INTERNAL_SERVER_ERROR, body); + + match error { + KinoviWebError::ApiGeneric( + KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + + #[test] + fn validation_failure_for_other_field_is_not_categorized() { + // Same zod shape, but for a field we don't have a variant for. + let body = r#"[{"error":{"json":{"message":"validation failed - String must contain at most 100 character(s)","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"zodError":{"formErrors":[],"fieldErrors":{"apiParams":[{"code":"custom","message":"String must contain at most 100 character(s)","path":["apiParams","negativePrompt"],"fatal":true}]}}}}}]"#.to_string(); + + let error = categorize_kinovi_web_error(StatusCode::BAD_REQUEST, body); + + match error { + KinoviWebError::ApiGeneric( + KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + } + + mod fallback_tests { + use super::*; + + #[test] + fn unknown_error_falls_back_to_uncategorized() { + let body = r#"{"error":"something else went wrong"}"#.to_string(); + + let error = categorize_kinovi_web_error( + StatusCode::INTERNAL_SERVER_ERROR, + body.clone(), + ); + + match error { + KinoviWebError::ApiGeneric( + KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b } + ) => { + assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(b, body); + } + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + + #[test] + fn empty_body_falls_back_to_uncategorized() { + let error = categorize_kinovi_web_error( + StatusCode::BAD_REQUEST, + String::new(), + ); + + match error { + KinoviWebError::ApiGeneric( + KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + } + + fn read_response_body(filename: &str) -> String { + std::fs::read_to_string(format!("test_data/responses/{}", filename)) + .expect("Failed to read test data file") + } +} diff --git a/crates/api_clients/kinovi_web_client/src/utils/common_headers.rs b/crates/api_clients/kinovi_web_client/src/utils/common_headers.rs new file mode 100644 index 0000000000..d10f62ab06 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/utils/common_headers.rs @@ -0,0 +1,2 @@ +pub const FIREFOX_USER_AGENT: &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0"; diff --git a/crates/api_clients/kinovi_web_client/src/utils/mod.rs b/crates/api_clients/kinovi_web_client/src/utils/mod.rs new file mode 100644 index 0000000000..579b7e7964 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/utils/mod.rs @@ -0,0 +1,4 @@ +pub mod categorize_kinovi_web_error; +pub mod common_headers; +pub mod number_coercion; +pub mod request_timeouts; diff --git a/crates/api_clients/kinovi_web_client/src/utils/number_coercion.rs b/crates/api_clients/kinovi_web_client/src/utils/number_coercion.rs new file mode 100644 index 0000000000..cf2ab1324f --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/utils/number_coercion.rs @@ -0,0 +1,177 @@ +//! Deserialization helpers for numeric fields that the Kinovi API has started +//! returning as floating-point values where it previously returned integers +//! (e.g. `width: 568.5`, `credits: 372215.18`). +//! +//! Each helper accepts EITHER a JSON integer or a JSON float and coerces it to +//! an unsigned integer: exact integers pass through unchanged (no precision +//! loss for large `u64` credit balances), and floats are rounded to the nearest +//! integer. Negative / non-finite values clamp to `0`. + +use serde::{Deserialize, Deserializer}; +use serde_json::Number; + +/// Coerce a parsed JSON number to `u64`. Exact non-negative integers pass +/// through; floats are rounded; anything negative / non-finite becomes `0`. +fn coerce_to_u64(number: &Number) -> u64 { + if let Some(unsigned) = number.as_u64() { + return unsigned; + } + if let Some(float) = number.as_f64() { + if float.is_finite() && float >= 0.0 { + return float.round() as u64; + } + } + 0 +} + +fn coerce_to_u32(number: &Number) -> u32 { + coerce_to_u64(number).min(u32::MAX as u64) as u32 +} + +/// Coerce a parsed JSON number to `i64`. Exact integers pass through; floats +/// are rounded; non-finite values become `0`. +fn coerce_to_i64(number: &Number) -> i64 { + if let Some(signed) = number.as_i64() { + return signed; + } + if let Some(float) = number.as_f64() { + if float.is_finite() { + return float.round() as i64; + } + } + 0 +} + +/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may +/// arrive as an integer or a float. +pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + Ok(coerce_to_u64(&number)) +} + +/// `#[serde(deserialize_with = "...")]` for a required `i64` field that may +/// arrive as an integer or a float (e.g. signed credit deltas). +pub fn de_i64_int_or_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + Ok(coerce_to_i64(&number)) +} + +/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles +/// `null` / missing as `None`). +pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let maybe_number = Option::::deserialize(deserializer)?; + Ok(maybe_number.as_ref().map(coerce_to_u64)) +} + +/// `#[serde(deserialize_with = "...")]` for an optional `u32` field (handles +/// `null` / missing as `None`). +pub fn de_opt_u32_int_or_float<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let maybe_number = Option::::deserialize(deserializer)?; + Ok(maybe_number.as_ref().map(coerce_to_u32)) +} + +/// `#[serde(deserialize_with = "...")]` for a required `u32` field that may +/// arrive as an integer or a float. +pub fn de_u32_int_or_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + Ok(coerce_to_u32(&number)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + struct U32Holder { + #[serde(deserialize_with = "de_u32_int_or_float")] + value: u32, + } + + #[derive(Deserialize)] + struct U64Holder { + #[serde(deserialize_with = "de_u64_int_or_float")] + value: u64, + } + + #[derive(Deserialize)] + struct OptU32Holder { + #[serde(default, deserialize_with = "de_opt_u32_int_or_float")] + value: Option, + } + + #[derive(Deserialize)] + struct I64Holder { + #[serde(deserialize_with = "de_i64_int_or_float")] + value: i64, + } + + fn parse_u32(json: &str) -> u32 { + serde_json::from_str::(json).unwrap().value + } + + fn parse_u64(json: &str) -> u64 { + serde_json::from_str::(json).unwrap().value + } + + fn parse_i64(json: &str) -> i64 { + serde_json::from_str::(json).unwrap().value + } + + #[test] + fn u32_accepts_integer() { + assert_eq!(parse_u32(r#"{"value": 568}"#), 568); + } + + #[test] + fn u32_accepts_float_and_rounds() { + assert_eq!(parse_u32(r#"{"value": 568.5}"#), 569); + assert_eq!(parse_u32(r#"{"value": 568.4}"#), 568); + } + + #[test] + fn u64_accepts_integer_exactly() { + // Large value that would lose precision via f64 — must pass through exact. + assert_eq!(parse_u64(r#"{"value": 372215}"#), 372215); + } + + #[test] + fn u64_accepts_float_and_rounds() { + assert_eq!(parse_u64(r#"{"value": 372215.18}"#), 372215); + } + + #[test] + fn negative_and_nonfinite_clamp_to_zero() { + assert_eq!(parse_u64(r#"{"value": -5}"#), 0); + assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0); + } + + #[test] + fn i64_accepts_negative_integers_and_floats() { + assert_eq!(parse_i64(r#"{"value": -454}"#), -454); + assert_eq!(parse_i64(r#"{"value": -454.6}"#), -455); + assert_eq!(parse_i64(r#"{"value": 25000}"#), 25000); + } + + #[test] + fn optional_handles_null_missing_and_values() { + assert_eq!(serde_json::from_str::(r#"{"value": null}"#).unwrap().value, None); + assert_eq!(serde_json::from_str::(r#"{}"#).unwrap().value, None); + assert_eq!(serde_json::from_str::(r#"{"value": 12.9}"#).unwrap().value, Some(13)); + assert_eq!(serde_json::from_str::(r#"{"value": 12}"#).unwrap().value, Some(12)); + } +} diff --git a/crates/api_clients/kinovi_web_client/src/utils/request_timeouts.rs b/crates/api_clients/kinovi_web_client/src/utils/request_timeouts.rs new file mode 100644 index 0000000000..2af0aab423 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/src/utils/request_timeouts.rs @@ -0,0 +1,13 @@ +use std::time::Duration; + +/// Total request deadline (connect + send + receive) for ordinary Kinovi +/// API calls: tRPC queries, order/character polling, generation submits. +/// +/// Without a deadline a peer that accepts the connection and then never +/// answers parks the caller forever — the job loops that poll Kinovi have no +/// other way to recover from that short of a pod restart. +pub const KINOVI_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Deadline for file uploads to Kinovi's storage, which legitimately take +/// longer than an API round trip for large reference videos. +pub const KINOVI_UPLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); diff --git a/crates/api_clients/kinovi_web_client/test_data/responses/create_material_photo.json b/crates/api_clients/kinovi_web_client/test_data/responses/create_material_photo.json new file mode 100644 index 0000000000..1c17dbb9db --- /dev/null +++ b/crates/api_clients/kinovi_web_client/test_data/responses/create_material_photo.json @@ -0,0 +1 @@ +[{"result":{"data":{"json":{"id":83519,"userId":"cmlrbvj2a000buo91m14ejhw0","materialId":"mat_iqs01mlyw5f2ph8g2dk55k1s","name":null,"url":"https://static.seedance2-pro.com/materials/20260801/1785593115274-52bcb691.png","format":"photo","size":2158735,"width":1024,"height":1024,"duration":null,"frameRate":null,"detectTaskId":null,"poster":"","detectUrl":null,"detectInfo":null,"detectStatus":"SUCCEEDED","detectStartAt":"1785593116303","detectFinishAt":null,"detectFinishFrom":0,"usedTimes":0,"userAgent":null,"thumbHash":[86,72,6,23,10,103,120,120,143,120,135,140,134,184,119,201,103,136,152,159,23,88,127,11],"status":1,"previewImageUrl":null,"expireDelAt":null,"createdAt":"2026-08-01T14:05:16.304Z","updatedAt":"2026-08-01T14:05:16.304Z","removeStatus":0,"removeAt":null,"featureType":0,"muxPlaybackId":null,"muxStreamUrl":null,"muxThumbnailUrl":null},"meta":{"values":{"detectStartAt":["bigint"],"createdAt":["Date"],"updatedAt":["Date"]},"v":1}}}}] diff --git a/crates/api_clients/kinovi_web_client/test_data/responses/create_material_video.json b/crates/api_clients/kinovi_web_client/test_data/responses/create_material_video.json new file mode 100644 index 0000000000..68aafb6835 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/test_data/responses/create_material_video.json @@ -0,0 +1 @@ +[{"result":{"data":{"json":{"id":83483,"userId":"cmlrbvj2a000buo91m14ejhw0","materialId":"mat_v1k48s02um7vow9q0rx38jlc","name":null,"url":"https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4","format":"video","size":4773897,"width":1280,"height":720,"duration":8,"frameRate":null,"detectTaskId":null,"poster":"","detectUrl":null,"detectInfo":null,"detectStatus":"PROCESSING","detectStartAt":"1785592620587","detectFinishAt":null,"detectFinishFrom":0,"usedTimes":0,"userAgent":null,"thumbHash":null,"status":1,"previewImageUrl":null,"expireDelAt":null,"createdAt":"2026-08-01T13:57:00.588Z","updatedAt":"2026-08-01T13:57:00.588Z","removeStatus":0,"removeAt":null,"featureType":0,"muxPlaybackId":null,"muxStreamUrl":null,"muxThumbnailUrl":null},"meta":{"values":{"detectStartAt":["bigint"],"createdAt":["Date"],"updatedAt":["Date"]},"v":1}}}}] diff --git a/crates/api_clients/kinovi_web_client/test_data/responses/not_enough_credits.json b/crates/api_clients/kinovi_web_client/test_data/responses/not_enough_credits.json new file mode 100644 index 0000000000..d1949a182c --- /dev/null +++ b/crates/api_clients/kinovi_web_client/test_data/responses/not_enough_credits.json @@ -0,0 +1,25 @@ +[ + { + "error": { + "json": { + "message": "credits not enough", + "code": -32600, + "data": { + "code": "BAD_REQUEST", + "httpStatus": 400, + "path": "workflow.runTask", + "zodError": null + }, + "stack": null + }, + "meta": { + "values": { + "stack": [ + "undefined" + ] + }, + "v": 1 + } + } + } +] diff --git a/crates/api_clients/kinovi_web_client/test_data/responses/prompt_too_long.json b/crates/api_clients/kinovi_web_client/test_data/responses/prompt_too_long.json new file mode 100644 index 0000000000..30086ad3d3 --- /dev/null +++ b/crates/api_clients/kinovi_web_client/test_data/responses/prompt_too_long.json @@ -0,0 +1,40 @@ +[ + { + "error": { + "json": { + "message": "[{\"code\": \"custom\",\"message\": \"参数验证失败 - String must contain at most 10000 character(s)\",\"path\": [\"apiParams\",\"prompt\"],\"fatal\": true}]", + "code": -32600, + "data": { + "code": "BAD_REQUEST", + "httpStatus": 400, + "path": "workflow.runTask", + "zodError": { + "formErrors": [], + "fieldErrors": { + "apiParams": [ + { + "code": "custom", + "message": "参数验证失败 - String must contain at most 10000 character(s)", + "path": [ + "apiParams", + "prompt" + ], + "fatal": true + } + ] + } + } + }, + "stack": null + }, + "meta": { + "values": { + "stack": [ + "undefined" + ] + }, + "v": 1 + } + } + } +] \ No newline at end of file diff --git a/crates/api_clients/kinovi_web_client/test_data/responses/too_many_urls.json b/crates/api_clients/kinovi_web_client/test_data/responses/too_many_urls.json new file mode 100644 index 0000000000..65a351a35c --- /dev/null +++ b/crates/api_clients/kinovi_web_client/test_data/responses/too_many_urls.json @@ -0,0 +1,40 @@ +[ + { + "error": { + "json": { + "message": "[{\"code\": \"custom\",\"message\": \"参数验证失败 - Array must contain at most 9 element(s)\",\"path\": [\"apiParams\",\"uploadedUrls\"],\"fatal\": true}]", + "code": -32600, + "data": { + "code": "BAD_REQUEST", + "httpStatus": 400, + "path": "workflow.runTask", + "zodError": { + "formErrors": [], + "fieldErrors": { + "apiParams": [ + { + "code": "custom", + "message": "参数验证失败 - Array must contain at most 9 element(s)", + "path": [ + "apiParams", + "uploadedUrls" + ], + "fatal": true + } + ] + } + } + }, + "stack": null + }, + "meta": { + "values": { + "stack": [ + "undefined" + ] + }, + "v": 1 + } + } + } +] \ No newline at end of file diff --git a/crates/api_clients/midjourney_client/Cargo.toml b/crates/api_clients/midjourney_client/Cargo.toml new file mode 100644 index 0000000000..177b4f403c --- /dev/null +++ b/crates/api_clients/midjourney_client/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "midjourney_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "midjourney_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +cloudflare_errors.workspace = true +cookie_store.workspace = true +errors.workspace = true + +# External +anyhow.workspace = true +futures.workspace = true +log.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["full"] } +url.workspace = true +wreq-util.workspace = true +wreq.workspace = true + +# NB: We need to parse HTML to get out the websocket token +dom_query = "0.20.2" + +[dev-dependencies] +filesys.workspace = true +test_data.workspace = true +test_utils.workspace = true diff --git a/crates/api_clients/midjourney_client/src/client/midjourney_hostname.rs b/crates/api_clients/midjourney_client/src/client/midjourney_hostname.rs new file mode 100644 index 0000000000..3110ff0c40 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/client/midjourney_hostname.rs @@ -0,0 +1,17 @@ + +/// Multiple third party Midjourney clients make this configurable. +/// Not sure why, I'm just cargo culting it. +#[derive(Clone)] +pub enum MidjourneyHostname { + Standard, + Custom(String), +} + +impl MidjourneyHostname { + pub fn as_str(&self) -> &str { + match self { + Self::Standard => "www.midjourney.com", + Self::Custom(domain) => domain, + } + } +} diff --git a/crates/api_clients/midjourney_client/src/client/midjourney_websocket_client.rs b/crates/api_clients/midjourney_client/src/client/midjourney_websocket_client.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/api_clients/midjourney_client/src/client/mod.rs b/crates/api_clients/midjourney_client/src/client/mod.rs new file mode 100644 index 0000000000..233febf33d --- /dev/null +++ b/crates/api_clients/midjourney_client/src/client/mod.rs @@ -0,0 +1,2 @@ +pub mod midjourney_hostname; +pub mod midjourney_websocket_client; diff --git a/crates/api_clients/midjourney_client/src/credentials/cookie_names.rs b/crates/api_clients/midjourney_client/src/credentials/cookie_names.rs new file mode 100644 index 0000000000..6eba239b04 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/credentials/cookie_names.rs @@ -0,0 +1,6 @@ + +/// One of the apparent authentication cookies used by Midjourney. +pub (crate) const AUTH_COOKIE_NAME_R : &str = "__Host-Midjourney.AuthUserTokenV3_r"; + +/// One of the apparent authentication cookies used by Midjourney. +pub (crate) const AUTH_COOKIE_NAME_I : &str = "__Host-Midjourney.AuthUserTokenV3_i"; diff --git a/crates/api_clients/midjourney_client/src/credentials/cookie_store_has_auth_cookies.rs b/crates/api_clients/midjourney_client/src/credentials/cookie_store_has_auth_cookies.rs new file mode 100644 index 0000000000..89827f00f9 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/credentials/cookie_store_has_auth_cookies.rs @@ -0,0 +1,8 @@ +use crate::credentials::cookie_names::{AUTH_COOKIE_NAME_I, AUTH_COOKIE_NAME_R}; +use cookie_store::cookie_store::CookieStore; + +/// Returns true if the given cookie store has the necessary authentication cookies. +pub fn cookie_store_has_auth_cookies(cookie_store: &CookieStore) -> bool { + cookie_store.has_cookie(AUTH_COOKIE_NAME_I) + && cookie_store.has_cookie(AUTH_COOKIE_NAME_R) +} diff --git a/crates/api_clients/midjourney_client/src/credentials/jwt_decode.rs b/crates/api_clients/midjourney_client/src/credentials/jwt_decode.rs new file mode 100644 index 0000000000..2efdd4e47e --- /dev/null +++ b/crates/api_clients/midjourney_client/src/credentials/jwt_decode.rs @@ -0,0 +1,31 @@ + +/* +{ + "name": "u3869671173", + "picture": "https://lh3.googleusercontent.com/a/ACg8ocKw0aaXKYSqy-Xkkn69ydIJFGnJA37S13XvjNY4d9xCzUzKUw=s96-c", + "midjourney_id": "f8a57ac3-e416-4dd4-9be8-2c4223691b01", + "iss": "https://securetoken.google.com/authjourney", + "aud": "authjourney", + "auth_time": 1754970849, + "user_id": "bS9idl73DlSbE61KnCyKpDfazCX2", + "sub": "bS9idl73DlSbE61KnCyKpDfazCX2", + "iat": 1754970849, + "exp": 1754974449, + "email": "victorvoice89@gmail.com", + "email_verified": true, + "firebase": { + "identities": { + "google.com": [ + "116459300198619888836" + ], + "email": [ + "victorvoice89@gmail.com" + ] + }, + "sign_in_provider": "google.com" + } +} +*/ + +/* + */ \ No newline at end of file diff --git a/crates/api_clients/midjourney_client/src/credentials/midjourney_user_id.rs b/crates/api_clients/midjourney_client/src/credentials/midjourney_user_id.rs new file mode 100644 index 0000000000..1df40c2dde --- /dev/null +++ b/crates/api_clients/midjourney_client/src/credentials/midjourney_user_id.rs @@ -0,0 +1,22 @@ + +/// The user ID used for several midjourney requests. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct MidjourneyUserId(pub String); + +impl MidjourneyUserId { + pub fn from_str(user_id: &str) -> Self { + Self(user_id.to_string()) + } + + pub fn from_string(user_id: String) -> Self { + Self(user_id) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.to_string() + } +} diff --git a/crates/api_clients/midjourney_client/src/credentials/mod.rs b/crates/api_clients/midjourney_client/src/credentials/mod.rs new file mode 100644 index 0000000000..3f9cb6efcb --- /dev/null +++ b/crates/api_clients/midjourney_client/src/credentials/mod.rs @@ -0,0 +1,4 @@ +pub mod cookie_names; +pub mod cookie_store_has_auth_cookies; +pub mod jwt_decode; +pub mod midjourney_user_id; diff --git a/crates/api_clients/midjourney_client/src/endpoints/get_index_page_html.rs b/crates/api_clients/midjourney_client/src/endpoints/get_index_page_html.rs new file mode 100644 index 0000000000..90949efa9d --- /dev/null +++ b/crates/api_clients/midjourney_client/src/endpoints/get_index_page_html.rs @@ -0,0 +1,95 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use log::error; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +/// This grabs the pre-rendered HTML page, which contains the websocket token and other details. +pub struct GetIndexPageRequest { + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +pub async fn get_index_page_html(req: GetIndexPageRequest) -> Result { + let url = format!("https://{}/", req.hostname.as_str()); + + let client = Client::builder() + .emulation(Emulation::Firefox139) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let cookie_header = req.cookie_header.trim(); + + if cookie_header.len() < 20 { + error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header); + return Err(MidjourneyClientError::CookieTooShort.into()); + } + + // NB: missing headers that were in the browser request: + // -H 'sec-ch-ua-platform: "macOS"' \ + // -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \ + + let mut http_request = client.get(url) + .header("cookie", cookie_header) + .header("Referrer-Policy", "origin-when-cross-origin") + .header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + .header("accept-language", "en-US,en;q=0.8") + .header("content-type", "application/json") + .header("priority", "u=0, i") + .header("sec-ch-ua-mobile", "?0") + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-site", "none") + .header("sec-fetch-user", "?1") + .header("upgrade-insecure-requests", "1"); + + let http_request = http_request + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let response = client.execute(http_request) + .await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + let status = response.status(); + + let response_body = response.text().await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + if !status.is_success() { + if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) { + return Err(MidjourneyApiError::CloudflareError(err).into()); + } + } + + Ok(response_body) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageRequest}; + + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + + let result = get_index_page_html(GetIndexPageRequest { + cookie_header, + hostname: MidjourneyHostname::Standard, + }).await?; + + println!("Response: {:?}\n\n", result); + + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/midjourney_client/src/endpoints/imagine.rs b/crates/api_clients/midjourney_client/src/endpoints/imagine.rs new file mode 100644 index 0000000000..35ea862e20 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/endpoints/imagine.rs @@ -0,0 +1,251 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::credentials::midjourney_user_id::MidjourneyUserId; +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use log::error; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +const DEFAULT_PAGE_SIZE : u64 = 1000; + +/// This returns Midjourney's job/media list +pub struct ImagineRequest<'a> { + pub hostname: MidjourneyHostname, + pub cookie_header: String, + pub user_id: &'a MidjourneyUserId, + pub page_size: Option, +} + +#[derive(Debug, Clone)] +pub struct ImagineResponse { + pub items: Vec, + pub cursor: Option, +} + +#[derive(Debug, Clone)] +pub struct ImagineItem { + pub id: Option, + + /// This is the prompt, e.g. + /// `cute puppy wearing a polar bear suit --ar 4:3 --raw --v 7.0` + pub full_command: Option, + + pub job_type: Option, +} + +#[derive(Debug, Clone)] +pub enum MidjourneyJobType { + V6Diffusion, + V6p1Diffusion, + V6p1RawDiffusion, + V7Diffusion, + V7DraftDiffusion, + V7DraftRawDiffusion, + V7RawDiffusion, + Vid11I2vRenderAJointVideo, + Vid11I2vRenderBJointVideo, + Vid11I2vStartEndAVideo, + Vid11I2vStartEndBVideo, + Other(String), +} + +impl MidjourneyJobType { + pub fn from_str(s: &str) -> MidjourneyJobType { + match s { + "v6_diffusion" => MidjourneyJobType::V6Diffusion, + "v6-1_diffusion" => MidjourneyJobType::V6p1Diffusion, + "v6-1_raw_diffusion" => MidjourneyJobType::V6p1RawDiffusion, + "v7_diffusion" => MidjourneyJobType::V7Diffusion, + "v7_draft_diffusion" => MidjourneyJobType::V7DraftDiffusion, + "v7_draft_raw_diffusion" => MidjourneyJobType::V7DraftRawDiffusion, + "v7_raw_diffusion" => MidjourneyJobType::V7RawDiffusion, + "vid_1.1_i2v_render_a_joint_video" => MidjourneyJobType::Vid11I2vRenderAJointVideo, + "vid_1.1_i2v_render_b_joint_video" => MidjourneyJobType::Vid11I2vRenderBJointVideo, + "vid_1.1_i2v_start_end_a_video" => MidjourneyJobType::Vid11I2vStartEndAVideo, + "vid_1.1_i2v_start_end_b_video" => MidjourneyJobType::Vid11I2vStartEndBVideo, + other => MidjourneyJobType::Other(other.to_string()), + } + } +} + +pub async fn imagine(req: ImagineRequest<'_>) -> Result { + let cookie_header = req.cookie_header.trim(); + + if cookie_header.len() < 20 { + error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header); + return Err(MidjourneyClientError::CookieTooShort.into()); + } + + let page_size = req.page_size.unwrap_or(DEFAULT_PAGE_SIZE); + + let referer = format!("https://{}/imagine", req.hostname.as_str()); + + let url = format!("https://{}/api/imagine?user_id={}&page_size={}", + req.hostname.as_str(), + req.user_id.as_str(), + page_size, + ); + + let client = Client::builder() + .emulation(Emulation::Firefox139) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + // NB: missing headers that were in the browser request: + // -H 'sec-ch-ua-platform: "macOS"' \ + // -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \ + + let mut http_request = client.get(url) + .header("cookie", cookie_header) + .header("Referer", &referer) + .header("Referrer-Policy", "origin-when-cross-origin") + .header("accept", "*/*") + .header("accept-encoding", "gzip, deflate, br, zstd") + .header("accept-language", "en-US,en;q=0.8") + .header("content-type", "application/json") + .header("priority", "u=1, i") + .header("sec-ch-ua-mobile", "?0") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("x-csrf-protection", "1"); + + let http_request = http_request + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let response = client.execute(http_request) + .await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + let status = response.status(); + + let response_body = response.text().await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + if !status.is_success() { + if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) { + return Err(MidjourneyApiError::CloudflareError(err).into()); + } + } + + /* + { + "data": [ + { + "id": "a2339f14-ff71-43e4-a69f-8da15e7ba871", + "enqueue_time": "2025-08-14T17:21:28.403560+00:00", + "parent_id": null, + "video_segments": null, + "rating": null, + "job_type": "v7_diffusion", + "event_type": "diffusion", + "parent_grid": null, + "full_command": "a modern n64 console", + "batch_size": 4, + "width": 1024, + "height": 1024, + "published": true, + "shown": true, + "user_hidden": false, + "template": null, + "vid_framecount": null + }, + ], + "cursor": "encrypted cursor", + "checkpoint": "encrypted something ???" + } + */ + + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawImagineResponse { + data: Vec, + cursor: Option, + //pub checkpoint: Option, + } + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawImagineItem { + // The job id + id: Option, + + // The prompt plus `--` args. + full_command: Option, + + // Number of images, typically 4 + batch_size: Option, + + // Some possible values: + // "v6_diffusion" + // "v7_diffusion" + // "v7_draft_raw_diffusion" + // "v7_raw_diffusion" + // "vid_1.1_i2v_render_b_joint_video", + // "vid_1.1_i2v_start_end_a_video" + // "vid_1.1_i2v_start_end_b_video" + job_type: Option, + + // Some possible values: + // "diffusion" + // "draft" + // "variation" + // "video_diffusion" + // "video_start_end" + event_type: Option, + } + + let response : RawImagineResponse = serde_json::from_str(&response_body) + .map_err(|err| MidjourneyApiError::DeserializationError(err))?; + + Ok(ImagineResponse { + cursor: response.cursor.clone(), + items: response.data + .into_iter() + .map(|item| { + ImagineItem { + id: item.id, + full_command: item.full_command, + job_type: item.job_type.as_ref() + .map(|jt| MidjourneyJobType::from_str(jt)) + } + }).collect(), + }) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use crate::credentials::midjourney_user_id::MidjourneyUserId; + use crate::endpoints::imagine::{imagine, ImagineRequest}; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + let user_id = read_to_trimmed_string("/Users/bt/secrets/midjourney/user_id.txt")?; + let user_id = MidjourneyUserId::from_string(user_id); + + let result = imagine(ImagineRequest { + cookie_header, + hostname: MidjourneyHostname::Standard, + user_id: &user_id, + page_size: None, + }).await?; + + println!("Response: {:?}\n\n", result); + + assert_eq!(1, 2); + + Ok(()) + } +} + + diff --git a/crates/api_clients/midjourney_client/src/endpoints/mod.rs b/crates/api_clients/midjourney_client/src/endpoints/mod.rs new file mode 100644 index 0000000000..6abc6a8e51 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/endpoints/mod.rs @@ -0,0 +1,5 @@ +pub mod get_index_page_html; +pub mod imagine; +pub mod storage_list; +pub mod submit_job; +pub mod user_connections; \ No newline at end of file diff --git a/crates/api_clients/midjourney_client/src/endpoints/storage_list.rs b/crates/api_clients/midjourney_client/src/endpoints/storage_list.rs new file mode 100644 index 0000000000..9fb0ad9f43 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/endpoints/storage_list.rs @@ -0,0 +1,147 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use log::error; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +/// This endpoint returns the user id +pub struct GetStorageListRequest { + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +#[derive(Debug)] +pub struct StorageItem { + // NB: The path contains the user ID. + pub bucket_pathname: Option, + + // NB: Other fields aren't necessary for us right now. +} + +pub async fn storage_list(req: GetStorageListRequest) -> Result, MidjourneyError> { + + let referer = format!("https://{}", req.hostname.as_str()); + + let url = format!("https://{}/api/storage-list", req.hostname.as_str()); + + let client = Client::builder() + .emulation(Emulation::Firefox139) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let cookie_header = req.cookie_header.trim(); + + if cookie_header.len() < 20 { + error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header); + return Err(MidjourneyClientError::CookieTooShort.into()); + } + + // NB: missing headers that were in the browser request: + // -H 'sec-ch-ua-platform: "macOS"' \ + // -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \ + + let mut http_request = client.get(url) + .header("cookie", cookie_header) + .header("Referer", &referer) + .header("Referrer-Policy", "origin-when-cross-origin") + .header("accept", "*/*") + .header("accept-language", "en-US,en;q=0.8") + .header("content-type", "application/json") + .header("priority", "u=1, i") + .header("sec-ch-ua-mobile", "?0") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("x-csrf-protection", "1"); + + let http_request = http_request + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let response = client.execute(http_request) + .await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + let status = response.status(); + + // if status == 301 { + // for (name, value) in response.headers().iter() { + // println!("- {}: {}", name.as_str(), value.to_str().unwrap()); + // } + // } + + let response_body = &response.text().await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + if !status.is_success() { + if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) { + return Err(MidjourneyApiError::CloudflareError(err).into()); + } + } + + /* + [ + { + "bucketPathname": "UUID/OTHER_HEX_ENTROPY.png", + "shortUrl": null, + "type": "image-prompt", + "origin": "web", + "timeCreated": TIMESTAMP, + "hidden": false, + "contentType": "image/png", + "state": "loaded" + } + ] + */ + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawStorageItem { + bucketPathname: Option, + } + + let response : Vec = serde_json::from_str(response_body) + .map_err(|err| MidjourneyApiError::DeserializationError(err))?; + + let items = response + .into_iter() + .map(|r| StorageItem { + bucket_pathname: r.bucketPathname, + }) + .collect::>(); + + Ok(items) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + use crate::endpoints::storage_list::{storage_list, GetStorageListRequest}; + + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + + let result = storage_list(GetStorageListRequest { + cookie_header, + hostname: MidjourneyHostname::Standard, + }).await?; + + println!("Response: {:?}\n\n", result); + + for provider in result { + println!("Provider: {:?}", provider); + } + + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/midjourney_client/src/endpoints/submit_job.rs b/crates/api_clients/midjourney_client/src/endpoints/submit_job.rs new file mode 100644 index 0000000000..c1240ee20d --- /dev/null +++ b/crates/api_clients/midjourney_client/src/endpoints/submit_job.rs @@ -0,0 +1,230 @@ +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; +use log::{error, warn}; +use serde::{Deserialize, Serialize}; + +use crate::client::midjourney_hostname::MidjourneyHostname; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use wreq::Client; +use wreq_util::Emulation; + +pub struct SubmitJobRequest<'a> { + pub prompt: &'a str, + pub channel_id: &'a str, + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +#[derive(Debug, Clone)] +pub struct SubmitJobResponse { + /// On success, the job ID is returned. + pub maybe_job_id: Option, + + /// On error, we have a list of error messages. + pub maybe_errors: Option>, +} + +#[derive(Debug, Clone)] +pub struct SubmitJobError { + pub error_type: Option, + pub message: Option, +} + +pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox139) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let referer = format!("https://{}", req.hostname.as_str()); + + // NB: Other recent clients use /api/app/submit-jobs, but this appears invalid. + let url = format!("https://{}/api/submit-jobs", req.hostname.as_str()); + + let cookie_header = req.cookie_header.trim(); + + if cookie_header.len() < 20 { + error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header); + return Err(MidjourneyClientError::CookieTooShort.into()); + } + + let mut http_request = client.post(url) + .header("cookie", cookie_header) + .header("Referer", &referer) + .header("Referrer-Policy", "origin-when-cross-origin") + .header("accept", "*/*") + .header("accept-language", "en-US,en;q=0.8") + .header("content-type", "application/json") + .header("priority", "u=1, i") + .header("sec-ch-ua-mobile", "?0") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("x-csrf-protection", "1"); + + #[derive(Serialize)] + struct F { + mode: String, + private: bool, + } + + #[derive(Serialize)] + #[allow(non_snake_case)] + struct Metadata { + imagePrompts: u8, + imageReferences: u8, + characterReferences: u8, + depthReferences: u8, + lightboxOpen: String, + } + + #[derive(Serialize)] + #[allow(non_snake_case)] + struct RawRequest { + f: F, + channelId: String, + metadata: Metadata, + t: String, + prompt: String, + } + + let body = RawRequest { + f: F { + mode: "fast".to_string(), + private: false, + }, + channelId: req.channel_id.to_string(), + metadata: Metadata { + imagePrompts: 0, + imageReferences: 0, + characterReferences: 0, + depthReferences: 0, + lightboxOpen: "".to_string(), + }, + t: "imagine".to_string(), + prompt: req.prompt.to_string(), + }; + + let http_request = http_request + .json(&body) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let response = client.execute(http_request) + .await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + let status = response.status(); + + // if status == 301 { + // for (name, value) in response.headers().iter() { + // println!("- {}: {}", name.as_str(), value.to_str().unwrap()); + // } + // } + + let response_body = &response.text().await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + if !status.is_success() { + if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) { + return Err(MidjourneyApiError::CloudflareError(err).into()); + } + } + + /* + {"success":[ + { + "job_id":"UUID-like-string", + "prompt":"prompt", + "is_queued":false, + "event_type":"diffusion", + "flags":{ + "mode":"fast","visibility":"public" + }, + "meta":{ + "height":1024,"width":1024,"batch_size":4,"parent_id":null,"parent_grid":null + }, + "optimisticJobIndex":0, + "personalization_codes":null + }], + "failure":[]} + */ + + #[derive(Deserialize)] + struct SuccessPayload { + job_id: String, + } + + #[derive(Deserialize)] + struct FailurePayload { + r#type: String, + message: String, + } + + #[derive(Deserialize)] + #[allow(non_snake_case)] + struct RawResponse { + success: Vec, + failure: Option>, + } + + let response = serde_json::from_str::(response_body) + .map_err(|err| MidjourneyApiError::DeserializationError(err))?; + + let maybe_job_id = response.success + .get(0) + .map(|s| s.job_id.clone()); + + if maybe_job_id.is_none() { + warn!("No job id found in body: {:?}", response_body); + } + + let mut maybe_errors = None; + + if let Some(failures) = response.failure.as_ref() { + if !failures.is_empty() { + maybe_errors = Some(failures.iter() + .map(|f| SubmitJobError { + error_type: Some(f.r#type.clone()), + message: Some(f.message.clone()), + }).collect()); + } + } + + Ok(SubmitJobResponse { + maybe_job_id, + maybe_errors, + }) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use crate::endpoints::submit_job::{submit_job, SubmitJobRequest}; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + + // Get channel id via: + // https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=[TOKEN] + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + let channel_id = read_to_trimmed_string("/Users/bt/secrets/midjourney/channel_id.txt")?; + + let result = submit_job(SubmitJobRequest { + prompt: "a modern n64 console", + channel_id: &channel_id, + cookie_header, + hostname: MidjourneyHostname::Standard, + }).await?; + + println!("Response: {:?}", result); + + assert_eq!(1, 2); + + + Ok(()) + } +} diff --git a/crates/api_clients/midjourney_client/src/endpoints/user_connections.rs b/crates/api_clients/midjourney_client/src/endpoints/user_connections.rs new file mode 100644 index 0000000000..5ddaae7c1f --- /dev/null +++ b/crates/api_clients/midjourney_client/src/endpoints/user_connections.rs @@ -0,0 +1,178 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use log::error; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +const USER_CONNECTIONS_URL :&str = "https://www.midjourney.com/api/user-connections"; + +pub struct GetUserConnectionsRequest { + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +#[derive(Debug)] +pub struct UserData { + pub provider_id: String, + pub display_name: Option, + pub email: Option, + pub user_id: Option, +} + +#[derive(Debug)] +pub struct Provider { + pub provider_id: String, + pub user_data: Option, + pub is_linked: bool, +} + +pub async fn user_connections(req: GetUserConnectionsRequest) -> Result, MidjourneyError> { + + let referer = format!("https://{}", req.hostname.as_str()); + + let url = format!("https://{}/api/user-connections", req.hostname.as_str()); + + let client = Client::builder() + .emulation(Emulation::Firefox139) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let cookie_header = req.cookie_header.trim(); + + if cookie_header.len() < 20 { + error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header); + return Err(MidjourneyClientError::CookieTooShort.into()); + } + + // NB: missing headers that were in the browser request: + // -H 'sec-ch-ua-platform: "macOS"' \ + // -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \ + + let mut http_request = client.get(url) + .header("cookie", cookie_header) + .header("Referer", &referer) + .header("Referrer-Policy", "origin-when-cross-origin") + .header("accept", "*/*") + .header("accept-language", "en-US,en;q=0.8") + .header("content-type", "application/json") + .header("priority", "u=1, i") + .header("sec-ch-ua-mobile", "?0") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("x-csrf-protection", "1"); + + let http_request = http_request + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let response = client.execute(http_request) + .await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + let status = response.status(); + + // if status == 301 { + // for (name, value) in response.headers().iter() { + // println!("- {}: {}", name.as_str(), value.to_str().unwrap()); + // } + // } + + let response_body = &response.text().await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + if !status.is_success() { + if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) { + return Err(MidjourneyApiError::CloudflareError(err).into()); + } + } + + /* + [ + { + "providerId": "google.com", + "userData": { + "providerId": "google.com", + "displayName": "John Doe", + "email": "john.doe@gmail.com", + "userId": "123456789000000001", + }, + "isLinked": true + }, + { + "providerId": "discord.com", + "isLinked": false + } + ] + */ + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawProviderUserData { + providerId: String, + displayName: Option, + email: Option, + userId: Option, + } + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawProvider { + providerId: String, + userData: Option, + isLinked: bool, + } + + let response : Vec = serde_json::from_str(response_body) + .map_err(|err| MidjourneyApiError::DeserializationError(err))?; + + let providers = response + .into_iter() + .map(|r| Provider { + provider_id: r.providerId, + user_data: r.userData.map(|u| UserData { + provider_id: u.providerId, + display_name: u.displayName, + email: u.email, + user_id: u.userId, + }), + is_linked: r.isLinked, + }) + .collect::>(); + + Ok(providers) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use crate::endpoints::user_connections::{user_connections, GetUserConnectionsRequest}; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + + let result = user_connections(GetUserConnectionsRequest { + cookie_header, + hostname: MidjourneyHostname::Standard, + }).await?; + + println!("Response: {:?}\n\n", result); + + for provider in result { + println!("Provider: {:?}", provider); + } + + assert_eq!(1, 2); + + Ok(()) + } +} + diff --git a/crates/api_clients/midjourney_client/src/error/midjourney_api_error.rs b/crates/api_clients/midjourney_client/src/error/midjourney_api_error.rs new file mode 100644 index 0000000000..59081f7d7e --- /dev/null +++ b/crates/api_clients/midjourney_client/src/error/midjourney_api_error.rs @@ -0,0 +1,117 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use errors::AnyhowError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; + +#[derive(Debug)] +pub enum MidjourneyApiError { + /// There was no user ID in an otherwise valid-looking response. + NoUserId, + + /// No user props in index HTML payload. + /// We need this for the user ID and the websocket token. + NoUserProps, + + /// No initialAuthUser in the index HTML payload. + /// We need this for the user ID and the websocket token. + NoInitialAuthUser, + + /// 400. The request was invalid. + InvalidRequest(String), + + /// 401. The request was not authorized. + Unauthorized(String), + + /// 403. The request was forbidden. + Forbidden(String), + + /// 404. The requested resource was not found. + NotFound(String), + + /// 429. Too many requests. + TooManyRequests(String), + + /// 500. An internal server error occurred. + InternalServerError { + body: String, + backend_hostname: Option, + }, + + /// Eg. when downloading images + UnknownHttpFailure { + status_code: u16, + body: String, + }, + + /// Cloudflare errors. + CloudflareError(CloudflareError), + + /// A deserialization error with the response. + DeserializationError(serde_json::Error), + + /// The request timed out. + Timeout(String), + + /// A network error occurred. + NetworkError(String), + + /// An error doing file I/O (on our side) + IoError(io::Error), + + /// Another type of error. + Other(AnyhowError), +} + +impl Error for MidjourneyApiError {} + +impl Display for MidjourneyApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + // Response body errors + Self::NoUserId => write!(f, "No user ID found in the response body."), + Self::NoUserProps => write!(f, "No user properties found in the index HTML payload."), + Self::NoInitialAuthUser => write!(f, "No initialAuthUser in the index HTML payload."), + // Server response code errors + Self::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg), + Self::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), + Self::Forbidden(msg) => write!(f, "Forbidden: {}", msg), + Self::NotFound(msg) => write!(f, "Not found: {}", msg), + Self::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg), + Self::InternalServerError {body, backend_hostname} => + write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body), + Self::UnknownHttpFailure {status_code, body} => + write!(f, "Unknown HTTP failure; status code: {}; body: {}", status_code, body), + // Deserialization errors + // Server response handling errors + Self::DeserializationError(error) => write!(f, "Deserialization error: {}", error), + // Network errors + Self::Timeout(msg) => write!(f, "Timeout: {}", msg), + Self::NetworkError(msg) => write!(f, "Network error: {}", msg), + // Cloudflare errors + Self::CloudflareError(error) => write!(f, "Cloudflare Error: {}", error), + // I/O errors + Self::IoError(error) => write!(f, "IO error: {}", error), + // Other + Self::Other(error) => write!(f, "Other error: {}", error), + } + } +} + +impl From for MidjourneyApiError { + fn from(error: serde_json::Error) -> Self { + Self::DeserializationError(error) + } +} + +impl From for MidjourneyApiError { + fn from(error: io::Error) -> Self { + Self::IoError(error) + } +} + +impl From for MidjourneyApiError { + fn from(error: CloudflareError) -> Self { + Self::CloudflareError(error) + } +} diff --git a/crates/api_clients/midjourney_client/src/error/midjourney_client_error.rs b/crates/api_clients/midjourney_client/src/error/midjourney_client_error.rs new file mode 100644 index 0000000000..de74e8e1cd --- /dev/null +++ b/crates/api_clients/midjourney_client/src/error/midjourney_client_error.rs @@ -0,0 +1,71 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; +use std::path::PathBuf; + +/// An error arising from the client, either before or after the request is sent. +#[derive(Debug)] +pub enum MidjourneyClientError { + /// The cookie was too short to be valid. + CookieTooShort, + + /// Tried to look up an image out of range [0,4) + InvalidImageIndex, + + /// Could not determine the filetype for the file. + FileTypeNotKnown(PathBuf), + + /// Could not handle the file type. + FileTypeNotHandled(PathBuf), + + /// An error doing file I/O (on our side) + IoError(io::Error), + + // /// An error setting up the client + // ReqwestError(reqwest::Error), + WreqError(wreq::Error), + + /// A serialization error with the request. + SerializationError(serde_json::Error), +} + +impl Error for MidjourneyClientError {} + +impl Display for MidjourneyClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::CookieTooShort => write!(f, "The cookie header is too short to be valid."), + Self::InvalidImageIndex => write!(f, "The image index is out of range. Valid range is [0, 4)."), + Self::FileTypeNotKnown(path) => write!(f, "Could not determine the filetype for the file: {:?}", path), + Self::FileTypeNotHandled(path) => write!(f, "Could not handle the file type for the file: {:?}", path), + Self::IoError(err) => write!(f, "IO error: {}", err), + //Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + Self::SerializationError(err) => write!(f, "Request serialization error: {}", err), + } + } +} + +impl From for MidjourneyClientError { + fn from(error: io::Error) -> Self { + Self::IoError(error) + } +} + +impl From for MidjourneyClientError { + fn from(error: serde_json::Error) -> Self { + Self::SerializationError(error) + } +} + +/*impl From for MidjourneyClientError { + fn from(error: reqwest::Error) -> Self { + Self::ReqwestError(error) + } +}*/ + +impl From for MidjourneyClientError { + fn from(error: wreq::Error) -> Self { + Self::WreqError(error) + } +} diff --git a/crates/api_clients/midjourney_client/src/error/midjourney_error.rs b/crates/api_clients/midjourney_client/src/error/midjourney_error.rs new file mode 100644 index 0000000000..61b6490ff7 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/error/midjourney_error.rs @@ -0,0 +1,32 @@ +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use std::error::Error; + +#[derive(Debug)] +pub enum MidjourneyError { + Api(MidjourneyApiError), + Client(MidjourneyClientError), +} + +impl Error for MidjourneyError {} + +impl std::fmt::Display for MidjourneyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Api(e) => write!(f, "Midjourney API error: {:?}", e), + Self::Client(e) => write!(f, "Midjourney Client error: {:?}", e), + } + } +} + +impl From for MidjourneyError { + fn from(error: MidjourneyApiError) -> Self { + Self::Api(error) + } +} + +impl From for MidjourneyError { + fn from(error: MidjourneyClientError) -> Self { + Self::Client(error) + } +} diff --git a/crates/api_clients/midjourney_client/src/error/mod.rs b/crates/api_clients/midjourney_client/src/error/mod.rs new file mode 100644 index 0000000000..ceefb7b1b1 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/error/mod.rs @@ -0,0 +1,3 @@ +pub mod midjourney_error; +pub mod midjourney_api_error; +pub mod midjourney_client_error; \ No newline at end of file diff --git a/crates/api_clients/midjourney_client/src/lib.rs b/crates/api_clients/midjourney_client/src/lib.rs new file mode 100644 index 0000000000..1adf93044d --- /dev/null +++ b/crates/api_clients/midjourney_client/src/lib.rs @@ -0,0 +1,8 @@ +//! A midjourney API client. + +pub mod client; +pub mod credentials; +pub mod endpoints; +pub mod error; +pub mod recipes; +pub mod utils; diff --git a/crates/api_clients/midjourney_client/src/recipes/channel_id.rs b/crates/api_clients/midjourney_client/src/recipes/channel_id.rs new file mode 100644 index 0000000000..b9f876cf3c --- /dev/null +++ b/crates/api_clients/midjourney_client/src/recipes/channel_id.rs @@ -0,0 +1,20 @@ +use crate::credentials::midjourney_user_id::MidjourneyUserId; + +#[derive(Debug, Clone)] +pub enum ChannelId { + /// Channel based on the user ID. + /// This is what Midjourney uses by default. + UserId(MidjourneyUserId), + + // Bare string ID + Raw(String), +} + +impl ChannelId { + pub fn to_string(&self) -> String { + match self { + Self::UserId(user_id) => format!("singleplayer_{}", user_id.0), + Self::Raw(id) => id.clone(), + } + } +} diff --git a/crates/api_clients/midjourney_client/src/recipes/get_midjourney_user_id_from_storage.rs b/crates/api_clients/midjourney_client/src/recipes/get_midjourney_user_id_from_storage.rs new file mode 100644 index 0000000000..1c0a112452 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/recipes/get_midjourney_user_id_from_storage.rs @@ -0,0 +1,52 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::credentials::midjourney_user_id::MidjourneyUserId; +use crate::endpoints::storage_list::{storage_list, GetStorageListRequest}; +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_error::MidjourneyError; + +pub struct GetMidjourneyUserIdRequest { + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +pub async fn get_midjourney_user_id_from_storage(req: GetMidjourneyUserIdRequest) -> Result { + let items = storage_list(GetStorageListRequest { + hostname: req.hostname, + cookie_header: req.cookie_header, + }).await?; + + let user_id = items + .into_iter() + .find_map(|item| item.bucket_pathname) + .and_then(|path| path.split('/').next().map(|id| id.to_owned())) + .map(|id| MidjourneyUserId(id.to_string())) + .ok_or(MidjourneyApiError::NoUserId)?; + + Ok(user_id) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use crate::recipes::get_midjourney_user_id_from_storage::{get_midjourney_user_id_from_storage, GetMidjourneyUserIdRequest}; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + + let result = get_midjourney_user_id_from_storage(GetMidjourneyUserIdRequest { + cookie_header, + hostname: MidjourneyHostname::Standard, + }).await?; + + println!("Response: {:?}\n\n", result); + + assert_eq!(1, 2); + + Ok(()) + } +} + diff --git a/crates/api_clients/midjourney_client/src/recipes/get_user_info.rs b/crates/api_clients/midjourney_client/src/recipes/get_user_info.rs new file mode 100644 index 0000000000..f1fee8f084 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/recipes/get_user_info.rs @@ -0,0 +1,153 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::credentials::midjourney_user_id::MidjourneyUserId; +use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageRequest}; +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_error::MidjourneyError; +use dom_query::Document; +use serde::Deserialize; + +pub struct GetUserInfoRequest { + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +#[derive(Debug, Clone)] +pub struct GetUserInfoResponse { + pub user_id: Option, + pub email: Option, + pub websocket_token: Option, +} + +pub async fn get_user_info(req: GetUserInfoRequest) -> Result { + let index_html = get_index_page_html(GetIndexPageRequest { + hostname: req.hostname, + cookie_header: req.cookie_header, + }).await?; + + /* + + */ + + let document = Document::from(index_html); + let maybe_nodes = document.select("script[id=initialProps]"); + + let node = match maybe_nodes.get(0) { + Some(node) => node, + None => { + return Err(MidjourneyApiError::NoUserProps.into()); + } + }; + + let inner_json = node.inner_html().to_string(); + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawAuthUser { + // NB: The Midjourney user id. + id: Option, + email: Option, + websocketToken: Option, + } + + #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] + struct RawBody { + initialAuthUser: Option, + } + + let response : RawBody = serde_json::from_str(&inner_json) + .map_err(|err| MidjourneyApiError::DeserializationError(err))?; + + let props = match response.initialAuthUser { + Some(props) => props, + None => { + return Err(MidjourneyApiError::NoInitialAuthUser.into()); + } + }; + + Ok(GetUserInfoResponse { + user_id: props.id + .map(|id| MidjourneyUserId::from_string(id)), + email: props.email + .map(|email| email.to_string()), + websocket_token: props.websocketToken + .map(|email| email.to_string()), + }) +} + +#[cfg(test)] +mod tests { + use crate::client::midjourney_hostname::MidjourneyHostname; + use crate::recipes::get_user_info::{get_user_info, GetUserInfoRequest}; + use errors::AnyhowResult; + use filesys::read_to_trimmed_string::read_to_trimmed_string; + + #[ignore] + #[tokio::test] + async fn test() -> AnyhowResult<()> { + let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?; + + let result = get_user_info(GetUserInfoRequest { + cookie_header, + hostname: MidjourneyHostname::Standard, + }).await?; + + println!("Response: {:?}\n\n", result); + + assert_eq!(1, 2); + + Ok(()) + } +} + diff --git a/crates/api_clients/midjourney_client/src/recipes/mod.rs b/crates/api_clients/midjourney_client/src/recipes/mod.rs new file mode 100644 index 0000000000..7ec937df23 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/recipes/mod.rs @@ -0,0 +1,4 @@ +mod get_midjourney_user_id_from_storage; +pub mod channel_id; +pub mod get_user_info; +pub mod text_to_image; \ No newline at end of file diff --git a/crates/api_clients/midjourney_client/src/recipes/text_to_image.rs b/crates/api_clients/midjourney_client/src/recipes/text_to_image.rs new file mode 100644 index 0000000000..77d15bce6f --- /dev/null +++ b/crates/api_clients/midjourney_client/src/recipes/text_to_image.rs @@ -0,0 +1,49 @@ +use crate::client::midjourney_hostname::MidjourneyHostname; +use crate::endpoints::submit_job::{submit_job, SubmitJobRequest}; +use crate::error::midjourney_error::MidjourneyError; +use crate::recipes::channel_id::ChannelId; + +pub struct TextToImageRequest<'a> { + pub prompt: &'a str, + pub channel_id: &'a ChannelId, + pub hostname: MidjourneyHostname, + pub cookie_header: String, +} + +#[derive(Debug, Clone)] +pub struct TextToImageResponse { + /// On success, the job ID is returned. + pub maybe_job_id: Option, + + /// On error, we have a list of error messages. + pub maybe_errors: Option>, +} + +#[derive(Debug, Clone)] +pub struct TextToImageError { + pub error_type: Option, + pub message: Option, +} + +/// Slightly more ergonomic text-to-image API. +/// As we add more `submit_job()` cases, we'll keep this simple. +pub async fn text_to_image(req: TextToImageRequest<'_>) -> Result { + let channel_id = req.channel_id.to_string(); + + let response = submit_job(SubmitJobRequest { + prompt: req.prompt, + channel_id: &channel_id, + hostname: req.hostname, + cookie_header: req.cookie_header, + }).await?; + + Ok(TextToImageResponse { + maybe_job_id: response.maybe_job_id, + maybe_errors: response.maybe_errors.map(|errs| { + errs.into_iter().map(|e| TextToImageError { + error_type: e.error_type, + message: e.message, + }).collect() + }), + }) +} diff --git a/crates/api_clients/midjourney_client/src/utils/get_image_url.rs b/crates/api_clients/midjourney_client/src/utils/get_image_url.rs new file mode 100644 index 0000000000..81116e8c74 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/utils/get_image_url.rs @@ -0,0 +1,9 @@ +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; + +pub fn get_image_url(job_id: &str, image_index: u8) -> Result { + if image_index > 3 { + return Err(MidjourneyClientError::InvalidImageIndex.into()) + } + Ok(format!("https://cdn.midjourney.com/{}/0_{}.png", job_id, image_index)) +} diff --git a/crates/api_clients/midjourney_client/src/utils/image_downloader_client.rs b/crates/api_clients/midjourney_client/src/utils/image_downloader_client.rs new file mode 100644 index 0000000000..c5e3720353 --- /dev/null +++ b/crates/api_clients/midjourney_client/src/utils/image_downloader_client.rs @@ -0,0 +1,70 @@ +use crate::error::midjourney_api_error::MidjourneyApiError; +use crate::error::midjourney_client_error::MidjourneyClientError; +use crate::error::midjourney_error::MidjourneyError; +use crate::utils::get_image_url::get_image_url; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use wreq::Client; +use wreq_util::Emulation; + +#[derive(Clone)] +pub struct ImageDownloaderClient { + client: Client, +} + +impl ImageDownloaderClient { + pub fn create() -> Result { + Ok(Self { + client: Client::builder() + .emulation(Emulation::Firefox139) + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))? + }) + } + + pub async fn download_image(&self, job_id: &str, image_index: u8) -> anyhow::Result, MidjourneyError> { + let url = get_image_url(job_id, image_index)?; + + // TODO: Cookies + // TODO: Cache control headers? + let mut http_request = self.client.get(url) + //.header("Referrer-Policy", "origin-when-cross-origin") + //.header("content-type", "application/json") + //.header("sec-fetch-user", "?1") + //.header("upgrade-insecure-requests", "1"); + .header("Referrer", "https://www.midjourney.com/") + .header("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + .header("accept-language", "en-US,en;q=0.8") + .header("priority", "i") + .header("sec-ch-ua-mobile", "?0") + .header("sec-fetch-dest", "image") + .header("sec-fetch-mode", "no-cors") + .header("sec-fetch-site", "same-site"); + + let http_request = http_request + .build() + .map_err(|err| MidjourneyClientError::WreqError(err))?; + + let response = self.client.execute(http_request) + .await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + let status = response.status(); + + let response_bytes = response.bytes().await + .map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?; + + if !status.is_success() { + let response_body = String::from_utf8_lossy(&response_bytes).to_string(); + if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) { + return Err(MidjourneyApiError::CloudflareError(err).into()); + } + + return Err(MidjourneyApiError::UnknownHttpFailure { + status_code: status.as_u16(), + body: response_body, + }.into()); + } + + Ok(response_bytes.to_vec()) + } +} diff --git a/crates/api_clients/midjourney_client/src/utils/mod.rs b/crates/api_clients/midjourney_client/src/utils/mod.rs new file mode 100644 index 0000000000..9f61b7f98a --- /dev/null +++ b/crates/api_clients/midjourney_client/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod get_image_url; +pub mod image_downloader_client; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/Cargo.toml b/crates/api_clients/openai_sora_client/Cargo.toml new file mode 100644 index 0000000000..0116948a68 --- /dev/null +++ b/crates/api_clients/openai_sora_client/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "openai_sora_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "openai_sora_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true +uuid_utils.workspace = true + +# External +anyhow.workspace = true +base64.workspace = true +chrono.workspace = true +cloudflare_errors.workspace = true +cookie.workspace = true +log.workspace = true +once_cell.workspace = true +rand.workspace = true +#reqwest = { workspace = true, features = ["multipart", "gzip"] } +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +sha3.workspace = true +thiserror.workspace = true +#tokio.workspace = true +toml.workspace = true +url.workspace = true + +tokio = { version = "1", features = ["full"] } +wreq-util.workspace = true +wreq.workspace = true + +[dev-dependencies] +test_utils = { path = "../../testing/test_utils" } diff --git a/crates/api_clients/openai_sora_client/src/constants/mod.rs b/crates/api_clients/openai_sora_client/src/constants/mod.rs new file mode 100644 index 0000000000..e4782e128e --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/constants/mod.rs @@ -0,0 +1 @@ +pub (crate) mod user_agent; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/constants/user_agent.rs b/crates/api_clients/openai_sora_client/src/constants/user_agent.rs new file mode 100644 index 0000000000..092f804529 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/constants/user_agent.rs @@ -0,0 +1,22 @@ + +// TODO: enum UserAgent { Mac, Linux, Windows } which maps to these. + +/// This user agent is tied to the sentinel generation. +/// If we need to change it, we may need to change sentinel generation too. +pub (crate) const CLIENT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; + + +// NB: from sentinel token request - +// /// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too. +// const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; + +// NB: from image gen request - +// /// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too. +// //const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; + +// Previous job status user agent +// const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; + +// NB: image gen status - +// /// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too. +// pub (crate) const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; diff --git a/crates/api_clients/openai_sora_client/src/creds/mod.rs b/crates/api_clients/openai_sora_client/src/creds/mod.rs new file mode 100644 index 0000000000..1cea80d0bc --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/mod.rs @@ -0,0 +1,6 @@ +pub mod sora_cookies; +pub mod sora_credential_builder; +pub mod sora_credential_set; +pub mod sora_jwt_bearer_token; +pub mod sora_sentinel; +pub mod sora_sentinel_token; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_cookies.rs b/crates/api_clients/openai_sora_client/src/creds/sora_cookies.rs new file mode 100644 index 0000000000..5ff387b645 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_cookies.rs @@ -0,0 +1,25 @@ +/// Cookies are the credential that are always required. +/// You can mint a JWT bearer token with just the cookies. + +#[derive(Clone)] +pub struct SoraCookies { + cookies: String, +} + +impl SoraCookies { + pub fn new(cookies: String) -> Self { + SoraCookies { cookies } + } + + pub fn as_str(&self) -> &str { + &self.cookies + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookies.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookies.clone() + } +} diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs b/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs new file mode 100644 index 0000000000..a23cbfc27b --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs @@ -0,0 +1,99 @@ +use crate::creds::sora_cookies::SoraCookies; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::creds::sora_sentinel::SoraSentinel; +use crate::creds::sora_sentinel_token::SoraSentinelToken; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; + +pub struct SoraCredentialBuilder { + cookies: Option, + + jwt_bearer_token: Option, + + #[deprecated(note="use sora_sentinel_token instead")] + sora_sentinel: Option, + + /// This is the newer sentinel token. Gradually phase out the older one. + sora_sentinel_token: Option, +} + +impl SoraCredentialBuilder { + pub fn new() -> Self { + SoraCredentialBuilder { + cookies: None, + jwt_bearer_token: None, + sora_sentinel: None, + sora_sentinel_token: None, + } + } + + // Consuming methods + + pub fn with_cookies(mut self, cookies: &str) -> Self { + self.cookies = Some(cookies.to_string()); + self + } + + pub fn with_jwt_bearer_token(mut self, token: &str) -> Self { + self.jwt_bearer_token = Some(token.to_string()); + self + } + + pub fn with_sora_sentinel(mut self, sentinel: &str) -> Self { + self.sora_sentinel = Some(sentinel.to_string()); + self + } + + pub fn with_sora_sentinel_token(mut self, sentinel_token: &SoraSentinelToken) -> Self { + self.sora_sentinel_token = Some(sentinel_token.clone()); + self + } + + // Mutable methods + + pub fn set_cookies(&mut self, cookies: &str) { + self.cookies = Some(cookies.to_string()); + } + + pub fn set_jwt_bearer_token(&mut self, token: &str) { + self.jwt_bearer_token = Some(token.to_string()); + } + + pub fn set_sora_sentinel(&mut self, sentinel: &str) { + self.sora_sentinel = Some(sentinel.to_string()); + } + + pub fn set_sora_sentinel_token(&mut self, sentinel_token: &SoraSentinelToken) { + self.sora_sentinel_token = Some(sentinel_token.clone()); + } + + pub fn build(self) -> Result { + let cookies = match self.cookies { + Some(c) => SoraCookies::new(c), + None => return Err(SoraClientError::SoraCredentialBuilderError("no cookies provided").into()), + }; + + let jwt_bearer_token = match self.jwt_bearer_token { + Some(t) => Some(SoraJwtBearerToken::new(t)?), + None => None, + }; + + let sora_sentinel = match self.sora_sentinel { + Some(s) => Some(SoraSentinel::new(s)), + None => None, + }; + + let sora_sentinel_token = match self.sora_sentinel_token { + Some(s) => Some(s), + None => None, + }; + + Ok(SoraCredentialSet { + cookies, + jwt_bearer_token, + sora_sentinel, + sora_sentinel_token, + }) + } +} diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs b/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs new file mode 100644 index 0000000000..5f885e6444 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs @@ -0,0 +1,63 @@ +use crate::creds::sora_cookies::SoraCookies; +use crate::creds::sora_credential_builder::SoraCredentialBuilder; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::creds::sora_sentinel::SoraSentinel; +use crate::creds::sora_sentinel_token::SoraSentinelToken; + +#[derive(Clone)] +pub struct SoraCredentialSet { + pub cookies: SoraCookies, + pub jwt_bearer_token: Option, + + #[deprecated(note="use sora_sentinel_token instead")] + pub sora_sentinel: Option, + + // NB: This is the newer sentinel token. Gradually phase out the older one. + pub sora_sentinel_token: Option, +} + +impl SoraCredentialSet { + pub fn initialize( + cookies: SoraCookies, + bearer: Option, + sentinel: Option, + sentinel_token: Option, + ) -> Self { + Self { + cookies, + jwt_bearer_token: bearer, + sora_sentinel: sentinel, + sora_sentinel_token: sentinel_token, + } + } + + pub fn initialize_with_just_cookies(cookies: SoraCookies) -> Self { + SoraCredentialSet { + cookies, + jwt_bearer_token: None, + sora_sentinel: None, + sora_sentinel_token: None, + } + } + + pub fn initialize_with_just_cookies_str(cookies: &str) -> Self { + Self::initialize_with_just_cookies( + SoraCookies::new(cookies.to_string()), + ) + } + + pub fn to_builder(&self) -> SoraCredentialBuilder { + let mut builder = SoraCredentialBuilder::new() + .with_cookies(self.cookies.as_str()); + if let Some(bearer) = &self.jwt_bearer_token { + builder = builder.with_jwt_bearer_token(bearer.as_str()); + } + if let Some(sentinel) = &self.sora_sentinel { + builder = builder.with_sora_sentinel(sentinel.as_str()); + } + if let Some(sentinel_token) = &self.sora_sentinel_token { + builder = builder.with_sora_sentinel_token(sentinel_token); + } + builder + } +} diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs b/crates/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs new file mode 100644 index 0000000000..b062a41414 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs @@ -0,0 +1,42 @@ +use crate::error::sora_error::SoraError; +use crate::utils_internal::lightweight_sora_jwt_parse::{lightweight_sora_jwt_parse, SoraJwtClaims}; + +/// Sora bearer tokens are JWT tokens that can be minted with a valid cookie/session. +#[derive(Clone)] +pub struct SoraJwtBearerToken { + token: String, + jwt_claims: SoraJwtClaims, +} + +impl SoraJwtBearerToken { + pub fn new(token: String) -> Result { + let jwt_claims = lightweight_sora_jwt_parse(&token)?; + Ok(SoraJwtBearerToken { + token, + jwt_claims, + }) + } + + pub fn token_str(&self) -> &str { + &self.token + } + + pub fn as_str(&self) -> &str { + &self.token + } + + pub fn as_bytes(&self) -> &[u8] { + self.token.as_bytes() + } + + pub fn jwt_claims(&self) -> &SoraJwtClaims { + &self.jwt_claims + } + + pub fn to_authorization_header_value(&self) -> String { + match self.token.get(0..6) { + Some("bearer") | Some("Bearer") => self.token.clone(), + _ => "Bearer ".to_owned() + &self.token, + } + } +} diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs new file mode 100644 index 0000000000..70aa657293 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs @@ -0,0 +1,31 @@ + +/// Sora Sentinel tokens can be generated without cookies or JWTs, and are needed for some API calls. +#[derive(Clone)] +#[deprecated(note="use SoraSentinelToken instead")] +pub struct SoraSentinel { + sentinel: String, + + // TODO(bt,2025-04-23): We may want to store the user agent alongside the sentinel + // as that appears to be important for the validity of the sentinel token. +} + +impl SoraSentinel { + pub fn new(sentinel: String) -> Self { + SoraSentinel { sentinel } + } + + #[deprecated(note="use SoraSentinelToken instead")] + pub fn get_sentinel(&self) -> &str { + &self.sentinel + } + + #[deprecated(note="use SoraSentinelToken instead")] + pub fn as_str(&self) -> &str { + &self.sentinel + } + + #[deprecated(note="use SoraSentinelToken instead")] + pub fn as_bytes(&self) -> &[u8] { + self.sentinel.as_bytes() + } +} diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_sentinel_token.rs b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel_token.rs new file mode 100644 index 0000000000..c59a285140 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel_token.rs @@ -0,0 +1,99 @@ +use crate::error::sora_client_error::SoraClientError; +use chrono::{DateTime, TimeDelta, Utc}; +use log::{error, warn}; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SoraSentinelToken { + /// The token sent over the wire. + /// (The other fields in this struct are metadata.) + pub token: RawSoraSentinelToken, + + /// User agent used to get the token. + pub browser_user_agent: String, + + /// Clientside time when it was generated (client-side). + pub generated_at: DateTime, + + /// Seconds of validity (reported by the server, or our best guess). + /// If the server doesn't tell us, we'll fill it in with our best guess. + pub expires_in_seconds: u32, +} + +/// This is what is sent over the wire in requests. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RawSoraSentinelToken { + /// "Proof of Work" token (we generated this clientside) + #[serde(rename = "p")] + pub p: String, + + /// Random UUID (we generated this clientside) + #[serde(rename = "id")] + pub id: String, + + /// Frontend UI flow (this is the website flow used) + #[serde(rename = "flow")] + pub flow: String, + + /// Turnstile dx from the response. + /// response["turnstile"]["dx"] + #[serde(rename = "t")] + pub t: String, + + /// Token from the response. + /// response["token"] + #[serde(rename = "c")] + pub c: String, +} + +impl SoraSentinelToken { + /// Check to see if the sentinel token is expired and in need of a refresh. + pub fn is_expired(&self) -> bool { + let now = Utc::now(); + + let maybe_expires_at = self.generated_at + .checked_add_signed(TimeDelta::seconds(self.expires_in_seconds as i64)); + + let expires_at = match maybe_expires_at { + Some(expires_at) => expires_at, + None => { + error!("Could not compute expires_at for sentinel token!"); + return false; + } + }; + + if now >= expires_at { + return true; + } + + if expires_at.signed_duration_since(now).num_seconds() < 30 { + warn!("Less than 30 seconds remaining on sentinel token, treating as expired."); + return true; + } + + false + } + + /// Convert to JSON, which is the raw over-the-wire format Sora consumes. + pub fn to_request_header_json(&self) -> Result { + self.token.to_request_header_json() + } + + pub fn to_persistent_storage_json(&self) -> Result { + serde_json::to_string(self) + .map_err(|err| SoraClientError::CouldNotSerializeSentinelTokenStore(err)) + } + + pub fn from_persistent_storage_json(json: &str) -> Result { + serde_json::from_str(json) + .map_err(|err| SoraClientError::CouldNotDeserializeSentinelTokenStore { error: err, raw_json: json.to_owned() }) + } +} + +impl RawSoraSentinelToken { + /// Convert to JSON, which is the raw over-the-wire format Sora consumes. + pub fn to_request_header_json(&self) -> Result { + serde_json::to_string(self) + .map_err(|err| SoraClientError::CouldNotSerializeSentinelToken(err)) + } +} diff --git a/crates/api_clients/openai_sora_client/src/error/mod.rs b/crates/api_clients/openai_sora_client/src/error/mod.rs new file mode 100644 index 0000000000..010e4fe745 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/error/mod.rs @@ -0,0 +1,4 @@ +pub mod sora_client_error; +pub mod sora_error; +pub mod sora_generic_api_error; +pub mod sora_specific_api_error; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs b/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs new file mode 100644 index 0000000000..6c5d946967 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs @@ -0,0 +1,70 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum SoraClientError { + /// An error reading the file for upload. + FileForUploadReadError(std::io::Error), + + /// The file path provided for upload is invalid. + FileForUploadHasInvalidPath, + + /// Something is wrong with the JWT bearer token. + /// This error originates on our end as we try to parse the JWT. + LocalJwtClaimsParseError(String), + + /// There was an error constructing the form-multipart request. + MultipartFormError(wreq::Error), + + /// We haven't received a bearer token yet + /// This is our own internal application state error, not something Sora returns. + /// We know our client can't make the request, so we preemptively fail it. + NoBearerTokenForRequest, + + /// A sentinel token is not present in the client, which is required for some requests. + NoSentinelTokenForRequest, + + /// Issue with using the SoraCredentialBuilder. + SoraCredentialBuilderError(&'static str), + + /// Error parsing a request URL. + UrlParseError(url::ParseError), + + /// An error was encountered in building the Wreq client + WreqClientError(wreq::Error), + + /// Error serializing the sentinel token to JSON + CouldNotSerializeSentinelToken(serde_json::Error), + + /// Error serializing the sentinel store token to JSON (typically for persistent storage). + CouldNotSerializeSentinelTokenStore(serde_json::Error), + + /// Error deserializing the sentinel token from JSON (typically for persistent storage). + CouldNotDeserializeSentinelTokenStore { + error: serde_json::Error, + raw_json: String + }, +} + +impl Error for SoraClientError {} + +impl Display for SoraClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::FileForUploadReadError(err) => write!(f, "Error reading file for upload: {}", err), + Self::FileForUploadHasInvalidPath => write!(f, "The file path provided for upload is invalid."), + Self::LocalJwtClaimsParseError(msg) => write!(f, "Local JWT claims parse error: {}", msg), + Self::MultipartFormError(err) => write!(f, "Multipart form error: {}", err), + Self::NoBearerTokenForRequest => write!(f, "No bearer token available. The client needs a bearer token to make the request."), + Self::NoSentinelTokenForRequest => write!(f, "No sentinel token available. The client needs a sentinel token to make the request."), + Self::SoraCredentialBuilderError(msg) => write!(f, "Sora Credential Builder error: {}", msg), + Self::UrlParseError(err) => write!(f, "URL parse error: {}", err), + Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), + Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err), + Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err), + Self::CouldNotDeserializeSentinelTokenStore { error, raw_json} => { + write!(f, "Could not deserialize sentinel token store from JSON: {:?}, raw JSON: {}", error, raw_json) + }, + } + } +} diff --git a/crates/api_clients/openai_sora_client/src/error/sora_error.rs b/crates/api_clients/openai_sora_client/src/error/sora_error.rs new file mode 100644 index 0000000000..5fc7ae3978 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/error/sora_error.rs @@ -0,0 +1,54 @@ +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; + +#[derive(Debug)] +pub enum SoraError { + Client(SoraClientError), + ApiSpecific(SoraSpecificApiError), + ApiGeneric(SoraGenericApiError), +} + +impl SoraError { + pub fn is_sora_having_downtime_issues(&self) -> bool { + match self { + Self::ApiGeneric(SoraGenericApiError::Http502ErrorBadGateway(_)) => true, + Self::ApiGeneric(SoraGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true, + Self::ApiGeneric(SoraGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true, + Self::ApiGeneric(SoraGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true, + _ => false, + } + } +} + +impl Error for SoraError {} + +impl std::fmt::Display for SoraError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "SoraClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "SoraSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "SoraGenericApiError: {:?}", e), + } + } +} + +impl From for SoraError { + fn from(error: SoraClientError) -> Self { + Self::Client(error) + } +} + +impl From for SoraError { + fn from(error: SoraSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for SoraError { + fn from(error: SoraGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/openai_sora_client/src/error/sora_generic_api_error.rs b/crates/api_clients/openai_sora_client/src/error/sora_generic_api_error.rs new file mode 100644 index 0000000000..a4004941b3 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/error/sora_generic_api_error.rs @@ -0,0 +1,54 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use wreq::StatusCode; + +#[derive(Debug)] +pub enum SoraGenericApiError { + /// Specific Cloudflare errors. + CloudflareError(CloudflareError), + + /// HTTP 502 Bad Gateway + /// Sora is having issues. + /// Typically served from Cloudflare + /// We preserve the message for debugging. + Http502ErrorBadGateway(String), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + /// Specifically, this is for non-200 (error) responses. + SerdeParseErrorWithBodyOnNon200(serde_json::Error, String), + + /// Another error occurred. + /// TODO: Try to get rid of this. + UncategorizedBadResponse(String), + + /// An uncategorized bad HTTP response from Sora. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// An uncaught error from the API client. + WreqError(wreq::Error), +} + +impl Error for SoraGenericApiError {} + +impl Display for SoraGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), + Self::Http502ErrorBadGateway(msg) => write!(f, "HTTP 502 Bad Gateway: {}", msg), + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response status: {}", msg), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + } + } +} diff --git a/crates/api_clients/openai_sora_client/src/error/sora_specific_api_error.rs b/crates/api_clients/openai_sora_client/src/error/sora_specific_api_error.rs new file mode 100644 index 0000000000..3366fedb29 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/error/sora_specific_api_error.rs @@ -0,0 +1,68 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum SoraSpecificApiError { + /// Failed to generate a bearer token despite the `200 OK` response. + /// This has been generated by sending old/expired session cookies or empty cookies. + FailedToGenerateBearerToken, + + /// The endpoint told us we used an invalid JWT. + InvalidJwt, + + /// Sora has responded with "sentinel block". + /// This usually means we need to refresh our sentinel token. + SentinelBlockError, + + /// Sora requires users to set up an account username before they can generate images. + /// This requires the user to set up that username. + SoraUsernameNotYetCreated, + + /// Sora has responded with "token expired". + /// This (probably?) means we need to refresh our JWT bearer token. + TokenExpiredError, + + /// We're sending too many tasks to Sora. + /// Sora is telling us to cool it. + TooManyConcurrentTasks, + + // TODO: Try to make concrete error types for these distinct cases. + /// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login. + /// + /// Example message, e.g. from the upload endpoint: + /// { + /// "error": { + /// "message": "Your authentication token has expired. Please try signing in again.", + /// "type": "invalid_request_error", + /// "param": null, + /// "code": "token_expired" + /// } + /// } + UnauthorizedCookieOrBearerExpired, + + /// The Sora sentinel token response is missing fields + SentinelResponseIsMissingFields { + missing_token: bool, + missing_turnstile: bool, + raw_response: String, + }, +} + +impl Error for SoraSpecificApiError {} + +impl Display for SoraSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::FailedToGenerateBearerToken => write!(f, "Failed to generate bearer token. Probably invalid or expired session cookies."), + Self::InvalidJwt => write!(f, "Invalid JWT token. Please refresh the token."), + Self::SentinelBlockError => write!(f, "Request blocked by Sentinel Token Block. Please refresh the sentinel token."), + Self::SoraUsernameNotYetCreated => write!(f, "Sora username not yet created. Please create a username in the Sora app or website."), + Self::TokenExpiredError => write!(f, "JWT token has expired. Please refresh the token."), + Self::TooManyConcurrentTasks => write!(f, "Too many concurrent tasks. Please wait."), + Self::UnauthorizedCookieOrBearerExpired => write!(f, "Unauthorized: cookie and/or bearer token expired"), + Self::SentinelResponseIsMissingFields { missing_token: token_missing, missing_turnstile: turnstile_missing, raw_response } => { + write!(f, "Sentinel response is missing fields. token_missing: {}, turnstile_missing: {}. Raw response: {}", token_missing, turnstile_missing, raw_response) + }, + } + } +} diff --git a/crates/api_clients/openai_sora_client/src/lib.rs b/crates/api_clients/openai_sora_client/src/lib.rs new file mode 100644 index 0000000000..78cefe3cc4 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/lib.rs @@ -0,0 +1,13 @@ +#[cfg(test)] +pub (crate) mod test_utils; + +// Internal lib +pub (crate) mod constants; +pub (crate) mod utils_internal; + +// User API +pub mod creds; +pub mod error; +pub mod recipes; +pub mod requests; +pub mod utils; diff --git a/crates/api_clients/openai_sora_client/src/recipes/generate_sora2_video/generate_sora2_video_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/generate_sora2_video/generate_sora2_video_with_session_auto_renew.rs new file mode 100644 index 0000000000..32f53d6828 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/generate_sora2_video/generate_sora2_video_with_session_auto_renew.rs @@ -0,0 +1,63 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::recipes::utils::maybe_renew_sentinel_token::maybe_renew_sentinel_token; +use crate::recipes::utils::maybe_renew_session_jwt::maybe_renew_session_jwt; +use crate::requests::generate_sora2_video::generate_sora2_video::{generate_sora2_video, GenerateSora2VideoArgs, GenerateSora2VideoResponse}; +use std::io::Write; + +/// Generate Sora 2 video with session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn generate_sora2_video_with_session_auto_renew( + args: GenerateSora2VideoArgs<'_>, +) -> Result<(GenerateSora2VideoResponse, Option), SoraError> { + + let mut maybe_new_creds = maybe_renew_session_jwt(&args.credentials).await?; + + let use_creds = maybe_new_creds.as_ref() + .unwrap_or_else(|| &args.credentials); + + let mut maybe_new_creds = maybe_renew_sentinel_token(use_creds).await?; + + let use_creds = maybe_new_creds.as_ref() + .unwrap_or_else(|| &args.credentials); + + let mut request = args.clone(); + request.credentials = use_creds; + + let result = generate_sora2_video(request).await; + + match result { + Ok(response) => Ok((response, maybe_new_creds)), + Err(err) => Err(err), // TODO: Should we retry? + } +} + +#[cfg(test)] +mod tests { + use crate::recipes::generate_sora2_video::generate_sora2_video_with_session_auto_renew::generate_sora2_video_with_session_auto_renew; + use crate::requests::generate_sora2_video::generate_sora2_video::{GenerateSora2VideoArgs, Orientation}; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + + let args = GenerateSora2VideoArgs { + prompt: "a Golden Retriever playing basketball at Venice Beach during sunset", + credentials: &creds, + request_timeout: None, + orientation: Orientation::Landscape, + image_reference_media_ids: None, + }; + + let (result , creds) = + generate_sora2_video_with_session_auto_renew(args).await?; + + println!("result: {:#?}", result); + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/generate_sora2_video/mod.rs b/crates/api_clients/openai_sora_client/src/recipes/generate_sora2_video/mod.rs new file mode 100644 index 0000000000..300e294aaa --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/generate_sora2_video/mod.rs @@ -0,0 +1 @@ +pub mod generate_sora2_video_with_session_auto_renew; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/recipes/image_remix_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/image_remix_with_session_auto_renew.rs new file mode 100644 index 0000000000..6c3ea911ca --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/image_remix_with_session_auto_renew.rs @@ -0,0 +1,142 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::creds::sora_sentinel::SoraSentinel; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; +use crate::requests::auth_sentinel::generate_sentinel_token::generate_sentinel_token; +use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse}; +use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest}; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info, warn}; +use std::time::Duration; + +pub struct ImageRemixAutoRenewRequest<'a> { + pub prompt: String, + pub num_images: NumImages, + pub image_size: ImageSize, + pub sora_media_tokens: Vec, + pub credentials: &'a SoraCredentialSet, + /// This function can try several different requests. This is the timeout for *individual* requests. + pub request_timeout: Option, +} + +/// Image request with retry and session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewRequest<'_>) -> Result<(SoraImageGenResponse, Option), SoraError> { + let result = sora_image_gen_remix(SoraImageGenRemixRequest { + prompt: request.prompt.clone(), // NB: Clone because used again + num_images: request.num_images, + image_size: request.image_size, + sora_media_tokens: request.sora_media_tokens.clone(), // NB: Clone because used again + credentials: request.credentials, + request_timeout: request.request_timeout, + }).await; + + let err = match result { + Ok(response) => return Ok((response, None)), + Err(err) => err, + }; + + warn!("Image generation failed: {:?}", err); + + let mut refresh_jwt = false; + let mut refresh_sentinel = false; + + match err { + // We'll specifically fail these non-retryable requests... + + SoraError::ApiSpecific(SoraSpecificApiError::TooManyConcurrentTasks) | + SoraError::ApiSpecific(SoraSpecificApiError::SoraUsernameNotYetCreated) => { + error!("Non-retryable image generation error: {:?}", err); + return Err(err); + } + + // We'll retry these requests... + + SoraError::ApiSpecific(SoraSpecificApiError::SentinelBlockError) => { + warn!("Image generation failed due to sentinel block error: {:?}", err); + refresh_sentinel = true; + } + SoraError::ApiSpecific(SoraSpecificApiError::TokenExpiredError) => { + warn!("Image generation failed due to token expired error: {:?}", err); + refresh_sentinel = true; // TODO: Not sure what this error is, actually. + refresh_jwt = true; + } + SoraError::ApiSpecific(SoraSpecificApiError::InvalidJwt) => { + warn!("Image generation failed due to invalid jwt error: {:?}", err); + refresh_jwt = true; + } + SoraError::ApiSpecific(SoraSpecificApiError::UnauthorizedCookieOrBearerExpired) => { + warn!("Image generation failed due to auth error (nb: this is a legacy error): {:?}", err); + refresh_jwt = true; + refresh_sentinel = true; + } + + // We'll fail everything else eagerly... + + _ => { + error!("Image generation error: {:?}", err); + return Err(err); + } + } + + let mut new_bearer = None; + + if refresh_jwt { + info!("Generating new JWT bearer token..."); + let cookies = request.credentials.cookies.as_str(); + let response = generate_bearer_jwt_with_cookie(cookies).await; + match response { + Err(err) => { + return Err(SoraGenericApiError::UncategorizedBadResponse(format!("failed to generate new JWT bearer token: {:?}", err)).into()) + } + Ok(bearer) => { + match SoraJwtBearerToken::new(bearer) { + Err(err) => { + return Err(SoraGenericApiError::UncategorizedBadResponse(format!("Failed to parse new JWT bearer token: {:?}", err)).into()); + } + Ok(bearer) => new_bearer = Some(bearer), + } + }, + } + } + + let mut new_sentinel = None; + + if refresh_sentinel { + info!("Generating new sentinel..."); + let response = generate_sentinel_token().await; + match response { + Err(err) => { + return Err(SoraGenericApiError::UncategorizedBadResponse(format!("failed to generate new sentinel: {:?}", err)).into()) + } + Ok(sentinel) => new_sentinel = Some(SoraSentinel::new(sentinel)), + } + } + + let mut new_creds = request.credentials.clone(); + + if let Some(bearer) = new_bearer { + new_creds.jwt_bearer_token = Some(bearer); + } + + if let Some(sentinel) = new_sentinel { + new_creds.sora_sentinel = Some(sentinel); + } + + // Now try again... + + let result = sora_image_gen_remix(SoraImageGenRemixRequest { + prompt: request.prompt, + num_images: request.num_images, + image_size: request.image_size, + sora_media_tokens: request.sora_media_tokens, + credentials: &new_creds, + request_timeout: request.request_timeout, + }).await?; + + Ok((result, Some(new_creds))) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/image_upload_from_bytes_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/image_upload_from_bytes_with_session_auto_renew.rs new file mode 100644 index 0000000000..7183369809 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/image_upload_from_bytes_with_session_auto_renew.rs @@ -0,0 +1,62 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::creds::sora_sentinel::SoraSentinel; +use crate::error::sora_error::SoraError; +use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error; +use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; +use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse}; +use crate::requests::image_gen::image_gen_http_request; +use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest}; +use crate::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes; +use crate::requests::upload::upload_media_http_request::upload_media_http_request; +use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info, warn}; +use std::time::Duration; + +pub struct ImageUploadFromBytesAutoRenewRequest<'a> { + pub file_bytes: Vec, + pub filename: String, + pub mime_type: &'a str, + pub credentials: &'a SoraCredentialSet, + + /// This function can try to upload several times. This is the timeout for *individual* requests. + pub request_timeout: Option, +} + +/// Image upload with retry and session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn image_upload_from_bytes_with_session_auto_renew( + request: ImageUploadFromBytesAutoRenewRequest<'_> +) -> Result<(SoraMediaUploadResponse, Option), SoraError> { + + let result = sora_media_upload_from_bytes( + request.file_bytes.clone(), // FIXME(bt): This is horrible, but the client needs to take ownership. :( + request.filename.clone(), // FIXME: Same + request.credentials, + request.request_timeout, + ).await; + + let err = match result { + Ok(response) => return Ok((response, None)), + Err(err) => err, + }; + + warn!("Image upload failed: {:?}", err); + + let new_creds = maybe_refresh_credentials_on_sora_error(request.credentials, err).await?; + + // Now try again... + + info!("Retrying image upload with new credentials..."); + + let result = sora_media_upload_from_bytes( + request.file_bytes, + request.filename, + &new_creds, + request.request_timeout, + ).await?; + + Ok((result, Some(new_creds))) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/image_upload_from_file_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/image_upload_from_file_with_session_auto_renew.rs new file mode 100644 index 0000000000..78a1299929 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/image_upload_from_file_with_session_auto_renew.rs @@ -0,0 +1,55 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error; +use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file; +use crate::requests::upload::upload_media_http_request::upload_media_http_request; +use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse; +use log::{info, warn}; +use std::path::Path; +use std::time::Duration; + +pub struct ImageUploadFromFileAutoRenewRequest<'a, P: AsRef> { + pub file_path: P, + pub credentials: &'a SoraCredentialSet, + + /// This function can try to upload several times. This is the timeout for *individual* requests. + pub request_timeout: Option, +} + +/// Image upload with retry and session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn image_upload_from_file_with_session_auto_renew>( + request: ImageUploadFromFileAutoRenewRequest<'_, P> +) -> Result<(SoraMediaUploadResponse, Option), SoraError> { + + let result = sora_media_upload_from_file( + request.file_path.as_ref().clone(), // FIXME(bt): This is horrible, but the client needs to take ownership. :( + request.credentials, + /// This function can try to upload several times. This is the timeout for *individual* requests. + request.request_timeout, + ).await; + + let err = match result { + Ok(response) => return Ok((response, None)), + // We can't retry some errors. + Err(err @ SoraError::ApiSpecific(SoraSpecificApiError::SoraUsernameNotYetCreated)) => return Err(err), + // Retry all other errors. + Err(err) => err, + }; + + warn!("Image upload failed: {:?}", err); + + let new_creds = maybe_refresh_credentials_on_sora_error(request.credentials, err).await?; + + info!("Retrying image upload with new credentials..."); + + let result = sora_media_upload_from_file( + request.file_path, + &new_creds, + request.request_timeout, + ).await?; + + Ok((result, Some(new_creds))) +} + diff --git a/crates/api_clients/openai_sora_client/src/recipes/list_classic_sora_tasks_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/list_classic_sora_tasks_with_session_auto_renew.rs new file mode 100644 index 0000000000..4d161886cc --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/list_classic_sora_tasks_with_session_auto_renew.rs @@ -0,0 +1,60 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::recipes::image_upload_from_file_with_session_auto_renew::ImageUploadFromFileAutoRenewRequest; +use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error; +use crate::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use crate::requests::common::task_id::TaskId; +use crate::requests::deprecated::job_status::sora_job_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse}; +use crate::requests::list_classic_tasks::list_classic_tasks::{list_classic_tasks, ListTasksResponse}; +use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file; +use anyhow::Error; +use log::{info, warn}; +use std::path::Path; + +/// Check Sora task statuses with session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn list_classic_sora_tasks_with_session_auto_renew( + credentials: &SoraCredentialSet, +) -> Result<(ListTasksResponse, Option), SoraError> { + + let mut maybe_new_creds = maybe_renew_session(&credentials).await?; + + let upgraded_creds = maybe_new_creds.as_ref() + .unwrap_or_else(|| &credentials); + + let result = list_classic_tasks(&upgraded_creds).await; + + let err = match result { + Ok(response) => return Ok((response, maybe_new_creds)), + Err(err) => err, + }; + + warn!("Task status polling failed: {:?}", err); + + // TODO(bt,2025-05-28): This should only be done if the credentials were known to be bad + let new_creds = maybe_refresh_credentials_on_sora_error(&upgraded_creds, err).await?; + + // TODO(bt,2025-05-28): This should only be done if the credentials were actually refreshed. + info!("Retrying task polling with new credentials..."); + + let result = list_classic_tasks(&new_creds).await?; + + Ok((result, Some(new_creds))) +} + +async fn maybe_renew_session(creds: &SoraCredentialSet) -> Result, SoraError> { + if !creds.jwt_bearer_token.is_none() { + // TODO: Handle JWT expired case as well. + return Ok(None); + } + + info!("JWT not set. Upgrading credentials..."); + let mut updated_creds = creds.clone(); + + if let Err(err) = maybe_upgrade_or_renew_session(&mut updated_creds).await { + warn!("Failed to upgrade or renew session: {:?}", err); + return Err(err); + } + + Ok(Some(updated_creds)) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/list_sora2_drafts/list_sora2_drafts_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/list_sora2_drafts/list_sora2_drafts_with_session_auto_renew.rs new file mode 100644 index 0000000000..021de7e6a3 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/list_sora2_drafts/list_sora2_drafts_with_session_auto_renew.rs @@ -0,0 +1,45 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::recipes::utils::maybe_renew_session_jwt::maybe_renew_session_jwt; +use crate::requests::list_sora2_drafts::list_sora2_drafts::{list_sora2_drafts, ListSora2DraftsResponse}; + +/// Check Sora 2 drafts with session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn list_sora2_drafts_with_session_auto_renew( + credentials: &SoraCredentialSet, +) -> Result<(ListSora2DraftsResponse, Option), SoraError> { + + let mut maybe_new_creds = maybe_renew_session_jwt(&credentials).await?; + + let use_creds = maybe_new_creds.as_ref() + .unwrap_or_else(|| &credentials); + + let result = list_sora2_drafts(&use_creds).await; + + match result { + Ok(response) => Ok((response, maybe_new_creds)), + Err(err) => Err(err), // TODO: Should we retry? + } +} + + +#[cfg(test)] +mod tests { + use crate::recipes::list_sora2_drafts::list_sora2_drafts_with_session_auto_renew::list_sora2_drafts_with_session_auto_renew; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + + let (result , creds) = + list_sora2_drafts_with_session_auto_renew(&creds).await?; + + println!("result: {:#?}", result); + assert_eq!(1, 2); + + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/list_sora2_drafts/mod.rs b/crates/api_clients/openai_sora_client/src/recipes/list_sora2_drafts/mod.rs new file mode 100644 index 0000000000..4909b4b23b --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/list_sora2_drafts/mod.rs @@ -0,0 +1 @@ +pub mod list_sora2_drafts_with_session_auto_renew; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/recipes/maybe_refresh_credentials_on_sora_error.rs b/crates/api_clients/openai_sora_client/src/recipes/maybe_refresh_credentials_on_sora_error.rs new file mode 100644 index 0000000000..63146f6746 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/maybe_refresh_credentials_on_sora_error.rs @@ -0,0 +1,53 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; +use anyhow::anyhow; +use log::{error, info, warn}; + +// TODO: Make other APIs work with this. + +pub async fn maybe_refresh_credentials_on_sora_error(creds: &SoraCredentialSet, error: SoraError) -> Result { + + match error { + SoraError::Client(SoraClientError::NoBearerTokenForRequest) => { + error!("Previous request failed due to missing bearer token."); + } + SoraError::ApiSpecific(SoraSpecificApiError::UnauthorizedCookieOrBearerExpired) => { + error!("Previous request failed due to invalid bearer token."); + } + _ => { + error!("Previous request failed due to the following error: {:?}", error); + return Err(error) + } + } + + info!("Generating new JWT bearer token..."); + + let cookies = creds.cookies.as_str(); + let response = generate_bearer_jwt_with_cookie(cookies).await; + + let new_bearer = match response { + Err(err) => { + error!("Error generating new JWT bearer token: {}", err); + return Err(err); + } + Ok(bearer) => { + match SoraJwtBearerToken::new(bearer) { + Err(err) => { + error!("Error parsing new JWT bearer token: {}", err); + return Err(err); + } + Ok(bearer) => bearer, + } + } + }; + + let mut new_creds = creds.clone(); + + new_creds.jwt_bearer_token = Some(new_bearer); + + Ok(new_creds) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs b/crates/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs new file mode 100644 index 0000000000..652cb47edb --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs @@ -0,0 +1,70 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::creds::sora_sentinel::SoraSentinel; +use crate::error::sora_error::SoraError; +use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; +use crate::requests::auth_sentinel::generate_sentinel_token::generate_sentinel_token; +use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2; +use chrono::{DateTime, TimeDelta, Utc}; +use errors::AnyhowResult; +use log::info; +use std::ops::Sub; + +const JWT_EXPIRATION_DEADLINE: TimeDelta = TimeDelta::hours(12); + +/// Call this at session startup to generate the tokens for the first time. +/// Call this periodically to refresh the JWT as it nears its expiration date. +/// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that. +#[deprecated(note="The sentinel appears to be going away. Use `maybe_renew_session_jwt` instead for newer '2.0' code.")] +pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result { + let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some(); + + if let Some(jwt) = &sora_credentials.jwt_bearer_token { + let now = Utc::now(); + let refresh_deadline = now.sub(JWT_EXPIRATION_DEADLINE); + if jwt.jwt_claims().expiration.lt(&now) { + info!("JWT expiration time has elapsed."); + refresh_jwt = true; + } else if jwt.jwt_claims().expiration.lt(&refresh_deadline) { + info!("JWT expiration time is under the expiration deadline."); + refresh_jwt = true; + } + } + + let mut credential_updated = false; + + if refresh_jwt { + info!("Refreshing JWT..."); + let cookies = sora_credentials.cookies.as_str(); + let token = generate_bearer_jwt_with_cookie(cookies).await?; + + info!("Parsing JWT bearer token."); + let token = SoraJwtBearerToken::new(token)?; + sora_credentials.jwt_bearer_token = Some(token); + credential_updated = true; + } + + //let refresh_sentinel = !sora_credentials.sora_sentinel.is_some(); + //if refresh_sentinel { + // info!("Generating new sentinel..."); + // let token = generate_sentinel_token().await?; + // let token = SoraSentinel::new(token); + // sora_credentials.sora_sentinel = Some(token); + // credential_updated = true; + //} + + let refresh_sentinel_token = sora_credentials.sora_sentinel_token + .as_ref() + .map(|t| t.is_expired()) + .unwrap_or(true); + + if refresh_sentinel_token { + info!("Refreshing sentinel token..."); + let token = generate_sentinel_token_2().await?; + sora_credentials.sora_sentinel = Some(SoraSentinel::new(token.to_request_header_json()?)); // Legacy + sora_credentials.sora_sentinel_token = Some(token); // New + credential_updated = true; + } + + Ok(credential_updated) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/mod.rs b/crates/api_clients/openai_sora_client/src/recipes/mod.rs new file mode 100644 index 0000000000..98675c8669 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/mod.rs @@ -0,0 +1,11 @@ +pub mod generate_sora2_video; +pub mod image_remix_with_session_auto_renew; +pub mod image_upload_from_bytes_with_session_auto_renew; +pub mod image_upload_from_file_with_session_auto_renew; +pub mod list_classic_sora_tasks_with_session_auto_renew; +pub mod list_sora2_drafts; +pub mod maybe_refresh_credentials_on_sora_error; +pub mod maybe_upgrade_or_renew_session; +pub mod simple_image_gen_with_session_auto_renew; +pub mod utils; +pub mod wait_for_image_gen_status; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/recipes/simple_image_gen_with_session_auto_renew.rs b/crates/api_clients/openai_sora_client/src/recipes/simple_image_gen_with_session_auto_renew.rs new file mode 100644 index 0000000000..f005902aee --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/simple_image_gen_with_session_auto_renew.rs @@ -0,0 +1,143 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::creds::sora_sentinel::SoraSentinel; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; +use crate::requests::auth_sentinel::generate_sentinel_token::generate_sentinel_token; +use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse}; +use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest}; +use crate::requests::image_gen::sora_image_gen_simple::{sora_image_gen_simple, SoraImageGenSimpleRequest}; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info, warn}; +use std::time::Duration; + +pub struct SimpleImageGenAutoRenewRequest<'a> { + pub prompt: String, + pub num_images: NumImages, + pub image_size: ImageSize, + pub credentials: &'a SoraCredentialSet, + /// This function can try several different requests. This is the timeout for *individual* requests. + pub request_timeout: Option, +} + +/// Image request with retry and session auto-renewal. +/// If a new sora credential is returned, replace the old one with the new one. +pub async fn simple_image_gen_with_session_auto_renew(request: SimpleImageGenAutoRenewRequest<'_>) -> Result<(SoraImageGenResponse, Option), SoraError> { + let result = sora_image_gen_simple(SoraImageGenSimpleRequest { + prompt: request.prompt.clone(), // NB: Clone because used again + num_images: request.num_images, + image_size: request.image_size, + credentials: request.credentials, + request_timeout: request.request_timeout, + }).await; + + let err = match result { + Ok(response) => return Ok((response, None)), + Err(err) => err, + }; + + warn!("Image generation failed: {:?}", err); + + let mut refresh_jwt = false; + let mut refresh_sentinel = false; + + match err { + // We'll specifically fail these non-retryable requests... + + SoraError::ApiSpecific(SoraSpecificApiError::TooManyConcurrentTasks) | + SoraError::ApiSpecific(SoraSpecificApiError::SoraUsernameNotYetCreated) => { + error!("Non-retryable image generation error: {:?}", err); + return Err(err); + } + + // We'll retry these requests... + + SoraError::ApiSpecific(SoraSpecificApiError::SentinelBlockError) => { + warn!("Image generation failed due to sentinel block error: {:?}", err); + refresh_sentinel = true; + } + SoraError::ApiSpecific(SoraSpecificApiError::TokenExpiredError) => { + warn!("Image generation failed due to token expired error: {:?}", err); + refresh_sentinel = true; // TODO: Not sure what this error is, actually. + refresh_jwt = true; + } + SoraError::ApiSpecific(SoraSpecificApiError::InvalidJwt) => { + warn!("Image generation failed due to invalid jwt error: {:?}", err); + refresh_jwt = true; + } + SoraError::ApiSpecific(SoraSpecificApiError::UnauthorizedCookieOrBearerExpired) => { + warn!("Image generation failed due to auth error (nb: this is a legacy error): {:?}", err); + refresh_jwt = true; + refresh_sentinel = true; + } + + // We'll fail everything else eagerly... + + _ => { + error!("Image generation error: {:?}", err); + return Err(err); + } + } + + let mut new_bearer = None; + + if refresh_jwt { + info!("Generating new JWT bearer token..."); + let cookies = request.credentials.cookies.as_str(); + let response = generate_bearer_jwt_with_cookie(cookies).await; + match response { + Err(err) => { + error!("Error asking to generate JWT: {:?}", err); + return Err(err); + } + Ok(bearer) => { + match SoraJwtBearerToken::new(bearer) { + Err(err) => { + error!("Failed to parse new JWT: {:?}", err); + return Err(err); + } + Ok(bearer) => new_bearer = Some(bearer), + } + }, + } + } + + let mut new_sentinel = None; + + if refresh_sentinel { + info!("Generating new sentinel..."); + let response = generate_sentinel_token().await; + match response { + Err(err) => { + error!("failed to generate new sentinel: {:?}", err); + return Err(err) + } + Ok(sentinel) => new_sentinel = Some(SoraSentinel::new(sentinel)), + } + } + + let mut new_creds = request.credentials.clone(); + + if let Some(bearer) = new_bearer { + new_creds.jwt_bearer_token = Some(bearer); + } + + if let Some(sentinel) = new_sentinel { + new_creds.sora_sentinel = Some(sentinel); + } + + // Now try again... + + let result = sora_image_gen_simple(SoraImageGenSimpleRequest { + prompt: request.prompt, + num_images: request.num_images, + image_size: request.image_size, + credentials: &new_creds, + request_timeout: request.request_timeout, + }).await?; + + Ok((result, Some(new_creds))) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/utils/maybe_renew_sentinel_token.rs b/crates/api_clients/openai_sora_client/src/recipes/utils/maybe_renew_sentinel_token.rs new file mode 100644 index 0000000000..1c68aedb4e --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/utils/maybe_renew_sentinel_token.rs @@ -0,0 +1,35 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_sentinel_token::SoraSentinelToken; +use crate::error::sora_error::SoraError; +use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2; +use log::info; + +/// Returns a new sentinel token if one was generated, or None if no update was needed. +pub async fn maybe_renew_sentinel_token(creds: &SoraCredentialSet) -> Result, SoraError> { + let mut refresh_sentinel = false; + + match creds.sora_sentinel_token.as_ref() { + None => { + info!("Sentinel token not set. It needs to be generated..."); + refresh_sentinel = true; + } + Some(sentinel) => { + let expired = sentinel.is_expired(); + if expired { + info!("Sentinel token is expired. It needs to be renewed..."); + refresh_sentinel = true; + } + } + } + + if !refresh_sentinel { + return Ok(None); + } + + let sentinel = generate_sentinel_token_2().await?; + + let mut new_creds = creds.clone(); + new_creds.sora_sentinel_token = Some(sentinel); + + Ok(Some(new_creds)) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/utils/maybe_renew_session_jwt.rs b/crates/api_clients/openai_sora_client/src/recipes/utils/maybe_renew_session_jwt.rs new file mode 100644 index 0000000000..630a2b568d --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/utils/maybe_renew_session_jwt.rs @@ -0,0 +1,48 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::error::sora_error::SoraError; +use crate::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; +use chrono::{TimeDelta, Utc}; +use log::{info, warn}; +use std::io::Write; +use std::ops::Sub; + +const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12); + +/// Returns a new set of credentials if they were updated, or None if no update was needed. +pub async fn maybe_renew_session_jwt(creds: &SoraCredentialSet) -> Result, SoraError> { + let mut refresh_jwt = false; + + match creds.jwt_bearer_token.as_ref() { + None => { + info!("JWT not set. It needs to be generated..."); + refresh_jwt = true; + } + Some(jwt) => { + let now = Utc::now(); + let refresh_deadline = now.sub(EXPIRATION_DEADLINE); + if jwt.jwt_claims().expiration.lt(&now) { + info!("JWT expiration time has elapsed. It needs to be renewed..."); + refresh_jwt = true; + } else if jwt.jwt_claims().expiration.lt(&refresh_deadline) { + info!("JWT expiration time is under the expiration deadline. It needs to be renewed..."); + refresh_jwt = true; + } + } + } + + if !refresh_jwt { + return Ok(None); + } + + info!("Refreshing JWT..."); + let token = generate_bearer_jwt_with_cookie(creds.cookies.as_str()).await?; + + info!("Parsing JWT bearer token..."); + let token = SoraJwtBearerToken::new(token)?; + + let mut updated_creds = creds.clone(); + updated_creds.jwt_bearer_token = Some(token); + + Ok(Some(updated_creds)) +} diff --git a/crates/api_clients/openai_sora_client/src/recipes/utils/mod.rs b/crates/api_clients/openai_sora_client/src/recipes/utils/mod.rs new file mode 100644 index 0000000000..d84aadc1d9 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod maybe_renew_session_jwt; +pub mod maybe_renew_sentinel_token; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/recipes/wait_for_image_gen_status.rs b/crates/api_clients/openai_sora_client/src/recipes/wait_for_image_gen_status.rs new file mode 100644 index 0000000000..de8cac04aa --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/recipes/wait_for_image_gen_status.rs @@ -0,0 +1,50 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::requests::deprecated::job_status::sora_job_status::{get_image_gen_status, StatusRequest, TaskResponse, TaskStatus, VideoGenStatusResponse}; +use errors::AnyhowResult; + +pub async fn wait_for_image_gen_status(task_id: &String, creds: &SoraCredentialSet, retry_limit: Option) -> AnyhowResult { + let status_request = StatusRequest { + limit: None, + before: None, + // before: Some(task_id.clone()), + }; + let retry_limit = retry_limit.unwrap_or(10); + + for _ in 0..retry_limit { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + let status_response = get_image_gen_status(&status_request, &creds).await?; + + match find_task_response_by_id(&status_response, task_id.clone()) { + Some(task_response) => match TaskStatus::from_str(&task_response.status) { + TaskStatus::Succeeded => { + return Ok(task_response.clone()); + }, + TaskStatus::Failed => { + return Err(anyhow::anyhow!("Task failed")); + }, + TaskStatus::Unknown(status) => { + println!("Unknown task status: {}", status); + continue; + }, + TaskStatus::Queued => { + println!("Task is queued"); + continue; + }, + TaskStatus::Running => { + println!("Task is running"); + continue; + }, + }, + None => { + return Err(anyhow::anyhow!("Task not found")); + }, + } + } + + Err(anyhow::anyhow!("Task not found")) +} + +fn find_task_response_by_id(status_response: &VideoGenStatusResponse, task_id: String) -> Option<&TaskResponse> { + status_response.task_responses.iter().find(|task| task.id.0 == task_id) +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_bearer/generate_bearer_jwt_with_cookie.rs b/crates/api_clients/openai_sora_client/src/requests/auth_bearer/generate_bearer_jwt_with_cookie.rs new file mode 100644 index 0000000000..59df7982c9 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_bearer/generate_bearer_jwt_with_cookie.rs @@ -0,0 +1,154 @@ +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::utils_internal::classify_general_http_error::classify_general_http_error; +use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body; +use errors::AnyhowResult; +use log::{debug, error, info}; +use serde_derive::Deserialize; +use wreq::Client; + +const SORA_BEARER_GENERATE_URL: &str = "https://sora.chatgpt.com/api/auth/session"; + +#[derive(Debug, Deserialize)] +pub struct SoraAuthResponse { + pub user: SoraUser, + pub expires: String, + #[serde(rename = "accessToken")] + pub access_token: String, + #[serde(rename = "internalApiBase")] + pub internal_api_base: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SoraUser { + pub id: String, + pub name: Option, + pub email: Option, + pub image: Option, + pub picture: Option, + pub provider: Option, + #[serde(rename = "lastAuthorizationCheck")] + pub last_authorization_check: Option, +} + +pub async fn generate_bearer_jwt_with_cookie(cookie: &str) -> Result { + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| { + error!("Error building the client: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + info!("Generating new JWT bearer token from session cookies... (cookie payload length: {})", cookie.len()); + + let response = client.get(SORA_BEARER_GENERATE_URL) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0") + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip, deflate, br") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Cookie", cookie) + .send() + .await + .map_err(|err| { + error!("Error sending request: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = &response.text().await + .map_err(|err| { + error!("Error reading body while attempting to generate bearer token: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("Failed to generate bearer token with session cookies: {} ; body = {}", status, response_body); + let error = classify_general_http_status_code_and_body(status, &response_body); + return Err(error); + } + + debug!("Bearer token generation response was 200."); + debug!("Auth Response: {}", response_body); + + if response_body == "null" { + error!("Failed to generate bearer token. Response was the string `null`."); + return Err(SoraSpecificApiError::FailedToGenerateBearerToken.into()) + } + + let auth_response: SoraAuthResponse = serde_json::from_str(&response_body) + .map_err(|err| { + error!("Error parsing body while attempting to generate bearer token: {:?}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()) + })?; + + Ok(auth_response.access_token) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_credentials::get_test_credentials; + + const LOGGED_OUT_COOKIE :&str = ""; + const OLD_COOKIE : &str = ""; + + mod success_case { + use super::*; + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn test_generate_bearer_with_cookie() { + let creds = get_test_credentials().unwrap(); + + let cookie = creds.cookies.as_str(); + + let result = generate_bearer_jwt_with_cookie(cookie).await; + + assert!(result.is_ok()); + + let bearer = result.unwrap(); + println!("Bearer token: {}", bearer); + } + } + + mod failure_cases { + use super::*; + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn logged_out_cookie_failure() { + let result = generate_bearer_jwt_with_cookie(LOGGED_OUT_COOKIE).await; + + println!("Results: {:?}", result); + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn old_cookie_failure() { + let result = generate_bearer_jwt_with_cookie(LOGGED_OUT_COOKIE).await; + + println!("Results: {:?}", result); + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn invalid_cookie_failure() { + let result = generate_bearer_jwt_with_cookie("").await; + + println!("Results: {:?}", result); + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_bearer/mod.rs b/crates/api_clients/openai_sora_client/src/requests/auth_bearer/mod.rs new file mode 100644 index 0000000000..6a2a8a4382 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_bearer/mod.rs @@ -0,0 +1 @@ +pub mod generate_bearer_jwt_with_cookie; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/config.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/config.rs new file mode 100644 index 0000000000..07aa12ad76 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/config.rs @@ -0,0 +1,671 @@ +use rand::Rng; + +// cores = [8, 16, 24, 32] +// cached_scripts = ["https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3"] + +// cached_dpl = ["prod-f501fe933b3edf57aea882da888e1a544df99840"] + +// navigator_key = [ +// "registerProtocolHandler−function registerProtocolHandler() { [native code] }", +// "storage−[object StorageManager]", +// "locks−[object LockManager]", +// "appCodeName−Mozilla", +// "permissions−[object Permissions]", +// "share−function share() { [native code] }", +// "webdriver−false", +// "managed−[object NavigatorManagedData]", +// "canShare−function canShare() { [native code] }", +// "vendor−Google Inc.", +// "vendor−Google Inc.", +// "mediaDevices−[object MediaDevices]", +// "vibrate−function vibrate() { [native code] }", +// "storageBuckets−[object StorageBucketManager]", +// "mediaCapabilities−[object MediaCapabilities]", +// "getGamepads−function getGamepads() { [native code] }", +// "bluetooth−[object Bluetooth]", +// "share−function share() { [native code] }", +// "cookieEnabled−true", +// "virtualKeyboard−[object VirtualKeyboard]", +// "product−Gecko", +// "mediaDevices−[object MediaDevices]", +// "canShare−function canShare() { [native code] }", +// "getGamepads−function getGamepads() { [native code] }", +// "product−Gecko", +// "xr−[object XRSystem]", +// "clipboard−[object Clipboard]", +// "storageBuckets−[object StorageBucketManager]", +// "unregisterProtocolHandler−function unregisterProtocolHandler() { [native code] }", +// "productSub−20030107", +// "login−[object NavigatorLogin]", +// "vendorSub−", +// "login−[object NavigatorLogin]", +// "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", +// "mediaDevices−[object MediaDevices]", +// "locks−[object LockManager]", +// "webkitGetUserMedia−function webkitGetUserMedia() { [native code] }", +// "vendor−Google Inc.", +// "xr−[object XRSystem]", +// "mediaDevices−[object MediaDevices]", +// "virtualKeyboard−[object VirtualKeyboard]", +// "virtualKeyboard−[object VirtualKeyboard]", +// "appName−Netscape", +// "storageBuckets−[object StorageBucketManager]", +// "presentation−[object Presentation]", +// "onLine−true", +// "mimeTypes−[object MimeTypeArray]", +// "credentials−[object CredentialsContainer]", +// "presentation−[object Presentation]", +// "getGamepads−function getGamepads() { [native code] }", +// "vendorSub−", +// "virtualKeyboard−[object VirtualKeyboard]", +// "serviceWorker−[object ServiceWorkerContainer]", +// "xr−[object XRSystem]", +// "product−Gecko", +// "keyboard−[object Keyboard]", +// "gpu−[object GPU]", +// "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", +// "webkitPersistentStorage−[object DeprecatedStorageQuota]", +// "doNotTrack", +// "clearAppBadge−function clearAppBadge() { [native code] }", +// "presentation−[object Presentation]", +// "serial−[object Serial]", +// "locks−[object LockManager]", +// "requestMIDIAccess−function requestMIDIAccess() { [native code] }", +// "locks−[object LockManager]", +// "requestMediaKeySystemAccess−function requestMediaKeySystemAccess() { [native code] }", +// "vendor−Google Inc.", +// "pdfViewerEnabled−true", +// "language−zh-CN", +// "setAppBadge−function setAppBadge() { [native code] }", +// "geolocation−[object Geolocation]", +// "userAgentData−[object NavigatorUAData]", +// "mediaCapabilities−[object MediaCapabilities]", +// "requestMIDIAccess−function requestMIDIAccess() { [native code] }", +// "getUserMedia−function getUserMedia() { [native code] }", +// "mediaDevices−[object MediaDevices]", +// "webkitPersistentStorage−[object DeprecatedStorageQuota]", +// "sendBeacon−function sendBeacon() { [native code] }", +// "hardwareConcurrency−32", +// "credentials−[object CredentialsContainer]", +// "storage−[object StorageManager]", +// "cookieEnabled−true", +// "pdfViewerEnabled−true", +// "windowControlsOverlay−[object WindowControlsOverlay]", +// "scheduling−[object Scheduling]", +// "pdfViewerEnabled−true", +// "hardwareConcurrency−32", +// "xr−[object XRSystem]", +// "webdriver−false", +// "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", +// "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", +// "bluetooth−[object Bluetooth]" +// ] + +// document_key = ['_reactListeningo743lnnpvdg', 'location'] + +// window_key = [ +// "0", +// "window", +// "self", +// "document", +// "name", +// "location", +// "customElements", +// "history", +// "navigation", +// "locationbar", +// "menubar", +// "personalbar", +// "scrollbars", +// "statusbar", +// "toolbar", +// "status", +// "closed", +// "frames", +// "length", +// "top", +// "opener", +// "parent", +// "frameElement", +// "navigator", +// "origin", +// "external", +// "screen", +// "innerWidth", +// "innerHeight", +// "scrollX", +// "pageXOffset", +// "scrollY", +// "pageYOffset", +// "visualViewport", +// "screenX", +// "screenY", +// "outerWidth", +// "outerHeight", +// "devicePixelRatio", +// "clientInformation", +// "screenLeft", +// "screenTop", +// "styleMedia", +// "onsearch", +// "isSecureContext", +// "trustedTypes", +// "performance", +// "onappinstalled", +// "onbeforeinstallprompt", +// "crypto", +// "indexedDB", +// "sessionStorage", +// "localStorage", +// "onbeforexrselect", +// "onabort", +// "onbeforeinput", +// "onbeforematch", +// "onbeforetoggle", +// "onblur", +// "oncancel", +// "oncanplay", +// "oncanplaythrough", +// "onchange", +// "onclick", +// "onclose", +// "oncontentvisibilityautostatechange", +// "oncontextlost", +// "oncontextmenu", +// "oncontextrestored", +// "oncuechange", +// "ondblclick", +// "ondrag", +// "ondragend", +// "ondragenter", +// "ondragleave", +// "ondragover", +// "ondragstart", +// "ondrop", +// "ondurationchange", +// "onemptied", +// "onended", +// "onerror", +// "onfocus", +// "onformdata", +// "oninput", +// "oninvalid", +// "onkeydown", +// "onkeypress", +// "onkeyup", +// "onload", +// "onloadeddata", +// "onloadedmetadata", +// "onloadstart", +// "onmousedown", +// "onmouseenter", +// "onmouseleave", +// "onmousemove", +// "onmouseout", +// "onmouseover", +// "onmouseup", +// "onmousewheel", +// "onpause", +// "onplay", +// "onplaying", +// "onprogress", +// "onratechange", +// "onreset", +// "onresize", +// "onscroll", +// "onsecuritypolicyviolation", +// "onseeked", +// "onseeking", +// "onselect", +// "onslotchange", +// "onstalled", +// "onsubmit", +// "onsuspend", +// "ontimeupdate", +// "ontoggle", +// "onvolumechange", +// "onwaiting", +// "onwebkitanimationend", +// "onwebkitanimationiteration", +// "onwebkitanimationstart", +// "onwebkittransitionend", +// "onwheel", +// "onauxclick", +// "ongotpointercapture", +// "onlostpointercapture", +// "onpointerdown", +// "onpointermove", +// "onpointerrawupdate", +// "onpointerup", +// "onpointercancel", +// "onpointerover", +// "onpointerout", +// "onpointerenter", +// "onpointerleave", +// "onselectstart", +// "onselectionchange", +// "onanimationend", +// "onanimationiteration", +// "onanimationstart", +// "ontransitionrun", +// "ontransitionstart", +// "ontransitionend", +// "ontransitioncancel", +// "onafterprint", +// "onbeforeprint", +// "onbeforeunload", +// "onhashchange", +// "onlanguagechange", +// "onmessage", +// "onmessageerror", +// "onoffline", +// "ononline", +// "onpagehide", +// "onpageshow", +// "onpopstate", +// "onrejectionhandled", +// "onstorage", +// "onunhandledrejection", +// "onunload", +// "crossOriginIsolated", +// "scheduler", +// "alert", +// "atob", +// "blur", +// "btoa", +// "cancelAnimationFrame", +// "cancelIdleCallback", +// "captureEvents", +// "clearInterval", +// "clearTimeout", +// "close", +// "confirm", +// "createImageBitmap", +// "fetch", +// "find", +// "focus", +// "getComputedStyle", +// "getSelection", +// "matchMedia", +// "moveBy", +// "moveTo", +// "open", +// "postMessage", +// "print", +// "prompt", +// "queueMicrotask", +// "releaseEvents", +// "reportError", +// "requestAnimationFrame", +// "requestIdleCallback", +// "resizeBy", +// "resizeTo", +// "scroll", +// "scrollBy", +// "scrollTo", +// "setInterval", +// "setTimeout", +// "stop", +// "structuredClone", +// "webkitCancelAnimationFrame", +// "webkitRequestAnimationFrame", +// "chrome", +// "caches", +// "cookieStore", +// "ondevicemotion", +// "ondeviceorientation", +// "ondeviceorientationabsolute", +// "launchQueue", +// "documentPictureInPicture", +// "getScreenDetails", +// "queryLocalFonts", +// "showDirectoryPicker", +// "showOpenFilePicker", +// "showSaveFilePicker", +// "originAgentCluster", +// "onpageswap", +// "onpagereveal", +// "credentialless", +// "speechSynthesis", +// "onscrollend", +// "webkitRequestFileSystem", +// "webkitResolveLocalFileSystemURL", +// "sendMsgToSolverCS", +// "webpackChunk_N_E", +// "__next_set_public_path__", +// "next", +// "__NEXT_DATA__", +// "__SSG_MANIFEST_CB", +// "__NEXT_P", +// "_N_E", +// "regeneratorRuntime", +// "__REACT_INTL_CONTEXT__", +// "DD_RUM", +// "_", +// "filterCSS", +// "filterXSS", +// "__SEGMENT_INSPECTOR__", +// "__NEXT_PRELOADREADY", +// "Intercom", +// "__MIDDLEWARE_MATCHERS", +// "__STATSIG_SDK__", +// "__STATSIG_JS_SDK__", +// "__STATSIG_RERENDER_OVERRIDE__", +// "_oaiHandleSessionExpired", +// "__BUILD_MANIFEST", +// "__SSG_MANIFEST", +// "__intercomAssignLocation", +// "__intercomReloadLocation" +// ] + + +const CORES: &[usize] = &[8, 16, 24, 32]; + +const CACHED_SCRIPTS: &[&str] = &["https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3"]; + +const CACHED_DPL: &[&str] = &["prod-f501fe933b3edf57aea882da888e1a544df99840"]; + +const NAVIGATOR_KEY: &[&str] = &[ + "registerProtocolHandler−function registerProtocolHandler() { [native code] }", + "storage−[object StorageManager]", + "locks−[object LockManager]", + "appCodeName−Mozilla", + "permissions−[object Permissions]", + "share−function share() { [native code] }", + "webdriver−false", + "managed−[object NavigatorManagedData]", + "canShare−function canShare() { [native code] }", + "vendor−Google Inc.", + "vendor−Google Inc.", + "mediaDevices−[object MediaDevices]", + "vibrate−function vibrate() { [native code] }", + "storageBuckets−[object StorageBucketManager]", + "mediaCapabilities−[object MediaCapabilities]", + "getGamepads−function getGamepads() { [native code] }", + "bluetooth−[object Bluetooth]", + "share−function share() { [native code] }", + "cookieEnabled−true", + "pdfViewerEnabled−true", + "windowControlsOverlay−[object WindowControlsOverlay]", + "scheduling−[object Scheduling]", + "pdfViewerEnabled−true", + "hardwareConcurrency−32", + "xr−[object XRSystem]", + "webdriver−false", + "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", + "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", + "bluetooth−[object Bluetooth]" +]; + +const DOCUMENT_KEY: &[&str] = &["_reactListeningo743lnnpvdg", "location"]; + +const WINDOW_KEY: &[&str] = &[ + "0", + "window", + "self", + "document", + "name", + "location", + "customElements", + "history", + "navigation", + "locationbar", + "menubar", + "personalbar", + "scrollbars", + "statusbar", + "toolbar", + "status", + "closed", + "frames", + "length", + "top", + "opener", + "parent", + "frameElement", + "navigator", + "origin", + "external", + "screen", + "innerWidth", + "innerHeight", + "scrollX", + "pageXOffset", + "scrollY", + "pageYOffset", + "visualViewport", + "screenX", + "screenY", + "outerWidth", + "outerHeight", + "devicePixelRatio", + "clientInformation", + "screenLeft", + "screenTop", + "styleMedia", + "onsearch", + "isSecureContext", + "trustedTypes", + "performance", + "onappinstalled", + "onbeforeinstallprompt", + "crypto", + "indexedDB", + "sessionStorage", + "localStorage", + "onbeforexrselect", + "onabort", + "onbeforeinput", + "onbeforematch", + "onbeforetoggle", + "onblur", + "oncancel", + "oncanplay", + "oncanplaythrough", + "onchange", + "onclick", + "onclose", + "oncontentvisibilityautostatechange", + "oncontextlost", + "oncontextmenu", + "oncontextrestored", + "oncuechange", + "ondblclick", + "ondrag", + "ondragend", + "ondragenter", + "ondragleave", + "ondragover", + "ondragstart", + "ondrop", + "ondurationchange", + "onemptied", + "onended", + "onerror", + "onfocus", + "onformdata", + "oninput", + "oninvalid", + "onkeydown", + "onkeypress", + "onkeyup", + "onload", + "onloadeddata", + "onloadedmetadata", + "onloadstart", + "onmousedown", + "onmouseenter", + "onmouseleave", + "onmousemove", + "onmouseout", + "onmouseover", + "onmouseup", + "onmousewheel", + "onpause", + "onplay", + "onplaying", + "onprogress", + "onratechange", + "onreset", + "onresize", + "onscroll", + "onsecuritypolicyviolation", + "onseeked", + "onseeking", + "onselect", + "onslotchange", + "onstalled", + "onsubmit", + "onsuspend", + "ontimeupdate", + "ontoggle", + "onvolumechange", + "onwaiting", + "onwebkitanimationend", + "onwebkitanimationiteration", + "onwebkitanimationstart", + "onwebkittransitionend", + "onwheel", + "onauxclick", + "ongotpointercapture", + "onlostpointercapture", + "onpointerdown", + "onpointermove", + "onpointerrawupdate", + "onpointerup", + "onpointercancel", + "onpointerover", + "onpointerout", + "onpointerenter", + "onpointerleave", + "onselectstart", + "onselectionchange", + "onanimationend", + "onanimationiteration", + "onanimationstart", + "ontransitionrun", + "ontransitionstart", + "ontransitionend", + "ontransitioncancel", + "onafterprint", + "onbeforeprint", + "onbeforeunload", + "onhashchange", + "onlanguagechange", + "onmessage", + "onmessageerror", + "onoffline", + "ononline", + "onpagehide", + "onpageshow", + "onpopstate", + "onrejectionhandled", + "onstorage", + "onunhandledrejection", + "onunload", + "crossOriginIsolated", + "scheduler", + "alert", + "atob", + "blur", + "btoa", + "cancelAnimationFrame", + "cancelIdleCallback", + "captureEvents", + "clearInterval", + "clearTimeout", + "close", + "confirm", + "createImageBitmap", + "fetch", + "find", + "focus", + "getComputedStyle", + "getSelection", + "matchMedia", + "moveBy", + "moveTo", + "open", + "postMessage", + "print", + "prompt", + "queueMicrotask", + "releaseEvents", + "reportError", + "requestAnimationFrame", + "requestIdleCallback", + "resizeBy", + "resizeTo", + "scroll", + "scrollBy", + "scrollTo", + "setInterval", + "setTimeout", + "stop", + "structuredClone", + "webkitCancelAnimationFrame", + "webkitRequestAnimationFrame", + "chrome", + "caches", + "cookieStore", + "ondevicemotion", + "ondeviceorientation", + "ondeviceorientationabsolute", + "launchQueue", + "documentPictureInPicture", + "getScreenDetails", + "queryLocalFonts", + "showDirectoryPicker", + "showOpenFilePicker", + "showSaveFilePicker", + "originAgentCluster", + "onpageswap", + "onpagereveal", + "credentialless", + "speechSynthesis", + "onscrollend", + "webkitRequestFileSystem", + "webkitResolveLocalFileSystemURL", + "sendMsgToSolverCS", + "webpackChunk_N_E", + "__next_set_public_path__", + "next", + "__NEXT_DATA__", + "__SSG_MANIFEST_CB", + "__NEXT_P", + "_N_E", + "regeneratorRuntime", + "__REACT_INTL_CONTEXT__", + "DD_RUM", + "_", + "filterCSS", + "filterXSS", + "__SEGMENT_INSPECTOR__", + "__NEXT_PRELOADREADY", + "Intercom", + "__MIDDLEWARE_MATCHERS", + "__STATSIG_SDK__", + "__STATSIG_JS_SDK__", + "__STATSIG_RERENDER_OVERRIDE__", + "_oaiHandleSessionExpired", + "__BUILD_MANIFEST", + "__SSG_MANIFEST", + "__intercomAssignLocation", + "__intercomReloadLocation" +]; + +pub fn get_random_core_count() -> usize { + CORES[rand::rng().random_range(0..CORES.len())] +} + +pub fn get_random_navigator_key() -> String { + NAVIGATOR_KEY[rand::rng().random_range(0..NAVIGATOR_KEY.len())].to_string() +} + +pub fn get_random_document_key() -> String { + DOCUMENT_KEY[rand::rng().random_range(0..DOCUMENT_KEY.len())].to_string() +} + +pub fn get_random_window_key() -> String { + WINDOW_KEY[rand::rng().random_range(0..WINDOW_KEY.len())].to_string() +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/generate_sentinel_token.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/generate_sentinel_token.rs new file mode 100644 index 0000000000..9491c3207b --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/generate_sentinel_token.rs @@ -0,0 +1,97 @@ +use super::request::GenerateSentinelRefreshRequest; +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::utils_internal::classify_general_http_error::classify_general_http_error; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::error; +use serde_derive::{Deserialize, Serialize}; +use thiserror::Error; +use wreq::Client; + +const SORA_IMAGE_GEN_URL: &str = "https://chatgpt.com/backend-api/sentinel/req"; + +const SENTINEL_FLOW: &str = "sora_create_task"; + + +#[derive(Debug, Serialize, Deserialize)] +pub struct SentinelRequest { + #[serde(rename = "p")] + problem: String, + + #[serde(rename = "id")] + id: String, + + #[serde(rename = "flow")] + flow: String, +} + + +impl SentinelRequest { + pub fn new(problem: String) -> Self { + let id = generate_random_uuid(); + Self { problem, id, flow: SENTINEL_FLOW.to_string() } + } +} + + +#[derive(Debug, Serialize, Deserialize)] +pub struct SentinelResponse { + persona: String, + token: String, +} + + +/// Note: This looks like an incomplete implementation of the original Python implementation. +/// Curiously, it worked with Sora 1.0 (and continues to work), but it fails for Sora 2.0 while the +/// unchanged Python implementation works. We'll be deprecating this. +#[deprecated(note="use new sentinel token flow")] +pub async fn generate_sentinel_token() -> Result { + let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth(); + let request = SentinelRequest::new(base64_request); + let client = Client::new(); + let response = client.post(SORA_IMAGE_GEN_URL) + .header("User-Agent", CLIENT_USER_AGENT) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|err| { + error!("Client failed to generate sentinel token: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + if !response.status().is_success() { + error!("Failed to generate sentinel: {}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + let text_body = &response.text().await + .map_err(|err| { + error!("sora error reading sentinel token text body: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + let response = serde_json::from_str::(&text_body) + .map_err(|err| { + error!("Failed to parse media list response: {}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, text_body.to_string()) + })?; + + Ok(response.token) +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] // Only manually trigger this + async fn test_generate_token() { + let token = generate_sentinel_token().await.unwrap(); + println!("{}", token); + } +} \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/mod.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/mod.rs new file mode 100644 index 0000000000..0c1f2e887f --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/mod.rs @@ -0,0 +1,3 @@ +pub mod generate_sentinel_token; +pub mod request; +pub(crate) mod config; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/request.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/request.rs new file mode 100644 index 0000000000..057aab068c --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel/request.rs @@ -0,0 +1,225 @@ +use crate::requests::auth_sentinel::config::{get_random_core_count, get_random_document_key, get_random_navigator_key, get_random_window_key}; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use chrono::FixedOffset; +use chrono::Utc; +use uuid_utils::uuid::generate_random_uuid; +use rand::Rng; +use sha3::{Digest, Sha3_512}; + +// [ +// 4000, // first +// "Fri Apr 11 2025 13:28:14 GMT+0530 (India Standard Time)", // second +// None, // third +// 28, // fourth +// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0", // fifth +// "https://sora-cdn.oaistatic.com/_next/static/chunks/f90f2e60-61da6d33257661c4.js", // sixth +// None, // seventh +// "en-US", // eighth +// "en-US,en", // ninth +// 29, // tenth +// "productSub−20100101", // eleventh +// "location", // twelfth +// "ondrop", // thirteenth +// 10520, // fourteenth - generated from time.perf_counter() * 1000 +// "48094014-a024-4d2d-8ec4-ba9c7cfef9c4", // fifteenth +// "", // sixteenth +// 16, // seventeenth - Number of cores +#[derive(Debug, Clone)] +pub struct GenerateSentinelRefreshRequest { + first: usize, + second: String, // Date + third: Option, + fourth: Option, + fifth: String, // User agent + sixth: String, // URL + seventh: Option, // Language + eighth: String, // Accept-Language + ninth: String, // Product Sub + tenth: Option, + eleventh: String, // Product Sub - Navigator key + twelfth: String, // Location - Document key + thirteenth: String, // On Drop - Window key + fourteenth: usize, // generated from time.perf_counter() * 1000 + fifteenth: String, // UUID + sixteenth: String, // empty string + seventeenth: usize, // Number of cores +} + +fn generate_date() -> String { + let now_utc = Utc::now(); + let offset = FixedOffset::west_opt(5 * 60 * 60).unwrap(); + let now_with_offset = Utc::now().with_timezone(&offset); + + let formatted_date = now_with_offset.format("%a %b %d %Y %H:%M:%S GMT-0500 (Eastern Standard Time)"); + return formatted_date.to_string(); +} + +impl GenerateSentinelRefreshRequest { + pub fn new() -> Self { + let first_candidates = vec![1920 + 1080, 2560 + 1440, 1920 + 1200, 2560 + 1600]; + let first = first_candidates[rand::rng().random_range(0..first_candidates.len())]; + + let second = generate_date(); + + let third = None; + + let fourth = None; + + let fifth = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0".to_string(); + + let sixth = "https://sora-cdn.oaistatic.com/_next/static/chunks/f90f2e60-61da6d33257661c4.js".to_string(); + + let seventh = None; + + let eighth = "en-US".to_string(); + + let ninth = "en-US,en".to_string(); + + let tenth = None; + + let eleventh = get_random_navigator_key(); + + let twelfth = get_random_document_key(); + + let thirteenth = get_random_window_key(); + + let fourteenth = 10520; + + let fifteenth = generate_random_uuid(); + + let sixteenth = "".to_string(); + + let seventeenth = get_random_core_count(); + Self { first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, tenth, eleventh: eleventh.to_string(), twelfth: twelfth.to_string(), thirteenth: thirteenth.to_string(), fourteenth, fifteenth, sixteenth, seventeenth } + } + + pub fn with_fourth(mut self, fourth: usize) -> Self { + self.fourth = Some(fourth); + self + } + + pub fn with_tenth(mut self, tenth: usize) -> Self { + self.tenth = Some(tenth); + self + } + + //TODO(kasisnu): This is a temporary function to build the request. + // It is extremely hacky and this whole struct should be rewritten to be more ergonomic. + pub fn build(self) -> String { + let mut request = String::new(); + request.push_str("["); + request.push_str(&self.first.to_string()); + request.push_str(","); + request.push_str(&string_with_quotes(self.second)); + request.push_str(","); + request.push_str(&self.third.unwrap_or("null".to_string())); + request.push_str(","); + request.push_str(&self.fourth.unwrap_or(0).to_string()); + request.push_str(","); + request.push_str(&string_with_quotes(self.fifth)); + request.push_str(","); + request.push_str(&string_with_quotes(self.sixth)); + request.push_str(","); + request.push_str(&self.seventh.unwrap_or("null".to_string())); + request.push_str(","); + request.push_str(&string_with_quotes(self.eighth)); + request.push_str(","); + request.push_str(&string_with_quotes(self.ninth)); + request.push_str(","); + request.push_str(&self.tenth.unwrap_or(0).to_string()); + request.push_str(","); + request.push_str(&string_with_quotes(self.eleventh)); + request.push_str(","); + request.push_str(&string_with_quotes(self.twelfth)); + request.push_str(","); + request.push_str(&string_with_quotes(self.thirteenth)); + request.push_str(","); + request.push_str(&self.fourteenth.to_string()); + request.push_str(","); + request.push_str(&string_with_quotes(self.fifteenth)); + request.push_str(","); + request.push_str(&string_with_quotes(self.sixteenth)); + request.push_str(","); + request.push_str(&self.seventeenth.to_string()); + request.push_str("]"); + request + } + + pub fn hash_request(seed: f64, request: String) -> Vec { + let mut hasher = Sha3_512::new(); + hasher.update(seed.to_string()); + let base64_request = BASE64_STANDARD.encode(request.as_bytes()); + hasher.update(base64_request); + hasher.finalize().to_vec() + } + + pub fn with_fourth_and_tenth(mut self) -> (Self, String) { + let mut fourth = 0; + let mut tenth = 0; + let max_iterations = 500000; + let diff_len = 6; + let target_bytes = vec![0x0f, 0xff, 0xff]; + let random_seed: f64 = rand::rng().random(); + + let mut base64_request = BASE64_STANDARD.encode(self.clone().build().as_bytes()); + for i in 0..max_iterations { + fourth = i; + tenth = i >> 1; + let request = self.clone().with_fourth(fourth).with_tenth(tenth).build(); + let hash_bytes = Self::hash_request(random_seed, request.clone()); + base64_request = BASE64_STANDARD.encode(request.as_bytes()); + + let mut is_less_or_equal = true; + for j in 0..std::cmp::min(diff_len, target_bytes.len()) { + if j < hash_bytes.len() { + if hash_bytes[j] > target_bytes[j] { + is_less_or_equal = false; + break; + } else if hash_bytes[j] < target_bytes[j] { + break; + } + } + } + + if is_less_or_equal { + break; + } + } + + (self.with_fourth(fourth).with_tenth(tenth), format!("gAAAAAC{}", base64_request)) + } +} + +fn string_with_quotes(value: String) -> String { + format!("\"{}\"", value) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_sentinel_refresh_request() { + let request = GenerateSentinelRefreshRequest::new(); + println!("{}", request.build()); + } + + #[test] + fn test_with_fourth_and_tenth() { + let request = GenerateSentinelRefreshRequest::new().with_fourth(100).with_tenth(200); + println!("{}", request.build()); + } + + #[test] + fn test_hash_request() { + let request = GenerateSentinelRefreshRequest::new().with_fourth(100).with_tenth(200); + let hash = GenerateSentinelRefreshRequest::hash_request(0.1, request.build()); + println!("{}", hash.iter().map(|b| format!("{:02x}", b)).collect::()); + } + + #[test] + fn test_with_fourth_and_tenth_2() { + let (request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth(); + println!("{}", base64_request); + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/extract_expiry.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/extract_expiry.rs new file mode 100644 index 0000000000..fa62c931e6 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/extract_expiry.rs @@ -0,0 +1,62 @@ +use crate::requests::auth_sentinel_2::response::SentinelResponse; +use chrono::{DateTime, TimeDelta, Utc}; +use log::{info, warn}; +use std::f32::consts::E; + +// Sora's servers typically tell us to expire in nine minutes (540 seconds). +const DEFAULT_EXPIRE_SECONDS : u32 = 60 * 3; // 3 minutes. + +pub (super) struct ExtractedExpiry { + pub (super) generated_at: DateTime, + pub (super) expires_in_seconds: u32, +} + +pub (super) fn extract_expiry(generated_at: DateTime, response: &SentinelResponse) -> ExtractedExpiry { + info!("Sentinel expires at: {:?} (in {:?} seconds)", response.expire_at, response.expire_after); + + const LOWER_BOUNDS_SECONDS: u32 = 60; + const UPPER_BOUNDS_SECONDS: u32 = 60 * 60; + + // Trust the `expire_at` timestamp first. + let mut maybe_expires_at = response.expire_at + .map(|timestamp| DateTime::from_timestamp(timestamp as i64, 0)) + .flatten(); + + if let Some(expires_at) = maybe_expires_at { + let seconds = expires_at.signed_duration_since(generated_at).num_seconds(); + + const LOWER_BOUNDS_SECONDS_I64: i64 = LOWER_BOUNDS_SECONDS as i64; + const UPPER_BOUNDS_SECONDS_I64: i64 = UPPER_BOUNDS_SECONDS as i64; + + if seconds.is_positive() + && seconds > LOWER_BOUNDS_SECONDS_I64 + && seconds < UPPER_BOUNDS_SECONDS_I64 + { + info!("Using sentinel expire_at timestamp, expires in {} seconds (at {})", seconds, expires_at); + + return ExtractedExpiry { + generated_at, + expires_in_seconds: seconds as u32, + } + } + } + + let mut expires_in_seconds = response.expire_after + .unwrap_or_else(|| { + warn!("No default expire_after from sentinel response, defaulting to {} seconds", DEFAULT_EXPIRE_SECONDS); + DEFAULT_EXPIRE_SECONDS + }); + + if expires_in_seconds < LOWER_BOUNDS_SECONDS { + warn!("Warning: sentinel token expires very soon: {} seconds, defaulting to {} seconds", expires_in_seconds, DEFAULT_EXPIRE_SECONDS); + expires_in_seconds = DEFAULT_EXPIRE_SECONDS; + } else if expires_in_seconds > UPPER_BOUNDS_SECONDS { + warn!("Warning: sentinel token expires very late: {} seconds, defaulting to {} seconds", expires_in_seconds, DEFAULT_EXPIRE_SECONDS); + expires_in_seconds = DEFAULT_EXPIRE_SECONDS; + } + + ExtractedExpiry { + generated_at, + expires_in_seconds, + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs new file mode 100644 index 0000000000..e1adb119d0 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs @@ -0,0 +1,169 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_sentinel_token::{RawSoraSentinelToken, SoraSentinelToken}; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::requests::auth_sentinel::request::GenerateSentinelRefreshRequest; +use crate::requests::auth_sentinel_2::extract_expiry::extract_expiry; +use crate::requests::auth_sentinel_2::request::SentinelRequest; +use crate::requests::auth_sentinel_2::response::SentinelResponse; +use crate::utils_internal::classify_general_http_error::classify_general_http_error; +use chrono::{DateTime, NaiveDateTime, TimeDelta, TimeZone, Utc}; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{debug, error, info}; +use serde_derive::{Deserialize, Serialize}; +use std::io::Write; +use thiserror::Error; +use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, USER_AGENT}; +use wreq::Client; + +const SORA_SENTINEL_ENDPOINT: &str = "https://chatgpt.com/backend-api/sentinel/req"; + + +/// This generates a Sentinel Token that works with Sora 1.0 and Sora 2.0 consumer products. +pub async fn generate_sentinel_token_2() -> Result { + let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth(); + + let request = SentinelRequest::new(base64_request); + let client = Client::new(); + + let request_builder = client.post(SORA_SENTINEL_ENDPOINT) + .header(USER_AGENT, CLIENT_USER_AGENT) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.9") + // TODO: Accept-Encoding + // TODO: Cookie + .header(REFERER, "https://chatgpt.com/backend-api/sentinel/frame.html") + .header(CONTENT_TYPE, "application/json") + .header(ORIGIN, "https://chatgpt.com") + .header("sec-gpc", "1") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .header("priority", "u=4") + .header("te", "trailers"); + + let response = request_builder + .json(&request) + .send() + .await + .map_err(|err| { + error!("Client failed to generate sentinel token: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + if !response.status().is_success() { + error!("Failed to generate sentinel: {}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + let text_body = &response.text().await + .map_err(|err| { + error!("sora error reading sentinel token text body: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + debug!("Sentinel endpoint response body: {}", text_body); + + let response = serde_json::from_str::(&text_body) + .map_err(|err| { + error!("Failed to parse sentinel token response: {}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, text_body.to_string()) + })?; + + let sentinel_token = into_sora_sentinel_token(request, response, text_body)?; + + Ok(sentinel_token) +} + +fn into_sora_sentinel_token(request: SentinelRequest, response: SentinelResponse, raw_body: &str) -> Result { + let expiry = extract_expiry(Utc::now(), &response); + + let maybe_token = response.token; + + let maybe_turnstile = response.turnstile + .map(|turn| turn.dx) + .flatten(); + + let mut missing_token = false; + let mut missing_turnstile = false; + + let (token, turnstile) = { + let maybe_items = match (maybe_token, maybe_turnstile) { + (Some(token), Some(turnstile)) => { + Some((token, turnstile)) + } + (None, None) => { + missing_token = true; + missing_turnstile = true; + None + } + (None, Some(_turnstile)) => { + missing_token = true; + None + } + (Some(_token), None) => { + missing_turnstile = true; + None + } + }; + match maybe_items { + Some((token, turnstile)) => (token, turnstile), + _ => return Err(SoraSpecificApiError::SentinelResponseIsMissingFields { + missing_token, + missing_turnstile, + raw_response: raw_body.to_string(), + }), + } + }; + + let sentinel_raw = RawSoraSentinelToken { + p: request.p, + id: request.id, + flow: request.flow, + t: turnstile, + c: token, + }; + + + let sentinel = SoraSentinelToken { + token: sentinel_raw, + browser_user_agent: CLIENT_USER_AGENT.to_string(), + generated_at: expiry.generated_at, + expires_in_seconds: expiry.expires_in_seconds, + }; + + Ok(sentinel) +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::write; + + #[tokio::test] + #[ignore] // Only manually trigger this + async fn test_generate_token_2() -> AnyhowResult<()> { + let token = generate_sentinel_token_2().await?; + println!("{:?}", token); + assert_eq!(1, 2); + Ok(()) + } + + #[tokio::test] + #[ignore] // Only manually trigger this + async fn write_token_to_disk() -> AnyhowResult<()> { + let filename = "/Users/bt/Artcraft/credentials/sora_sentinel_token_store.json"; + let token = generate_sentinel_token_2().await?; + + let json = token.to_persistent_storage_json()?; + + write(filename, &json)?; + + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/config.py b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/config.py new file mode 100644 index 0000000000..0b7f7c998c --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/config.py @@ -0,0 +1,357 @@ +cores = [8, 16, 24, 32] +cached_scripts = ["https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3"] + +cached_dpl = ["prod-f501fe933b3edf57aea882da888e1a544df99840"] + +navigator_key = [ + "registerProtocolHandler−function registerProtocolHandler() { [native code] }", + "storage−[object StorageManager]", + "locks−[object LockManager]", + "appCodeName−Mozilla", + "permissions−[object Permissions]", + "share−function share() { [native code] }", + "webdriver−false", + "managed−[object NavigatorManagedData]", + "canShare−function canShare() { [native code] }", + "vendor−Google Inc.", + "vendor−Google Inc.", + "mediaDevices−[object MediaDevices]", + "vibrate−function vibrate() { [native code] }", + "storageBuckets−[object StorageBucketManager]", + "mediaCapabilities−[object MediaCapabilities]", + "getGamepads−function getGamepads() { [native code] }", + "bluetooth−[object Bluetooth]", + "share−function share() { [native code] }", + "cookieEnabled−true", + "virtualKeyboard−[object VirtualKeyboard]", + "product−Gecko", + "mediaDevices−[object MediaDevices]", + "canShare−function canShare() { [native code] }", + "getGamepads−function getGamepads() { [native code] }", + "product−Gecko", + "xr−[object XRSystem]", + "clipboard−[object Clipboard]", + "storageBuckets−[object StorageBucketManager]", + "unregisterProtocolHandler−function unregisterProtocolHandler() { [native code] }", + "productSub−20030107", + "login−[object NavigatorLogin]", + "vendorSub−", + "login−[object NavigatorLogin]", + "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", + "mediaDevices−[object MediaDevices]", + "locks−[object LockManager]", + "webkitGetUserMedia−function webkitGetUserMedia() { [native code] }", + "vendor−Google Inc.", + "xr−[object XRSystem]", + "mediaDevices−[object MediaDevices]", + "virtualKeyboard−[object VirtualKeyboard]", + "virtualKeyboard−[object VirtualKeyboard]", + "appName−Netscape", + "storageBuckets−[object StorageBucketManager]", + "presentation−[object Presentation]", + "onLine−true", + "mimeTypes−[object MimeTypeArray]", + "credentials−[object CredentialsContainer]", + "presentation−[object Presentation]", + "getGamepads−function getGamepads() { [native code] }", + "vendorSub−", + "virtualKeyboard−[object VirtualKeyboard]", + "serviceWorker−[object ServiceWorkerContainer]", + "xr−[object XRSystem]", + "product−Gecko", + "keyboard−[object Keyboard]", + "gpu−[object GPU]", + "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", + "webkitPersistentStorage−[object DeprecatedStorageQuota]", + "doNotTrack", + "clearAppBadge−function clearAppBadge() { [native code] }", + "presentation−[object Presentation]", + "serial−[object Serial]", + "locks−[object LockManager]", + "requestMIDIAccess−function requestMIDIAccess() { [native code] }", + "locks−[object LockManager]", + "requestMediaKeySystemAccess−function requestMediaKeySystemAccess() { [native code] }", + "vendor−Google Inc.", + "pdfViewerEnabled−true", + "language−zh-CN", + "setAppBadge−function setAppBadge() { [native code] }", + "geolocation−[object Geolocation]", + "userAgentData−[object NavigatorUAData]", + "mediaCapabilities−[object MediaCapabilities]", + "requestMIDIAccess−function requestMIDIAccess() { [native code] }", + "getUserMedia−function getUserMedia() { [native code] }", + "mediaDevices−[object MediaDevices]", + "webkitPersistentStorage−[object DeprecatedStorageQuota]", + "sendBeacon−function sendBeacon() { [native code] }", + "hardwareConcurrency−32", + "credentials−[object CredentialsContainer]", + "storage−[object StorageManager]", + "cookieEnabled−true", + "pdfViewerEnabled−true", + "windowControlsOverlay−[object WindowControlsOverlay]", + "scheduling−[object Scheduling]", + "pdfViewerEnabled−true", + "hardwareConcurrency−32", + "xr−[object XRSystem]", + "webdriver−false", + "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", + "getInstalledRelatedApps−function getInstalledRelatedApps() { [native code] }", + "bluetooth−[object Bluetooth]" +] + +document_key = ['_reactListeningo743lnnpvdg', 'location'] + +window_key = [ + "0", + "window", + "self", + "document", + "name", + "location", + "customElements", + "history", + "navigation", + "locationbar", + "menubar", + "personalbar", + "scrollbars", + "statusbar", + "toolbar", + "status", + "closed", + "frames", + "length", + "top", + "opener", + "parent", + "frameElement", + "navigator", + "origin", + "external", + "screen", + "innerWidth", + "innerHeight", + "scrollX", + "pageXOffset", + "scrollY", + "pageYOffset", + "visualViewport", + "screenX", + "screenY", + "outerWidth", + "outerHeight", + "devicePixelRatio", + "clientInformation", + "screenLeft", + "screenTop", + "styleMedia", + "onsearch", + "isSecureContext", + "trustedTypes", + "performance", + "onappinstalled", + "onbeforeinstallprompt", + "crypto", + "indexedDB", + "sessionStorage", + "localStorage", + "onbeforexrselect", + "onabort", + "onbeforeinput", + "onbeforematch", + "onbeforetoggle", + "onblur", + "oncancel", + "oncanplay", + "oncanplaythrough", + "onchange", + "onclick", + "onclose", + "oncontentvisibilityautostatechange", + "oncontextlost", + "oncontextmenu", + "oncontextrestored", + "oncuechange", + "ondblclick", + "ondrag", + "ondragend", + "ondragenter", + "ondragleave", + "ondragover", + "ondragstart", + "ondrop", + "ondurationchange", + "onemptied", + "onended", + "onerror", + "onfocus", + "onformdata", + "oninput", + "oninvalid", + "onkeydown", + "onkeypress", + "onkeyup", + "onload", + "onloadeddata", + "onloadedmetadata", + "onloadstart", + "onmousedown", + "onmouseenter", + "onmouseleave", + "onmousemove", + "onmouseout", + "onmouseover", + "onmouseup", + "onmousewheel", + "onpause", + "onplay", + "onplaying", + "onprogress", + "onratechange", + "onreset", + "onresize", + "onscroll", + "onsecuritypolicyviolation", + "onseeked", + "onseeking", + "onselect", + "onslotchange", + "onstalled", + "onsubmit", + "onsuspend", + "ontimeupdate", + "ontoggle", + "onvolumechange", + "onwaiting", + "onwebkitanimationend", + "onwebkitanimationiteration", + "onwebkitanimationstart", + "onwebkittransitionend", + "onwheel", + "onauxclick", + "ongotpointercapture", + "onlostpointercapture", + "onpointerdown", + "onpointermove", + "onpointerrawupdate", + "onpointerup", + "onpointercancel", + "onpointerover", + "onpointerout", + "onpointerenter", + "onpointerleave", + "onselectstart", + "onselectionchange", + "onanimationend", + "onanimationiteration", + "onanimationstart", + "ontransitionrun", + "ontransitionstart", + "ontransitionend", + "ontransitioncancel", + "onafterprint", + "onbeforeprint", + "onbeforeunload", + "onhashchange", + "onlanguagechange", + "onmessage", + "onmessageerror", + "onoffline", + "ononline", + "onpagehide", + "onpageshow", + "onpopstate", + "onrejectionhandled", + "onstorage", + "onunhandledrejection", + "onunload", + "crossOriginIsolated", + "scheduler", + "alert", + "atob", + "blur", + "btoa", + "cancelAnimationFrame", + "cancelIdleCallback", + "captureEvents", + "clearInterval", + "clearTimeout", + "close", + "confirm", + "createImageBitmap", + "fetch", + "find", + "focus", + "getComputedStyle", + "getSelection", + "matchMedia", + "moveBy", + "moveTo", + "open", + "postMessage", + "print", + "prompt", + "queueMicrotask", + "releaseEvents", + "reportError", + "requestAnimationFrame", + "requestIdleCallback", + "resizeBy", + "resizeTo", + "scroll", + "scrollBy", + "scrollTo", + "setInterval", + "setTimeout", + "stop", + "structuredClone", + "webkitCancelAnimationFrame", + "webkitRequestAnimationFrame", + "chrome", + "caches", + "cookieStore", + "ondevicemotion", + "ondeviceorientation", + "ondeviceorientationabsolute", + "launchQueue", + "documentPictureInPicture", + "getScreenDetails", + "queryLocalFonts", + "showDirectoryPicker", + "showOpenFilePicker", + "showSaveFilePicker", + "originAgentCluster", + "onpageswap", + "onpagereveal", + "credentialless", + "speechSynthesis", + "onscrollend", + "webkitRequestFileSystem", + "webkitResolveLocalFileSystemURL", + "sendMsgToSolverCS", + "webpackChunk_N_E", + "__next_set_public_path__", + "next", + "__NEXT_DATA__", + "__SSG_MANIFEST_CB", + "__NEXT_P", + "_N_E", + "regeneratorRuntime", + "__REACT_INTL_CONTEXT__", + "DD_RUM", + "_", + "filterCSS", + "filterXSS", + "__SEGMENT_INSPECTOR__", + "__NEXT_PRELOADREADY", + "Intercom", + "__MIDDLEWARE_MATCHERS", + "__STATSIG_SDK__", + "__STATSIG_JS_SDK__", + "__STATSIG_RERENDER_OVERRIDE__", + "_oaiHandleSessionExpired", + "__BUILD_MANIFEST", + "__SSG_MANIFEST", + "__intercomAssignLocation", + "__intercomReloadLocation" +] \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/proof_of_work.py b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/proof_of_work.py new file mode 100644 index 0000000000..25797d305a --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/proof_of_work.py @@ -0,0 +1,71 @@ +import hashlib, json, random, time, uuid, pybase64 +from datetime import datetime, timedelta, timezone +from config import cores, cached_scripts, cached_dpl, navigator_key, document_key, window_key + + +MAX_ITERATION = 500000 + +def get_parse_time(): + now = datetime.now(timezone(timedelta(hours=-5))) + return now.strftime("%a %b %d %Y %H:%M:%") + " GMT-0500 (Eastern Standard Time)" + + + +def get_config(user_agent): + config = [ + random.choice([1920 + 1080, 2560 + 1440, 1920 + 1200, 2560 + 1600]), + get_parse_time(), + 4294705152, + 0, + user_agent, + random.choice(cached_scripts) if cached_scripts else "", + cached_dpl, + "en-US", + "en-US,es-US,en,es", + 0, + random.choice(navigator_key), + random.choice(document_key), + random.choice(window_key), + time.perf_counter() * 1000, + str(uuid.uuid4()), + "", + random.choice(cores), + time.time() * 1000 - (time.perf_counter() * 1000), + ] + return config + + + +def generate_answer(seed, diff, config): + diff_len = len(diff) + seed_encoded = seed.encode() + static_config_part1 = (json.dumps(config[:3], separators=(',', ':'), ensure_ascii=False)[:-1] + ',').encode() + static_config_part2 = (',' + json.dumps(config[4:9], separators=(',', ':'), ensure_ascii=False)[1:-1] + ',').encode() + static_config_part3 = (',' + json.dumps(config[10:], separators=(',', ':'), ensure_ascii=False)[1:]).encode() + + target_diff = bytes.fromhex(diff) + + for i in range(MAX_ITERATION): + dynamic_json_i = str(i).encode() + dynamic_json_j = str(i >> 1).encode() + final_json_bytes = static_config_part1 + dynamic_json_i + static_config_part2 + dynamic_json_j + static_config_part3 + base_encode = pybase64.b64encode(final_json_bytes) + hash_value = hashlib.sha3_512(seed_encoded + base_encode).digest() + if hash_value[:diff_len] <= target_diff: + return base_encode.decode(), True + + return "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + pybase64.b64encode(f'"{seed}"'.encode()).decode(), False + + +def get_requirements_token(config): + seed = format(random.random()) + diff = "0fffff" + solution, found = generate_answer(seed, diff, config) + return 'gAAAAAC' + solution + +def get_pow_token(): + config = get_config("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36") + return get_requirements_token(config) + +if __name__ == "__main__": + print(get_pow_token()) \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/readme.MD b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/readme.MD new file mode 100644 index 0000000000..3cf95b72ce --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/readme.MD @@ -0,0 +1,100 @@ +# OpenAI Sentinel Token Generator + +This repository provides a Python-based implementation to generate **Proof of Work (PoW) tokens** and **Sentinel tokens** for bypassing security mechanisms in OpenAI's ChatGPT and Sora private APIs. By reverse engineering the browser's token generation process, this project enables programmatic access to these APIs without requiring manual interaction or browser-based token generation. + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [How It Works](#how-it-works) +- [Installation](#installation) +- [Usage](#usage) +- [File Structure](#file-structure) +- [Disclaimer](#disclaimer) + +--- + +## Overview + +OpenAI's private APIs, such as ChatGPT and Sora, use advanced security mechanisms like Proof of Work (PoW) and Sentinel tokens to prevent unauthorized access. These tokens are typically generated in the browser using a combination of cryptographic hashing, unique identifiers, and environmental data. + +This project reverse-engineers the token generation process to allow developers to programmatically generate these tokens and interact with the APIs directly. This can be useful for automation, testing, or research purposes. + +--- +## Features + +- **Proof of Work Token Generation**: Implements a hashing-based PoW mechanism to generate valid tokens. +- **Sentinel Token Generation**: Creates Sentinel tokens required for API authentication. +- **Reverse-Engineered Browser Behavior**: Mimics browser-specific configurations and environmental data. +- **Customizable**: Easily modify configurations like user agents, navigator keys, and more. + +--- +## How It Works +### Proof of Work (PoW) Token Generation +The PoW token is generated by solving a computational challenge. The process involves: +1. Generating a random seed and difficulty level. +2. Iteratively hashing the seed with encoded configuration data until the hash meets the difficulty criteria. +3. Returning the solution as a base64-encoded string. + +The implementation is in [`proof_of_work.py`](proof_of_work.py), which uses cryptographic hashing (`sha3_512`) and base64 encoding to generate the token. + +### Sentinel Token Generation +The Sentinel token is generated by: +1. Fetching API requirements using the PoW token. +2. Parsing the API response to extract necessary fields like `turnstile` and `token`. +3. Constructing a payload with the extracted data and generating the final Sentinel token. + +The implementation is in [`sentinel_token.py`](sentinel_token.py), which handles API communication and token generation. + +--- +## Installation + +1. Clone the repository: + ```bash + git clone https://github.com/your-username/openai-sentinel.git + cd openai-sentinel + ``` +2. Install the required dependencies: + ```bash + pip install -r requirements.txt + ``` + Ensure you have Python 3.8 or higher installed. + +___ +## Usage +### Generating a Proof of Work (PoW) Token +To generate a PoW token, run the following command: +```bash +python proof_of_work.py +``` +This will output a valid PoW token that can be used for API authentication. +### Generating a Sentinel Token +To generate a Sentinel token, run the following command: +```bash +python sentinel_token.py +``` +This will output a valid Sentinel token that can be used for API authentication. + +--- +## File Structure + +```plaintext +openai-sentinel/ +├── proof_of_work.py +├── sentinel_token.py +├── config.py +├── requirements.txt +├── README.md +``` +### Key Files: +- `proof_of_work.py`: Contains the implementation for generating PoW tokens. +- `sentinel_token.py`: Contains the implementation for generating Sentinel tokens. +- `config.py`: Configuration file for user agents, navigator keys, and other settings. +- `requirements.txt`: Lists the required Python packages. +- `README.md`: This documentation file. +--- +## Disclaimer +This project is intended for educational and research purposes only. The use of this code to bypass security mechanisms or access APIs without authorization may violate the terms of service of OpenAI or other third-party services. Use at your own risk, and ensure compliance with all applicable laws and regulations. +``` +Please do not use this code for malicious purposes or to infringe on the rights of others. The author and contributors are not responsible for any misuse or damage caused by the use of this code. +``` \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/sentinel_token.py b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/sentinel_token.py new file mode 100644 index 0000000000..85ef940733 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/metadata/python_impl/sentinel_token.py @@ -0,0 +1,55 @@ +import requests, json +from uuid import uuid4 +from proof_of_work import get_pow_token + +current_parameters = None + + +def generate_id(): + return str(uuid4()) + +def generate_payload(data, flow): + data['id'] = generate_id() + data['flow'] = flow + return json.dumps(data) + +def fetch_requirements(flow, pow_token): + max_retries = 3 + for attempt in range(max_retries): + try: + response = requests.post( + url = "https://chatgpt.com/backend-api/sentinel/req", + data = generate_payload({'p': pow_token}, flow), + ) + + result = response.json() + return result, True + except requests.exceptions.RequestException as err: + if attempt >= 2: + return generate_payload({'e': str(err)}, flow), False + +def refresh_token(flow): + pow_token = get_pow_token() + response,_ = fetch_requirements(flow, pow_token) + if not _: + return response + + try: + + payload = generate_payload({ + 'p': pow_token, + 't': response.get("turnstile", {}).get('dx', ""), + 'c': response.get('token') + }, flow) + return payload + except Exception as err: + failure = generate_payload({'e': str(err), 'p': pow_token}, flow) + return failure + +def get_sentinel_token(): + flow = 'sora_create_task' + token = refresh_token(flow) #This can be used now in 'OpenAI-Sentinel-Token' header + return token + +if __name__ == "__main__": + print(get_sentinel_token()) \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/mod.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/mod.rs new file mode 100644 index 0000000000..3647ad408d --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/mod.rs @@ -0,0 +1,4 @@ +pub (super) mod extract_expiry; +pub (super) mod request; +pub (super) mod response; +pub mod generate_sentinel_token_2; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/request.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/request.rs new file mode 100644 index 0000000000..9beb3ddab3 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/request.rs @@ -0,0 +1,32 @@ +use uuid_utils::uuid::generate_random_uuid; +use serde_derive::Serialize; + +// TODO: Support more of these. +const SENTINEL_FLOW: &str = "sora_init"; +const SENTINEL_FLOW_OLD: &str = "sora_create_task"; + +#[derive(Debug, Serialize)] +pub struct SentinelRequest { + /// "Proof of Work" token + #[serde(rename = "p")] + pub (crate) p: String, + + /// Random UUID + #[serde(rename = "id")] + pub (crate) id: String, + + /// Frontend UI flow + #[serde(rename = "flow")] + pub (crate) flow: String, +} + +impl SentinelRequest { + pub fn new(pow_token: String) -> Self { + let id = generate_random_uuid(); + Self { + p: pow_token, + id, + flow: SENTINEL_FLOW.to_string() + } + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/response.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/response.rs new file mode 100644 index 0000000000..0f8a32fff5 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/response.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde_derive::Deserialize; + +#[derive(Debug, Deserialize)] +pub (crate) struct SentinelResponse { + pub (crate) token: Option, + pub (crate) turnstile: Option, + + // NB: appears to be integer seconds. + // Not required for the sentinel token flow, but helps with renew. + pub (crate) expire_after: Option, + + // NB: appears to be Unix timestamp in seconds. + // Not required for the sentinel token flow, but helps with renew. + pub (crate) expire_at: Option, +} + +#[derive(Debug, Deserialize)] +pub (crate) struct SentinelTurnstile { + pub (crate) dx: Option, +} diff --git a/crates/api_clients/openai_sora_client/src/requests/common/mod.rs b/crates/api_clients/openai_sora_client/src/requests/common/mod.rs new file mode 100644 index 0000000000..aaf466b628 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/common/mod.rs @@ -0,0 +1 @@ +pub mod task_id; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/common/task_id.rs b/crates/api_clients/openai_sora_client/src/requests/common/task_id.rs new file mode 100644 index 0000000000..131d933fe9 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/common/task_id.rs @@ -0,0 +1,34 @@ +use std::fmt::Display; +use serde_derive::{Deserialize, Serialize}; + +/// A strongly typed task ID for Sora tasks. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +pub struct TaskId(pub String); + +impl TaskId { + pub fn from_string(id: String) -> Self { + Self(id) + } + + pub fn from_str(id: &str) -> Self { + Self(id.to_string()) + } + + pub fn new(id: String) -> Self { + Self(id) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.to_string() + } +} + +impl Display for TaskId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/mod.rs b/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/mod.rs new file mode 100644 index 0000000000..e679dd1843 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/mod.rs @@ -0,0 +1,2 @@ +pub mod sora_job_status; +pub mod unimplemented_sora_job_status; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/sora_job_status.rs b/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/sora_job_status.rs new file mode 100644 index 0000000000..fce3287c0c --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/sora_job_status.rs @@ -0,0 +1,359 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::requests::common::task_id::TaskId; +use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{debug, error, warn}; +use serde_derive::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; +use std::{fs::File, io::Write, path::Path}; +use url::Url; +use wreq::Client; + +// NB(2025-10-02): The new URL is: +// https://sora.chatgpt.com/backend/v2/list_tasks?limit=20 +const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen"; + + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum TaskStatus { + Queued, + Running, + Succeeded, + Failed, + Unknown(String), +} + +impl TaskStatus { + pub fn from_str(s: &str) -> Self { + match s { + "queued" => TaskStatus::Queued, + "running" => TaskStatus::Running, + "succeeded" => TaskStatus::Succeeded, + "failed" => TaskStatus::Failed, + _ => TaskStatus::Unknown(s.to_string()), + } + } +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub struct VideoGenStatusResponse { + pub task_responses: Vec, + pub last_id: String, + pub has_more: bool, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct TaskResponse { + pub id: TaskId, + pub user: String, + pub created_at: String, + pub status: String, + pub progress_pct: Option, + pub progress_pos_in_queue: Option, + pub estimated_queue_wait_time: Option, + pub queue_status_message: Option, + pub priority: i32, + pub r#type: String, + pub prompt: Option, + pub n_variants: i32, + pub n_frames: i32, + pub height: i32, + pub width: i32, + pub model: Option, + pub operation: String, + pub inpaint_items: Option>, + pub preset_id: Option, + pub caption: Option, + //pub actions: Option, + //pub interpolation: Option, + //pub sdedit: Option, + //pub remix_config: Option, + pub quality: Option, + pub generations: Vec, + pub num_unsafe_generations: i32, + pub title: String, + //pub moderation_result: ModerationResult, + pub failure_reason: Option, + pub needs_user_review: bool, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct Generation { + pub id: String, + pub task_id: TaskId, + pub created_at: String, + pub deleted_at: Option, + pub url: String, + pub seed: Option, + pub can_download: Option, + pub download_status: Option, + pub is_favorite: Option, + pub is_liked: Option, + pub is_public: Option, + pub is_archived: Option, + pub is_featured: Option, + pub has_feedback: Option, + pub like_count: Option, + //pub cloudflare_metadata: Option, + pub cf_thumbnail_url: Option, + pub encodings: Encodings, + pub width: i32, + pub height: i32, + pub n_frames: i32, + pub prompt: String, + pub title: String, + pub actions: Option, + pub inpaint_items: Option>, + //pub interpolation: Option, + //pub sdedit: Option, + pub operation: String, + pub model: Option, + pub preset_id: Option, + //pub user: User, + //pub moderation_result: ModerationResult, + //pub paragen_status: Option, + pub task_type: String, + pub remix_config: Option, + pub quality: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct Encodings { + pub source: Source, + pub md: Option, + pub ld: Option, + pub thumbnail: Thumbnail, + pub spritesheet: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct Source { + pub path: String, + pub size: Option, + pub width: Option, + pub height: Option, + pub duration_secs: Option, + pub ssim: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct Thumbnail { + pub path: String, + pub size: Option, +} + +//#[derive(Deserialize, Clone, Debug)] +//#[serde(rename_all = "snake_case")] +//pub struct User { +// pub id: String, +// pub username: String, +//} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct InpaintItem { + pub crop_bounds: Option, + pub r#type: String, + pub preset_id: Option, + pub generation_id: Option, + pub upload_media_id: Option, + pub frame_index: i32, + pub source_start_frame: i32, +} + +//#[derive(Deserialize, Clone, Debug)] +//#[serde(rename_all = "snake_case")] +//pub struct ModerationResult { +// pub r#type: String, +// pub results_by_frame_index: serde_json::Value, +// pub code: Option, +// pub is_output_rejection: bool, +// pub task_id: TaskId, +//} + +pub struct StatusRequest { + pub limit: Option, + pub before: Option, +} + +impl StatusRequest { + pub fn new(limit: Option, before: Option) -> Self { + let limit = limit.unwrap_or(100); + Self { limit: Some(limit), before } + } +} + +/// Gets the status of image generation tasks from Sora API +pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentialSet) -> Result { + + let bearer_header = match credentials.jwt_bearer_token.as_ref() { + Some(bearer) => bearer.to_authorization_header_value(), + None => { + warn!("No JWT bearer token in client - cannot fetch image gen status!"); + return Err(SoraClientError::NoBearerTokenForRequest.into()); + }, + }; + + let client = Client::new(); + + let mut url = Url::parse(SORA_STATUS_URL) + .map_err(|err| { + SoraClientError::UrlParseError(err) + })?; + + // Add query parameters + if let Some(limit) = status_request.limit { + url.query_pairs_mut().append_pair("limit", &limit.to_string()); + } + + if let Some(before) = &status_request.before { + url.query_pairs_mut().append_pair("before", &before); + } + + let http_request = client.get(url.as_str()) + .header("User-Agent", CLIENT_USER_AGENT) + .header("Cookie", credentials.cookies.as_str()) + .header("Authorization", bearer_header) + .header("Content-Type", "application/json"); + + let http_request = http_request.build() + .map_err(|err| { + SoraClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Client failed to fetch sora image gen status: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = &response.text() + .await + .map_err(|err| { + error!("Client failed to read sora image gen status: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + debug!("response_body: {}", response_body); + + if !status.is_success() { + error!("The sora status request failed; status = {:?} ; response body = {}", status, response_body); + let error = classify_general_http_status_code_and_body(status, &response_body); + return Err(error); + } + + let maybe_response = serde_json::from_str::(response_body); + + match maybe_response { + Ok(response) => Ok(response), + Err(err) => { + warn!("Failed to parse status response: {:?}", err); + Err(SoraGenericApiError::SerdeResponseParseErrorWithBody( + err, response_body.to_string()).into()) + } + } +} + +pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> { + let dir = Path::new(dir); + if !dir.exists() { + std::fs::create_dir_all(dir)?; + } + for generation in generations { + let response = wreq::get(generation.url.clone()).send().await?; + let image_bytes = response.bytes().await?; + + let url = Url::parse(&generation.url)?; + let ext = url.path().split(".").last().unwrap_or("png"); + let path = Path::new(dir).join(format!("{}.{}", generation.id, ext)); + + let mut file = File::create(path)?; + file.write_all(&image_bytes)?; + + let thumbnail_url = generation.encodings.thumbnail.path.clone(); + let thumbnail_response = wreq::get(thumbnail_url).send().await?; + let thumbnail_bytes = thumbnail_response.bytes().await?; + let thumbnail_path = Path::new(dir).join(format!("{}_thumbnail.{}", generation.id, ext)); + let mut thumbnail_file = File::create(thumbnail_path)?; + thumbnail_file.write_all(&thumbnail_bytes)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::creds::sora_credential_builder::SoraCredentialBuilder; + use crate::recipes::wait_for_image_gen_status::wait_for_image_gen_status; + use crate::requests::deprecated::job_status::sora_job_status::{get_image_gen_status, save_generations_to_dir, StatusRequest, VideoGenStatusResponse}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_utils::test_file_path::test_file_path; + + #[ignore] + #[tokio::test] + pub async fn test_deserialize_status_response() -> AnyhowResult<()> { + let response = read_to_string(test_file_path("test_data/image_gen/test_status_response_long.json")?)?; + let _response: VideoGenStatusResponse = serde_json::from_str(&response)?; + Ok(()) + } + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let sentinel = read_to_string(test_file_path("test_data/temp/sentinel.txt")?)?; + let sentinel = sentinel.trim().to_string(); + + let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?; + let cookie = cookie.trim().to_string(); + + let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?; + let bearer = bearer.trim().to_string(); + + let creds = SoraCredentialBuilder::new() + .with_cookies(&cookie) + .with_jwt_bearer_token(&bearer) + .with_sora_sentinel(&sentinel) + .build()?; + + // Get the task status for a specific task + // let response = get_image_gen_status(&StatusRequest::new(None, Some("task_01jr9yvpfyetx9r7qvvx38scna".to_string())), &creds).await?; + + let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &creds).await?; + println!("task_id: {}", response.task_responses[0].id); + + let task_id = response.task_responses[0].id.clone(); + let task_response = wait_for_image_gen_status( + &task_id.0, + &creds, + Some(10) + ).await?; + + assert!(task_response.status == "succeeded"); + Ok(()) + } + + #[ignore] + #[tokio::test] + pub async fn test_save_generations_to_dir() -> AnyhowResult<()> { + let response = read_to_string(test_file_path("test_data/image_gen/test_status_response_long.json")?)?; + let response: VideoGenStatusResponse = serde_json::from_str(&response)?; + save_generations_to_dir(&response.task_responses[0].generations, test_file_path("test_data/image_gen/generations")?.to_str().unwrap()).await?; + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/unimplemented_sora_job_status.rs b/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/unimplemented_sora_job_status.rs new file mode 100644 index 0000000000..51ed21ed79 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/deprecated/job_status/unimplemented_sora_job_status.rs @@ -0,0 +1,79 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use log::error; +use wreq::Client; + +// https://sora.com/backend/notif?limit=100&before=task_01jqwwrkvgeqp8jsf5mqk1jceh + +pub async fn unimplemented_sora_job_status( + task_id: &str, + credentials: &SoraCredentialSet, +) -> Result<(), SoraError> { + let url = format!("https://sora.com/backend/notif?limit=100&before={}", task_id); + + let cookie = credentials.cookies.to_string(); + + let authorization_header = credentials.jwt_bearer_token.as_ref() + .ok_or(SoraClientError::NoBearerTokenForRequest)? + .to_authorization_header_value(); + + let client = Client::new(); + + let request = client + .get(url) + .header("Authorization", &authorization_header) + .header("User-Agent", CLIENT_USER_AGENT) + .header("Cookie", &cookie); + + let response = request.send() + .await + .map_err(|err| { + error!("sora fetch job status error: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + let json_response = &response.text().await + .map_err(|err| { + error!("sora read job status error: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + unimplemented!("job status is not parsed") +} + +#[cfg(test)] +mod tests { + use crate::creds::sora_credential_builder::SoraCredentialBuilder; + use crate::requests::deprecated::job_status::unimplemented_sora_job_status::unimplemented_sora_job_status; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_utils::test_file_path::test_file_path; + + #[ignore] + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let sentinel = read_to_string(test_file_path("test_data/temp/sentinel.txt")?)?; + let sentinel = sentinel.trim().to_string(); + + let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?; + let cookie = cookie.trim().to_string(); + + let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?; + let bearer = bearer.trim().to_string(); + + let creds = SoraCredentialBuilder::new() + .with_cookies(&cookie) + .with_jwt_bearer_token(&bearer) + .with_sora_sentinel(&sentinel) + .build()?; + + let task_id = "task_01jqwwrkvgeqp8jsf5mqk1jceh"; + + let result = unimplemented_sora_job_status(task_id, &creds).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/deprecated/list_media/list_media.rs b/crates/api_clients/openai_sora_client/src/requests/deprecated/list_media/list_media.rs new file mode 100644 index 0000000000..bb3fcd0421 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/deprecated/list_media/list_media.rs @@ -0,0 +1,117 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::utils_internal::classify_general_http_error::classify_general_http_error; +use log::{error, info}; +use once_cell::sync::Lazy; +use serde_derive::Deserialize; +use wreq::Client; + +//const SORA_MEDIA_LIST_URL: &str = "https://sora.chatgpt.com/backend/video_gen?limit=50"; +const SORA_MEDIA_LIST_URL : &str = "https://sora.chatgpt.com/backend/v2/list_tasks?limit=20"; + +#[derive(Debug, Deserialize)] +pub struct ListMediaResponse { + pub task_responses: Vec, + pub last_id: Option, + pub has_more: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TaskResponse { + /// Task id (eg. task_{foo}) + pub id: String, + pub title: String, + pub user: String, + pub created_at: String, + pub status: String, + /// Text prompt that generated the image + pub prompt: String, + pub r#type: String, + pub height: u32, + pub width: u32, + pub operation: String, + + // There are many more fields [...] +} + +/// Note: this request only requires a valid bearer token, and it doesn't require a cookie payload at all! +pub async fn list_media(credentials: &SoraCredentialSet) -> Result { + let auth_header = credentials.jwt_bearer_token + .as_ref() + .map(|bearer| bearer.to_authorization_header_value()) + .ok_or_else(|| SoraClientError::NoBearerTokenForRequest)?; + + let cookie = credentials.cookies.to_string(); + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| { + error!("Failed to build HTTP client: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + let response = client.get(SORA_MEDIA_LIST_URL) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0") + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip, deflate, br") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Cookie", &cookie) + .header("Authorization", &auth_header) + .send() + .await + .map_err(|err| { + error!("Failed to fetch media list: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + if !response.status().is_success() { + error!("Failed to fetch media list: {}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + info!("Successfully generated bearer token."); + + let text_body = &response.text().await + .map_err(|err| { + error!("sora error reading media list text body: {}", err); + SoraGenericApiError::WreqError(err) + })?; + + let response = serde_json::from_str::(text_body) + .map_err(|err| { + error!("Failed to parse media list response: {}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, text_body.to_string()) + })?; + + Ok(response) +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn test_list_media() -> AnyhowResult<()> { + let cookie = ""; + let bearer = ""; + + let mut creds = SoraCredentialSet::initialize_with_just_cookies_str(cookie); + creds.jwt_bearer_token = Some(SoraJwtBearerToken::new(bearer.to_string())?); + + let results = list_media(&creds).await.expect("should work"); + + println!("{:?}", results); + + assert!(results.task_responses.len() > 0); + + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/deprecated/list_media/mod.rs b/crates/api_clients/openai_sora_client/src/requests/deprecated/list_media/mod.rs new file mode 100644 index 0000000000..b366dc8e8a --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/deprecated/list_media/mod.rs @@ -0,0 +1 @@ +pub mod list_media; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/deprecated/mod.rs b/crates/api_clients/openai_sora_client/src/requests/deprecated/mod.rs new file mode 100644 index 0000000000..1473cf0aca --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/deprecated/mod.rs @@ -0,0 +1,2 @@ +pub mod job_status; +pub mod list_media; diff --git a/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs new file mode 100644 index 0000000000..e8c0f8f7bd --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs @@ -0,0 +1,177 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2; +use crate::requests::common::task_id::TaskId; +use crate::requests::generate_sora2_video::http_request::{HttpCreateRequest, InpaintItem}; +use crate::requests::generate_sora2_video::http_response::HttpCreateResponse; +use crate::requests::image_gen::image_gen_http_request::{RawSoraImageGenRequest, RawSoraResponse}; +use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body; +use log::error; +use std::io::Write; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, USER_AGENT}; +use wreq::Client; + +const GENERATE_SORA_2_VIDEO_URL: &str = "https://sora.chatgpt.com/backend/nf/create"; + +#[derive(Clone)] +pub struct GenerateSora2VideoArgs<'a> { + pub prompt: &'a str, + pub credentials: &'a SoraCredentialSet, + pub request_timeout: Option, + pub orientation: Orientation, + pub image_reference_media_ids: Option<&'a Vec>, +} + +#[derive(Clone, Copy)] +pub enum Orientation { + Portrait, + Landscape, +} + +#[derive(Debug)] +pub struct GenerateSora2VideoResponse { + pub task_id: TaskId, +} + +pub (crate) async fn generate_sora2_video( + args: GenerateSora2VideoArgs<'_>, +) -> Result { + + let client = Client::new(); + + let authorization_header = args.credentials.jwt_bearer_token.as_ref() + .ok_or(SoraClientError::NoBearerTokenForRequest)? + .to_authorization_header_value(); + + let sentinel_token = args.credentials.sora_sentinel_token + .as_ref() + .ok_or(SoraClientError::NoSentinelTokenForRequest)?; + + let sentinel_token = sentinel_token.to_request_header_json()?; + + let cookie = args.credentials.cookies.to_string(); + + // TODO: Make the sec-* headers match the user agent and platform + // TODO: device id + //-H 'oai-device-id: 7c216860-5b73-4e63-983f-142dbcae1809' \ + let mut request_builder = client.post(GENERATE_SORA_2_VIDEO_URL) + .header(ACCEPT, "*/*") + .header("priority", "u=1, i") + .header(REFERER, "https://sora.chatgpt.com/explore") + .header(ORIGIN, "https://sora.chatgpt.com") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header(USER_AGENT, CLIENT_USER_AGENT) + .header(COOKIE, &cookie) + .header("OpenAI-Sentinel-Token", sentinel_token) + .header(AUTHORIZATION, &authorization_header) + .header(CONTENT_TYPE, "application/json") + .header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"") + .header("sec-ch-ua-mobile", "?0") + .header("sec-ch-ua-platform", "macOS") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin"); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let orientation = match args.orientation { + Orientation::Portrait => "portrait", + Orientation::Landscape => "landscape", + }; + + let inpaint_items; + + if let Some(media_ids) = args.image_reference_media_ids { + inpaint_items = media_ids.iter() + .map(|item_id| InpaintItem { + kind: "upload".to_string(), + upload_id: item_id.to_string(), + }) + .collect(); + } else { + inpaint_items = Vec::new(); + } + + let request_body = HttpCreateRequest { + kind: "video".to_string(), + prompt: args.prompt.to_string(), + title: None, + orientation: orientation.to_string(), + size: "small".to_string(), + n_frames: 300, + inpaint_items, + cameo_ids: None, + cameo_replacements: None, + model: "sy_8".to_string(), + style_id: None, + audio_caption: None, + audio_transcript: None, + video_caption: None, + storyboard_id: None, + }; + + let http_request = request_builder.json(&request_body) + .build() + .map_err(|err| { + error!("Error building Sora image generation HTTP request: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during Sora image generation request: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + let status = response.status(); + + let response_body = &response.text().await + .map_err(|err| { + error!("Error reading Sora image generation response body: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + if !status.is_success() { + error!("Sora image generation request returned an error (code {}) : {:?}", status.as_u16(), response_body); + return Err(classify_general_http_status_code_and_body(status, response_body)); + } + + let response : HttpCreateResponse = serde_json::from_str(response_body) + .map_err(|err| SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(GenerateSora2VideoResponse { + task_id: response.id, + }) +} + +#[cfg(test)] +mod tests { + use crate::requests::generate_sora2_video::generate_sora2_video::{generate_sora2_video, GenerateSora2VideoArgs, Orientation}; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + let request = GenerateSora2VideoArgs { + //prompt: "A cute corgi wearing glasses, sitting on a picnic blanket and reading a book, digital art", + prompt: "A dog eating pancakes in an iHop", + credentials: &creds, + request_timeout: None, + orientation: Orientation::Landscape, + image_reference_media_ids: None, + }; + let result = generate_sora2_video(request).await?; + println!("result: {:#?}", result); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/http_request.rs b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/http_request.rs new file mode 100644 index 0000000000..5afb5d7e19 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/http_request.rs @@ -0,0 +1,60 @@ +use serde_derive::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub (super) struct HttpCreateRequest { + // eg. "video" + pub kind: String, + + // User prompt + pub prompt: String, + + // NB: sent as null + pub title: Option, + + // eg. "portrait" + pub orientation: String, + + // eg. "small" + pub size: String, + + // eg. 300 + pub n_frames: u16, + + // NB: sent as empty array + pub inpaint_items: Vec, + + // NB: Sent as null + pub cameo_ids: Option, + + // NB: Sent as null + pub cameo_replacements: Option, + + // eg. "sy_8" + pub model: String, + + // NB: Sent as null + pub style_id: Option, + + // NB: Sent as null + pub audio_caption: Option, + + // NB: Sent as null + pub audio_transcript: Option, + + // NB: Sent as null + pub video_caption: Option, + + // NB: Sent as null + pub storyboard_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub (super) struct InpaintItem { + // eg. "upload" + pub kind: String, + + // eg. "media_01abc..." + pub upload_id: String, +} diff --git a/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/http_response.rs b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/http_response.rs new file mode 100644 index 0000000000..7e49bf97a5 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/http_response.rs @@ -0,0 +1,9 @@ +use crate::requests::common::task_id::TaskId; +use serde_derive::Deserialize; + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub (super) struct HttpCreateResponse { + pub id: TaskId, + // NB: Ignoring other fields, like "priority". +} diff --git a/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/mod.rs b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/mod.rs new file mode 100644 index 0000000000..34de76d9a8 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/mod.rs @@ -0,0 +1,3 @@ +pub mod generate_sora2_video; +mod http_request; +mod http_response; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/image_gen/common.rs b/crates/api_clients/openai_sora_client/src/requests/image_gen/common.rs new file mode 100644 index 0000000000..8f1f8b8917 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/image_gen/common.rs @@ -0,0 +1,49 @@ +use crate::requests::common::task_id::TaskId; + +#[derive(Debug, Clone)] +pub struct SoraImageGenResponse { + pub task_id: TaskId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumImages { + One, + Two, + Four +} + +impl NumImages { + pub fn as_count(&self) -> usize { + match self { + NumImages::One => 1, + NumImages::Two => 2, + NumImages::Four => 4, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageSize { + Square, + Wide, + Tall, +} + +impl ImageSize { + // TODO: Verify these dimensions. 480x480 is correct. + pub fn as_dimensions(&self) -> (u16, u16) { + match self { + ImageSize::Square => (480, 480), + ImageSize::Wide => (854, 480), + ImageSize::Tall => (480, 854), + } + } + + pub fn as_width(&self) -> u16 { + self.as_dimensions().0 + } + + pub fn as_height(&self) -> u16 { + self.as_dimensions().1 + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/image_gen/image_gen_http_request.rs b/crates/api_clients/openai_sora_client/src/requests/image_gen/image_gen_http_request.rs new file mode 100644 index 0000000000..4d4955dc2d --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/image_gen/image_gen_http_request.rs @@ -0,0 +1,251 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use crate::requests::common::task_id::TaskId; +use log::{error, warn}; +use serde_derive::{Deserialize, Serialize}; +use std::io::Write; +use std::time::Duration; +use thiserror::Error; +use wreq::{Client, StatusCode}; + +const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen"; + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub (crate) enum VideoGenType { + ImageGen, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub (crate) enum OperationType { + /// Simple prompt without reference images + SimpleCompose, + /// Prompt with reference images + Remix, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub (crate) enum InpaintItemType { + Image, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub (crate) struct InpaintItem { + pub r#type: InpaintItemType, + /// Typically "0" + pub frame_index: u32, + /// Unknown field; defaults to null + pub preset_id: Option, + /// Unknown field; defaults to null + pub generation_id: Option, + /// Token identifier of the item, eg. "media_01jqt9vt20erx9zvryf3v1pecx" + pub upload_media_id: String, + /// Typically "0" + pub source_start_frame: u32, + /// Typically "0" + pub source_end_frame: u32, + /// Unknown field; defaults to null + pub crop_bounds: Option, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub (crate) struct RawSoraImageGenRequest { + /// eg. "image_gen" + pub r#type: VideoGenType, + /// eg. "simple_compose" + pub operation: OperationType, + /// The user's raw prompt + pub prompt: String, + /// The number of variants to generate, eg. 2 + pub n_variants: usize, + /// The width of the image, eg. 480 + pub width: u16, + /// The height of the image, eg. 480 + pub height: u16, + /// The number of frames to generate, eg. 1 + pub n_frames: u8, + /// The items to inpaint, eg. [] + pub inpaint_items: Vec, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub (crate) struct RawSoraResponse { + /// eg. "task_01jqsz9dsae9tvjygf1abrv3xf" + pub id: TaskId, + /// not known + pub priority: Option, +} + +// https://sora.com/backend/notif?limit=100&before=task_01jqpg8qghenet0rw2a79p0vbn +// error +// message "Missing bearer authentication in header" +// type "invalid_request_error" +// param null +// code null +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub struct RawSoraErrorResponse { + error: RawSoraErrorInner, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum RawSoraError { + Error(RawSoraErrorInner), + Unknown(String), +} + + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum SoraErrorCode { + SentinelBlock, + InvalidJwt, + TokenExpired, + TooManyConcurrentTasks, + UsernameRequired, + Unknown(String), +} + + +#[derive(Deserialize, Debug, Clone, Error)] +#[serde(rename_all = "snake_case")] +pub struct RawSoraErrorInner { + message: String, + r#type: String, + param: Option, + code: Option, +} + +impl std::fmt::Display for RawSoraErrorInner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Sora API error: {}", self.message) + } +} + +/// Don't expose the internal request implementation as there are only a few "correct" ways to call the API. +pub (crate) async fn image_gen_http_request( + sora_request: RawSoraImageGenRequest, + credentials: &SoraCredentialSet, + request_timeout: Option, +) -> Result { + let client = Client::new(); + + let cookie = credentials.cookies.to_string(); + + let authorization_header = credentials.jwt_bearer_token.as_ref() + .ok_or(SoraClientError::NoBearerTokenForRequest)? + .to_authorization_header_value(); + + let sentinel = credentials.sora_sentinel_token.as_ref() + .map(|sentinel| sentinel.to_request_header_json()) + .transpose()? + .ok_or(SoraClientError::NoSentinelTokenForRequest)?; + + //let sentinel = credentials.sora_sentinel.as_ref() + // .map(|sentinel| sentinel.get_sentinel().to_string()) + // .ok_or(SoraClientError::NoSentinelTokenForRequest)?; + + let mut http_request = client.post(SORA_IMAGE_GEN_URL) + .header("User-Agent", CLIENT_USER_AGENT) + .header("Cookie", &cookie) + .header("Authorization", &authorization_header) + .header("Content-Type", "application/json") + .header("OpenAI-Sentinel-Token", &sentinel); + + if let Some(timeout) = request_timeout { + http_request = http_request.timeout(timeout); + } + + let http_request = http_request.json(&sora_request).build() + .map_err(|err| { + error!("Error building Sora image generation HTTP request: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during Sora image generation request: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + let status = response.status(); + + let response_body = &response.text().await + .map_err(|err| { + error!("Error reading Sora image generation response body: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + if status != StatusCode::OK { + warn!("Sora image generation failure. Raw response: {:?}", response_body); + + let error_response: RawSoraErrorResponse = serde_json::from_str(response_body) + .map_err(|err| SoraGenericApiError::SerdeParseErrorWithBodyOnNon200(err, response_body.to_string()))?; + + // Check for specific error codes + if let Some(code) = &error_response.error.code { + match code { + SoraErrorCode::TokenExpired => { + error!("Sora token expired: {}", error_response.error); + return Err(SoraSpecificApiError::TokenExpiredError.into()); + } + SoraErrorCode::SentinelBlock => { + error!("Sora image generation error sentinel block: {}", error_response.error); + return Err(SoraSpecificApiError::SentinelBlockError.into()); + } + SoraErrorCode::TooManyConcurrentTasks => { + error!("Too many concurrent tasks. Please wait."); + return Err(SoraSpecificApiError::TooManyConcurrentTasks.into()); + } + SoraErrorCode::Unknown(message) => { + error!("Unknown error: {} - message: {}", error_response.error.message, message); + return Err(SoraGenericApiError::UncategorizedBadResponse(error_response.error.message).into()); + } + SoraErrorCode::InvalidJwt => { + error!("Invalid JWT token: {}", error_response.error.message); + return Err(SoraSpecificApiError::InvalidJwt.into()); + } + SoraErrorCode::UsernameRequired => { + error!("Username required: {}", error_response.error.message); + return Err(SoraSpecificApiError::SoraUsernameNotYetCreated.into()); + } + } + } + + error!("Sora image generation failure: {}", error_response.error); + return Err(SoraGenericApiError::UncategorizedBadResponse( + format!("Unknown error code: {:?}, message: {:?}", error_response.error.code, error_response.error.message)) + .into()); + } + + let response = serde_json::from_str(response_body) + .map_err(|err| SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(response) +} + +#[cfg(test)] +mod tests { + use crate::requests::image_gen::image_gen_http_request::RawSoraResponse; + use errors::AnyhowResult; + + #[test] + #[ignore] // Run manually + fn deserialize_task_id() -> AnyhowResult<()> { + let json = "{\"id\": \"task_foobarbaz\"}"; + let response : RawSoraResponse = serde_json::from_str(json)?; + assert_eq!(response.id.0, "task_foobarbaz"); + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/image_gen/mod.rs b/crates/api_clients/openai_sora_client/src/requests/image_gen/mod.rs new file mode 100644 index 0000000000..e25841c171 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/image_gen/mod.rs @@ -0,0 +1,4 @@ +pub (crate) mod image_gen_http_request; +pub mod common; +pub mod sora_image_gen_remix; +pub mod sora_image_gen_simple; diff --git a/crates/api_clients/openai_sora_client/src/requests/image_gen/sora_image_gen_remix.rs b/crates/api_clients/openai_sora_client/src/requests/image_gen/sora_image_gen_remix.rs new file mode 100644 index 0000000000..e17dafc4d6 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/image_gen/sora_image_gen_remix.rs @@ -0,0 +1,93 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse}; +use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, VideoGenType}; +use log::info; +use std::time::Duration; + +pub struct SoraImageGenRemixRequest<'a> { + pub prompt: String, + pub num_images: NumImages, + pub image_size: ImageSize, + pub sora_media_tokens: Vec, + pub credentials: &'a SoraCredentialSet, + pub request_timeout: Option, +} + +/// The "remix" commands let you supply additional images as context. +/// Sora "media tokens" of previously uploaded images must be supplied. +pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Result { + let args = RawSoraImageGenRequest { + r#type: VideoGenType::ImageGen, + operation: OperationType::Remix, + prompt: request.prompt, + n_variants: request.num_images.as_count(), + width: request.image_size.as_width(), + height: request.image_size.as_height(), + n_frames: 1, + inpaint_items: request.sora_media_tokens.into_iter().map(|token| { + InpaintItem { + r#type: InpaintItemType::Image, + frame_index: 0, + preset_id: None, + generation_id: None, + upload_media_id: token, + source_start_frame: 0, + source_end_frame: 0, + crop_bounds: None, + } + }).collect(), + }; + + // TODO: Replace this with debug level logging in the future. + info!("Sending remix request: {:?}", args); + + let result = image_gen_http_request(args, request.credentials, request.request_timeout).await?; + + Ok(SoraImageGenResponse { + task_id: result.id, + }) +} + +#[cfg(test)] +mod tests { + use crate::creds::sora_credential_builder::SoraCredentialBuilder; + use crate::requests::image_gen::common::{ImageSize, NumImages}; + use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest}; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_utils::test_file_path::test_file_path; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let sentinel = read_to_string(test_file_path("test_data/temp/sentinel.txt")?)?; + let sentinel = sentinel.trim().to_string(); + + let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?; + let cookie = cookie.trim().to_string(); + + let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?; + let bearer = bearer.trim().to_string(); + + let creds = SoraCredentialBuilder::new() + .with_cookies(&cookie) + .with_jwt_bearer_token(&bearer) + .with_sora_sentinel(&sentinel) + .build()?; + + let response = sora_image_gen_remix(SoraImageGenRemixRequest { + prompt: "Match the pose and scene layout from the uploaded image exactly. A smart dog getting kisses from another dog. Brick building. Anime style".to_string(), + num_images: NumImages::One, + image_size: ImageSize::Square, + sora_media_tokens: vec!["media_01jqyhrz4detyvtzwp2p4j63ad".to_string()], + credentials: &creds, + request_timeout: None, + }).await?; + + println!("task_id: {}", response.task_id); + + assert!(response.task_id.0.starts_with("task_")); + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/image_gen/sora_image_gen_simple.rs b/crates/api_clients/openai_sora_client/src/requests/image_gen/sora_image_gen_simple.rs new file mode 100644 index 0000000000..73f9674fe3 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/image_gen/sora_image_gen_simple.rs @@ -0,0 +1,64 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse}; +use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, OperationType, RawSoraImageGenRequest, VideoGenType}; +use std::time::Duration; + +pub struct SoraImageGenSimpleRequest<'a> { + pub prompt: String, + pub num_images: NumImages, + pub image_size: ImageSize, + pub credentials: &'a SoraCredentialSet, + pub request_timeout: Option, +} + +pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> Result { + let sora_request = RawSoraImageGenRequest { + r#type: VideoGenType::ImageGen, + operation: OperationType::SimpleCompose, + prompt: args.prompt, + n_variants: args.num_images.as_count(), + width: args.image_size.as_width(), + height: args.image_size.as_height(), + n_frames: 1, + inpaint_items: vec![], + }; + + let result = image_gen_http_request( + sora_request, + args.credentials, + args.request_timeout + ).await?; + + Ok(SoraImageGenResponse { + task_id: result.id, + }) +} + +#[cfg(test)] +mod tests { + use crate::requests::image_gen::common::{ImageSize, NumImages}; + use crate::requests::image_gen::sora_image_gen_simple::{sora_image_gen_simple, SoraImageGenSimpleRequest}; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + + let response = sora_image_gen_simple(SoraImageGenSimpleRequest { + prompt: "A pirate ship catches on fire as an alien ufo attacks it, photorealistic".to_string(), + num_images: NumImages::One, + image_size: ImageSize::Square, + credentials: &creds, + request_timeout: None, + }).await?; + + println!("task_id: {}", response.task_id); + + assert!(response.task_id.0.starts_with("task_")); + assert_eq!(1, 2); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/list_classic_tasks/list_classic_tasks.rs b/crates/api_clients/openai_sora_client/src/requests/list_classic_tasks/list_classic_tasks.rs new file mode 100644 index 0000000000..84083ce42a --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/list_classic_tasks/list_classic_tasks.rs @@ -0,0 +1,163 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::requests::common::task_id::TaskId; +use crate::requests::deprecated::job_status::sora_job_status::{Generation, StatusRequest, VideoGenStatusResponse}; +use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body; +use log::{debug, error, warn}; +use serde_derive::Deserialize; +use url::Url; +use wreq::Client; + +const SORA_LIST_CLASSIC_TASKS_URL: &str = "https://sora.chatgpt.com/backend/v2/list_tasks?limit=20"; + + +/// NB: We omit fields we're not using to prevent breakage. +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct ListTasksResponse { + pub task_responses: Vec, + pub last_id: Option, + pub has_more: bool, +} + +/// NB: We omit fields we're not using to prevent breakage. +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct PartialTaskResponse { + pub id: TaskId, + pub status: TaskStatus, + + /// Text prompt used to generate the media. + pub prompt: Option, + + pub generations: Vec, + + //pub user: String, + //pub created_at: String, + //pub status: String, // eg. "succeeded" + //pub progress_pct: Option, + //pub progress_pos_in_queue: Option, + //pub encodings: Vec // NB: These are webp images. + // ... lots of other fields ... +} + + +#[derive(Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + Queued, + Running, + Succeeded, + Failed, + #[serde(untagged)] + Unknown(String), +} + +/// NB: We omit fields we're not using to prevent breakage. +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct PartialGeneration { + // A unique ID for the generation, eg. "gen_01abc..." + pub id: String, + pub url: String, + + //pub id: String, + //pub task_id: TaskId, + //pub created_at: String, + //pub deleted_at: Option, + //pub url: String, + //pub seed: Option, + //pub can_download: Option, + // ... lots of other fields ... +} + +pub async fn list_classic_tasks(credentials: &SoraCredentialSet) -> Result { + + let bearer_header = match credentials.jwt_bearer_token.as_ref() { + Some(bearer) => bearer.to_authorization_header_value(), + None => { + warn!("No JWT bearer token in client - cannot fetch image gen status!"); + return Err(SoraClientError::NoBearerTokenForRequest.into()); + }, + }; + + let client = Client::new(); + + let mut url = Url::parse(SORA_LIST_CLASSIC_TASKS_URL) + .map_err(|err| { + SoraClientError::UrlParseError(err) + })?; + + + // TODO: Future pagination. + // Add query parameters + //if let Some(limit) = status_request.limit { + // url.query_pairs_mut().append_pair("limit", &limit.to_string()); + //} + //if let Some(before) = &status_request.before { + // url.query_pairs_mut().append_pair("before", &before); + //} + + let http_request = client.get(url.as_str()) + .header("User-Agent", CLIENT_USER_AGENT) + .header("Cookie", credentials.cookies.as_str()) + .header("Authorization", bearer_header) + .header("Content-Type", "application/json"); + + let http_request = http_request.build() + .map_err(|err| { + SoraClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Client failed to fetch sora task list: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = &response.text() + .await + .map_err(|err| { + error!("Client failed to read sora task list: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + debug!("response_body: {}", response_body); + + if !status.is_success() { + error!("The sora task list request failed; status = {:?} ; response body = {}", status, response_body); + let error = classify_general_http_status_code_and_body(status, &response_body); + return Err(error); + } + + let response = serde_json::from_str::(response_body) + .map_err(|err| { + error!("Failed to parse status response: {:?}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()) + })?; + + Ok(response) +} + +#[cfg(test)] +mod tests { + use crate::requests::list_classic_tasks::list_classic_tasks::list_classic_tasks; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + let result = list_classic_tasks(&creds).await?; + println!("result: {:#?}", result); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/list_classic_tasks/mod.rs b/crates/api_clients/openai_sora_client/src/requests/list_classic_tasks/mod.rs new file mode 100644 index 0000000000..22f2d9226f --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/list_classic_tasks/mod.rs @@ -0,0 +1 @@ +pub mod list_classic_tasks; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/http_response.rs b/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/http_response.rs new file mode 100644 index 0000000000..5c319923ff --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/http_response.rs @@ -0,0 +1,57 @@ +use serde_derive::Deserialize; + +#[derive(Deserialize)] +pub (super) struct HttpDraftsResponse { + pub items: Vec, + + // NB: This is nullable. + // It's null in my tests, so I don't know if this is a string or integer yet. Probably an opaque string? + //pub cursor: Option, +} + +#[derive(Deserialize)] +pub (super) struct PartialItem { + /// The generation id, eg. "gen_01abc..." + pub id: String, + + /// The parent task id, eg "task_01abc..." + pub task_id: String, + + //pub generation_id: String, // it looks like this matches "id" for now + + /// The kind of draft + pub kind: DraftKind, + + // The text prompt + pub prompt: String, + + /// URL to the generation + /// This will not be present if the request failed, eg. for content reasons. + pub url: Option, + + /// "downloadable" URL to the generation + /// it looks like this matches "url" for now + /// This will not be present if the request failed, eg. for content reasons. + pub downloadable_url: Option, + + /// If there was a content violation, this will describe why. + pub reason_str: Option, + + // There are other things that might be interesting: "encodings", "width", "height", etc. +} + + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DraftKind { + /// Generated video + SoraDraft, + + /// Video was blocked due to content + /// These failures will have different "reason_str" reasons. + SoraContentViolation, + + /// Unknown type. + #[serde(untagged)] + Unknown(String), +} diff --git a/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/list_sora2_drafts.rs b/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/list_sora2_drafts.rs new file mode 100644 index 0000000000..d295adf180 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/list_sora2_drafts.rs @@ -0,0 +1,180 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::requests::common::task_id::TaskId; +use crate::requests::deprecated::job_status::sora_job_status::{Generation, StatusRequest, VideoGenStatusResponse}; +use crate::requests::list_sora2_drafts::http_response::{DraftKind, HttpDraftsResponse}; +use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body; +use log::{debug, error, info, warn}; +use serde_derive::Deserialize; +use url::Url; +use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER}; +use wreq::Client; + +const SORA_LIST_DRAFTS_URL: &str = "https://sora.chatgpt.com/backend/project_y/profile/drafts?limit=15"; + + +/// NB: We omit fields we're not using to prevent breakage. +#[derive(Clone, Debug)] +pub struct ListSora2DraftsResponse { + pub drafts: Vec, +} + +#[derive(Clone, Debug)] +pub enum Draft { + Success(DraftSuccess), + Failure(DraftFailure), +} + +#[derive(Clone, Debug)] +pub struct DraftSuccess { + pub id: String, + pub task_id: TaskId, + pub url: String, + pub prompt: String, +} + +#[derive(Clone, Debug)] +pub struct DraftFailure { + pub task_id: TaskId, + pub reason_message: String, +} + +pub async fn list_sora2_drafts(credentials: &SoraCredentialSet) -> Result { + + let client = Client::new(); + + let authorization_header = credentials.jwt_bearer_token.as_ref() + .ok_or(SoraClientError::NoBearerTokenForRequest)? + .to_authorization_header_value(); + + let cookie = credentials.cookies.to_string(); + + // TODO: Make the sec-* headers match the user agent and platform + // TODO: device id + //-H 'oai-device-id: 7c216860-5b73-4e63-983f-142dbcae1809' \ + let mut http_request = client.get(SORA_LIST_DRAFTS_URL) + //.header("OpenAI-Sentinel-Token", &sentinel); + .header(ACCEPT, "*/*") + .header(REFERER, "https://sora.chatgpt.com/drafts") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header(wreq::header::USER_AGENT, CLIENT_USER_AGENT) + .header(COOKIE, &cookie) + .header(AUTHORIZATION, &authorization_header) + .header("priority", "u=1, i") + .header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"") + .header("sec-ch-ua-mobile", "?0") + .header("sec-ch-ua-platform", "macOS") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin"); + + //if let Some(timeout) = args.request_timeout { + // http_request = http_request.timeout(timeout); + //} + + let http_request = http_request.build() + .map_err(|err| { + error!("Error building Sora 2 list drafts HTTP request: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during Sora 2 list drafts request: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + let status = response.status(); + + let response_body = &response.text().await + .map_err(|err| { + error!("Error reading Sora 2 list drafts response body: {:?}", err); + SoraClientError::WreqClientError(err) + })?; + + if !status.is_success() { + error!("Sora list drafts request returned an error (code {}) : {:?}", status.as_u16(), response_body); + + // TODO: Categorize failures. + // error!("The sora task list request failed; status = {:?} ; response body = {}", status, response_body); + // let error = classify_general_http_status_code_and_body(status, &response_body).await; + // return Err(error); + + return Err(SoraGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body.to_string(), + }.into()); + } + + let response : HttpDraftsResponse = serde_json::from_str(response_body) + .map_err(|err| SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(ListSora2DraftsResponse { + drafts: response.items + .into_iter() + .map(|draft| { + let task_id = TaskId::from_string(draft.task_id); + match draft.kind { + DraftKind::SoraDraft => { + match draft.url { + Some(url) => { + // Success case + Draft::Success(DraftSuccess { + id: draft.id, + task_id, + url, + prompt: draft.prompt, + }) + } + None => { + return Draft::Failure(DraftFailure { + task_id, + reason_message: "Generation failed. URL missing from the draft.".to_string(), + }); + } + } + } + DraftKind::SoraContentViolation => { + Draft::Failure(DraftFailure { + task_id, + reason_message: format!( + "Generation failed due to content violation: {}", + draft.reason_str + .unwrap_or_else(|| "no additional reason".to_string())), + }) + } + DraftKind::Unknown(value) => { + Draft::Failure(DraftFailure { + task_id, + reason_message: format!( + "Generation failed with unknown failure code: {}. Possible message: {}", + value, + draft.reason_str + .unwrap_or_else(|| "no additional message".to_string())), + }) + } + } + }).collect(), + }) +} + +#[cfg(test)] +mod tests { + use crate::requests::list_sora2_drafts::list_sora2_drafts::list_sora2_drafts; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + let result = list_sora2_drafts(&creds).await?; + println!("result: {:#?}", result); + assert_eq!(1, 2); + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/mod.rs b/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/mod.rs new file mode 100644 index 0000000000..1b250f4e07 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/list_sora2_drafts/mod.rs @@ -0,0 +1,2 @@ +pub mod list_sora2_drafts; +pub (super) mod http_response; diff --git a/crates/api_clients/openai_sora_client/src/requests/mod.rs b/crates/api_clients/openai_sora_client/src/requests/mod.rs new file mode 100644 index 0000000000..a159398741 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/mod.rs @@ -0,0 +1,11 @@ +pub mod auth_bearer; +pub mod auth_sentinel; +pub mod auth_sentinel_2; +pub mod common; +pub mod deprecated; +pub mod generate_sora2_video; +pub mod image_gen; +pub mod list_classic_tasks; +pub mod list_sora2_drafts; +pub mod upload; +pub mod upload_sora2; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/upload/mod.rs b/crates/api_clients/openai_sora_client/src/requests/upload/mod.rs new file mode 100644 index 0000000000..d80cfa0605 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload/mod.rs @@ -0,0 +1,5 @@ +pub (crate) mod upload_media_http_request; +pub mod upload_media_http_response; + +pub mod upload_media_from_bytes; +pub mod upload_media_from_file; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_from_bytes.rs b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_from_bytes.rs new file mode 100644 index 0000000000..d3aaaeb19a --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_from_bytes.rs @@ -0,0 +1,31 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::requests::upload::upload_media_http_request::upload_media_http_request; +use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse; +use std::path::PathBuf; +use std::time::Duration; + +/// Upload bytes. +/// The underlying reqwest lib needs to own the bytes, so we can't pass as a reference. +pub async fn sora_media_upload_from_bytes( + bytes: Vec, + file_name: String, + creds: &SoraCredentialSet, + maybe_timeout: Option, +) -> Result { + let file_path = PathBuf::from(&file_name); + + // TODO: Read file magic bytes first, then fall back to this. + let mime_type = match file_path.extension().and_then(|e| e.to_str()) { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + // Some("webp") => "image/webp", + // Some("gif") => "image/gif", + // Some("mp4") => "video/mp4", + // Some("mov") => "video/quicktime", + // Some("webm") => "video/webm", + _ => "application/octet-stream", + }; + + Ok(upload_media_http_request(bytes, file_name, mime_type, creds, maybe_timeout).await?) +} diff --git a/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_from_file.rs b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_from_file.rs new file mode 100644 index 0000000000..6ea06bfa77 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_from_file.rs @@ -0,0 +1,86 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::requests::upload::upload_media_http_request::upload_media_http_request; +use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse; +use log::error; +use std::path::Path; +use std::time::Duration; +use tokio::fs::File; +use tokio::io::AsyncReadExt; + +/// Try to prevent buffer reallocations. +/// There's a better way to implement this. +const INITIAL_BUFFER_SIZE : usize = 1024*1024; + +pub async fn sora_media_upload_from_file>( + file_path: P, + creds: &SoraCredentialSet, + maybe_timeout: Option, +) -> Result { + let mut file = File::open(&file_path).await + .map_err(|err| { + error!("Failed to open file for upload: {}", err); + SoraClientError::FileForUploadReadError(err) + })?; + + let mut buffer = Vec::with_capacity(INITIAL_BUFFER_SIZE); + + file.read_to_end(&mut buffer).await + .map_err(|err| { + error!("Failed to read file for upload: {}", err); + SoraClientError::FileForUploadReadError(err) + })?; + + let filename = file_path.as_ref().file_name() + .ok_or_else(|| SoraClientError::FileForUploadHasInvalidPath)? + .to_string_lossy() + .to_string(); + + let maybe_ext = file_path.as_ref().extension().and_then(|e| e.to_str()); + + // TODO: Read file magic bytes first, then fall back to this. + let mime_type = match maybe_ext { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + // Some("webp") => "image/webp", + // Some("gif") => "image/gif", + // Some("mp4") => "video/mp4", + // Some("mov") => "video/quicktime", + // Some("webm") => "video/webm", + _ => "application/octet-stream", + }; + + Ok(upload_media_http_request(buffer, filename, mime_type, creds, maybe_timeout).await?) +} + +#[cfg(test)] +mod tests { + use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file; + use crate::test_utils::get_test_credentials::get_test_credentials; + use errors::AnyhowResult; + use test_utils::test_file_path::test_file_path; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let creds = get_test_credentials()?; + + let image_path = test_file_path("test_data/image/juno.jpg")?; // media_01jqyqgqpwf40tkcapq5bmaz5d + + let response = sora_media_upload_from_file( + image_path, + &creds, + None, + ).await?; + + println!("media: {:?}", response); + + println!("media_id: {}", response.id); + println!("media_url: {}", response.url); + + assert!(response.id.starts_with("media_")); + + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_http_request.rs b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_http_request.rs new file mode 100644 index 0000000000..bd4af01f62 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_http_request.rs @@ -0,0 +1,94 @@ +use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::requests::upload::upload_media_http_response::SoraMediaUploadResponse; +use crate::utils_internal::classify_general_http_error::classify_general_http_error; +use log::{error, info}; +use serde::Deserialize; +use std::io::empty; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, COOKIE, ORIGIN, REFERER}; +use wreq::multipart::{Form, Part}; +use wreq::Client; + +const SORA_UPLOAD_MEDIA_URL: &str = "https://sora.com/backend/uploads"; + +pub (crate) struct SoraMediaUploadRequest<'a> { + pub file_path: String, + pub credentials: &'a SoraCredentialSet, +} + +pub (crate) async fn upload_media_http_request( + file_bytes: Vec, + filename: String, + mime_type: &str, + credentials: &SoraCredentialSet, + maybe_timeout: Option, +) -> Result { + + // Create multipart form + let part = Part::bytes(file_bytes) // NB: Reqwest needs to own the bytes. + .file_name(filename) // NB: Reqwest needs to own the bytes + .mime_str(mime_type) + .map_err(|e| SoraClientError::MultipartFormError(e))?; + + let form = Form::new().part("file", part); + + let cookie = credentials.cookies.to_string(); + let auth_header = credentials.jwt_bearer_token + .as_ref() + .ok_or(SoraClientError::NoBearerTokenForRequest)? + .to_authorization_header_value(); + + // Make API request + let client = Client::new(); + let mut request_builder = client.post(SORA_UPLOAD_MEDIA_URL) + .multipart(form) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header(AUTHORIZATION, &auth_header) + .header(COOKIE, &cookie) + .header(ORIGIN, "https://sora.chatgpt.com") + .header("user-agent", CLIENT_USER_AGENT) + .header(REFERER, "https://sora.chatgpt.com/explore") + .header("priority", "u=1, i") + .header("sec-ch-ua", "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"") + .header("sec-ch-ua-mobile", "?0") + .header("sec-ch-ua-platform", "macOS") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin"); + + if let Some(timeout) = maybe_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + SoraGenericApiError::WreqError(err) + })?; + + // Check response status + if !response.status().is_success() { + info!("Error uploading image: {:?}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + let response_body = &response.text().await + .map_err(|err| { + error!("Error reading response body while attempting file upload: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + let upload_response : SoraMediaUploadResponse = serde_json::from_str(&response_body) + .map_err(|err| { + error!("Error parsing response body while attempting file upload: {:?}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()) + })?; + + Ok(upload_response) +} diff --git a/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_http_response.rs b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_http_response.rs new file mode 100644 index 0000000000..0cb637c50c --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload/upload_media_http_response.rs @@ -0,0 +1,51 @@ +use serde_derive::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct SoraMediaUploadResponse { + /// This is the media token id. + pub id: String, + pub r#type: String, + pub created_at: String, + pub filename: String, + pub extension: String, + pub mime_type: String, + pub url: String, + pub width: Option, + pub height: Option, + pub duration_sec: Option, + pub n_frames: Option, + pub size_bytes: u64, + pub thumbnail_url: Option, +} + +// Content-Disposition: form-data; name="file"; filename="imagename.webp" + +/* +Response: +{ + "id": "media_01jqt9vt20erx9zvryf3v1pecx", + "type": "image", + "created_at": "2025-04-02T03:49:04.185867Z", + "filename": "Rith_essa.webp", + "extension": "jpeg", + "mime_type": "image/jpeg", + "url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpeg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=%2BdnTQyw9OFF2kaVoEReT2cVfTqmKjGt18m7P4wKCWCI%3D&az=oaivgprodscus", + "width": 1200, + "height": 675, + "duration_sec": null, + "n_frames": 1, + "size_bytes": 118501, + "thumbnail_url": "https://videos.openai.com/vg-assets/assets%2Fclient_upload%2Fmedia%2Fuser-9wf6JFdgRSJLjvSJ53LNAbV8%2Fmedia_01jqt9vt20erx9zvryf3v1pecx.jpg?st=2025-04-02T02%3A39%3A01Z&se=2025-04-08T03%3A39%3A01Z&sks=b&skt=2025-04-02T02%3A39%3A01Z&ske=2025-04-08T03%3A39%3A01Z&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skoid=3d249c53-07fa-4ba4-9b65-0bf8eb4ea46a&skv=2019-02-02&sv=2018-11-09&sr=b&sp=r&spr=https%2Chttp&sig=9aU9xKI0BkhRtrTb0RD05%2BT6prBnmXk1I7nV2nTsFbY%3D&az=oaivgprodscus" +} + +Error response: + +Failed to upload scene media to Sora: Upload failed with status 401 Unauthorized: { + "error": { + "message": "Your authentication token has expired. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "token_expired" + } + } +*/ diff --git a/crates/api_clients/openai_sora_client/src/requests/upload_sora2/http_response.rs b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/http_response.rs new file mode 100644 index 0000000000..db23ee8d0e --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/http_response.rs @@ -0,0 +1,21 @@ +use serde_derive::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct SoraMediaUploadResponse { + /// This is the media token id. + pub id: String, + + // NB: Don't need these, so commented out: + //pub r#type: String, + //pub created_at: String, + //pub filename: String, + //pub extension: String, + //pub mime_type: String, + //pub url: String, + //pub width: Option, + //pub height: Option, + //pub duration_sec: Option, + //pub n_frames: Option, + //pub size_bytes: u64, + //pub thumbnail_url: Option, +} diff --git a/crates/api_clients/openai_sora_client/src/requests/upload_sora2/mod.rs b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/mod.rs new file mode 100644 index 0000000000..a80e5b7f36 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/mod.rs @@ -0,0 +1,5 @@ +//pub mod upload_media_sora2_from_bytes; +//pub mod upload_media_sora2_from_file; +// +//pub mod upload_media_sora2_http_request; +//mod http_response; diff --git a/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_from_bytes.rs b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_from_bytes.rs new file mode 100644 index 0000000000..3703cda94f --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_from_bytes.rs @@ -0,0 +1,30 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_error::SoraError; +use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse}; +use std::path::PathBuf; +use std::time::Duration; + +/// Upload bytes. +/// The underlying reqwest lib needs to own the bytes, so we can't pass as a reference. +pub async fn sora_media_upload_from_bytes( + bytes: Vec, + file_name: String, + creds: &SoraCredentialSet, + maybe_timeout: Option, +) -> Result { + let file_path = PathBuf::from(&file_name); + + // TODO: Read file magic bytes first, then fall back to this. + let mime_type = match file_path.extension().and_then(|e| e.to_str()) { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + // Some("webp") => "image/webp", + // Some("gif") => "image/gif", + // Some("mp4") => "video/mp4", + // Some("mov") => "video/quicktime", + // Some("webm") => "video/webm", + _ => "application/octet-stream", + }; + + Ok(upload_media_http_request(bytes, file_name, mime_type, creds, maybe_timeout).await?) +} diff --git a/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_from_file.rs b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_from_file.rs new file mode 100644 index 0000000000..597e1c4b8f --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_from_file.rs @@ -0,0 +1,95 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse}; +use log::error; +use std::path::Path; +use std::time::Duration; +use tokio::fs::File; +use tokio::io::AsyncReadExt; + +/// Try to prevent buffer reallocations. +/// There's a better way to implement this. +const INITIAL_BUFFER_SIZE : usize = 1024*1024; + +pub async fn sora_media_upload_from_file>( + file_path: P, + creds: &SoraCredentialSet, + maybe_timeout: Option, +) -> Result { + let mut file = File::open(&file_path).await + .map_err(|err| { + error!("Failed to open file for upload: {}", err); + SoraClientError::FileForUploadReadError(err) + })?; + + let mut buffer = Vec::with_capacity(INITIAL_BUFFER_SIZE); + + file.read_to_end(&mut buffer).await + .map_err(|err| { + error!("Failed to read file for upload: {}", err); + SoraClientError::FileForUploadReadError(err) + })?; + + let filename = file_path.as_ref().file_name() + .ok_or_else(|| SoraClientError::FileForUploadHasInvalidPath)? + .to_string_lossy() + .to_string(); + + let maybe_ext = file_path.as_ref().extension().and_then(|e| e.to_str()); + + // TODO: Read file magic bytes first, then fall back to this. + let mime_type = match maybe_ext { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + // Some("webp") => "image/webp", + // Some("gif") => "image/gif", + // Some("mp4") => "video/mp4", + // Some("mov") => "video/quicktime", + // Some("webm") => "video/webm", + _ => "application/octet-stream", + }; + + Ok(upload_media_http_request(buffer, filename, mime_type, creds, maybe_timeout).await?) +} + +#[cfg(test)] +mod tests { + use crate::creds::sora_credential_builder::SoraCredentialBuilder; + use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_utils::test_file_path::test_file_path; + + #[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI. + #[tokio::test] + pub async fn manual_test() -> AnyhowResult<()> { + let cookie = read_to_string(test_file_path("test_data/temp/cookie.txt")?)?; + let cookie = cookie.trim().to_string(); + + let bearer = read_to_string(test_file_path("test_data/temp/bearer.txt")?)?; + let bearer = bearer.trim().to_string(); + + let image_path = test_file_path("test_data/image/juno.jpg")?; // media_01jqyqgqpwf40tkcapq5bmaz5d + + let creds = SoraCredentialBuilder::new() + .with_cookies(&cookie) + .with_jwt_bearer_token(&bearer) + .build()?; + + let response = sora_media_upload_from_file( + image_path, + &creds, + None, + ).await?; + + println!("media: {:?}", response); + + println!("media_id: {}", response.id); + println!("media_url: {}", response.url); + + assert!(response.id.starts_with("media_")); + + Ok(()) + } +} diff --git a/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_http_request.rs b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_http_request.rs new file mode 100644 index 0000000000..e87c73ac69 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/requests/upload_sora2/upload_media_sora2_http_request.rs @@ -0,0 +1,88 @@ +use crate::constants::user_agent::USER_AGENT; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::utils_internal::classify_general_http_error::classify_general_http_error; +use log::{error, info}; +use serde::Deserialize; +use std::io::empty; +use std::time::Duration; +use wreq::multipart::{Form, Part}; +use wreq::Client; +use crate::requests::list_sora2_drafts::list_sora2_drafts::Draft; + +const SORA_UPLOAD_MEDIA_URL: &str = "https://sora.chatgpt.com/backend/uploads"; + + + +pub (crate) struct SoraMediaUploadArgs<'a> { + pub credentials: &'a SoraCredentialSet, + pub file_bytes: Vec, // NB: Reqwest needs to own the bytes, so we can't pass as a reference. + pub filename: String, // NB: Reqwest needs to own the bytes, so we can't pass as a reference. + pub mime_type: &'a str, + pub maybe_timeout: Option, +} + +#[derive(Clone, Debug)] +pub struct SoraMediaUploadResult { + pub drafts: Vec, +} + +pub (crate) async fn upload_media_sora2_http_request( + args: SoraMediaUploadArgs<'_> +) -> Result { + + // Create multipart form + let part = Part::bytes(args.file_bytes) // NB: Reqwest needs to own the bytes. + .file_name(args.filename) // NB: Reqwest needs to own the bytes + .mime_str(args.mime_type) + .map_err(|e| SoraClientError::MultipartFormError(e))?; + + let form = Form::new().part("file", part); + + let cookie = args.credentials.cookies.to_string(); + let auth_header = args.credentials.jwt_bearer_token + .as_ref() + .ok_or(SoraClientError::NoBearerTokenForRequest)? + .to_authorization_header_value(); + + // Make API request + let client = Client::new(); + let mut request_builder = client.post(SORA_UPLOAD_MEDIA_URL) + .multipart(form) + .header("User-Agent", USER_AGENT) + .header("Cookie", &cookie) + .header("Authorization", &auth_header); + + if let Some(timeout) = args.maybe_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + SoraGenericApiError::WreqError(err) + })?; + + // Check response status + if !response.status().is_success() { + info!("Error uploading image: {:?}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + let response_body = &response.text().await + .map_err(|err| { + error!("Error reading response body while attempting file upload: {:?}", err); + SoraGenericApiError::WreqError(err) + })?; + + let upload_response : SoraMediaUploadResponse = serde_json::from_str(&response_body) + .map_err(|err| { + error!("Error parsing response body while attempting file upload: {:?}", err); + SoraGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()) + })?; + + Ok(upload_response) +} diff --git a/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs b/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs new file mode 100644 index 0000000000..bedc1b2e0f --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs @@ -0,0 +1,33 @@ +use crate::creds::sora_credential_builder::SoraCredentialBuilder; +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_sentinel_token::SoraSentinelToken; +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub fn get_test_credentials() -> AnyhowResult { + // TODO: This should work on other folks' machines when they exist + // This is so brittle... + let sentinel_token = { + let sentinel_json = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel_token_store.json")?; + let sentinel_json = sentinel_json.trim().to_string(); + SoraSentinelToken::from_persistent_storage_json(&sentinel_json)? + }; + + let sentinel = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel.txt")?; + + let cookie = read_to_string("/Users/bt/Artcraft/credentials/sora_cookies.txt")?; + let cookie = cookie.trim().to_string(); + + let bearer = read_to_string("/Users/bt/Artcraft/credentials/sora_bearer_token.txt")?; + let bearer = bearer.trim().to_string(); + + let creds = SoraCredentialBuilder::new() + .with_cookies(&cookie) + .with_jwt_bearer_token(&bearer) + .with_sora_sentinel(&sentinel) + .with_sora_sentinel_token(&sentinel_token) + .build()?; + + Ok(creds) +} diff --git a/crates/api_clients/openai_sora_client/src/test_utils/mod.rs b/crates/api_clients/openai_sora_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..9346cd1864 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/test_utils/mod.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +pub (crate) mod get_test_credentials; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/utils/has_session_cookie.rs b/crates/api_clients/openai_sora_client/src/utils/has_session_cookie.rs new file mode 100644 index 0000000000..63a17cf9ce --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/utils/has_session_cookie.rs @@ -0,0 +1,104 @@ +use cookie::Cookie; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use std::collections::HashSet; + +pub const SESSION_COOKIE_NAME_OLD : &str = "__Secure-authjs.session-token"; +pub const SESSION_COOKIE_NAME_NEW : &str = "__Secure-next-auth.session-token"; + +/// Cookies Sora.com mints that are not the session cookie. We can ignore these. +/// This list is very likely to change over time. +const IRRELEVANT_COOKIE_NAMES: Lazy> = Lazy::new(|| { + HashSet::from([ + "cf_clearance", + "oai-did", + "_cfuvid", + "__cflb", + "__cf_bm", + "__Host-authjs.csrf-token", + "__Secure-authjs.callback-url", + "__Secure-authjs.pkce.code_verifier", + "__Secure-authjs.state", + ]) +}); + +/// Super naive heuristic for "logged out" total cookie length being ~1972 characters. +const SESSION_COOKIE_LENGTH_HEURISTIC : usize = 2000; + +//pub fn has_session_cookie(cookies: &str) -> bool { +// // NB: This is not a valid check for cookies, just a cheap heuristic. +// cookies.contains(SESSION_COOKIE_NAME) +//} + +#[derive(Copy,Clone,Eq,PartialEq,Debug)] +pub enum SessionCookiePresence { + Present, + MaybePresent, + Absent, +} + +/// OpenAI might change the name of the session cookie, and we don't want to break clients, +/// so we use some heuristics to determine if the cookie might be present. +pub fn has_session_cookie(cookies_header: &str) -> AnyhowResult { + let cookies_header = cookies_header.trim(); + if (cookies_header.is_empty()) { + return Ok(SessionCookiePresence::Absent); + } + + let cookies = Cookie::split_parse(cookies_header); + let mut unknown_cookie_count = 0; // NB: In the event they change their session cookie + + for cookie in cookies { + let cookie= cookie?; + let cookie_name = cookie.name(); + if cookie_name == SESSION_COOKIE_NAME_NEW { + return Ok(SessionCookiePresence::Present); + } else if cookie_name == SESSION_COOKIE_NAME_OLD { + return Ok(SessionCookiePresence::Present); + } else if !IRRELEVANT_COOKIE_NAMES.contains(cookie_name) { + unknown_cookie_count += 1; + } + } + + // This is a really stupid heuristic, but we want to be safe. + if cookies_header.len() > SESSION_COOKIE_LENGTH_HEURISTIC { + return Ok(SessionCookiePresence::MaybePresent); + } + + // Another stupid heuristic. + if unknown_cookie_count > 3 { + return Ok(SessionCookiePresence::MaybePresent); + } + + Ok(SessionCookiePresence::Absent) +} + +#[cfg(test)] +mod tests { + use crate::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; + + #[test] + fn test() { + assert_eq!(has_session_cookie("").unwrap(), SessionCookiePresence::Absent); + assert_eq!(has_session_cookie("foo=bar").unwrap(), SessionCookiePresence::Absent); + + // Heuristic: unknown cookies. + assert_eq!(has_session_cookie("foo=bar;bin=baz;bat=ban;bash=barn").unwrap(), SessionCookiePresence::MaybePresent); + + // Heuristic: cookie length + let cookies = format!("cookie={}", "a".repeat(10000)); + assert_eq!(has_session_cookie(&cookies).unwrap(), SessionCookiePresence::MaybePresent); + + // Synthetic fixture (the original real session token was redacted); the session + // cookie name is what matters for detection. + let cookies = "__Host-authjs.csrf-token=abc123; \ + __Secure-authjs.callback-url=https%3A%2F%2Fsora.com%2F; \ + __Secure-authjs.session-token=fake-session-token-value"; + + assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present); + + let cookies = "__Secure-next-auth.session-token=fake-session-token-value"; + + assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present); + } +} diff --git a/crates/api_clients/openai_sora_client/src/utils/mod.rs b/crates/api_clients/openai_sora_client/src/utils/mod.rs new file mode 100644 index 0000000000..d86421b410 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod has_session_cookie; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/utils_internal/classify_general_http_error.rs b/crates/api_clients/openai_sora_client/src/utils_internal/classify_general_http_error.rs new file mode 100644 index 0000000000..69d161c23b --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/utils_internal/classify_general_http_error.rs @@ -0,0 +1,15 @@ +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::utils_internal::classify_general_http_status_code_and_body::classify_general_http_status_code_and_body; + +/// This assumes the request failed and returned a non-200. +/// The caller should check. +pub async fn classify_general_http_error(response: wreq::Response) -> SoraError { + let status = response.status(); + let message = match response.text().await { + Ok(text) => text, + Err(err) => return SoraGenericApiError::WreqError(err).into(), + }; + + classify_general_http_status_code_and_body(status, &message) +} diff --git a/crates/api_clients/openai_sora_client/src/utils_internal/classify_general_http_status_code_and_body.rs b/crates/api_clients/openai_sora_client/src/utils_internal/classify_general_http_status_code_and_body.rs new file mode 100644 index 0000000000..5f29aacad3 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/utils_internal/classify_general_http_status_code_and_body.rs @@ -0,0 +1,71 @@ +use crate::error::sora_error::SoraError; +use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::error::sora_specific_api_error::SoraSpecificApiError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use wreq::StatusCode; +/* + +Old cookie - + +Message: { + "error": { + "message": "Your authentication token has expired. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "token_expired" + } +} + +No cookie - + +Message: { + "error": { + "message": "Your authentication token has expired. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "token_expired" + } +} + +*/ + +/// Classify the type of error that occurred. +/// This first assumes the request failed and returned a non-200. +pub fn classify_general_http_status_code_and_body(status: StatusCode, response_body: &str) -> SoraError { + let message = response_body.to_string(); + + // TODO: I *think* this is just the bearer token, not the cookie. + let cookie_expired = + message.contains("Your authentication token has expired. Please try signing in again.") + || message.contains("token_expired"); + + if cookie_expired { + return SoraSpecificApiError::UnauthorizedCookieOrBearerExpired.into(); + } + + let needs_onboarding = + message.contains("You must onboard before using this service") + || message.contains("onboarding_required"); + + if needs_onboarding { + return SoraSpecificApiError::SoraUsernameNotYetCreated.into(); + } + + let status_code = status.as_u16(); + + if let Err(err) = filter_cloudflare_errors(status_code, &response_body) { + return SoraGenericApiError::CloudflareError(err).into(); + } + + match status_code { + 502 => { + return SoraGenericApiError::Http502ErrorBadGateway(message.to_string()).into(); + } + _ => {}, // Fall-through + } + + SoraGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body.to_string(), + }.into() +} diff --git a/crates/api_clients/openai_sora_client/src/utils_internal/lightweight_sora_jwt_parse.rs b/crates/api_clients/openai_sora_client/src/utils_internal/lightweight_sora_jwt_parse.rs new file mode 100644 index 0000000000..fa324fe6d9 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/utils_internal/lightweight_sora_jwt_parse.rs @@ -0,0 +1,157 @@ +//! Adapted from 'jwt' crate + +use crate::error::sora_client_error::SoraClientError; +use crate::error::sora_error::SoraError; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use base64::Engine; +use chrono::{DateTime, Utc}; +use errors::AnyhowResult; +use log::warn; +use serde_json::Value; + +const SEPARATOR: &str = "."; + +#[derive(Clone, Debug)] +pub struct SoraJwtClaims { + pub created: DateTime, + pub expiration: DateTime, + pub user_id: String, + pub email: String, + pub email_verified: bool, +} + +pub fn lightweight_sora_jwt_parse(token: &str) -> Result { + let [_header_str, claims_str, _signature_str] = split_components(token)?; + + let claims = decode_base64(claims_str) + .map_err(|err| as_jwt_error(format!("failure to parse claims base64: {:?}", err)))?; + + let claims : serde_json::Map = serde_json::from_str(&claims) + .map_err(|err| as_jwt_error( + format!("failure to parse claims json: {:?}", err)))?; + + let iat = claims.get("iat") + .map(|val| val.as_number()) + .flatten() + .ok_or_else(|| jwt_error("no iat claim"))?; + + let iat = iat.as_i64() + .ok_or_else(|| jwt_error("iat is not a number"))?; + + let iat = DateTime::from_timestamp(iat, 0) + .ok_or_else(|| jwt_error("iat is not a valid timestamp"))?; + + let exp = claims.get("exp") + .map(|val| val.as_number()) + .flatten() + .ok_or_else(|| jwt_error("no exp claim"))?; + + let exp = exp.as_i64() + .ok_or_else(|| jwt_error("exp is not a number"))?; + + let exp = DateTime::from_timestamp(exp, 0) + .ok_or_else(|| jwt_error("iat is not a valid timestamp"))?; + + let user_id = claims.get("https://api.openai.com/auth") + .map(|val| val.get("user_id")) + .flatten() + .map(|val| val.as_str()) + .flatten() + .ok_or_else(|| jwt_error("no user_id claim"))?; + + let profile = claims.get("https://api.openai.com/profile") + .ok_or_else(|| jwt_error("no user_id claim"))?; + + let email = profile.get("email") + .map(|val| val.as_str()) + .flatten() + .ok_or_else(|| jwt_error("no email claim"))?; + + let email_verified = profile.get("email_verified") + .map(|val| val.as_bool()) + .flatten() + .unwrap_or_else(|| { + warn!("no email_verified claim"); + false + }); + + Ok(SoraJwtClaims { + created: iat, + expiration: exp, + user_id: user_id.to_string(), + email: email.to_string(), + email_verified, + }) +} + +fn split_components(token: &str) -> Result<[&str; 3], SoraError> { + let mut components = token.split(SEPARATOR); + let header = components.next() + .ok_or_else(|| jwt_error("no header component"))?; + + let claims = components.next() + .ok_or_else(|| jwt_error("no claims component"))?; + + let signature = components.next() + .ok_or_else(|| jwt_error("no signature component"))?; + + if components.next().is_some() { + return Err(jwt_error("too many components")); + } + + Ok([header, claims, signature]) +} + +fn decode_base64(raw: &str) -> AnyhowResult { + let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?; + let json_str = String::from_utf8(json_bytes.clone())?; + Ok(json_str) +} + +fn jwt_error(reason: &str) -> SoraError { + SoraError::Client(SoraClientError::LocalJwtClaimsParseError(reason.to_string())) +} + +fn as_jwt_error(reason: String) -> SoraError { + SoraError::Client(SoraClientError::LocalJwtClaimsParseError(reason)) +} + +#[cfg(test)] +mod tests { + use crate::utils_internal::lightweight_sora_jwt_parse::lightweight_sora_jwt_parse; + + #[test] + fn test_sora_jwt_bearer_token() { + let token = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6Ik\ + pXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfTTFuUTN0UjV2\ + VzdYOWpMMnBFNmdIOGRLNCIsImV4cCI6MTc0NTgwMDQ3MiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InV\ + zZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2\ + ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6M\ + Tc0NDkzNjQ3MSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiI0NTZmZjY5Yi0xZTZhLTQzOWYt\ + YTYzMC1jNmFjYzI3NzU1YjAiLCJuYmYiOjE3NDQ5MzY0NzEsInB3ZF9hdXRoX3RpbWUiOjE3NDQwNTkzMTc3NTAsInN\ + jcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyJdLCJzZXNzaW9uX2lkIjoiYXV0aH\ + Nlc3NfcEE4UGl3V0trNmJsSE1qQnVCQnVSRmh2Iiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTMxMDE5Njc2MTIzOTY3O\ + TM3NzcifQ.JOxYfrQSIsHdf-VN1zcOqDOS3tF1HpUYnRVfPKgi65am3sNiniOM1kWTNrKE5-sh4DaZ33G_XniGkhg-9\ + QKF8tspXpZ6BnTNkf-QdGqR_v89AIO8pZgQ7oECLXhnmg-31G-LAHlGfwWedJ6qPVy4dL5zoR0wVyoQ8gYv5QzQC-zd\ + IF7uY-umKKloAAYP_tkKLlQHackjTynN-bpu2mKv55-h7a3hSEPfxvgX0SsvL69xtNsZkwb43bqyP1x_c2ErfYoPyzg\ + rqgMIRZjCw9TI33vISKM_BfQXBiZT_BHkX4s0Jng3ph4vrwvnBsa0HpNxBhQYKb1u9gZXs7XjLZhjZJZsDTQI1V4Wov\ + iAL7ihvZkLxJRtDCOmX7-5BTEsMInFgXUAhCDhQUMwSfLLJuinBS96NGSX5_TfIVMkbCl-HWzdQx7KZAsahNe_CmuYT\ + lZH32NuRn78ohY4eViRyfC8OXVLwoLxxpen2CWlV2dxV5wwXeij_kHt5wOXSGYpSfxz37cK0CaK8K2pwudasxQ0lv2V\ + _Xx_z4UaEOr5wqT_BK1A1HO4HRjvFX2soF-qm4GCSWAbmFZs7DVEClrotxRx2romifRVb8XRia2M6YTmDyIT0YphFgG\ + nnrDIZNKO23uDYT60LVMVYpCCY2RiwxCR9bn7AeGbmi7cpL9aYZ5uJRM"; + + // jwt payload : + // - "https://api.openai.com/auth".user_id = "user-6NxJflAHoEBDJzZl9iXhqbDG" + // - "https://api.openai.com/profile".email = "vocodes2020@gmail.com" + // - "https://api.openai.com/profile".email_verified = true + // - "iat": 1744936471, - issued at claim (5 days ago) + // - "nbf": 1744936471, - not before, don't use before date + // - "exp": 1745800472, - in 5 days, expiry + + let result = lightweight_sora_jwt_parse(token).expect("claim should parse"); + + assert_eq!(result.email, "vocodes2020@gmail.com"); + assert_eq!(result.email_verified, true); + } +} diff --git a/crates/api_clients/openai_sora_client/src/utils_internal/mod.rs b/crates/api_clients/openai_sora_client/src/utils_internal/mod.rs new file mode 100644 index 0000000000..73261e81d8 --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/utils_internal/mod.rs @@ -0,0 +1,3 @@ +pub mod classify_general_http_error; +pub mod classify_general_http_status_code_and_body; +pub mod lightweight_sora_jwt_parse; diff --git a/crates/api_clients/worldlabs_api_client/Cargo.toml b/crates/api_clients/worldlabs_api_client/Cargo.toml new file mode 100644 index 0000000000..fc2bb2996a --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "worldlabs_api_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "worldlabs_api_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true +filesys.workspace = true + +# External +anyhow.workspace = true +log.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["full"] } +uuid.workspace = true +wreq.workspace = true + +[dev-dependencies] +filesys.workspace = true +env_logger.workspace = true +env.workspace = true +chrono.workspace = true +strum.workspace = true +test_data.workspace = true diff --git a/crates/api_clients/worldlabs_api_client/src/api/api_types/media_asset_id.rs b/crates/api_clients/worldlabs_api_client/src/api/api_types/media_asset_id.rs new file mode 100644 index 0000000000..604aa51fa0 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/api_types/media_asset_id.rs @@ -0,0 +1,9 @@ +/// ID for a media asset in the World Labs API. +#[derive(Clone, Debug)] +pub struct MediaAssetId(pub String); + +impl MediaAssetId { + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/api_types/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/api_types/mod.rs new file mode 100644 index 0000000000..6b2a76cbd4 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/api_types/mod.rs @@ -0,0 +1,4 @@ +pub mod media_asset_id; +pub mod operation_id; +pub mod world_id; +pub mod world_labs_model; diff --git a/crates/api_clients/worldlabs_api_client/src/api/api_types/operation_id.rs b/crates/api_clients/worldlabs_api_client/src/api/api_types/operation_id.rs new file mode 100644 index 0000000000..83504ee68e --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/api_types/operation_id.rs @@ -0,0 +1,9 @@ +/// ID for an async operation returned by the World Labs API. +#[derive(Clone, Debug)] +pub struct OperationId(pub String); + +impl OperationId { + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/api_types/world_id.rs b/crates/api_clients/worldlabs_api_client/src/api/api_types/world_id.rs new file mode 100644 index 0000000000..2e2985852f --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/api_types/world_id.rs @@ -0,0 +1,13 @@ +/// Strongly-typed World ID +#[derive(Clone, Debug)] +pub struct WorldId(pub String); + +impl WorldId { + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/api_types/world_labs_model.rs b/crates/api_clients/worldlabs_api_client/src/api/api_types/world_labs_model.rs new file mode 100644 index 0000000000..c73fe75e34 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/api_types/world_labs_model.rs @@ -0,0 +1,190 @@ +/// The available World Labs generation models. +#[derive(Clone, Copy, Debug)] +pub enum WorldLabsModel { + /// `Marble 0.1-mini` is good for quick drafts + /// Generation time: 30-45 seconds + /// Cost: 150-330 credits + #[deprecated(note="Marble 0.1-mini is deprecated. Use `marble-1.0-draft` instead.")] + Marble0p1Mini, + + /// `Marble 0.1-plus` is best for final renders + /// Generation time: ~5 minutes, + /// Cost: 1500-1600 credits + #[deprecated(note="Marble 0.1-plus is deprecated. Use `marble-1.0` instead.")] + Marble0p1Plus, + + /// marble-1.0 + Marble1p0, + + /// marble-1.0-draft + Marble1p0Draft, + + /// marble-1.1 + Marble1p1, + + /// marble-1.1-plus + Marble1p1Plus, +} + +impl WorldLabsModel { + /// Returns the official API name string used in HTTP requests. + pub fn get_model_api_name_str(&self) -> &'static str { + match self { + Self::Marble0p1Mini => "Marble 0.1-mini", + Self::Marble0p1Plus => "Marble 0.1-plus", + Self::Marble1p0 => "marble-1.0", + Self::Marble1p0Draft => "marble-1.0-draft", + Self::Marble1p1 => "marble-1.1", + Self::Marble1p1Plus => "marble-1.1-plus", + } + } + + pub fn is_deprecated(&self) -> bool { + match self { + Self::Marble0p1Mini | Self::Marble0p1Plus => true, + _ => false, + } + } + + pub fn to_new_value(self) -> Self { + match self { + Self::Marble0p1Mini => Self::Marble1p0Draft, + Self::Marble0p1Plus => Self::Marble1p0, + _ => self, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod api_name { + use super::*; + + #[allow(deprecated)] + #[test] + fn marble_0p1_mini() { + assert_eq!(WorldLabsModel::Marble0p1Mini.get_model_api_name_str(), "Marble 0.1-mini"); + } + + #[allow(deprecated)] + #[test] + fn marble_0p1_plus() { + assert_eq!(WorldLabsModel::Marble0p1Plus.get_model_api_name_str(), "Marble 0.1-plus"); + } + + #[test] + fn marble_1p0() { + assert_eq!(WorldLabsModel::Marble1p0.get_model_api_name_str(), "marble-1.0"); + } + + #[test] + fn marble_1p0_draft() { + assert_eq!(WorldLabsModel::Marble1p0Draft.get_model_api_name_str(), "marble-1.0-draft"); + } + + #[test] + fn marble_1p1() { + assert_eq!(WorldLabsModel::Marble1p1.get_model_api_name_str(), "marble-1.1"); + } + + #[test] + fn marble_1p1_plus() { + assert_eq!(WorldLabsModel::Marble1p1Plus.get_model_api_name_str(), "marble-1.1-plus"); + } + } + + mod is_deprecated { + use super::*; + + #[allow(deprecated)] + #[test] + fn marble_0p1_mini_is_deprecated() { + assert!(WorldLabsModel::Marble0p1Mini.is_deprecated()); + } + + #[allow(deprecated)] + #[test] + fn marble_0p1_plus_is_deprecated() { + assert!(WorldLabsModel::Marble0p1Plus.is_deprecated()); + } + + #[test] + fn marble_1p0_is_not_deprecated() { + assert!(!WorldLabsModel::Marble1p0.is_deprecated()); + } + + #[test] + fn marble_1p0_draft_is_not_deprecated() { + assert!(!WorldLabsModel::Marble1p0Draft.is_deprecated()); + } + + #[test] + fn marble_1p1_is_not_deprecated() { + assert!(!WorldLabsModel::Marble1p1.is_deprecated()); + } + + #[test] + fn marble_1p1_plus_is_not_deprecated() { + assert!(!WorldLabsModel::Marble1p1Plus.is_deprecated()); + } + } + + mod to_new_value { + use super::*; + + #[allow(deprecated)] + #[test] + fn marble_0p1_mini_maps_to_1p0_draft() { + let new = WorldLabsModel::Marble0p1Mini.to_new_value(); + assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft"); + assert!(!new.is_deprecated()); + } + + #[allow(deprecated)] + #[test] + fn marble_0p1_plus_maps_to_1p0() { + let new = WorldLabsModel::Marble0p1Plus.to_new_value(); + assert_eq!(new.get_model_api_name_str(), "marble-1.0"); + assert!(!new.is_deprecated()); + } + + #[test] + fn marble_1p0_stays_as_1p0() { + let new = WorldLabsModel::Marble1p0.to_new_value(); + assert_eq!(new.get_model_api_name_str(), "marble-1.0"); + } + + #[test] + fn marble_1p0_draft_stays_as_1p0_draft() { + let new = WorldLabsModel::Marble1p0Draft.to_new_value(); + assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft"); + } + + #[test] + fn marble_1p1_stays_as_1p1() { + let new = WorldLabsModel::Marble1p1.to_new_value(); + assert_eq!(new.get_model_api_name_str(), "marble-1.1"); + } + + #[test] + fn marble_1p1_plus_stays_as_1p1_plus() { + let new = WorldLabsModel::Marble1p1Plus.to_new_value(); + assert_eq!(new.get_model_api_name_str(), "marble-1.1-plus"); + } + + #[test] + fn new_models_are_not_deprecated_after_conversion() { + for model in [ + WorldLabsModel::Marble1p0, + WorldLabsModel::Marble1p0Draft, + WorldLabsModel::Marble1p1, + WorldLabsModel::Marble1p1Plus, + ] { + let new = model.to_new_value(); + assert!(!new.is_deprecated(), "{:?} should not be deprecated after to_new_value", model); + } + } + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/mod.rs new file mode 100644 index 0000000000..3a0804a33a --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod api_types; +pub mod requests; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/delete_world.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/delete_world.rs new file mode 100644 index 0000000000..c481ce8186 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/delete_world.rs @@ -0,0 +1,102 @@ +use crate::api::api_types::world_id::WorldId; +use crate::api::requests::delete_world::http_request::RawResponse; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::time::Duration; +use wreq::Client; + +const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/worlds"; + +pub struct DeleteWorldArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub world_id: &'a WorldId, + pub request_timeout: Option, +} + +pub struct DeleteWorldResponse { + pub world_id: WorldId, + pub deleted: bool, +} + +/// DELETE /marble/v1/worlds/{world_id} +/// +/// Permanently delete a world and its assets. Only the owner can delete. +pub async fn delete_world(args: DeleteWorldArgs<'_>) -> Result { + let client = Client::new(); + + let url = format!("{}/{}", BASE_URL, args.world_id.as_str()); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.delete(&url) + .header("WLT-Api-Key", args.creds.api_key()); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during delete_world request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("delete_world returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(DeleteWorldResponse { + world_id: WorldId(raw.world_id), + deleted: raw.deleted, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_delete_world() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + // Use a known world_id to delete + let world_id = WorldId::from_str("REPLACE_WITH_REAL_ID"); + + let response = delete_world(DeleteWorldArgs { + creds: &creds, + world_id: &world_id, + request_timeout: None, + }).await.unwrap(); + + println!("World ID: {}", response.world_id.as_str()); + println!("Deleted: {}", response.deleted); + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/http_request.rs new file mode 100644 index 0000000000..954d02f54a --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/http_request.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub(crate) struct RawResponse { + pub world_id: String, + pub deleted: bool, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/mod.rs new file mode 100644 index 0000000000..247dfa704b --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/delete_world/mod.rs @@ -0,0 +1,2 @@ +pub mod delete_world; +pub(crate) mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/generate_world.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/generate_world.rs new file mode 100644 index 0000000000..7c12eaf75c --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/generate_world.rs @@ -0,0 +1,449 @@ +use crate::api::api_types::operation_id::OperationId; +use crate::api::api_types::world_labs_model::WorldLabsModel; +use crate::api::requests::generate_world::http_request::{RawRequest, RawResponse, WorldPrompt, Permission}; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::time::Duration; +use wreq::Client; + +const URL: &str = "https://api.worldlabs.ai/marble/v1/worlds:generate"; + +pub struct GenerateWorldArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub world_prompt: WorldPrompt, + pub display_name: Option, + pub model: WorldLabsModel, + pub seed: Option, + pub tags: Option>, + pub permission: Option, + pub request_timeout: Option, +} + +pub struct GenerateWorldResponse { + pub operation_id: OperationId, + pub done: bool, +} + +/// POST /marble/v1/worlds:generate +/// +/// Start world generation. Returns an operation_id to poll with get_operation. +pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result { + let client = Client::new(); + + let model_name = args.model + .to_new_value() + .get_model_api_name_str() + .to_string(); + + let payload = RawRequest { + world_prompt: args.world_prompt, + display_name: args.display_name, + model: Some(model_name), + seed: args.seed, + tags: args.tags, + permission: args.permission, + }; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header("WLT-Api-Key", args.creds.api_key()) + .header("Content-Type", "application/json") + .json(&payload); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during generate_world request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("generate_world returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(GenerateWorldResponse { + operation_id: OperationId(raw.operation_id), + done: raw.done.unwrap_or(false), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::generate_world::http_request::{ContentReference, WorldPrompt}; + use crate::api::requests::prepare_upload::prepare_upload::{prepare_upload, PrepareUploadArgs, MediaAssetKind}; + use crate::api::requests::upload_to_signed_url::upload_to_signed_url::{upload_to_signed_url, UploadToSignedUrlArgs}; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use filesys::file_read_bytes::file_read_bytes; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_generate_world_text_prompt() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + let response = generate_world(GenerateWorldArgs { + creds: &creds, + world_prompt: WorldPrompt::Text { + text_prompt: Some("A cozy cabin in the snowy mountains".to_string()), + disable_recaption: None, + }, + display_name: Some("Test World".to_string()), + model: WorldLabsModel::Marble0p1Mini, + seed: None, + tags: None, + permission: None, + request_timeout: None, + }).await.unwrap(); + + println!("Operation ID: {}", response.operation_id.as_str()); + println!("Done: {}", response.done); + + assert_eq!(1, 2); + } + + #[tokio::test] + #[ignore] + async fn test_generate_world_image_uri() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + let response = generate_world(GenerateWorldArgs { + creds: &creds, + world_prompt: WorldPrompt::Image { + image_prompt: ContentReference::Uri { + uri: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png".to_string(), + }, + text_prompt: None, + is_pano: None, + disable_recaption: None, + }, + display_name: None, + model: WorldLabsModel::Marble0p1Mini, + seed: None, + tags: None, + permission: None, + request_timeout: None, + }).await.unwrap(); + + println!("Operation ID: {}", response.operation_id.as_str()); + println!("Done: {}", response.done); + + assert_eq!(1, 2); + } + + /// End-to-end: upload a safe image step by step (prepare → upload → + /// generate_world), printing each step's result for debugging. + /// + /// Uses a synthetic gradient PNG from test_data/. + #[tokio::test] + #[ignore] + async fn test_generate_world_for_safe_image() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + // --- Read file --- + let file_bytes = file_read_bytes("/Users/bt/dev/storyteller/artcraft/test_data/image/mochi.jpg") + .expect("failed to read safe test image"); + println!("Read {} bytes from safe test image", file_bytes.len()); + + // --- Step 1: prepare_upload --- + println!("\n=== Step 1: prepare_upload ==="); + let prepare_result = prepare_upload(PrepareUploadArgs { + creds: &creds, + file_name: "mochi.jpg", + kind: MediaAssetKind::Image, + request_timeout: None, + }).await; + + println!("prepare_upload result: {:?}", prepare_result.as_ref().map(|r| &r.media_asset_id)); + + let prepare_response = match prepare_result { + Ok(r) => { + println!(" Media Asset ID: {}", r.media_asset_id.as_str()); + println!(" Upload URL: {}", r.upload_url); + println!(" Required headers: {:?}", r.required_headers); + r + } + Err(err) => { + println!(" ERROR at prepare_upload: {:?}", err); + panic!("prepare_upload failed: {:?}", err); + } + }; + + // --- Step 2: upload_to_signed_url --- + println!("\n=== Step 2: upload_to_signed_url ==="); + let upload_result = upload_to_signed_url(UploadToSignedUrlArgs { + upload_url: &prepare_response.upload_url, + file_bytes, + required_headers: &prepare_response.required_headers, + content_type: "image/jpeg", + request_timeout: None, + }).await; + + match &upload_result { + Ok(()) => println!(" Upload succeeded."), + Err(err) => { + println!(" ERROR at upload_to_signed_url: {:?}", err); + panic!("upload_to_signed_url failed: {:?}", err); + } + } + + // --- Step 3: generate_world --- + println!("\n=== Step 3: generate_world ==="); + let generate_result = generate_world(GenerateWorldArgs { + creds: &creds, + world_prompt: WorldPrompt::Image { + image_prompt: ContentReference::MediaAsset { + media_asset_id: prepare_response.media_asset_id.as_str().to_string(), + }, + text_prompt: Some("A colorful gradient landscape".to_string()), + is_pano: None, + disable_recaption: None, + }, + display_name: None, + model: WorldLabsModel::Marble0p1Mini, + seed: None, + tags: None, + permission: None, + request_timeout: None, + }).await; + + match &generate_result { + Ok(r) => { + println!(" SUCCESS — safe image"); + println!(" Operation ID: {}", r.operation_id.as_str()); + println!(" Done: {}", r.done); + } + Err(err) => { + println!(" ERROR at generate_world: {:?}", err); + } + } + + assert_eq!(1, 2, "Inspect output above"); + } + + /// End-to-end: upload an NSFW image step by step (prepare → upload → + /// generate_world), printing each step's result for debugging. + /// + /// We expect the NSFW rejection to happen at one of these steps. + /// This test helps us identify *which* step returns the moderation error + /// and what the error response body looks like. + /// + /// NB: Place the NSFW test image at `test_data/images/nsfw_test_image.png`. + /// This file is NOT checked into git. + #[tokio::test] + #[ignore] + async fn test_generate_world_for_nsfw_image() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + // --- Read file --- + let file_bytes = file_read_bytes("/Users/bt/Pictures/nsfw_test/nsfw_test_1.jpg") + .expect("failed to read NSFW test image"); + println!("Read {} bytes from NSFW test image", file_bytes.len()); + + // --- Step 1: prepare_upload --- + println!("\n=== Step 1: prepare_upload ==="); + let prepare_result = prepare_upload(PrepareUploadArgs { + creds: &creds, + file_name: "nsfw_test_1.jpg", + kind: MediaAssetKind::Image, + request_timeout: None, + }).await; + + let prepare_response = match prepare_result { + Ok(r) => { + println!(" OK — Media Asset ID: {}", r.media_asset_id.as_str()); + println!(" Upload URL: {}", r.upload_url); + r + } + Err(err) => { + println!(" NSFW CAUGHT AT STEP 1 (prepare_upload)!"); + println!(" Error (Debug): {:?}", err); + println!(" Error (Display): {}", err); + assert_eq!(1, 2, "NSFW caught at prepare_upload — inspect output above"); + unreachable!(); + } + }; + + // --- Step 2: upload_to_signed_url --- + println!("\n=== Step 2: upload_to_signed_url ==="); + let upload_result = upload_to_signed_url(UploadToSignedUrlArgs { + upload_url: &prepare_response.upload_url, + file_bytes, + required_headers: &prepare_response.required_headers, + content_type: "image/jpeg", + request_timeout: None, + }).await; + + match &upload_result { + Ok(()) => println!(" OK — upload succeeded."), + Err(err) => { + println!(" NSFW CAUGHT AT STEP 2 (upload_to_signed_url)!"); + println!(" Error (Debug): {:?}", err); + println!(" Error (Display): {}", err); + assert_eq!(1, 2, "NSFW caught at upload — inspect output above"); + } + } + + // --- Step 3: generate_world --- + println!("\n=== Step 3: generate_world ==="); + let generate_result = generate_world(GenerateWorldArgs { + creds: &creds, + world_prompt: WorldPrompt::Image { + image_prompt: ContentReference::MediaAsset { + media_asset_id: prepare_response.media_asset_id.as_str().to_string(), + }, + text_prompt: None, + is_pano: None, + disable_recaption: None, + }, + display_name: None, + model: WorldLabsModel::Marble0p1Mini, + seed: None, + tags: None, + permission: None, + request_timeout: None, + }).await; + + match &generate_result { + Ok(r) => { + println!(" UNEXPECTED SUCCESS — NSFW image was NOT rejected at generate_world!"); + println!(" Operation ID: {}", r.operation_id.as_str()); + println!(" Done: {}", r.done); + } + Err(err) => { + println!(" NSFW CAUGHT AT STEP 3 (generate_world)!"); + println!(" Error (Debug): {:?}", err); + println!(" Error (Display): {}", err); + } + } + + assert_eq!(1, 2, "Inspect output above"); + } + + /// Marble 1.1: generate world from a mountain/tree image via URI. + #[tokio::test] + #[ignore] + async fn test_generate_world_marble_1p1_image_uri() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + let image_url = test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL; + + println!("=== generate_world with Marble 1.1 ==="); + println!("Image URL: {}", image_url); + + let result = generate_world(GenerateWorldArgs { + creds: &creds, + world_prompt: WorldPrompt::Image { + image_prompt: ContentReference::Uri { + uri: image_url.to_string(), + }, + text_prompt: Some("Mountain landscape with a tree".to_string()), + is_pano: None, + disable_recaption: None, + }, + display_name: None, + model: WorldLabsModel::Marble1p1, + seed: None, + tags: None, + permission: None, + request_timeout: None, + }).await; + + match &result { + Ok(r) => { + println!("SUCCESS — Marble 1.1"); + println!(" Operation ID: {}", r.operation_id.as_str()); + println!(" Done: {}", r.done); + } + Err(err) => { + println!("ERROR — Marble 1.1"); + println!(" Error (Debug): {:?}", err); + println!(" Error (Display): {}", err); + } + } + + assert_eq!(1, 2, "Inspect output above"); + } + + /// Marble 1.1-plus: generate world from a wide fall mountains image via URI. + #[tokio::test] + #[ignore] + async fn test_generate_world_marble_1p1_plus_image_uri() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + let image_url = test_data::web::image_urls::SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL; + + println!("=== generate_world with Marble 1.1-plus ==="); + println!("Image URL: {}", image_url); + + let result = generate_world(GenerateWorldArgs { + creds: &creds, + world_prompt: WorldPrompt::Image { + image_prompt: ContentReference::Uri { + uri: image_url.to_string(), + }, + text_prompt: Some("Autumn mountain panorama".to_string()), + is_pano: None, + disable_recaption: None, + }, + display_name: None, + model: WorldLabsModel::Marble1p1Plus, + seed: None, + tags: None, + permission: None, + request_timeout: None, + }).await; + + match &result { + Ok(r) => { + println!("SUCCESS — Marble 1.1-plus"); + println!(" Operation ID: {}", r.operation_id.as_str()); + println!(" Done: {}", r.done); + } + Err(err) => { + println!("ERROR — Marble 1.1-plus"); + println!(" Error (Debug): {:?}", err); + println!(" Error (Display): {}", err); + } + } + + assert_eq!(1, 2, "Inspect output above"); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/http_request.rs new file mode 100644 index 0000000000..fb26365ec8 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/http_request.rs @@ -0,0 +1,114 @@ +use serde::{Deserialize, Serialize}; + +// ── Request types ── + +#[derive(Serialize)] +pub(crate) struct RawRequest { + pub world_prompt: WorldPrompt, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Permission { + #[serde(skip_serializing_if = "Option::is_none")] + pub public: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_id_access: Option, +} + +/// The world prompt — a tagged union describing the input. +/// +/// Discriminated on the `type` field: +/// - `"text"` — text-only prompt +/// - `"image"` — single image (+ optional text) +/// - `"multi-image"` — multiple images with optional azimuth +/// - `"video"` — video input (+ optional text) +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum WorldPrompt { + Text { + #[serde(skip_serializing_if = "Option::is_none")] + text_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disable_recaption: Option, + }, + Image { + image_prompt: ContentReference, + #[serde(skip_serializing_if = "Option::is_none")] + text_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + is_pano: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disable_recaption: Option, + }, + MultiImage { + multi_image_prompt: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + text_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reconstruct_images: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disable_recaption: Option, + }, + Video { + video_prompt: ContentReference, + #[serde(skip_serializing_if = "Option::is_none")] + text_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disable_recaption: Option, + }, +} + +/// A content reference — tagged union on `source`. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "source", rename_all = "snake_case")] +pub enum ContentReference { + Uri { + uri: String, + }, + MediaAsset { + media_asset_id: String, + }, + DataBase64 { + data_base64: String, + #[serde(skip_serializing_if = "Option::is_none")] + extension: Option, + }, +} + +/// A spherically-located content item for multi-image prompts. +#[derive(Clone, Debug, Serialize)] +pub struct SphericallyLocatedContent { + pub content: ContentReference, + #[serde(skip_serializing_if = "Option::is_none")] + pub azimuth: Option, +} + +// ── Response types ── + +#[derive(Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawResponse { + pub operation_id: String, + pub done: Option, + pub created_at: Option, + pub updated_at: Option, + pub expires_at: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawOperationError { + pub code: Option, + pub message: Option, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/mod.rs new file mode 100644 index 0000000000..c26446968e --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/generate_world/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_world; +pub mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/get_media_asset.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/get_media_asset.rs new file mode 100644 index 0000000000..938736b8be --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/get_media_asset.rs @@ -0,0 +1,112 @@ +use crate::api::requests::get_media_asset::http_request::RawResponse; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::time::Duration; +use wreq::Client; + +const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/media-assets"; + +pub struct GetMediaAssetArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub media_asset_id: &'a str, + pub request_timeout: Option, +} + +pub struct GetMediaAssetResponse { + pub media_asset_id: String, + pub file_name: String, + pub kind: String, + pub extension: Option, + pub created_at: Option, + pub updated_at: Option, +} + +/// GET /marble/v1/media-assets/{media_asset_id} +/// +/// Get info about a previously uploaded media asset. +pub async fn get_media_asset(args: GetMediaAssetArgs<'_>) -> Result { + let client = Client::new(); + + let url = format!("{}/{}", BASE_URL, args.media_asset_id); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.get(&url) + .header("WLT-Api-Key", args.creds.api_key()); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during get_media_asset request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("get_media_asset returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(GetMediaAssetResponse { + media_asset_id: raw.media_asset_id, + file_name: raw.file_name, + kind: raw.kind, + extension: raw.extension, + created_at: raw.created_at, + updated_at: raw.updated_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_get_media_asset() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + // Use a known media_asset_id from a previous prepare_upload call + let media_asset_id = "REPLACE_WITH_REAL_ID"; + + let response = get_media_asset(GetMediaAssetArgs { + creds: &creds, + media_asset_id, + request_timeout: None, + }).await.unwrap(); + + println!("Media asset ID: {}", response.media_asset_id); + println!("File name: {}", response.file_name); + println!("Kind: {}", response.kind); + println!("Extension: {:?}", response.extension); + println!("Created at: {:?}", response.created_at); + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/http_request.rs new file mode 100644 index 0000000000..49b0bab55c --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/http_request.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawResponse { + pub media_asset_id: String, + pub file_name: String, + pub kind: String, + pub extension: Option, + pub metadata: Option>, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/mod.rs new file mode 100644 index 0000000000..b999d42a44 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_media_asset/mod.rs @@ -0,0 +1,2 @@ +pub mod get_media_asset; +pub(crate) mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/get_operation.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/get_operation.rs new file mode 100644 index 0000000000..6833b1a3d4 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/get_operation.rs @@ -0,0 +1,253 @@ +use crate::api::api_types::operation_id::OperationId; +use crate::api::api_types::world_id::WorldId; +use crate::api::requests::get_operation::http_request::{RawResponse, RawWorld}; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error, warn}; +use std::time::Duration; +use wreq::Client; + +const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/operations"; + +pub struct GetOperationArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub operation_id: &'a OperationId, + pub request_timeout: Option, +} + +pub struct GetOperationResponse { + pub operation_id: String, + pub done: bool, + pub created_at: Option, + pub updated_at: Option, + pub expires_at: Option, + pub error: Option, + pub metadata: Option, + /// Structured World object, present when done=true and no error. + pub world: Option, +} + +#[derive(Debug, Clone)] +pub struct OperationError { + pub code: Option, + pub message: Option, +} + +/// A completed World returned from an operation. +#[derive(Debug, Clone)] +pub struct World { + pub world_id: WorldId, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + pub status: Option, + pub tags: Option>, + pub assets: Option, +} + +/// Assets associated with a completed World. +#[derive(Debug, Clone)] +pub struct WorldAssets { + pub caption: Option, + pub thumbnail_url: Option, + pub pano_url: Option, + pub collider_mesh_url: Option, + pub splats: Option, +} + +/// Splat (3D Gaussian) assets for a World. +#[derive(Debug, Clone)] +pub struct SplatAssets { + /// Low-res 100k splat URL + pub spz_url_100k: Option, + /// Medium-res 500k splat URL + pub spz_url_500k: Option, + /// Full resolution splat URL + pub spz_url_full_res: Option, + pub metric_scale_factor: Option, + pub ground_plane_offset: Option, +} + +/// GET /marble/v1/operations/{operation_id} +/// +/// Poll the status of an async operation. +/// When `done` is true, `world` contains the structured World object. +pub async fn get_operation(args: GetOperationArgs<'_>) -> Result { + let client = Client::new(); + + let url = format!("{}/{}", BASE_URL, args.operation_id.as_str()); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.get(&url) + .header("WLT-Api-Key", args.creds.api_key()); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during get_operation request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("get_operation returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let world = raw.response.and_then(|raw_world| { + match try_parse_world(raw_world) { + Some(w) => Some(w), + None => { + warn!("Operation response present but missing world_id — skipping world parse"); + None + } + } + }); + + Ok(GetOperationResponse { + operation_id: raw.operation_id, + done: raw.done.unwrap_or(false), + created_at: raw.created_at, + updated_at: raw.updated_at, + expires_at: raw.expires_at, + error: raw.error.map(|e| OperationError { + code: e.code, + message: e.message, + }), + metadata: raw.metadata, + world, + }) +} + +/// Try to parse a RawWorld into a strongly-typed World. +/// Returns None if world_id is missing (not a valid World). +fn try_parse_world(raw: RawWorld) -> Option { + let world_id = raw.world_id?; + + let assets = raw.assets.map(|a| { + let splats = a.splats.map(|s| { + let semantics = s.semantics_metadata; + SplatAssets { + spz_url_100k: s.spz_urls.as_ref().and_then(|u| u.low.clone()), + spz_url_500k: s.spz_urls.as_ref().and_then(|u| u.medium.clone()), + spz_url_full_res: s.spz_urls.as_ref().and_then(|u| u.full_res.clone()), + metric_scale_factor: semantics.as_ref().and_then(|m| m.metric_scale_factor), + ground_plane_offset: semantics.as_ref().and_then(|m| m.ground_plane_offset), + } + }); + + WorldAssets { + caption: a.caption, + thumbnail_url: a.thumbnail_url, + pano_url: a.imagery.and_then(|i| i.pano_url), + collider_mesh_url: a.mesh.and_then(|m| m.collider_mesh_url), + splats, + } + }); + + Some(World { + world_id: WorldId(world_id), + display_name: raw.display_name, + world_marble_url: raw.world_marble_url, + created_at: raw.created_at, + updated_at: raw.updated_at, + model: raw.model, + status: raw.status, + tags: raw.tags, + assets, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_get_operation() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + //let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string()); + let operation_id = OperationId("a0894fa6-594d-4680-8f3a-5109f22664b1".to_string()); + + let response = get_operation(GetOperationArgs { + creds: &creds, + operation_id: &operation_id, + request_timeout: None, + }).await.unwrap(); + + println!("Operation ID: {}", response.operation_id); + println!("Done: {}", response.done); + println!("Created at: {:?}", response.created_at); + println!("Updated at: {:?}", response.updated_at); + println!("Expires at: {:?}", response.expires_at); + println!("Error: {:?}", response.error); + println!("Metadata: {:?}", response.metadata); + + match &response.world { + None => println!("World: None (not yet complete)"), + Some(world) => { + println!("World:"); + println!(" World ID: {}", world.world_id.as_str()); + println!(" Display name: {:?}", world.display_name); + println!(" Marble URL: {:?}", world.world_marble_url); + println!(" Created at: {:?}", world.created_at); + println!(" Updated at: {:?}", world.updated_at); + println!(" Model: {:?}", world.model); + println!(" Status: {:?}", world.status); + println!(" Tags: {:?}", world.tags); + match &world.assets { + None => println!(" Assets: None"), + Some(assets) => { + println!(" Assets:"); + println!(" Caption: {:?}", assets.caption); + println!(" Thumbnail URL: {:?}", assets.thumbnail_url); + println!(" Pano URL: {:?}", assets.pano_url); + println!(" Collider mesh URL: {:?}", assets.collider_mesh_url); + match &assets.splats { + None => println!(" Splats: None"), + Some(splats) => { + println!(" Splats:"); + println!(" SPZ 100k: {:?}", splats.spz_url_100k); + println!(" SPZ 500k: {:?}", splats.spz_url_500k); + println!(" SPZ full res: {:?}", splats.spz_url_full_res); + println!(" Metric scale factor: {:?}", splats.metric_scale_factor); + println!(" Ground plane offset: {:?}", splats.ground_plane_offset); + } + } + } + } + } + } + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/http_request.rs new file mode 100644 index 0000000000..97a710938a --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/http_request.rs @@ -0,0 +1,82 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub(crate) struct RawResponse { + pub operation_id: String, + pub done: Option, + pub created_at: Option, + pub updated_at: Option, + pub expires_at: Option, + pub error: Option, + pub metadata: Option, + pub response: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct RawOperationError { + pub code: Option, + pub message: Option, +} + +/// The World object embedded in `response` when the operation completes. +/// All fields are optional to tolerate partial/unexpected shapes. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawWorld { + pub world_id: Option, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + pub status: Option, + pub tags: Option>, + pub assets: Option, + pub world_prompt: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawWorldAssets { + pub caption: Option, + pub thumbnail_url: Option, + pub imagery: Option, + pub mesh: Option, + pub splats: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawImagery { + pub pano_url: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawMesh { + pub collider_mesh_url: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSplats { + pub spz_urls: Option, + pub semantics_metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSemanticsMetadata { + pub metric_scale_factor: Option, + pub ground_plane_offset: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSpzUrls { + #[serde(rename = "100k")] + pub low: Option, + #[serde(rename = "500k")] + pub medium: Option, + pub full_res: Option, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/mod.rs new file mode 100644 index 0000000000..e337b282ee --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_operation/mod.rs @@ -0,0 +1,2 @@ +pub mod get_operation; +pub(crate) mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/get_world.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/get_world.rs new file mode 100644 index 0000000000..1bccc59281 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/get_world.rs @@ -0,0 +1,163 @@ +use crate::api::api_types::world_id::WorldId; +use crate::api::requests::get_world::http_request::RawResponse; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::time::Duration; +use wreq::Client; + +const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/worlds"; + +pub struct GetWorldArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub world_id: &'a WorldId, + pub request_timeout: Option, +} + +pub struct GetWorldResponse { + pub world_id: WorldId, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + /// e.g. "SUCCEEDED", "PENDING", "FAILED", "RUNNING" + pub status: Option, + pub tags: Option>, + pub is_public: Option, + pub assets: Option, +} + +pub struct WorldAssets { + pub caption: Option, + pub thumbnail_url: Option, + pub pano_url: Option, + pub collider_mesh_url: Option, + pub spz_url_100k: Option, + pub spz_url_500k: Option, + pub spz_url_full_res: Option, +} + +/// GET /marble/v1/worlds/{world_id} +/// +/// Retrieve a world by its ID. +pub async fn get_world(args: GetWorldArgs<'_>) -> Result { + let client = Client::new(); + + let url = format!("{}/{}", BASE_URL, args.world_id.as_str()); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.get(&url) + .header("WLT-Api-Key", args.creds.api_key()); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during get_world request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("get_world returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let assets = raw.assets.map(|a| { + let spz_urls = a.splats.and_then(|s| s.spz_urls); + WorldAssets { + caption: a.caption, + thumbnail_url: a.thumbnail_url, + pano_url: a.imagery.and_then(|i| i.pano_url), + collider_mesh_url: a.mesh.and_then(|m| m.collider_mesh_url), + spz_url_100k: spz_urls.as_ref().and_then(|u| u.low.clone()), + spz_url_500k: spz_urls.as_ref().and_then(|u| u.medium.clone()), + spz_url_full_res: spz_urls.as_ref().and_then(|u| u.full_res.clone()), + } + }); + + Ok(GetWorldResponse { + world_id: WorldId(raw.world_id), + display_name: raw.display_name, + world_marble_url: raw.world_marble_url, + created_at: raw.created_at, + updated_at: raw.updated_at, + model: raw.model, + status: raw.status, + tags: raw.tags, + is_public: raw.permission.and_then(|p| p.public), + assets, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_get_world() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + // Use a known world_id from a previous generation + let world_id = WorldId::from_str("0048d009-1c7a-4e13-9881-07e9b6ff32e1"); + //let world_id = WorldId::from_str("c8a89fb1-f8b4-44c6-ae50-525f7205c65f"); + + let response = get_world(GetWorldArgs { + creds: &creds, + world_id: &world_id, + request_timeout: None, + }).await.unwrap(); + + println!("World ID: {}", response.world_id.as_str()); + println!("Display name: {:?}", response.display_name); + println!("Marble URL: {:?}", response.world_marble_url); + println!("Created at: {:?}", response.created_at); + println!("Updated at: {:?}", response.updated_at); + println!("Model: {:?}", response.model); + println!("Status: {:?}", response.status); + println!("Tags: {:?}", response.tags); + println!("Is public: {:?}", response.is_public); + match &response.assets { + None => println!("Assets: None"), + Some(assets) => { + println!("Assets:"); + println!(" Caption: {:?}", assets.caption); + println!(" Thumbnail URL: {:?}", assets.thumbnail_url); + println!(" Pano URL: {:?}", assets.pano_url); + println!(" Collider mesh URL: {:?}", assets.collider_mesh_url); + println!(" SPZ 100k: {:?}", assets.spz_url_100k); + println!(" SPZ 500k: {:?}", assets.spz_url_500k); + println!(" SPZ full res: {:?}", assets.spz_url_full_res); + } + } + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/http_request.rs new file mode 100644 index 0000000000..7b2d3352a3 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/http_request.rs @@ -0,0 +1,64 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct RawResponse { + pub world_id: String, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + pub status: Option, + pub tags: Option>, + pub permission: Option, + pub assets: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawPermission { + pub public: Option, + pub allow_id_access: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RawWorldAssets { + pub caption: Option, + pub thumbnail_url: Option, + pub imagery: Option, + pub mesh: Option, + pub splats: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RawImagery { + pub pano_url: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RawMesh { + pub collider_mesh_url: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSplats { + pub spz_urls: Option, + pub semantics_metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSemanticsMetadata { + pub metric_scale_factor: Option, + pub ground_plane_offset: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RawSpzUrls { + #[serde(rename = "100k")] + pub low: Option, + #[serde(rename = "500k")] + pub medium: Option, + pub full_res: Option, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/mod.rs new file mode 100644 index 0000000000..50dd8ec6bd --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/get_world/mod.rs @@ -0,0 +1,2 @@ +pub mod get_world; +pub(crate) mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/http_request.rs new file mode 100644 index 0000000000..748f2035a9 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/http_request.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub(crate) struct RawRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_public: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_by: Option, +} + +#[derive(Deserialize)] +pub(crate) struct RawResponse { + pub worlds: Vec, + pub next_page_token: Option, +} + +#[derive(Deserialize)] +pub(crate) struct RawWorldSummary { + pub world_id: String, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + pub status: Option, + pub tags: Option>, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/list_worlds.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/list_worlds.rs new file mode 100644 index 0000000000..cc0f90103f --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/list_worlds.rs @@ -0,0 +1,170 @@ +use crate::api::api_types::world_id::WorldId; +use crate::api::requests::list_worlds::http_request::{RawRequest, RawResponse}; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::time::Duration; +use wreq::Client; + +const URL: &str = "https://api.worldlabs.ai/marble/v1/worlds:list"; + +pub struct ListWorldsArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + /// 1-100, default 20 + pub page_size: Option, + /// Opaque cursor from a previous response + pub page_token: Option, + /// "SUCCEEDED", "PENDING", "FAILED", "RUNNING" + pub status: Option, + /// "Marble 0.1-mini" or "Marble 0.1-plus" + pub model: Option, + pub tags: Option>, + pub is_public: Option, + /// ISO 8601 datetime + pub created_after: Option, + /// ISO 8601 datetime + pub created_before: Option, + /// "created_at" or "updated_at" + pub sort_by: Option, + pub request_timeout: Option, +} + +pub struct ListWorldsResponse { + pub worlds: Vec, + pub next_page_token: Option, +} + +pub struct WorldSummary { + pub world_id: WorldId, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + /// e.g. "SUCCEEDED", "PENDING", "FAILED", "RUNNING" + pub status: Option, + pub tags: Option>, +} + +/// POST /marble/v1/worlds:list +/// +/// List worlds with optional filtering and pagination. +pub async fn list_worlds(args: ListWorldsArgs<'_>) -> Result { + let client = Client::new(); + + let payload = RawRequest { + page_size: args.page_size, + page_token: args.page_token, + status: args.status, + model: args.model, + tags: args.tags, + is_public: args.is_public, + created_after: args.created_after, + created_before: args.created_before, + sort_by: args.sort_by, + }; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header("WLT-Api-Key", args.creds.api_key()) + .header("Content-Type", "application/json") + .json(&payload); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during list_worlds request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("list_worlds returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let worlds = raw.worlds.into_iter().map(|w| WorldSummary { + world_id: WorldId(w.world_id), + display_name: w.display_name, + world_marble_url: w.world_marble_url, + created_at: w.created_at, + updated_at: w.updated_at, + model: w.model, + status: w.status, + tags: w.tags, + }).collect(); + + Ok(ListWorldsResponse { + worlds, + next_page_token: raw.next_page_token, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_list_worlds() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + let response = list_worlds(ListWorldsArgs { + creds: &creds, + page_size: Some(5), + page_token: None, + status: None, + model: None, + tags: None, + is_public: None, + created_after: None, + created_before: None, + sort_by: None, + request_timeout: None, + }).await.unwrap(); + + println!("Worlds count: {}", response.worlds.len()); + println!("Next page token: {:?}", response.next_page_token); + println!(); + + for (i, world) in response.worlds.iter().enumerate() { + println!("--- World {} ---", i + 1); + println!(" World ID: {}", world.world_id.as_str()); + println!(" Display name: {:?}", world.display_name); + println!(" Marble URL: {:?}", world.world_marble_url); + println!(" Created at: {:?}", world.created_at); + println!(" Updated at: {:?}", world.updated_at); + println!(" Model: {:?}", world.model); + println!(" Status: {:?}", world.status); + println!(" Tags: {:?}", world.tags); + } + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/mod.rs new file mode 100644 index 0000000000..48a82cd1d2 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/list_worlds/mod.rs @@ -0,0 +1,2 @@ +pub mod list_worlds; +pub(crate) mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/mod.rs new file mode 100644 index 0000000000..82ed574fb4 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/mod.rs @@ -0,0 +1,8 @@ +pub mod delete_world; +pub mod generate_world; +pub mod get_media_asset; +pub mod get_operation; +pub mod get_world; +pub mod list_worlds; +pub mod prepare_upload; +pub mod upload_to_signed_url; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/http_request.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/http_request.rs new file mode 100644 index 0000000000..6e43d7dac4 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/http_request.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize)] +pub(crate) struct RawRequest { + pub file_name: String, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Deserialize)] +pub(crate) struct RawResponse { + pub media_asset: RawMediaAsset, + pub upload_info: RawUploadInfo, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawMediaAsset { + pub media_asset_id: String, + pub file_name: String, + pub kind: String, + pub extension: Option, +} + +#[derive(Deserialize)] +pub(crate) struct RawUploadInfo { + pub upload_url: String, + pub upload_method: String, + pub required_headers: Option>, +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/mod.rs new file mode 100644 index 0000000000..b79a751480 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/mod.rs @@ -0,0 +1,2 @@ +pub mod prepare_upload; +pub(crate) mod http_request; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/prepare_upload.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/prepare_upload.rs new file mode 100644 index 0000000000..002c9311fe --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/prepare_upload/prepare_upload.rs @@ -0,0 +1,151 @@ +use crate::api::api_types::media_asset_id::MediaAssetId; +use crate::api::requests::prepare_upload::http_request::{RawRequest, RawResponse}; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::collections::HashMap; +use std::time::Duration; +use wreq::Client; + +const URL: &str = "https://api.worldlabs.ai/marble/v1/media-assets:prepare_upload"; + +/// The kind of media asset to upload. +#[derive(Clone, Copy, Debug)] +pub enum MediaAssetKind { + Image, + Video, +} + +impl MediaAssetKind { + fn as_str(&self) -> &'static str { + match self { + Self::Image => "image", + Self::Video => "video", + } + } +} + +pub struct PrepareUploadArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub file_name: &'a str, + pub kind: MediaAssetKind, + pub request_timeout: Option, +} + +pub struct PrepareUploadResponse { + pub media_asset_id: MediaAssetId, + pub upload_url: String, + pub upload_method: String, + pub required_headers: HashMap, +} + +/// Parse the file extension from a file name, defaulting to "png". +fn extension_from_file_name(file_name: &str) -> String { + file_name + .rsplit_once('.') + .map(|(_, ext)| ext.to_lowercase()) + .unwrap_or_else(|| "png".to_string()) +} + +/// POST /marble/v1/media-assets:prepare_upload +/// +/// Get a signed upload URL and media_asset_id for uploading a file. +/// The extension is parsed from `file_name`; defaults to "png" if none found. +pub async fn prepare_upload(args: PrepareUploadArgs<'_>) -> Result { + let client = Client::new(); + + let extension = extension_from_file_name(args.file_name); + + let payload = RawRequest { + file_name: args.file_name.to_string(), + kind: args.kind.as_str().to_string(), + extension: Some(extension), + metadata: None, + }; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header("WLT-Api-Key", args.creds.api_key()) + .header("Content-Type", "application/json") + .json(&payload); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during prepare_upload request: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("prepare_upload returned error (code {}): {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let raw: RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(PrepareUploadResponse { + media_asset_id: MediaAssetId(raw.media_asset.media_asset_id), + upload_url: raw.upload_info.upload_url, + upload_method: raw.upload_info.upload_method, + required_headers: raw.upload_info.required_headers.unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_prepare_upload() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + let response = prepare_upload(PrepareUploadArgs { + creds: &creds, + file_name: "test_image.jpg", + kind: MediaAssetKind::Image, + request_timeout: None, + }).await.unwrap(); + + println!("Media asset ID: {}", response.media_asset_id.as_str()); + println!("Upload URL: {}", response.upload_url); + println!("Upload method: {}", response.upload_method); + println!("Required headers: {:?}", response.required_headers); + + assert_eq!(1, 2); + } + + #[test] + fn test_extension_parsing() { + assert_eq!(extension_from_file_name("photo.jpg"), "jpg"); + assert_eq!(extension_from_file_name("image.PNG"), "png"); + assert_eq!(extension_from_file_name("video.mp4"), "mp4"); + assert_eq!(extension_from_file_name("no_extension"), "png"); + assert_eq!(extension_from_file_name("multi.dots.webp"), "webp"); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/upload_to_signed_url/mod.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/upload_to_signed_url/mod.rs new file mode 100644 index 0000000000..64ed089560 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/upload_to_signed_url/mod.rs @@ -0,0 +1 @@ +pub mod upload_to_signed_url; diff --git a/crates/api_clients/worldlabs_api_client/src/api/requests/upload_to_signed_url/upload_to_signed_url.rs b/crates/api_clients/worldlabs_api_client/src/api/requests/upload_to_signed_url/upload_to_signed_url.rs new file mode 100644 index 0000000000..7d6a5b79b3 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/api/requests/upload_to_signed_url/upload_to_signed_url.rs @@ -0,0 +1,66 @@ +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use log::{debug, error}; +use std::collections::HashMap; +use std::time::Duration; +use wreq::Client; + +pub struct UploadToSignedUrlArgs<'a> { + pub upload_url: &'a str, + pub file_bytes: Vec, + pub required_headers: &'a HashMap, + pub content_type: &'a str, + pub request_timeout: Option, +} + +/// PUT {upload_url} +/// +/// Upload raw file bytes to the signed URL returned by prepare_upload. +/// Include any required_headers from the prepare_upload response. +pub async fn upload_to_signed_url(args: UploadToSignedUrlArgs<'_>) -> Result<(), WorldLabsError> { + let client = Client::new(); + + debug!("Uploading to signed URL: {}", args.upload_url); + + let mut request_builder = client.put(args.upload_url) + .header("Content-Type", args.content_type) + .body(args.file_bytes); + + for (key, value) in args.required_headers { + request_builder = request_builder.header(key.as_str(), value.as_str()); + } + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder.send() + .await + .map_err(|err| { + error!("Error during signed URL upload: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + if !status.is_success() { + let body = response.text() + .await + .unwrap_or_default(); + error!("Signed URL upload failed (code {}): {:?}", status.as_u16(), body); + return Err(WorldLabsGenericApiError::GoogleUploadFailed { + status_code: status, + body, + }.into()); + } + + debug!("Upload to signed URL succeeded"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + // Upload tests are covered by the end-to-end recipe test, + // since this endpoint requires a fresh signed URL from prepare_upload. +} diff --git a/crates/api_clients/worldlabs_api_client/src/credentials/mod.rs b/crates/api_clients/worldlabs_api_client/src/credentials/mod.rs new file mode 100644 index 0000000000..d576a60ee4 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/credentials/mod.rs @@ -0,0 +1 @@ +pub mod world_labs_api_creds; diff --git a/crates/api_clients/worldlabs_api_client/src/credentials/world_labs_api_creds.rs b/crates/api_clients/worldlabs_api_client/src/credentials/world_labs_api_creds.rs new file mode 100644 index 0000000000..b16442bc8e --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/credentials/world_labs_api_creds.rs @@ -0,0 +1,16 @@ +/// Credentials for the official World Labs API. +/// Uses a simple API key for authentication via the `WLT-Api-Key` header. +#[derive(Clone)] +pub struct WorldLabsApiCreds { + api_key: String, +} + +impl WorldLabsApiCreds { + pub fn new(api_key: String) -> Self { + Self { api_key } + } + + pub fn api_key(&self) -> &str { + &self.api_key + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/error/filter_world_labs_http_error.rs b/crates/api_clients/worldlabs_api_client/src/error/filter_world_labs_http_error.rs new file mode 100644 index 0000000000..d31a695f42 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/error/filter_world_labs_http_error.rs @@ -0,0 +1,195 @@ +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use wreq::StatusCode; + +/// Detect error HTTP responses and coerce the response as a Rust Error. +pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), WorldLabsError> { + if status_code.is_success() { + return Ok(()); + } + + match status_code { + StatusCode::PAYMENT_REQUIRED => { + return Err(WorldLabsSpecificApiError::InsufficientCredits.into()); + }, + _ => {}, + } + + // Check for NSFW content policy rejection (403 with nsfw/sexuality keywords). + if let Some(nsfw_error) = check_nsfw_content_rejection(status_code, maybe_body) { + return Err(nsfw_error.into()); + } + + if let Some(body) = maybe_body { + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: body.to_string(), + }.into()); + } + + Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: String::new(), + }.into()) +} + +/// Check if a 403 response is an NSFW content policy rejection. +/// Returns `Some(WorldLabsSpecificApiError)` if it matches, `None` otherwise. +fn check_nsfw_content_rejection( + status_code: StatusCode, + maybe_body: Option<&str>, +) -> Option { + if status_code != StatusCode::FORBIDDEN { + return None; + } + + let body = maybe_body?; + let body_lower = body.to_lowercase(); + + if body_lower.contains("nsfw") || body_lower.contains("sexuality") { + Some(WorldLabsSpecificApiError::NsfwContentPolicyRejected { + message: Some(body.to_string()), + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_200_returns_ok() { + let result = filter_world_labs_http_error(StatusCode::OK, None); + assert!(result.is_ok()); + } + + #[test] + fn success_200_with_body_returns_ok() { + let result = filter_world_labs_http_error(StatusCode::OK, Some("{}")); + assert!(result.is_ok()); + } + + // --- NSFW 403 --- + + #[test] + fn nsfw_403_real_response() { + let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#; + let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body)); + let err = result.unwrap_err(); + + assert!(err.is_403_forbidden()); + assert!(matches!(err, WorldLabsError::ApiSpecific( + WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. } + ))); + } + + #[test] + fn nsfw_403_preserves_message() { + let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#; + let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body)); + let err = result.unwrap_err(); + + if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = err { + assert_eq!(message.as_deref(), Some(body)); + } else { + panic!("expected NsfwContentPolicyRejected, got: {:?}", err); + } + } + + #[test] + fn nsfw_403_case_insensitive_nsfw() { + let body = r#"{"detail":"nsfw content detected"}"#; + let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body)); + assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific( + WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. } + ))); + } + + #[test] + fn nsfw_403_case_insensitive_sexuality() { + let body = r#"{"detail":"Rejected due to Sexuality content"}"#; + let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body)); + assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific( + WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. } + ))); + } + + // --- Non-NSFW 403 --- + + #[test] + fn non_nsfw_403_is_generic_error() { + let body = r#"{"detail":"Forbidden: invalid API key"}"#; + let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body)); + let err = result.unwrap_err(); + + assert!(err.is_403_forbidden()); + assert!(matches!(err, WorldLabsError::ApiGeneric( + WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ))); + } + + #[test] + fn non_nsfw_403_no_body_is_generic_error() { + let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, None); + let err = result.unwrap_err(); + assert!(err.is_403_forbidden()); + assert!(matches!(err, WorldLabsError::ApiGeneric( + WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ))); + } + + // --- 402 Payment Required --- + + #[test] + fn payment_required_402() { + let result = filter_world_labs_http_error(StatusCode::PAYMENT_REQUIRED, Some("{}")); + let err = result.unwrap_err(); + assert!(matches!(err, WorldLabsError::ApiSpecific( + WorldLabsSpecificApiError::InsufficientCredits + ))); + assert!(!err.is_403_forbidden()); + } + + // --- Other errors --- + + #[test] + fn server_error_500_is_generic() { + let body = r#"{"detail":"Internal server error"}"#; + let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some(body)); + let err = result.unwrap_err(); + assert!(!err.is_403_forbidden()); + assert!(matches!(err, WorldLabsError::ApiGeneric( + WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ))); + } + + #[test] + fn bad_request_400_is_generic() { + let body = r#"{"detail":"Bad request"}"#; + let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body)); + assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric( + WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ))); + } + + #[test] + fn nsfw_keyword_in_non_403_is_not_nsfw_error() { + // Even if the body mentions "nsfw", a 400 should NOT be classified as NSFW rejection. + let body = r#"{"detail":"nsfw check failed"}"#; + let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body)); + assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric( + WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ))); + } + + #[test] + fn error_500_no_body() { + let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, None); + assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric( + WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { body, .. } + ) if body.is_empty())); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/error/mod.rs b/crates/api_clients/worldlabs_api_client/src/error/mod.rs new file mode 100644 index 0000000000..015cdc8108 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod world_labs_client_error; +pub mod world_labs_error; +pub mod world_labs_generic_api_error; +pub mod world_labs_specific_api_error; +pub (crate) mod filter_world_labs_http_error; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_api_client/src/error/world_labs_client_error.rs b/crates/api_clients/worldlabs_api_client/src/error/world_labs_client_error.rs new file mode 100644 index 0000000000..5c2073cb7e --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/error/world_labs_client_error.rs @@ -0,0 +1,23 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum WorldLabsClientError { + + /// An error was encountered in building the Wreq client + WreqClientError(wreq::Error), + + /// Can't read a local file for uploading. + CannotReadLocalFileForUpload(std::io::Error), +} + +impl Error for WorldLabsClientError {} + +impl Display for WorldLabsClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), + Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err), + } + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/error/world_labs_error.rs b/crates/api_clients/worldlabs_api_client/src/error/world_labs_error.rs new file mode 100644 index 0000000000..611e835f21 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/error/world_labs_error.rs @@ -0,0 +1,51 @@ +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use std::error::Error; + +#[derive(Debug)] +pub enum WorldLabsError { + Client(WorldLabsClientError), + ApiSpecific(WorldLabsSpecificApiError), + ApiGeneric(WorldLabsGenericApiError), +} + +impl WorldLabsError { + pub fn is_403_forbidden(&self) -> bool { + match self { + Self::ApiSpecific(e) => e.is_403_forbidden(), + Self::ApiGeneric(e) => e.is_403_forbidden(), + Self::Client(_) => false, + } + } +} + +impl Error for WorldLabsError {} + +impl std::fmt::Display for WorldLabsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "WorldLabsClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "WorldLabsSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "WorldLabsGenericApiError: {:?}", e), + } + } +} + +impl From for WorldLabsError { + fn from(error: WorldLabsClientError) -> Self { + Self::Client(error) + } +} + +impl From for WorldLabsError { + fn from(error: WorldLabsSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for WorldLabsError { + fn from(error: WorldLabsGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/error/world_labs_generic_api_error.rs b/crates/api_clients/worldlabs_api_client/src/error/world_labs_generic_api_error.rs new file mode 100644 index 0000000000..2eb42ab7ef --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/error/world_labs_generic_api_error.rs @@ -0,0 +1,50 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use wreq::StatusCode; + +#[derive(Debug)] +pub enum WorldLabsGenericApiError { + /// Unknown error occurred when uploading to signed URL + GoogleUploadFailed { + status_code: StatusCode, + body: String, + }, + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// An uncategorized bad HTTP response. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// An uncaught error from the API client. + WreqError(wreq::Error), +} + +impl WorldLabsGenericApiError { + pub fn is_403_forbidden(&self) -> bool { + match self { + Self::UncategorizedBadResponseWithStatusAndBody { status_code, .. } + | Self::GoogleUploadFailed { status_code, .. } => { + *status_code == StatusCode::FORBIDDEN + } + _ => false, + } + } +} + +impl Error for WorldLabsGenericApiError {} + +impl Display for WorldLabsGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::GoogleUploadFailed { status_code, body } => write!(f, "Signed URL upload failed with HTTP {}: {:?}", status_code, body), + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + } + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/error/world_labs_specific_api_error.rs b/crates/api_clients/worldlabs_api_client/src/error/world_labs_specific_api_error.rs new file mode 100644 index 0000000000..7990f68df3 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/error/world_labs_specific_api_error.rs @@ -0,0 +1,39 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum WorldLabsSpecificApiError { + /// There aren't sufficient funds for generation. + InsufficientCredits, + + /// The content was rejected by WorldLabs' NSFW / content policy filter. + NsfwContentPolicyRejected { + message: Option, + }, +} + +impl WorldLabsSpecificApiError { + pub fn is_403_forbidden(&self) -> bool { + match self { + Self::NsfwContentPolicyRejected { .. } => true, + _ => false, + } + } +} + +impl Error for WorldLabsSpecificApiError {} + +impl Display for WorldLabsSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InsufficientCredits => write!(f, "Insufficient credits"), + Self::NsfwContentPolicyRejected { message } => { + write!(f, "NSFW content policy rejection")?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/lib.rs b/crates/api_clients/worldlabs_api_client/src/lib.rs new file mode 100644 index 0000000000..304ccae77d --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/lib.rs @@ -0,0 +1,9 @@ + +#[cfg(test)] +pub (crate) mod test_utils; + +pub mod api; +pub mod credentials; +pub mod error; +pub mod pricing; +pub mod recipes; diff --git a/crates/api_clients/worldlabs_api_client/src/pricing/check_pricing.rs b/crates/api_clients/worldlabs_api_client/src/pricing/check_pricing.rs new file mode 100644 index 0000000000..8bb334b363 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/pricing/check_pricing.rs @@ -0,0 +1,226 @@ +use crate::api::api_types::world_labs_model::WorldLabsModel; + +/// The type of input used to generate a world. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(test, derive(strum::EnumIter, strum::Display))] +pub enum InputType { + Text, + ImageNonPanorama, + ImagePanorama, + MultiImage, + Video, +} + +/// Cost breakdown for a World Labs generation request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WorldLabsCost { + pub worldlabs_credits: u32, + pub us_dollar_cents: u32, +} + +/// Calculate the cost of a World Labs generation request. +pub fn calculate_cost(model: WorldLabsModel, input_type: InputType) -> WorldLabsCost { + let worldlabs_credits = credits_for(model, input_type); + let us_dollar_cents = credits_to_us_dollar_cents(worldlabs_credits); + WorldLabsCost { worldlabs_credits, us_dollar_cents } +} + +/// Returns the credit cost for a given model and input type, +/// based on the official pricing table at https://docs.worldlabs.ai/api/pricing +fn credits_for(model: WorldLabsModel, input_type: InputType) -> u32 { + match (model, input_type) { + // Marble 0.1-mini + (WorldLabsModel::Marble0p1Mini, InputType::ImagePanorama) => 150, + (WorldLabsModel::Marble0p1Mini, InputType::Text) => 230, + (WorldLabsModel::Marble0p1Mini, InputType::ImageNonPanorama) => 230, + (WorldLabsModel::Marble0p1Mini, InputType::MultiImage) => 250, + (WorldLabsModel::Marble0p1Mini, InputType::Video) => 250, + + // Marble 0.1-plus + (WorldLabsModel::Marble0p1Plus, InputType::ImagePanorama) => 1500, + (WorldLabsModel::Marble0p1Plus, InputType::Text) => 1580, + (WorldLabsModel::Marble0p1Plus, InputType::ImageNonPanorama) => 1580, + (WorldLabsModel::Marble0p1Plus, InputType::MultiImage) => 1600, + (WorldLabsModel::Marble0p1Plus, InputType::Video) => 1600, + + // Marble 1.0 (successor to 0.1-plus) + (WorldLabsModel::Marble1p0, InputType::ImagePanorama) => 1500, + (WorldLabsModel::Marble1p0, InputType::Text) => 1580, + (WorldLabsModel::Marble1p0, InputType::ImageNonPanorama) => 1580, + (WorldLabsModel::Marble1p0, InputType::MultiImage) => 1600, + (WorldLabsModel::Marble1p0, InputType::Video) => 1600, + + // Marble 1.0-draft (successor to 0.1-mini) + (WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama) => 150, + (WorldLabsModel::Marble1p0Draft, InputType::Text) => 230, + (WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama) => 230, + (WorldLabsModel::Marble1p0Draft, InputType::MultiImage) => 250, + (WorldLabsModel::Marble1p0Draft, InputType::Video) => 250, + + // Marble 1.1 + (WorldLabsModel::Marble1p1, InputType::ImagePanorama) => 1500, + (WorldLabsModel::Marble1p1, InputType::Text) => 1580, + (WorldLabsModel::Marble1p1, InputType::ImageNonPanorama) => 1580, + (WorldLabsModel::Marble1p1, InputType::MultiImage) => 1600, + (WorldLabsModel::Marble1p1, InputType::Video) => 1600, + + // TODO: Looks like there's no way for us to know what the price will be + // for us to bill this accurately, we'll have to bill this after the fact. + // also - they don't have an API for us to ascertain this??! Wat. + // Marble 1.1-plus (range pricing — we use the max to charge enough upfront) + // Actual range: 1500–3000 / 1580–3080 / 1600–3100 + (WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama) => 3000, + (WorldLabsModel::Marble1p1Plus, InputType::Text) => 3080, + (WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama) => 3080, + (WorldLabsModel::Marble1p1Plus, InputType::MultiImage) => 3100, + (WorldLabsModel::Marble1p1Plus, InputType::Video) => 3100, + } +} + +/// Converts credits to US dollar cents. +/// Rate: $1.00 USD per 1,250 credits → 100 cents per 1,250 credits. +fn credits_to_us_dollar_cents(credits: u32) -> u32 { + // (credits * 100) / 1250 = (credits * 4) / 50 = (credits * 2) / 25 + // Use u64 to avoid overflow on large values. + ((credits as u64 * 100) / 1250) as u32 +} + + +#[cfg(test)] +mod tests { + use super::*; + use strum::IntoEnumIterator; + + #[test] + fn test_pricing_table() { + let models = [ + WorldLabsModel::Marble0p1Mini, + WorldLabsModel::Marble0p1Plus, + WorldLabsModel::Marble1p0, + WorldLabsModel::Marble1p0Draft, + WorldLabsModel::Marble1p1, + WorldLabsModel::Marble1p1Plus, + ]; + + println!("\n{:<20} {:<20} {:>10} {:>12}", "Model", "Input Type", "Credits", "USD Cents"); + println!("{}", "-".repeat(65)); + + for model in &models { + for input_type in InputType::iter() { + let cost = calculate_cost(*model, input_type); + println!( + "{:<20} {:<20} {:>10} {:>12}", + model.get_model_api_name_str(), + input_type, + cost.worldlabs_credits, + cost.us_dollar_cents, + ); + } + } + } + + #[test] + fn test_mini_credits() { + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::Text).worldlabs_credits, 230); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::ImageNonPanorama).worldlabs_credits, 230); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::ImagePanorama).worldlabs_credits, 150); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::MultiImage).worldlabs_credits, 250); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::Video).worldlabs_credits, 250); + } + + #[test] + fn test_plus_credits() { + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::Text).worldlabs_credits, 1580); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::ImageNonPanorama).worldlabs_credits, 1580); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::ImagePanorama).worldlabs_credits, 1500); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::MultiImage).worldlabs_credits, 1600); + assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::Video).worldlabs_credits, 1600); + } + + #[test] + fn test_marble_1p0_draft_credits() { + // Same as Marble 0.1-mini (its successor) + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Text).worldlabs_credits, 230); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama).worldlabs_credits, 230); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama).worldlabs_credits, 150); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::MultiImage).worldlabs_credits, 250); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Video).worldlabs_credits, 250); + } + + #[test] + fn test_marble_1p0_credits() { + // Standard model, same pricing as Marble 0.1-plus + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Text).worldlabs_credits, 1580); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama).worldlabs_credits, 1580); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImagePanorama).worldlabs_credits, 1500); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::MultiImage).worldlabs_credits, 1600); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Video).worldlabs_credits, 1600); + } + + #[test] + fn test_marble_1p1_credits() { + // Standard model, same pricing tier as 1.0 + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Text).worldlabs_credits, 1580); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama).worldlabs_credits, 1580); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImagePanorama).worldlabs_credits, 1500); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::MultiImage).worldlabs_credits, 1600); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Video).worldlabs_credits, 1600); + } + + #[test] + fn test_marble_1p1_plus_credits() { + // Plus model uses max of the range pricing (1500–3000, 1580–3080, 1600–3100) + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Text).worldlabs_credits, 3080); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama).worldlabs_credits, 3080); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama).worldlabs_credits, 3000); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::MultiImage).worldlabs_credits, 3100); + assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Video).worldlabs_credits, 3100); + } + + #[test] + fn test_marble_1p1_plus_is_more_expensive_than_standard() { + for input_type in InputType::iter() { + let standard = calculate_cost(WorldLabsModel::Marble1p1, input_type); + let plus = calculate_cost(WorldLabsModel::Marble1p1Plus, input_type); + assert!( + plus.worldlabs_credits > standard.worldlabs_credits, + "1.1-plus should cost more than 1.1 for {:?}: {} vs {}", + input_type, plus.worldlabs_credits, standard.worldlabs_credits, + ); + } + } + + #[test] + fn test_dollar_conversion() { + // 1250 credits = $1.00 = 100 cents + assert_eq!(credits_to_us_dollar_cents(1250), 100); + // 150 credits = $0.12 = 12 cents + assert_eq!(credits_to_us_dollar_cents(150), 12); + // 230 credits = $0.184 = 18 cents (truncated) + assert_eq!(credits_to_us_dollar_cents(230), 18); + // 1580 credits = $1.264 = 126 cents (truncated) + assert_eq!(credits_to_us_dollar_cents(1580), 126); + // 1600 credits = $1.28 = 128 cents + assert_eq!(credits_to_us_dollar_cents(1600), 128); + } + + #[test] + fn test_all_combinations_have_costs() { + let models = [ + WorldLabsModel::Marble0p1Mini, + WorldLabsModel::Marble0p1Plus, + WorldLabsModel::Marble1p0, + WorldLabsModel::Marble1p0Draft, + WorldLabsModel::Marble1p1, + WorldLabsModel::Marble1p1Plus, + ]; + + for model in &models { + for input_type in InputType::iter() { + let cost = calculate_cost(*model, input_type); + assert!(cost.worldlabs_credits > 0, "credits should be > 0 for {:?} {:?}", model, input_type); + assert!(cost.us_dollar_cents > 0, "dollar cents should be > 0 for {:?} {:?}", model, input_type); + } + } + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/pricing/mod.rs b/crates/api_clients/worldlabs_api_client/src/pricing/mod.rs new file mode 100644 index 0000000000..b53ea40376 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/pricing/mod.rs @@ -0,0 +1 @@ +pub mod check_pricing; diff --git a/crates/api_clients/worldlabs_api_client/src/recipes/mod.rs b/crates/api_clients/worldlabs_api_client/src/recipes/mod.rs new file mode 100644 index 0000000000..d17ac4bb12 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/recipes/mod.rs @@ -0,0 +1 @@ +pub mod upload_image_and_create_world_with_retry; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_api_client/src/recipes/upload_image_and_create_world_with_retry.rs b/crates/api_clients/worldlabs_api_client/src/recipes/upload_image_and_create_world_with_retry.rs new file mode 100644 index 0000000000..62e259369d --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/recipes/upload_image_and_create_world_with_retry.rs @@ -0,0 +1,166 @@ +use crate::api::api_types::media_asset_id::MediaAssetId; +use crate::api::api_types::operation_id::OperationId; +use crate::api::api_types::world_labs_model::WorldLabsModel; +use crate::api::requests::generate_world::generate_world::{generate_world, GenerateWorldArgs}; +use crate::api::requests::generate_world::http_request::{ContentReference, WorldPrompt}; +use crate::api::requests::prepare_upload::prepare_upload::{prepare_upload, PrepareUploadArgs, MediaAssetKind}; +use crate::api::requests::upload_to_signed_url::upload_to_signed_url::{upload_to_signed_url, UploadToSignedUrlArgs}; +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use filesys::file_read_bytes::file_read_bytes; +use log::{error, info}; +use std::path::PathBuf; +use std::time::Duration; + +pub struct UploadImageAndCreateWorldArgs<'a> { + pub creds: &'a WorldLabsApiCreds, + pub file: FileBytesOrPath, + pub text_prompt: Option, + pub model: WorldLabsModel, + pub individual_request_timeout: Option, +} + +pub enum FileBytesOrPath { + Bytes(Vec), + Path(PathBuf), +} + +pub struct UploadImageAndCreateWorldResponse { + pub operation_id: OperationId, + pub media_asset_id: MediaAssetId, +} + +/// Official World Labs API: Upload an image and generate a world. +/// +/// Flow (3 steps): +/// 1. prepare_upload — get media_asset_id + signed upload URL +/// 2. upload_to_signed_url — PUT file bytes to signed URL +/// 3. generate_world — start world generation with media_asset source +pub async fn upload_image_and_create_world(args: UploadImageAndCreateWorldArgs<'_>) -> Result { + + info!("Checking file input..."); + + let file_bytes = match args.file { + FileBytesOrPath::Bytes(bytes) => { + info!("File bytes provided ({} bytes)", bytes.len()); + bytes + } + FileBytesOrPath::Path(path) => { + info!("File path provided: {:?}", path); + match file_read_bytes(&path) { + Ok(bytes) => bytes, + Err(err) => { + error!("Error reading file bytes from path: {:?} - error: {:?}", path, err); + return Err(WorldLabsClientError::CannotReadLocalFileForUpload(err).into()); + } + } + } + }; + + // Step 1: Prepare upload + info!("Step 1 of 3: prepare_upload ..."); + + let prepare_response = prepare_upload(PrepareUploadArgs { + creds: args.creds, + file_name: "upload.jpg", + kind: MediaAssetKind::Image, + request_timeout: args.individual_request_timeout, + }).await?; + + let media_asset_id = prepare_response.media_asset_id; + let upload_url = prepare_response.upload_url; + let required_headers = prepare_response.required_headers; + + info!("Media asset ID: {}", media_asset_id.as_str()); + info!("Upload URL: {}", upload_url); + + // Step 2: Upload to signed URL + info!("Step 2 of 3: upload_to_signed_url ..."); + + upload_to_signed_url(UploadToSignedUrlArgs { + upload_url: &upload_url, + file_bytes, + required_headers: &required_headers, + content_type: "image/jpeg", + request_timeout: args.individual_request_timeout, + }).await?; + + info!("Upload complete."); + + // Step 3: Generate world + info!("Step 3 of 3: generate_world ..."); + + let world_prompt = WorldPrompt::Image { + image_prompt: ContentReference::MediaAsset { + media_asset_id: media_asset_id.as_str().to_string(), + }, + text_prompt: args.text_prompt, + is_pano: None, + disable_recaption: None, + }; + + let generate_response = generate_world(GenerateWorldArgs { + creds: args.creds, + world_prompt, + display_name: None, + model: args.model, + seed: None, + tags: None, + permission: None, + request_timeout: args.individual_request_timeout, + }).await?; + + info!("Operation ID: {}", generate_response.operation_id.as_str()); + info!("Done: {}", generate_response.done); + + Ok(UploadImageAndCreateWorldResponse { + operation_id: generate_response.operation_id, + media_asset_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + use filesys::file_read_bytes::file_read_bytes; + use log::LevelFilter; + + #[tokio::test] + #[ignore] + async fn test_upload_and_generate() { + setup_test_logging(LevelFilter::Debug); + + let creds = get_test_api_key().unwrap(); + + //let file_path = "/Users/bt/Pictures/Midjourney/moon_door.png"; + let file_path = "/Users/bt/Pictures/Midjourney/meadow_mirror.png"; + let file_bytes = file_read_bytes(file_path).unwrap(); + + println!("File bytes len: {}", file_bytes.len()); + + let results = upload_image_and_create_world(UploadImageAndCreateWorldArgs { + creds: &creds, + individual_request_timeout: None, + file: FileBytesOrPath::Bytes(file_bytes), + text_prompt: None, + model: WorldLabsModel::Marble0p1Plus, + }).await.unwrap(); + + println!("Operation ID: {}", results.operation_id.as_str()); + println!("Media Asset ID: {}", results.media_asset_id.as_str()); + + /* +00:55:36.237287 [DEBUG] - Requesting URL: https://api.worldlabs.ai/marble/v1/worlds:generate +00:55:45.047221 [DEBUG] - Response body (200): {"operation_id":"1fab3bf1-05a1-4929-907e-c6df07c539e2","created_at":"2026-03-09T04:55:37Z","updated_at":"2026-03-09T04:55:37Z","expires_at":"2026-03-09T05:55:37Z","done":false,"error":null,"metadata":null,"response":null} +00:55:45.047756 [INFO] - Operation ID: 1fab3bf1-05a1-4929-907e-c6df07c539e2 +00:55:45.047839 [INFO] - Done: false +Operation ID: 1fab3bf1-05a1-4929-907e-c6df07c539e2 +Media Asset ID: bc57b2bc-542e-416d-8089-924294152f0d + */ + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_api_client/src/test_utils/get_test_api_key.rs b/crates/api_clients/worldlabs_api_client/src/test_utils/get_test_api_key.rs new file mode 100644 index 0000000000..b445a6549a --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/test_utils/get_test_api_key.rs @@ -0,0 +1,10 @@ +use crate::credentials::world_labs_api_creds::WorldLabsApiCreds; +use anyhow::Result as AnyhowResult; + +const API_KEY_FILE: &str = "/Users/bt/Artcraft/credentials/world_labs_api_key.txt"; + +#[cfg(test)] +pub(crate) fn get_test_api_key() -> AnyhowResult { + let api_key = std::fs::read_to_string(API_KEY_FILE)?.trim().to_string(); + Ok(WorldLabsApiCreds::new(api_key)) +} diff --git a/crates/api_clients/worldlabs_api_client/src/test_utils/mod.rs b/crates/api_clients/worldlabs_api_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..35f004a135 --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/test_utils/mod.rs @@ -0,0 +1,2 @@ +pub mod get_test_api_key; +pub mod setup_test_logging; diff --git a/crates/api_clients/worldlabs_api_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/worldlabs_api_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..9438da106c --- /dev/null +++ b/crates/api_clients/worldlabs_api_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,52 @@ +use chrono::Local; +use env_logger::Builder; +use log::{debug, error, info, trace, warn, LevelFilter}; +use std::io::Write; + +const RUST_LOG : &str = "RUST_LOG"; + +pub fn setup_test_logging(level: LevelFilter) { + let level_str = get_level_str(level); + + println!("Previous log level: {:?}", env::var(RUST_LOG)); + + let is_unsafe = env::set_var(RUST_LOG, level_str); + + if is_unsafe.is_none() { + println!("Unsafely setting Rust Log Level..."); + unsafe { std::env::set_var(RUST_LOG, level_str) } + } + + println!("Log level: {:?}", env::var(RUST_LOG)); + + Builder::new() + .is_test(true) + .format(|buf, record| { + writeln!(buf, + "{} [{}] - {}", + Local::now().format("%H:%M:%S%.6f"), + record.level(), + record.args() + ) + }) + .filter(None, level) + .filter_level(level) + .init(); + + trace!("Test trace log"); + debug!("Test debug log"); + info!("Test info log"); + warn!("Test warn log"); + error!("Test error log"); +} + +fn get_level_str(level: LevelFilter) -> &'static str { + match level { + LevelFilter::Error => "error", + LevelFilter::Warn => "warn", + LevelFilter::Info => "info", + LevelFilter::Debug => "debug", + LevelFilter::Trace => "trace", + LevelFilter::Off => "off", + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/Cargo.toml b/crates/api_clients/worldlabs_consumer_client/Cargo.toml new file mode 100644 index 0000000000..4ae65a7ebf --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "worldlabs_consumer_client" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "worldlabs_consumer_client" +path = "src/lib.rs" + +[dependencies] + +# Internal +cloudflare_errors.workspace = true +cloudflare_mitigation.workspace = true +cookie_store.workspace = true +errors.workspace = true +filesys.workspace = true +http_headers.workspace = true +jwt_light.workspace = true + +# External +anyhow.workspace = true +base64.workspace = true +bstr.workspace = true +byteorder.workspace = true +chrono.workspace = true +futures.workspace = true +indexmap.workspace = true +log.workspace = true +once_cell.workspace = true +regex.workspace = true +scraper = "0.24.0" +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio = { version = "1", features = ["full"] } +url.workspace = true +uuid.workspace = true +wreq-util.workspace = true +wreq.workspace = true + +[dev-dependencies] +filesys.workspace = true +test_data.workspace = true +test_utils.workspace = true +log4rs = "1.4.0" +env_logger.workspace = true +env.workspace = true +chrono.workspace = true diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/image_input_object_id.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/image_input_object_id.rs new file mode 100644 index 0000000000..207d6a5ead --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/image_input_object_id.rs @@ -0,0 +1,26 @@ +use uuid::Uuid; + +/// Type for Image Input Object Ids +/// These are used to refer to worlds +/// These appear to be bare UUIDs. +#[derive(Clone, Debug)] +pub struct ImageInputObjectId(pub String); + +impl ImageInputObjectId { + pub fn generate_new() -> Self { + let uuid = Uuid::new_v4().to_string(); + Self(uuid) + } + + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/meta_world_object_id.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/meta_world_object_id.rs new file mode 100644 index 0000000000..54cd53a198 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/meta_world_object_id.rs @@ -0,0 +1,26 @@ +use uuid::Uuid; + +/// Type for "meta" World IDs (not sure what to term this). +/// These are in the first run patch request +/// These appear to be bare UUIDs. +#[derive(Clone, Debug)] +pub struct MetaWorldObjectId(pub String); + +impl MetaWorldObjectId { + pub fn generate_new() -> Self { + let uuid = Uuid::new_v4().to_string(); + Self(uuid) + } + + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/mod.rs new file mode 100644 index 0000000000..ed4b37eb86 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/mod.rs @@ -0,0 +1,8 @@ +pub mod image_input_object_id; +pub mod meta_world_object_id; +pub mod pano_object_id; +pub mod run_object; +pub mod run_object_id; +pub mod upload_mime_type; +pub mod upload_object_id; +pub mod world_id; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/pano_object_id.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/pano_object_id.rs new file mode 100644 index 0000000000..96c359f4c6 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/pano_object_id.rs @@ -0,0 +1,26 @@ +use uuid::Uuid; + +/// Type for Pano Object Ids +/// These are used to refer to worlds +/// These appear to be bare UUIDs. +#[derive(Clone, Debug)] +pub struct PanoObjectId(pub String); + +impl PanoObjectId { + pub fn generate_new() -> Self { + let uuid = Uuid::new_v4().to_string(); + Self(uuid) + } + + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/run_object.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/run_object.rs new file mode 100644 index 0000000000..e923607c90 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/run_object.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const RUN_MIME_TYPE: &str = "application/run+json"; + +const RUN_VERSION : &str = "0.0.1"; + +/// World Labs' API is very frontend driven +/// They send whole objects back to the frontend that the frontend is in charge of mutatingbjects or PATCH them back to the server (yuck): +/// - request #1 - create run +/// - request #6 - patch run after image upload +/// Gotta go fast? +#[derive(Serialize, Deserialize, Clone)] +pub struct RunObject { + /// The primary ID of the run. + /// This won't exist until we post it to the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + pub metadata: RunObjectMetadata, + pub mime_type: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct RunObjectMetadata { + pub version: String, + + #[serde(rename = "createdAt")] + pub created_at: u64, + + #[serde(rename = "updatedAt")] + pub updated_at: u64, + + #[serde(rename = "usesAdvancedEditing")] + pub uses_advanced_editing: bool, + + #[serde(rename = "draftMode")] + pub draft_mode: bool, + + /// Polymorphic set of ID-to-object mappings. + pub nodes: HashMap, +} + +impl Default for RunObject { + fn default() -> Self { + let now = Utc::now(); + let now = now.timestamp().unsigned_abs() * 1000; // Millisecond resolution + Self { + id: None, + metadata: RunObjectMetadata{ + version: RUN_VERSION.to_string(), + created_at: now, + updated_at: now, + uses_advanced_editing: false, + draft_mode: false, + nodes: HashMap::new(), + }, + mime_type: RUN_MIME_TYPE.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::run_object::RunObject; + + #[test] + fn default_runobject() { + let object = RunObject::default(); + assert!(object.id.is_none()); + assert_eq!(&object.metadata.version, "0.0.1"); + assert!(object.metadata.created_at > 1705433415549); + assert!(object.metadata.updated_at > 1705433415549); + assert_eq!(&object.mime_type, "application/run+json"); + } +} + diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/run_object_id.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/run_object_id.rs new file mode 100644 index 0000000000..73a50ae277 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/run_object_id.rs @@ -0,0 +1,15 @@ +/// Type for Object IDs. +/// These are used to create inference runs and refer to the result +/// These appear to be bare UUIDs. +#[derive(Clone, Debug)] +pub struct RunObjectId(pub String); + +impl RunObjectId { + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + pub fn to_string(&self) -> String { + self.0.clone() + } +} \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/upload_mime_type.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/upload_mime_type.rs new file mode 100644 index 0000000000..72c60853d8 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/upload_mime_type.rs @@ -0,0 +1,21 @@ +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Serialize)] +pub enum UploadMimeType { + #[serde(rename = "image/jpeg")] + ImageJpeg, +} + +impl UploadMimeType { + pub fn content_type(&self) -> &'static str { + match self { + Self::ImageJpeg => "image/jpeg", + } + } + + pub fn extension_without_period(&self) -> &'static str { + match self { + Self::ImageJpeg => "jpeg", + } + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/upload_object_id.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/upload_object_id.rs new file mode 100644 index 0000000000..26eeb401fd --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/upload_object_id.rs @@ -0,0 +1,15 @@ +/// Type for Upload IDs. +/// These are used to upload images. +/// These appear to be bare UUIDs. +#[derive(Clone, Debug)] +pub struct UploadObjectId(pub String); + +impl UploadObjectId { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/api_types/world_id.rs b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/world_id.rs new file mode 100644 index 0000000000..5d6b32b16e --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/api_types/world_id.rs @@ -0,0 +1,26 @@ +use uuid::Uuid; + +/// Type for World IDs. +/// These are used to refer to worlds +/// These appear to be bare UUIDs. +#[derive(Clone, Debug)] +pub struct WorldObjectId(pub String); + +impl WorldObjectId { + pub fn new() -> Self { + let uuid = Uuid::new_v4().to_string(); + Self(uuid) + } + + pub fn from_str(s: &str) -> Self { + Self(s.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/common/common_header_values.rs b/crates/api_clients/worldlabs_consumer_client/src/api/common/common_header_values.rs new file mode 100644 index 0000000000..2df742bef2 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/common/common_header_values.rs @@ -0,0 +1,6 @@ + +/// Referer header value +pub(crate) const REFERER_VALUE: &str = "https://marble.worldlabs.ai/"; + +/// Origin header value +pub(crate) const ORIGIN_VALUE: &str = "https://marble.worldlabs.ai/"; diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/common/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/common/mod.rs new file mode 100644 index 0000000000..6356e9460a --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/common/mod.rs @@ -0,0 +1 @@ +pub (crate) mod common_header_values; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/mod.rs new file mode 100644 index 0000000000..9729e3e72d --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod api_types; +pub mod requests; + +pub (crate) mod common; +pub mod utils; diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_refresh_token/google_refresh_token.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_refresh_token/google_refresh_token.rs new file mode 100644 index 0000000000..7db5e6b9b1 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_refresh_token/google_refresh_token.rs @@ -0,0 +1,154 @@ +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::accept_encoding::ACCEPT_ENCODING_GZIP_ETC; +use http_headers::values::accept_language::ACCEPT_LANGUAGE_EN_US; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::{PRIORITY_4, PRIORITY_6}; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::multipart::Form; +use wreq::Client; +use wreq_util::Emulation; + +/// NB: key is the public API key for WorldLabs +/// https://docs.cloud.google.com/identity-platform/docs/use-rest-api +const URL : &str = "https://securetoken.googleapis.com/v1/token?key=AIzaSyAA88yObUN-0pnXAxcgOrT2exZxFWLW1BI"; +pub struct GoogleRefreshTokenArgs<'a> { + pub refresh_token: &'a WorldLabsRefreshToken, + pub request_timeout: Option, +} + +pub struct GoogleRefreshTokenResponse { + pub bearer_token: WorldLabsBearerToken, + pub refresh_token: WorldLabsRefreshToken, +} + +/// Refresh Access Token for WorldLabs using Google. +pub async fn google_refresh_token(args: GoogleRefreshTokenArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, ACCEPT_LANGUAGE_EN_US) + .header(ACCEPT_ENCODING, ACCEPT_ENCODING_GZIP_ETC) + .header(REFERER, REFERER_VALUE) + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(TE, TE_TRAILERS); + + let mut form = HashMap::new(); + form.insert("grant_type", "refresh_token".to_string()); + form.insert("refresh_token", args.refresh_token.to_raw_string()); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let http_request = request_builder + .form(&form) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + + if response_body.contains("SignatureDoesNotMatch") || response_body.contains("does not match the signature") { + return Err(WorldLabsSpecificApiError::GoogleUploadSignatureDoesNotMatch.into()); + } else { + return Err(WorldLabsGenericApiError::GoogleUploadFailed { status_code: status, body: response_body }.into()); + } + } + + debug!("Response body (200): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let bearer_token = WorldLabsBearerToken::new(response.access_token); + let refresh_token = WorldLabsRefreshToken::new(response.refresh_token); + + Ok(GoogleRefreshTokenResponse { + bearer_token, + refresh_token, + }) +} + +#[derive(Deserialize)] +struct RawResponse { + pub access_token: String, + pub refresh_token: String, + + // eg. "3600" + pub expires_in: Option, + pub id_token: Option, + pub user_id: Option, +} + +#[cfg(test)] +mod tests { + use crate::api::requests::google_refresh_token::google_refresh_token::{google_refresh_token, GoogleRefreshTokenArgs}; + use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_requests() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Debug); + + let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778"; + let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string()); + + let result = google_refresh_token(GoogleRefreshTokenArgs { + refresh_token: &refresh_token, + request_timeout: None, + }).await?; + + Ok(()) + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_refresh_token/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_refresh_token/mod.rs new file mode 100644 index 0000000000..cfade37fe6 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_refresh_token/mod.rs @@ -0,0 +1 @@ +pub mod google_refresh_token; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_upload/google_upload_image.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_upload/google_upload_image.rs new file mode 100644 index 0000000000..7d2e0dc9b4 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_upload/google_upload_image.rs @@ -0,0 +1,91 @@ +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_6; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error}; +use serde::Serialize; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +pub struct GoogleUploadImageArgs<'a> { + pub upload_url: &'a str, + pub upload_mime_type: UploadMimeType, + pub file_bytes: Vec, + pub request_timeout: Option, +} + +/// Marble Image-to-World +/// Upload the binary to GCP using the signed URL +/// Request #4 (of ~10) +pub async fn google_upload_image(args: GoogleUploadImageArgs<'_>) -> Result<(), WorldLabsError> { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + let content_type = args.upload_mime_type.content_type(); + + debug!("Requesting URL: {}", args.upload_url); + + let mut request_builder = client.put(args.upload_url) + .header(CONTENT_TYPE, content_type) + .header("x-goog-content-length-range", "0,1048576000"); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let http_request = request_builder + .body(args.file_bytes) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + + if response_body.contains("SignatureDoesNotMatch") || response_body.contains("does not match the signature") { + return Err(WorldLabsSpecificApiError::GoogleUploadSignatureDoesNotMatch.into()); + } else { + return Err(WorldLabsGenericApiError::GoogleUploadFailed { status_code: status, body: response_body }.into()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_upload/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_upload/mod.rs new file mode 100644 index 0000000000..66f712bf42 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/google_upload/mod.rs @@ -0,0 +1 @@ +pub mod google_upload_image; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/mod.rs new file mode 100644 index 0000000000..618aa293b1 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/mod.rs @@ -0,0 +1,5 @@ +pub mod google_upload; +pub mod objects; +pub mod recaption; +pub mod worlds; +pub mod google_refresh_token; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/begin_object_image_upload.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/begin_object_image_upload.rs new file mode 100644 index 0000000000..26d55d551d --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/begin_object_image_upload.rs @@ -0,0 +1,147 @@ +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use uuid::Uuid; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects"; + +fn get_url(upload_id: &UploadObjectId) -> String { + format!("{}/{}:upload", BASE_URL, upload_id.0) +} + +pub struct BeginObjectImageUploadArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub upload_mime_type: UploadMimeType, + pub upload_id: &'a UploadObjectId, + pub request_timeout: Option, +} + +pub struct BeginObjectImageUploadResponse { + pub upload_url: String, +} + +/// Marble Image-to-World +/// This request follows object attachment and signals upload. It returns the upload URL. +/// Request #3 (of ~10) +pub async fn begin_object_image_upload(args: BeginObjectImageUploadArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + let url = get_url(args.upload_id); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.post(url) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(CONTENT_LENGTH, 0) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let http_request = request_builder + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200) (ready for Google upload): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + info!("Curl command: {}", response.curl_example); + + Ok(BeginObjectImageUploadResponse { + upload_url: response.upload_url, + }) +} + +#[derive(Deserialize)] +struct RawResponse { + /// eg. "https://storage.googleapis.com/wlt-training-gsc-marble-assets-prod/temp/objects/{...}", + pub upload_url: String, + + /// Just for debugging, the server reports a test cURL command + pub curl_example: String, +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::upload_object_id::UploadObjectId; + use crate::api::requests::objects::begin_object_image_upload::{get_url, BASE_URL}; + + #[test] + fn test_get_url() { + let upload_id = UploadObjectId("foo-bar-baz-bin".to_string()); + let expected = format!("{}/foo-bar-baz-bin:upload", BASE_URL); + assert_eq!(get_url(&upload_id), expected); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/create_run_object.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/create_run_object.rs new file mode 100644 index 0000000000..341cc32129 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/create_run_object.rs @@ -0,0 +1,124 @@ +use crate::api::api_types::run_object::RunObject; +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_HIGHEST; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const URL : &str = "https://api.worldlabs.ai/api/v1/objects"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects"; + +pub struct CreateRunObjectArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub request_timeout: Option, +} + +pub struct CreateRunObjectResponse { + pub id: RunObjectId, + pub run_object: RunObject, +} + +/// Marble Image-to-World +/// This request starts the process: it initializes a new world object inference and returns the object id. +/// Request #1 (of ~10) +pub async fn create_run_object(args: CreateRunObjectArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_HIGHEST) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let new_run_object = RunObject::default(); + + let http_request = request_builder.json(&new_run_object) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let response : RunObject = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let run_id = match &response.id { + Some(id) => RunObjectId(id.to_string()), + None => { + return Err(WorldLabsSpecificApiError::NoRunIdInResponse { response_body }.into()) + } + }; + + Ok(CreateRunObjectResponse { + id: run_id, + run_object: response, + }) +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/create_upload_object.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/create_upload_object.rs new file mode 100644 index 0000000000..ce329a84ef --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/create_upload_object.rs @@ -0,0 +1,155 @@ +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use uuid::Uuid; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const URL : &str = "https://api.worldlabs.ai/api/v1/objects"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects"; + +pub struct CreateUploadObjectArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub upload_mime_type: UploadMimeType, + pub request_timeout: Option, +} + +pub struct CreateUploadObjectResponse { + pub id: UploadObjectId, +} + +/// Marble Image-to-World +/// This request follows object initialization and prepares for image upload. +/// Request #2 (of ~10) +pub async fn create_upload_object(args: CreateUploadObjectArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let request_payload = RawRequest::for_mime_type(args.upload_mime_type); + + let http_request = request_builder.json(&request_payload) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(CreateUploadObjectResponse { + id: UploadObjectId(response.id), + }) +} + +#[derive(Serialize)] +struct RawRequest { + pub display_name: String, + pub mime_type: String, +} + +impl RawRequest { + pub fn for_mime_type(mime_type: UploadMimeType) -> Self { + let uuid = Uuid::new_v4().to_string(); + let extension = mime_type.extension_without_period(); + let display_name = format!("uploaded_image_{uuid}.{extension}"); + Self { + display_name, + mime_type: mime_type.content_type().to_string(), + } + } +} + +#[derive(Deserialize)] +struct RawResponse { + pub id: String, +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::upload_mime_type::UploadMimeType; + use crate::api::requests::objects::create_upload_object::RawRequest; + + #[test] + fn test_request() { + let request = RawRequest::for_mime_type(UploadMimeType::ImageJpeg); + // The display name embeds a random uuid, eg. "uploaded_image_{uuid}.jpeg" + assert!(request.display_name.starts_with("uploaded_image_")); + assert!(request.display_name.ends_with(".jpeg")); + assert_eq!(request.mime_type, "image/jpeg"); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/finalize_object_image_upload.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/finalize_object_image_upload.rs new file mode 100644 index 0000000000..de10d2d511 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/finalize_object_image_upload.rs @@ -0,0 +1,172 @@ +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use uuid::Uuid; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects"; + +fn get_url(upload_id: &UploadObjectId) -> String { + format!("{}/{}:complete", BASE_URL, upload_id.0) +} + +pub struct FinalizeObjectImageUploadArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub upload_id: &'a UploadObjectId, + pub request_timeout: Option, +} + +pub struct FinalizeObjectImageUploadResponse { + pub object_url: String, + pub updated_at: u64, +} + +/// Marble Image-to-World +/// Mark the upload as complete. +/// Request #5 (of ~10) +pub async fn finalize_object_image_upload(args: FinalizeObjectImageUploadArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + let url = get_url(args.upload_id); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.post(url) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(CONTENT_LENGTH, 0) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let http_request = request_builder + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(FinalizeObjectImageUploadResponse { + object_url: response.object_uri, + updated_at: response.updated_at, + }) +} + +#[derive(Deserialize)] +struct RawResponse { + /// eg. "https://cdn.marble.worldlabs.ai/object/foo-bar-baz-bin/asset.jpg" + pub object_uri: String, + + /// Seconds-resolution timestamp. + /// eg. `1765745771` + pub updated_at: u64, +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::upload_object_id::UploadObjectId; + use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, get_url, FinalizeObjectImageUploadArgs, BASE_URL}; + use crate::test_utils::get_test_bearer_token::get_test_bearer_token; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[test] + fn test_get_url() { + let upload_id = UploadObjectId("foo-bar-baz-bin".to_string()); + let expected = format!("{}/foo-bar-baz-bin:complete", BASE_URL); + assert_eq!(get_url(&upload_id), expected); + } + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_requests() { + setup_test_logging(LevelFilter::Debug); + + let cookies = get_typed_test_cookies().unwrap(); + let bearer_token = get_test_bearer_token().unwrap(); + + let upload_id = UploadObjectId("09d86ef8-edcd-42d9-9b95-8efd28be0bee".to_string()); + + let response = finalize_object_image_upload(FinalizeObjectImageUploadArgs { + cookies: &cookies, + bearer_token: &bearer_token, + upload_id: &upload_id, + request_timeout: None, + }).await.unwrap(); + + println!("Object URL: {}", response.object_url); + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/mod.rs new file mode 100644 index 0000000000..3c27307f0d --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/mod.rs @@ -0,0 +1,6 @@ +pub mod create_upload_object; +pub mod begin_object_image_upload; +pub mod create_run_object; +pub mod finalize_object_image_upload; +pub mod update_run_object_with_upload; +pub mod update_run_object_with_world; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/update_run_object_with_upload.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/update_run_object_with_upload.rs new file mode 100644 index 0000000000..4414b7c4ed --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/update_run_object_with_upload.rs @@ -0,0 +1,523 @@ +use crate::api::api_types::image_input_object_id::ImageInputObjectId; +use crate::api::api_types::meta_world_object_id::MetaWorldObjectId; +use crate::api::api_types::pano_object_id::PanoObjectId; +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use indexmap::IndexMap; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects"; + +fn get_url(run_id: &RunObjectId) -> String { + format!("{}/{}", BASE_URL, run_id.0) +} + +pub struct UpdateRunObjectWithUploadArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + + pub payload_args: UpdateRunObjectWithUploadPayloadArgs<'a>, + + pub request_timeout: Option, +} + +pub struct UpdateRunObjectWithUploadPayloadArgs<'a> { + pub run_id: &'a RunObjectId, + pub run_created_at_timestamp: u64, + + // eg. https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg + pub image_upload_url: &'a str, + + pub image_input_id: &'a ImageInputObjectId, + pub pano_id: &'a PanoObjectId, + pub meta_world_id: &'a MetaWorldObjectId, +} + +pub struct UpdateRunObjectWithUploadResponse { + pub image_input_id: ImageInputObjectId, + pub pano_id: PanoObjectId, + pub meta_world_id: MetaWorldObjectId, + pub run_updated_timestamp: u64, +} + +/// Marble Image-to-World +/// This request patches an object. Seems to update it with the attached files. +/// Request #6 (of ~10) +pub async fn update_run_object_with_upload(args: UpdateRunObjectWithUploadArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + let url = get_url(args.payload_args.run_id); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.patch(url) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let payload = RawRequest::from_args(&args.payload_args); + let updated_at = payload.object.metadata.updated_at; + + let http_request = request_builder.json(&payload) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let _response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(UpdateRunObjectWithUploadResponse { + image_input_id: args.payload_args.image_input_id.clone(), + pano_id: args.payload_args.pano_id.clone(), + meta_world_id: args.payload_args.meta_world_id.clone(), + run_updated_timestamp: updated_at, + }) +} + +#[derive(Serialize)] +struct RawRequest { + pub object: ObjectDef, + pub update_mask: Vec, +} + +impl Default for RawRequest { + fn default() -> Self { + let now = Utc::now(); + let now = now.timestamp_millis().unsigned_abs(); + Self { + object: ObjectDef { + metadata: ObjectMetadata { + version: "0.0.1".to_string(), + created_at: now, + updated_at: now, + uses_advanced_editing: false, + draft_mode: false, + nodes: IndexMap::new(), + } + }, + update_mask: vec!["metadata".to_string()] + } + } +} + +impl RawRequest { + pub fn from_args(args: &UpdateRunObjectWithUploadPayloadArgs) -> Self { + let mut request = RawRequest::default(); + request.add_image_input_node({ + let mut node = ImageInputNode::default(); + node.id = args.image_input_id.to_string(); + node.source.image_url = args.image_upload_url.to_string(); + node + }); + request.add_pano_node({ + let mut node = PanoNode::default(); + node.id = args.pano_id.to_string(); + node.parent_id= Some(args.image_input_id.to_string()); + node + }); + request.add_world_node({ + let mut node = WorldNode::default(); + node.id = args.meta_world_id.to_string(); + node.parent_id= Some(args.pano_id.to_string()); + node + }); + request.set_updated_now(); + request + } + + pub fn add_image_input_node(&mut self, mut node: ImageInputNode) { + node.created_at = self.object.metadata.updated_at; + self.object.metadata.nodes.insert(node.id.clone(), NodeValue::ImageInput(node)); + } + + pub fn add_pano_node(&mut self, mut node: PanoNode) { + node.created_at = self.object.metadata.updated_at; + self.object.metadata.nodes.insert(node.id.clone(), NodeValue::Pano(node)); + } + + pub fn add_world_node(&mut self, mut node: WorldNode) { + node.created_at = self.object.metadata.updated_at; + self.object.metadata.nodes.insert(node.id.clone(), NodeValue::World(node)); + } + + pub fn set_updated_now(&mut self) { + let now = Utc::now(); + let now = now.timestamp_millis().unsigned_abs(); + self.set_updated_at(now); + } + + pub fn set_updated_at(&mut self, updated_at: u64) { + self.object.metadata.updated_at = updated_at; + for (_key, node) in self.object.metadata.nodes.iter_mut() { + match node { + NodeValue::ImageInput(value) => value.created_at = updated_at, + NodeValue::Pano(value) => value.created_at = updated_at, + NodeValue::World(value) => value.created_at = updated_at, + } + } + } +} + +#[derive(Serialize)] +struct ObjectDef { + pub metadata: ObjectMetadata, +} + +#[derive(Serialize)] +struct ObjectMetadata { + pub version: String, + + #[serde(rename = "createdAt")] + pub created_at: u64, + + #[serde(rename = "updatedAt")] + pub updated_at: u64, + + #[serde(rename = "usesAdvancedEditing")] + pub uses_advanced_editing: bool, + + #[serde(rename = "draftMode")] + pub draft_mode: bool, + + /// Polymorphic set of ID-to-object mappings. + /// NB: IndexMap maintains insertion order. + pub nodes: IndexMap, +} + +#[derive(Serialize)] +#[serde(untagged)] +//#[serde(tag = "type", content = "value")] +enum NodeValue { + ImageInput(ImageInputNode), + Pano(PanoNode), + World(WorldNode), +} + +#[derive(Serialize, Default)] +struct ImageInputNode { + pub id: String, + pub r#type: TypeInput, + #[serde(rename = "parentId")] + pub parent_id: Option, + pub source: ImageInputNodeSource, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Serialize, Default)] +struct ImageInputNodeSource { + pub r#type: TypeImage, + pub image_url: String, +} + +#[derive(Serialize, Default)] +enum TypeInput { + #[serde(rename = "input")] + #[default] + Input +} + +#[derive(Serialize, Default)] +enum TypeImage { + #[serde(rename = "image")] + #[default] + Image +} + +#[derive(Serialize, Default)] +enum StatusPending { + #[serde(rename = "pending")] + #[default] + Pending +} + +impl ImageInputNode { + pub fn with_id(id: &str) -> Self{ + Self { + id: id.to_string(), + ..Default::default() + } + } +} + +#[derive(Serialize, Default)] +struct PanoNode { + pub id: String, + pub r#type: TypePano, + #[serde(rename = "parentId")] + pub parent_id: Option, + pub source: PanoNodeSource, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Serialize, Default)] +struct PanoNodeSource { + pub r#type: TypeGenerateWorld, + pub world_id: StatusPending, + pub status: StatusPending, +} + +#[derive(Serialize, Default)] +enum TypePano { + #[serde(rename = "pano")] + #[default] + Pano +} + +#[derive(Serialize, Default)] +enum TypeGenerateWorld { + #[serde(rename = "generate_world")] + #[default] + GenerateWorld +} + +impl PanoNode { + pub fn with_id(id: &str) -> Self{ + Self { + id: id.to_string(), + ..Default::default() + } + } +} + +#[derive(Serialize, Default)] +struct WorldNode { + pub id: String, + pub r#type: TypeWorld, + #[serde(rename = "parentId")] + pub parent_id: Option, + pub source: WorldNodeSource, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Serialize, Default)] +struct WorldNodeSource { + pub r#type: TypeGenerateWorld, + pub world_id: StatusPending, + pub posed_cubemaps_url: String, + pub minimap_url: String, + pub minimap_metadata: String, + pub status: StatusPending, +} + +#[derive(Serialize, Default)] +enum TypeWorld { + #[serde(rename = "world")] + #[default] + World +} + +impl WorldNode { + pub fn with_id(id: &str) -> Self{ + Self { + id: id.to_string(), + ..Default::default() + } + } +} + +#[derive(Deserialize)] +struct RawResponse { + pub id: String, +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::image_input_object_id::ImageInputObjectId; + use crate::api::api_types::meta_world_object_id::MetaWorldObjectId; + use crate::api::api_types::pano_object_id::PanoObjectId; + use crate::api::api_types::run_object_id::RunObjectId; + use crate::api::requests::objects::update_run_object_with_upload::{RawRequest, UpdateRunObjectWithUploadPayloadArgs}; + use serde_json::{json, Value}; + + /// Any timestamp from `Utc::now()` should exceed this (2023-11-14 in epoch millis). + const MIN_PLAUSIBLE_EPOCH_MILLIS: u64 = 1_700_000_000_000; + + #[test] + fn request_default() { + let request = RawRequest::default(); + assert_eq!(request.object.metadata.version, "0.0.1"); + assert!(request.object.metadata.created_at > 10000); + assert!(request.object.metadata.updated_at > 10000); + } + + #[test] + fn json() { + let run_id = RunObjectId::from_str("79795b32-e44d-4333-bac1-05b2c9a3ea12"); + let image_input_id = ImageInputObjectId::from_str("bc17252b-811e-49d7-be3e-b7c538df9d30"); + let pano_id = PanoObjectId::from_str("82f6b488-8afe-4311-9022-80ad5789ad92"); + let world_id = MetaWorldObjectId::from_str("f08ec501-601c-47c6-a104-402d1820b8f0"); + + let args = UpdateRunObjectWithUploadPayloadArgs { + run_created_at_timestamp: 1234, + image_upload_url: "https://todo.com", + run_id: &run_id, + image_input_id: &image_input_id, + pano_id: &pano_id, + meta_world_id: &world_id, + }; + + let request = RawRequest::from_args(&args); + + // The `createdAt` / `updatedAt` timestamps come from `Utc::now()` at build time, + // so they're nondeterministic. Sanity-check them, then zero them out before + // comparing the rest of the document. + assert!(request.object.metadata.created_at > MIN_PLAUSIBLE_EPOCH_MILLIS); + assert!(request.object.metadata.updated_at >= request.object.metadata.created_at); + + let mut actual = serde_json::to_value(&request).unwrap(); + zero_out_timestamps(&mut actual); + + let expected = json!({ + "object": { + "metadata": { + "version": "0.0.1", + "createdAt": 0, + "updatedAt": 0, + "usesAdvancedEditing": false, + "draftMode": false, + "nodes": { + "bc17252b-811e-49d7-be3e-b7c538df9d30": { + "id": "bc17252b-811e-49d7-be3e-b7c538df9d30", + "type": "input", + "parentId": null, + "source": { + "type": "image", + "image_url": "https://todo.com" + }, + "createdAt": 0 + }, + "82f6b488-8afe-4311-9022-80ad5789ad92": { + "id": "82f6b488-8afe-4311-9022-80ad5789ad92", + "type": "pano", + "parentId": "bc17252b-811e-49d7-be3e-b7c538df9d30", + "source": { + "type": "generate_world", + "world_id": "pending", + "status": "pending" + }, + "createdAt": 0 + }, + "f08ec501-601c-47c6-a104-402d1820b8f0": { + "id": "f08ec501-601c-47c6-a104-402d1820b8f0", + "type": "world", + "parentId": "82f6b488-8afe-4311-9022-80ad5789ad92", + "source": { + "type": "generate_world", + "world_id": "pending", + "posed_cubemaps_url": "", + "minimap_url": "", + "minimap_metadata": "", + "status": "pending" + }, + "createdAt": 0 + } + } + } + }, + "update_mask": [ + "metadata" + ] + }); + + assert_eq!(actual, expected); + } + + /// Recursively replaces nondeterministic `createdAt` / `updatedAt` values with 0. + fn zero_out_timestamps(value: &mut Value) { + match value { + Value::Object(map) => { + for (key, child) in map.iter_mut() { + if key == "createdAt" || key == "updatedAt" { + *child = json!(0); + } else { + zero_out_timestamps(child); + } + } + }, + Value::Array(items) => { + for item in items.iter_mut() { + zero_out_timestamps(item); + } + }, + _ => {}, + } + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/update_run_object_with_world.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/update_run_object_with_world.rs new file mode 100644 index 0000000000..f04f49ff4f --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/objects/update_run_object_with_world.rs @@ -0,0 +1,540 @@ +use crate::api::api_types::image_input_object_id::ImageInputObjectId; +use crate::api::api_types::pano_object_id::PanoObjectId; +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::api_types::meta_world_object_id::MetaWorldObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use indexmap::IndexMap; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; +use crate::api::api_types::world_id::WorldObjectId; +const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects"; + +fn get_url(run_id: &RunObjectId) -> String { + format!("{}/{}", BASE_URL, run_id.0) +} + +pub struct UpdateRunObjectWithWorldArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + + pub payload_args: UpdateRunObjectWithWorldPayloadArgs<'a>, + + pub request_timeout: Option, +} + +pub struct UpdateRunObjectWithWorldPayloadArgs<'a> { + pub run_id: &'a RunObjectId, + pub run_created_at_timestamp: u64, + pub first_patch_timestamp: u64, + + // eg. https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg + pub image_upload_url: &'a str, + + pub image_input_id: &'a ImageInputObjectId, + pub pano_id: &'a PanoObjectId, + pub meta_world_id: &'a MetaWorldObjectId, + pub world_id: &'a WorldObjectId, + + pub title: &'a str, + pub text_prompt: &'a str, +} + +pub struct UpdateRunObjectWithWorldResponse { + //pub image_input_id: ImageInputObjectId, + //pub pano_id: PanoObjectId, + //pub meta_world_id: MetaWorldObjectId, + //pub world_id: WorldObjectId, +} + +/// Marble Image-to-World +/// This request patches the run object a second time. It updates +/// more fields - prompt, world_id, etc. +/// Request #9 (of ~10) +pub async fn update_run_object_with_world(args: UpdateRunObjectWithWorldArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + let url = get_url(args.payload_args.run_id); + + debug!("Requesting URL: {}", url); + + let mut request_builder = client.patch(url) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let payload = RawRequest::from_args(&args.payload_args); + + let http_request = request_builder.json(&payload) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let _response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(UpdateRunObjectWithWorldResponse { + //image_input_id: args.payload_args.image_input_id.clone(), + //pano_id: args.payload_args.pano_id.clone(), + //meta_world_id: args.payload_args.meta_world_id.clone(), + }) +} + +#[derive(Serialize)] +struct RawRequest { + pub object: ObjectDef, + pub update_mask: Vec, +} + +impl Default for RawRequest { + fn default() -> Self { + let now = Utc::now(); + let now = now.timestamp_millis().unsigned_abs(); + Self { + object: ObjectDef { + metadata: ObjectMetadata { + version: "0.0.1".to_string(), + name: None, + created_at: now, + updated_at: now, + uses_advanced_editing: false, + draft_mode: false, + nodes: IndexMap::new(), + } + }, + update_mask: vec!["metadata".to_string()] + } + } +} + +impl RawRequest { + pub fn from_args(args: &UpdateRunObjectWithWorldPayloadArgs) -> Self { + let mut request = RawRequest::default(); + request.add_image_input_node({ + let mut node = ImageInputNode::default(); + node.id = args.image_input_id.to_string(); + node.source.image_url = args.image_upload_url.to_string(); + node.title = args.title.to_string(); + node.caption = args.text_prompt.to_string(); + node.created_at = args.first_patch_timestamp; + node + }); + request.add_pano_node({ + let mut node = PanoNode::default(); + node.id = args.pano_id.to_string(); + node.parent_id = Some(args.image_input_id.to_string()); + node.source.world_id = Some(args.world_id.to_string()); + node.created_at = args.first_patch_timestamp; + node + }); + request.add_world_node({ + let mut node = WorldNode::default(); + node.id = args.meta_world_id.to_string(); + node.parent_id= Some(args.pano_id.to_string()); + node.source.world_id = Some(args.world_id.to_string()); + node.created_at = args.first_patch_timestamp; + node + }); + let now = Utc::now(); + let now = now.timestamp_millis().unsigned_abs(); + request.object.metadata.updated_at = now; + request.object.metadata.name = Some(args.title.to_string()); + request + } + + pub fn add_image_input_node(&mut self, mut node: ImageInputNode) { + node.created_at = self.object.metadata.updated_at; + self.object.metadata.nodes.insert(node.id.clone(), NodeValue::ImageInput(node)); + } + + pub fn add_pano_node(&mut self, mut node: PanoNode) { + node.created_at = self.object.metadata.updated_at; + self.object.metadata.nodes.insert(node.id.clone(), NodeValue::Pano(node)); + } + + pub fn add_world_node(&mut self, mut node: WorldNode) { + node.created_at = self.object.metadata.updated_at; + self.object.metadata.nodes.insert(node.id.clone(), NodeValue::World(node)); + } +} + +#[derive(Serialize)] +struct ObjectDef { + pub metadata: ObjectMetadata, +} + +#[derive(Serialize)] +struct ObjectMetadata { + pub version: String, + + // NB: Not null in request, just for builder + pub name: Option, + + #[serde(rename = "createdAt")] + pub created_at: u64, + + #[serde(rename = "updatedAt")] + pub updated_at: u64, + + #[serde(rename = "usesAdvancedEditing")] + pub uses_advanced_editing: bool, + + #[serde(rename = "draftMode")] + pub draft_mode: bool, + + /// Polymorphic set of ID-to-object mappings. + /// NB: IndexMap maintains insertion order. + pub nodes: IndexMap, +} + +#[derive(Serialize)] +#[serde(untagged)] +//#[serde(tag = "type", content = "value")] +enum NodeValue { + ImageInput(ImageInputNode), + Pano(PanoNode), + World(WorldNode), +} + +#[derive(Serialize, Default)] +struct ImageInputNode { + pub id: String, + pub r#type: TypeInput, + #[serde(rename = "parentId")] + pub parent_id: Option, + pub source: ImageInputNodeSource, + #[serde(rename = "createdAt")] + pub created_at: u64, + pub title: String, + pub caption: String, +} + +#[derive(Serialize, Default)] +struct ImageInputNodeSource { + pub r#type: TypeImage, + pub image_url: String, +} + +#[derive(Serialize, Default)] +enum TypeInput { + #[serde(rename = "input")] + #[default] + Input +} + +#[derive(Serialize, Default)] +enum TypeImage { + #[serde(rename = "image")] + #[default] + Image +} + +#[derive(Serialize, Default)] +enum StatusPending { + #[serde(rename = "pending")] + #[default] + Pending +} + +impl ImageInputNode { + pub fn with_id(id: &str) -> Self{ + Self { + id: id.to_string(), + ..Default::default() + } + } +} + +#[derive(Serialize, Default)] +struct PanoNode { + pub id: String, + pub r#type: TypePano, + #[serde(rename = "parentId")] + pub parent_id: Option, + pub source: PanoNodeSource, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Serialize, Default)] +struct PanoNodeSource { + pub r#type: TypeGenerateWorld, + // NB: Not null in request, just for builder + pub world_id: Option, + pub status: StatusPending, +} + +#[derive(Serialize, Default)] +enum TypePano { + #[serde(rename = "pano")] + #[default] + Pano +} + +#[derive(Serialize, Default)] +enum TypeGenerateWorld { + #[serde(rename = "generate_world")] + #[default] + GenerateWorld +} + +impl PanoNode { + pub fn with_id(id: &str) -> Self{ + Self { + id: id.to_string(), + ..Default::default() + } + } +} + +#[derive(Serialize, Default)] +struct WorldNode { + pub id: String, + pub r#type: TypeWorld, + #[serde(rename = "parentId")] + pub parent_id: Option, + pub source: WorldNodeSource, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +#[derive(Serialize, Default)] +struct WorldNodeSource { + pub r#type: TypeGenerateWorld, + // NB: Not null in request, just for + pub world_id: Option, + pub posed_cubemaps_url: String, + pub minimap_url: String, + pub minimap_metadata: String, + pub status: StatusPending, +} + +#[derive(Serialize, Default)] +enum TypeWorld { + #[serde(rename = "world")] + #[default] + World +} + +impl WorldNode { + pub fn with_id(id: &str) -> Self{ + Self { + id: id.to_string(), + ..Default::default() + } + } +} + +#[derive(Deserialize)] +struct RawResponse { + pub id: String, +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::image_input_object_id::ImageInputObjectId; + use crate::api::api_types::pano_object_id::PanoObjectId; + use crate::api::api_types::run_object_id::RunObjectId; + use crate::api::api_types::meta_world_object_id::MetaWorldObjectId; + use crate::api::api_types::world_id::WorldObjectId; + use crate::api::requests::objects::update_run_object_with_world::{RawRequest, UpdateRunObjectWithWorldPayloadArgs}; + use serde_json::{json, Value}; + + /// Any timestamp from `Utc::now()` should exceed this (2023-11-14 in epoch millis). + const MIN_PLAUSIBLE_EPOCH_MILLIS: u64 = 1_700_000_000_000; + + #[test] + fn request_default() { + let request = RawRequest::default(); + assert_eq!(request.object.metadata.version, "0.0.1"); + assert!(request.object.metadata.created_at > 10000); + assert!(request.object.metadata.updated_at > 10000); + } + + #[test] + fn json() { + let run_id = RunObjectId::from_str("79795b32-e44d-4333-bac1-05b2c9a3ea12"); + let image_input_id = ImageInputObjectId::from_str("bc17252b-811e-49d7-be3e-b7c538df9d30"); + let pano_id = PanoObjectId::from_str("82f6b488-8afe-4311-9022-80ad5789ad92"); + let meta_world_id = MetaWorldObjectId::from_str("f08ec501-601c-47c6-a104-402d1820b8f0"); + let world_id = WorldObjectId::from_str("d60cfa21-5506-43de-9a9c-0707fc17a5ec"); + + let title = "Cows Grazing in Indoor Pasture"; + let caption = "The scene is a surreal indoor agricultural environment, depicted with a realistic and slightly eerie tone, as rows of bright lights illuminate a vast, artificial pasture. The overall mood is quiet and contemplative, emphasizing the unusual juxtaposition of natural elements within a manufactured setting. Large herds of cattle are distributed across the lush, green grassy floor, peacefully grazing on the abundant vegetation. The entire space is encased within a massive, warehouse-like structure, supported by countless concrete columns that extend from the floor to the ceiling in an orderly grid. Above the columns, a network of metal beams forms the infrastructure of the roof, from which an extensive array of industrial lights is suspended. These lights are arranged in long, parallel lines, creating a striking visual perspective that converges towards a distant vanishing point at the center of the structure. The dense, vibrant green grass covers every inch of the ground, appearing soft and uniform, without any discernible paths or bare patches. Each cow is distinct, varying in color from black to brown and white, and each is engaged in individual grazing or resting. The concrete columns maintain a consistent light gray color and texture, creating a rhythmic architectural presence throughout the vast indoor landscape. To the right, another cluster of columns continues the architectural pattern, while the grassy expanse remains uninterrupted, extending toward the edges of the structure. "; + + let args = UpdateRunObjectWithWorldPayloadArgs { + run_created_at_timestamp: 1765756772176, + first_patch_timestamp: 1765756773647, + image_upload_url: "https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg", + run_id: &run_id, + image_input_id: &image_input_id, + pano_id: &pano_id, + meta_world_id: &meta_world_id, + world_id: &world_id, + title, + text_prompt: caption, + }; + + let request = RawRequest::from_args(&args); + + // The `createdAt` / `updatedAt` timestamps come from `Utc::now()` at build time, + // so they're nondeterministic. Sanity-check them, then zero them out before + // comparing the rest of the document. + assert!(request.object.metadata.created_at > MIN_PLAUSIBLE_EPOCH_MILLIS); + assert!(request.object.metadata.updated_at >= request.object.metadata.created_at); + + let mut actual = serde_json::to_value(&request).unwrap(); + zero_out_timestamps(&mut actual); + + let expected = json!({ + "object": { + "metadata": { + "version": "0.0.1", + "name": title, + "createdAt": 0, + "updatedAt": 0, + "usesAdvancedEditing": false, + "draftMode": false, + "nodes": { + "bc17252b-811e-49d7-be3e-b7c538df9d30": { + "id": "bc17252b-811e-49d7-be3e-b7c538df9d30", + "type": "input", + "parentId": null, + "source": { + "type": "image", + "image_url": "https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg" + }, + "createdAt": 0, + "title": title, + "caption": caption + }, + "82f6b488-8afe-4311-9022-80ad5789ad92": { + "id": "82f6b488-8afe-4311-9022-80ad5789ad92", + "type": "pano", + "parentId": "bc17252b-811e-49d7-be3e-b7c538df9d30", + "source": { + "type": "generate_world", + "world_id": "d60cfa21-5506-43de-9a9c-0707fc17a5ec", + "status": "pending" + }, + "createdAt": 0 + }, + "f08ec501-601c-47c6-a104-402d1820b8f0": { + "id": "f08ec501-601c-47c6-a104-402d1820b8f0", + "type": "world", + "parentId": "82f6b488-8afe-4311-9022-80ad5789ad92", + "source": { + "type": "generate_world", + "world_id": "d60cfa21-5506-43de-9a9c-0707fc17a5ec", + "posed_cubemaps_url": "", + "minimap_url": "", + "minimap_metadata": "", + "status": "pending" + }, + "createdAt": 0 + } + } + } + }, + "update_mask": [ + "metadata" + ] + }); + + assert_eq!(actual, expected); + } + + /// Recursively replaces nondeterministic `createdAt` / `updatedAt` values with 0. + fn zero_out_timestamps(value: &mut Value) { + match value { + Value::Object(map) => { + for (key, child) in map.iter_mut() { + if key == "createdAt" || key == "updatedAt" { + *child = json!(0); + } else { + zero_out_timestamps(child); + } + } + }, + Value::Array(items) => { + for item in items.iter_mut() { + zero_out_timestamps(item); + } + }, + _ => {}, + } + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/recaption/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/recaption/mod.rs new file mode 100644 index 0000000000..58671219b6 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/recaption/mod.rs @@ -0,0 +1 @@ +pub mod recaption_image; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/recaption/recaption_image.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/recaption/recaption_image.rs new file mode 100644 index 0000000000..29c9732776 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/recaption/recaption_image.rs @@ -0,0 +1,174 @@ +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const URL : &str = "https://api.worldlabs.ai/api/v1/recaption2"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/recaption2"; + +pub struct RecaptionImageArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub upload_id: &'a UploadObjectId, + pub upload_mime_type: UploadMimeType, + pub run_id: &'a RunObjectId, + pub request_timeout: Option, +} + +pub struct RecaptionImageResponse { + pub title: String, + pub caption: String, +} + +/// Marble Image-to-World +/// This request generates captions and titles for images. It's a VLM. +/// The prompts are then used downstream to create the scenes. +/// Request #6 (of ~10) +pub async fn recaption_image(args: RecaptionImageArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let request_payload = RawRequest::for_image_and_run(args.upload_id, args.upload_mime_type, args.run_id); + + let http_request = request_builder.json(&request_payload) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(RecaptionImageResponse { + title: response.title, + caption: response.caption, + }) +} + +#[derive(Serialize)] +struct RawRequest { + pub world_prompt: WorldPrompt, + pub user_caption: String, + pub context: Context, +} + +#[derive(Serialize)] +struct WorldPrompt { + pub r#type: String, + pub image_prompt: ImagePrompt, + pub text_prompt: String, +} + +#[derive(Serialize)] +struct ImagePrompt { + pub uri: String, +} + +#[derive(Serialize)] +struct Context { + pub run_id: String, +} + +impl RawRequest { + pub fn for_image_and_run(image_upload_id: &UploadObjectId, image_mime_type: UploadMimeType, run_id: &RunObjectId) -> Self { + let image_url = upload_id_to_image_url(image_upload_id, image_mime_type); + Self { + world_prompt: WorldPrompt { + r#type: "image".to_string(), + image_prompt: ImagePrompt { + uri: image_url, + }, + text_prompt: "".to_string(), // NB: empty + }, + user_caption: "".to_string(), // NB: empty + context: Context { + run_id: run_id.0.clone(), + } + } + } +} + +#[derive(Deserialize)] +struct RawResponse { + pub caption: String, + pub title: String, +} + +#[cfg(test)] +mod tests { +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/create_world.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/create_world.rs new file mode 100644 index 0000000000..5ebd570227 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/create_world.rs @@ -0,0 +1,186 @@ +use crate::api::api_types::meta_world_object_id::MetaWorldObjectId; +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::api_types::world_id::WorldObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::filter_world_labs_http_error::filter_world_labs_http_error; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const URL : &str = "https://api.worldlabs.ai/api/v1/worlds"; + +// Note: WorldLabs is phasing out the old URL scheme: +//const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/worlds"; + +pub struct CreateWorldArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + + // eg. https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg + pub image_upload_url: &'a str, + pub text_prompt: &'a str, + + pub request_timeout: Option, +} + +pub struct CreateWorldResponse { + pub world_id: WorldObjectId, +} + +/// Marble Image-to-World +/// This request starts world generation +/// Request #7 (of ~10) +pub async fn create_world(args: CreateWorldArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + debug!("Requesting URL: {}", URL); + + let mut request_builder = client.post(URL) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let mut payload = RawRequest::default(); + payload.generation_input.prompt.image_prompt.uri = Some(args.image_upload_url.to_string()); + payload.generation_input.prompt.text_prompt = Some(args.text_prompt.to_string()); + + let http_request = request_builder.json(&payload) + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + } + + filter_world_labs_http_error(status, Some(&response_body))?; + + debug!("Response body (200): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + Ok(CreateWorldResponse { + world_id: WorldObjectId(response.id), + }) +} + +#[derive(Serialize)] +struct RawRequest { + pub permission: Permission, + pub generation_input: GenerationInput, +} + +#[derive(Serialize)] +struct Permission { + pub public: bool, +} + +#[derive(Serialize)] +struct GenerationInput { + pub model: String, + pub prompt: Prompt, +} + +#[derive(Serialize)] +struct Prompt { + pub r#type: String, + pub image_prompt: ImagePrompt, + + // NB: Not null in request, just for building + pub text_prompt: Option, +} + +#[derive(Serialize)] +struct ImagePrompt { + // NB: Not null in request, just for building + pub uri: Option, +} + +impl Default for RawRequest { + fn default() -> Self { + Self { + permission: Permission { + public: true, + }, + generation_input: GenerationInput { + model: "Marble 0.1-plus".to_string(), + prompt: Prompt { + r#type: "image".to_string(), + image_prompt: ImagePrompt { + uri: None, + }, + text_prompt: None, + }, + }, + } + } +} + + +#[derive(Deserialize)] +struct RawResponse { + pub id: String, +} + +#[cfg(test)] +mod tests { +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/mod.rs new file mode 100644 index 0000000000..58522dea54 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/mod.rs @@ -0,0 +1,2 @@ +pub mod create_world; +pub mod poll_world_status; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/poll_world_status.rs b/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/poll_world_status.rs new file mode 100644 index 0000000000..55b7c09c9d --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/requests/worlds/poll_world_status.rs @@ -0,0 +1,212 @@ +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::api_types::world_id::WorldObjectId; +use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use chrono::Utc; +use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC}; +use http_headers::values::accept::ACCEPT_ALL; +use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE; +use http_headers::values::connection::CONNECTION_KEEP_ALIVE; +use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON; +use http_headers::values::pragma::PRAGMA_NO_CACHE; +use http_headers::values::priority::PRIORITY_4; +use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE}; +use http_headers::values::te::TE_TRAILERS; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use uuid::Uuid; +use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE}; +use wreq::Client; +use wreq_util::Emulation; + +const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/worlds"; + +// Note: WorldLabs is phasing out the old URL scheme: +// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/worlds"; + +fn get_url(world_id: &WorldObjectId) -> String { + format!("{}/{}", BASE_URL, world_id.0) +} + +pub struct PollWorldStatusArgs<'a> { + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub world_id: &'a WorldObjectId, + pub request_timeout: Option, +} + +pub struct PollWorldStatusResponse { + pub is_complete: bool, + pub spz_splat_url: Option, +} + +/// Marble Image-to-World +/// Poll the world generation status +/// Request #10 (of ~10) +pub async fn poll_world_status(args: PollWorldStatusArgs<'_>) -> Result { + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| WorldLabsClientError::WreqClientError(err))?; + + let url = get_url(args.world_id); + + info!("Polling World: {}", url); + + let mut request_builder = client.get(url) + .header(ACCEPT, ACCEPT_ALL) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") + .header(ACCEPT_ENCODING, "gzip, deflate, br, zstd") + .header(REFERER, REFERER_VALUE) + .header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string()) + .header(ORIGIN, ORIGIN_VALUE) + .header(SEC_GPC, "1") + .header(CONNECTION, CONNECTION_KEEP_ALIVE) + .header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY) + .header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS) + .header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE) + .header(PRIORITY, PRIORITY_4) + .header(PRAGMA, PRAGMA_NO_CACHE) + .header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE) + .header(CONTENT_LENGTH, 0) + .header(TE, TE_TRAILERS); + + if let Some(timeout) = args.request_timeout { + request_builder = request_builder.timeout(timeout); + } + + let http_request = request_builder + .build() + .map_err(|err| { + error!("Error building request: {:?}", err); + WorldLabsClientError::WreqClientError(err) + })?; + + let response = client.execute(http_request) + .await + .map_err(|err| { + error!("Error during request execution: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + let status = response.status(); + + let response_body = response.text() + .await + .map_err(|err| { + error!("Error reading response body: {:?}", err); + WorldLabsGenericApiError::WreqError(err) + })?; + + // TODO: Handle errors (Cloudflare, Grok, etc.) + if !status.is_success() { + error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body); + //return Err(classify_general_http_status_code_and_body(status, response_body)); + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into()) + } + + debug!("Response body (200): {}", response_body); + + let response : RawResponse = serde_json::from_str(&response_body) + .map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + debug!("World status: {}", response.status); + + let maybe_spz_url = response.generation_output + .map(|out| out.spz_urls.full_res); + + Ok(PollWorldStatusResponse { + is_complete: maybe_spz_url.is_some(), + spz_splat_url: maybe_spz_url, + }) +} + +#[derive(Deserialize)] +struct RawResponse { + /// eg. "INITIALIZING", "SUCCEEDED", etc. + pub status: String, + + pub generation_output: Option, +} + +#[derive(Deserialize)] +struct GenerationOutput { + pub spz_urls: SpzUrls, + +} + +#[derive(Deserialize)] +struct SpzUrls { + // eg. https://cdn.marble.worldlabs.ai/d60cfa21-5506-43de-9a9c-0707fc17a5ec/1002dd35-fcad-4eeb-ba15-0d113c1778d8_ceramic.spz + pub full_res: String, +} + +#[cfg(test)] +mod tests { + use crate::api::api_types::world_id::WorldObjectId; + use crate::api::requests::worlds::poll_world_status::{get_url, poll_world_status, PollWorldStatusArgs, BASE_URL}; + use crate::test_utils::get_test_bearer_token::get_test_bearer_token; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use log::LevelFilter; + + #[test] + fn test_get_url() { + let world_id = WorldObjectId("d60cfa21-5506-43de-9a9c-0707fc17a5ec".to_string()); + let expected = format!("{}/d60cfa21-5506-43de-9a9c-0707fc17a5ec", BASE_URL); + assert_eq!(get_url(&world_id), expected); + } + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_requests() { + setup_test_logging(LevelFilter::Debug); + + let cookies = get_typed_test_cookies().unwrap(); + let bearer_token = get_test_bearer_token().unwrap(); + + let world_id = WorldObjectId("403c379b-b7d2-4449-8f1e-ebdb56142604".to_string()); + + let response = poll_world_status(PollWorldStatusArgs { + cookies: &cookies, + bearer_token: &bearer_token, + world_id: &world_id, + request_timeout: None, + }).await.unwrap(); + + println!("Is complete: {}", response.is_complete); + println!("Spz Url: {}", response.spz_splat_url.unwrap_or_else(|| "incomplete".to_string())); + + assert_eq!(1, 2); + } + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_request_2() { + setup_test_logging(LevelFilter::Debug); + + let cookies = get_typed_test_cookies().unwrap(); + let bearer_token = get_test_bearer_token().unwrap(); + + let world_id = WorldObjectId("e739b11e-6039-49c7-b73d-b79e1b28e295".to_string()); + + let response = poll_world_status(PollWorldStatusArgs { + cookies: &cookies, + bearer_token: &bearer_token, + world_id: &world_id, + request_timeout: None, + }).await.unwrap(); + + println!("Is complete: {}", response.is_complete); + println!("Spz Url: {}", response.spz_splat_url.unwrap_or_else(|| "incomplete".to_string())); + + assert_eq!(1, 2); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/utils/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/api/utils/mod.rs new file mode 100644 index 0000000000..ff1db7c174 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/utils/mod.rs @@ -0,0 +1 @@ +pub mod upload_id_to_image_url; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/api/utils/upload_id_to_image_url.rs b/crates/api_clients/worldlabs_consumer_client/src/api/utils/upload_id_to_image_url.rs new file mode 100644 index 0000000000..3dc266bb5f --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/api/utils/upload_id_to_image_url.rs @@ -0,0 +1,38 @@ +use crate::api::api_types::upload_object_id::UploadObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; + +pub fn upload_id_to_image_url(upload_id: &UploadObjectId, upload_mime_type: UploadMimeType) -> String { + let extension_without_period = match upload_mime_type { + UploadMimeType::ImageJpeg => "jpg", + }; + let upload_id_str = upload_id.as_str(); + format!("https://cdn.marble.worldlabs.ai/object/{upload_id_str}/asset.{extension_without_period}") +} + +#[cfg(test)] +mod tests { + use super::upload_id_to_image_url; + use crate::api::api_types::upload_object_id::UploadObjectId; + use crate::api::api_types::upload_mime_type::UploadMimeType; + + // llm tests... sigh + #[test] + fn builds_jpeg_url_with_jpg_extension() { + let id = UploadObjectId("abc123".to_string()); + let url = upload_id_to_image_url(&id, UploadMimeType::ImageJpeg); + assert_eq!( + url, + "https://cdn.marble.worldlabs.ai/object/abc123/asset.jpg" + ); + } + + // llm tests... sigh + #[test] + fn preserves_exact_upload_id_in_url() { + let raw_id = "550e8400-e29b-41d4-a716-446655440000"; + let id = UploadObjectId(raw_id.to_string()); + let url = upload_id_to_image_url(&id, UploadMimeType::ImageJpeg); + assert!(url.contains(raw_id)); + assert!(url.ends_with("/asset.jpg")); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/client/browser_user_agents.rs b/crates/api_clients/worldlabs_consumer_client/src/client/browser_user_agents.rs new file mode 100644 index 0000000000..94f3923b48 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/client/browser_user_agents.rs @@ -0,0 +1,10 @@ + +/// Firefox 139 (2025) on macOS, which matches wreq's Emulation::Firefox139 +pub const FIREFOX_139_MAC_USER_AGENT : &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0"; + +pub const FIREFOX_143_MAC_USER_AGENT : &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0"; + +pub const FIREFOX_144_MAC_USER_AGENT_UNSUPPORTED_TLS_FINGERPRINTING : &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0"; diff --git a/crates/api_clients/worldlabs_consumer_client/src/client/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/client/mod.rs new file mode 100644 index 0000000000..82c176fa31 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/client/mod.rs @@ -0,0 +1 @@ +pub mod browser_user_agents; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/credentials/jwt_claims.rs b/crates/api_clients/worldlabs_consumer_client/src/credentials/jwt_claims.rs new file mode 100644 index 0000000000..aa15ba35c2 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/credentials/jwt_claims.rs @@ -0,0 +1,101 @@ +use base64::Engine; +use chrono::{DateTime, Utc}; +use jwt_light::common_claims::CommonClaims; +use jwt_light::error::JwtError; +use jwt_light::parse_jwt_claims_trait::ParseJwtClaims; +use serde_json::{Map, Value}; + + +#[derive(Clone, Debug)] +pub struct JwtClaims { + /// From the "iat" field + pub created: DateTime, + + /// From the "exp" field + pub expiration: DateTime, + + /// From the "user_id" field + pub user_id: Option, + + /// From the "email" field + pub email: Option, +} + +impl ParseJwtClaims for JwtClaims { + fn extract_claims(common_claims: CommonClaims, extra_claims: Map) -> Result { + let user_id = extra_claims.get("user_id") + .map(|val| val.as_str()) + .flatten() + .map(|val| val.to_string()); + + let mut email = extra_claims.get("email") + .map(|val| val.as_str()) + .flatten() + .map(|val| val.to_string()); + + if email.is_none() { + // Parse out email from this field: + // "firebase": { + // "identities": { + // "email": [ + // "sabalid234@crsay.com" + // ] + // }, + // }, + email = extra_claims.get("firebase") + .and_then(|val| val.as_object()) + .and_then(|fb| fb.get("identities")) + .and_then(|val| val.as_object()) + .and_then(|id| id.get("email")) + .and_then(|emails| emails.as_array()) + .and_then(|emails| emails.get(0)) + .and_then(|email| email.as_str()) + .map(|email| email.to_string()); + } + + Ok(Self { + created: common_claims.created, + expiration: common_claims.expiration, + user_id, + email, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::credentials::jwt_claims::JwtClaims; + use jwt_light::parse_jwt_claims_trait::ParseJwtClaims; + + #[test] + fn test_token() { + /* + { + "iss": "https://securetoken.google.com/wlt-training-gsc", + "aud": "wlt-training-gsc", + "auth_time": 1766002923, + "user_id": "CArJJ2PQFFd8l2Rf8uptZhl86d33", + "sub": "CArJJ2PQFFd8l2Rf8uptZhl86d33", + "iat": 1766099113, + "exp": 1766102713, + "email": "sabalid234@crsay.com", + "email_verified": true, + "firebase": { + "identities": { + "email": [ + "sabalid234@crsay.com" + ] + }, + "sign_in_provider": "password" + } + } + */ + let jwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjM4MTFiMDdmMjhiODQxZjRiNDllNDgyNTg1ZmQ2NmQ1NWUzOGRiNWQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2x0LXRyYWluaW5nLWdzYyIsImF1ZCI6IndsdC10cmFpbmluZy1nc2MiLCJhdXRoX3RpbWUiOjE3NjYwMDI5MjMsInVzZXJfaWQiOiJDQXJKSjJQUUZGZDhsMlJmOHVwdFpobDg2ZDMzIiwic3ViIjoiQ0FySkoyUFFGRmQ4bDJSZjh1cHRaaGw4NmQzMyIsImlhdCI6MTc2NjA5OTExMywiZXhwIjoxNzY2MTAyNzEzLCJlbWFpbCI6InNhYmFsaWQyMzRAY3JzYXkuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsic2FiYWxpZDIzNEBjcnNheS5jb20iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9fQ.byDW2QiRfajxDgQMD89YNdlRxO5rsHves3nwK_Ry5ZLFThARbdU9-wTgbCz1mzz9P3amUkM_XM9E4rEAnj2u-kCv5iW_FIfRDeJ0Z8FlwqxCt6ekvJgwYE80BUIedEvTiNpP_AilThz1ILqAu3gVQ2GXUcolquvS9i12iGTl-thJH6gnZgv3UdP2Vrr5ZP9VhKgMi5gQER5umFQ4Ud64kD9P9SLttRI2RJdolavJengQBAfec8hOEk6uikw20rEBeT_xzl8TpLNG6i4SIGYqJQHfRALGNniYoU9RlIoFR9VGeyk_6BFFGRd8I_EgL-fxnfmpV8w-riXHlOUg6uNRgw"; + let claims = JwtClaims::parse_claims(jwt).unwrap(); + + assert_eq!(claims.user_id, Some("CArJJ2PQFFd8l2Rf8uptZhl86d33".to_string())); + assert_eq!(claims.email, Some("sabalid234@crsay.com".to_string())); + assert_eq!(claims.created.timestamp(), 1766099113); + assert_eq!(claims.expiration.timestamp(), 1766102713); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/credentials/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/credentials/mod.rs new file mode 100644 index 0000000000..f752f37ea7 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/credentials/mod.rs @@ -0,0 +1,4 @@ +pub mod world_labs_bearer_token; +pub mod world_labs_cookies; +pub mod worldlabs_refresh_token; +pub mod jwt_claims; diff --git a/crates/api_clients/worldlabs_consumer_client/src/credentials/world_labs_bearer_token.rs b/crates/api_clients/worldlabs_consumer_client/src/credentials/world_labs_bearer_token.rs new file mode 100644 index 0000000000..779b3968f0 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/credentials/world_labs_bearer_token.rs @@ -0,0 +1,69 @@ +use crate::credentials::jwt_claims::JwtClaims; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use jwt_light::parse_jwt_claims_trait::ParseJwtClaims; +use log::error; + +/* + +This is issued by Google, even for email+password login: + + https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={ENTROPY} + +Value: + +{ + "kind": "identitytoolkit#VerifyPasswordResponse", + "localId": "{{WorldLabsUserId}}", + "email": "{{email@whatever.com}}", + "displayName": "", + "idToken": "{{BEARER_TOKEN}}", + "registered": true, + "refreshToken": "{{REFRESH_TOKEN}}", + "expiresIn": "3600" +} + +*/ + +#[derive(Clone)] +pub struct WorldLabsBearerToken { + /// The bearer token + /// Without "Authorization:" header name and without "Bearer(space)" prefix. + bearer_token: String, +} + +impl WorldLabsBearerToken { + pub fn new(mut bearer_token: String) -> Self { + if bearer_token.starts_with("Bearer") { + bearer_token = bearer_token + .trim_start_matches("Bearer") + .trim() + .to_string(); + } + Self { bearer_token } + } + + pub fn as_raw_str(&self) -> &str { + &self.bearer_token + } + + pub fn as_raw_bytes(&self) -> &[u8] { + self.bearer_token.as_bytes() + } + + pub fn to_raw_string(&self) -> String { + self.bearer_token.clone() + } + + pub fn to_bearer_token_header_string(&self) -> String { + format!("Bearer {}", self.bearer_token) + } + + pub fn parse_jwt_claims(&self) -> Result { + JwtClaims::parse_claims(&self.bearer_token) + .map_err(|err| { + error!("Failed to parse bearer token into JWT claims: {}", err); + WorldLabsError::Client(WorldLabsClientError::FailedToParseJwtClaims(err)) + }) + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/credentials/world_labs_cookies.rs b/crates/api_clients/worldlabs_consumer_client/src/credentials/world_labs_cookies.rs new file mode 100644 index 0000000000..f953c47dde --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/credentials/world_labs_cookies.rs @@ -0,0 +1,24 @@ + +#[derive(Clone)] +pub struct WorldLabsCookies { + /// The entire cookies header (for now) + cookies: String, +} + +impl WorldLabsCookies { + pub fn new(cookies: String) -> Self { + Self { cookies } + } + + pub fn as_str(&self) -> &str { + &self.cookies + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookies.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookies.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/credentials/worldlabs_refresh_token.rs b/crates/api_clients/worldlabs_consumer_client/src/credentials/worldlabs_refresh_token.rs new file mode 100644 index 0000000000..5e305ec425 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/credentials/worldlabs_refresh_token.rs @@ -0,0 +1,25 @@ + +#[derive(Clone)] +pub struct WorldLabsRefreshToken { + /// The refresh token + /// Used to get a new bearer token + refresh_token: String, +} + +impl WorldLabsRefreshToken { + pub fn new(refresh_token: String) -> Self { + Self { refresh_token } + } + + pub fn as_raw_str(&self) -> &str { + &self.refresh_token + } + + pub fn as_raw_bytes(&self) -> &[u8] { + self.refresh_token.as_bytes() + } + + pub fn to_raw_string(&self) -> String { + self.refresh_token.clone() + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/error/filter_world_labs_http_error.rs b/crates/api_clients/worldlabs_consumer_client/src/error/filter_world_labs_http_error.rs new file mode 100644 index 0000000000..46cc9547cc --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/error/filter_world_labs_http_error.rs @@ -0,0 +1,32 @@ +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors; +use wreq::StatusCode; + +/// Detect error HTTP responses and coerce the response as a Rust Error. +pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), WorldLabsError> { + if status_code.is_success() { + return Ok(()); + } + + /// Early and easy to detect errors. + match status_code { + StatusCode::PAYMENT_REQUIRED => { + // {"detail":"Insufficient credits for product 'Marble 0.1-plus'. Please add credits to your account."} + return Err(WorldLabsSpecificApiError::InsufficientCredits.into()); + }, + _ => {}, // Fall Through. + } + + if let Some(body) = maybe_body { + filter_cloudflare_errors(status_code.as_u16(), body)?; + + return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: body.to_string(), + }.into()); + } + + Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatus(status_code).into()) +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/error/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/error/mod.rs new file mode 100644 index 0000000000..015cdc8108 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod world_labs_client_error; +pub mod world_labs_error; +pub mod world_labs_generic_api_error; +pub mod world_labs_specific_api_error; +pub (crate) mod filter_world_labs_http_error; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_client_error.rs b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_client_error.rs new file mode 100644 index 0000000000..abcfb7f7af --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_client_error.rs @@ -0,0 +1,158 @@ +use base64::DecodeError; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum WorldLabsClientError { + + /// An error was encountered in building the Wreq client + WreqClientError(wreq::Error), + + /// Failed to parse JWT claims. + FailedToParseJwtClaims(jwt_light::error::JwtError), + + /// Error serializing a message to send the websocket + WebsocketRequestSerializationError(serde_json::Error), + + /// Error locking the websocket for sending/receiving + WebsocketLockError, + + /// Error reading from a websocket. + WebsocketReadError(wreq::Error), + + /// Error sending to an open websocket. + WebsocketSendError(wreq::Error), + + /// Can't open a local file for uploading. + CannotOpenLocalFileForUpload(std::io::Error), + + /// Can't read a local file for uploading. + CannotReadLocalFileForUpload(std::io::Error), + + /// Couldn't write to the file + CannotOpenLocalFileForWriting(std::io::Error), + + /// Couldn't write to the file + CannotWriteLocalFile(std::io::Error), + + /// File for upload has an invalid path. + FileForUploadHasInvalidPath, + + /// Error parsing HTML + HtmlParsingError, + + /// Error decoding verification token + FailedToDecodeVerificationToken(DecodeError), + + /// Verification token bytes are invalid + InvalidVerificationTokenBytes, + + /// Our script logic is out of date + ScriptLogicOutOfDate, + + /// Our script logic is out of date (SVG) + ScriptSvgLogicOutOfDate, + + /// Our script logic is out of date (script 1) + Script1LogicOutOfDate, + + /// Our script logic is out of date (script 2) + Script2LogicOutOfDate, + + /// Our script logic is out of date (xsid script) + ScriptXsidLogicOutOfDate, + + /// Signature algorithm isn't working with inputs + BadSignatureInputs, + + /// Something is broken with timeout math + TimeoutMathBroken, + + /// Can't make request because cookies aren't present + NoCookiesPresent, + + /// Unknown error generating video + ErrorGeneratingVideo, + +// /// An error reading the file for upload. +// FileForUploadReadError(std::io::Error), +// +// /// The file path provided for upload is invalid. +// FileForUploadHasInvalidPath, +// +// /// Something is wrong with the JWT bearer token. +// /// This error originates on our end as we try to parse the JWT. +// LocalJwtClaimsParseError(String), +// +// /// There was an error constructing the form-multipart request. +// MultipartFormError(wreq::Error), +// +// /// We haven't received a bearer token yet +// /// This is our own internal application state error, not something Sora returns. +// /// We know our client can't make the request, so we preemptively fail it. +// NoBearerTokenForRequest, +// +// /// A sentinel token is not present in the client, which is required for some requests. +// NoSentinelTokenForRequest, +// +// /// Issue with using the SoraCredentialBuilder. +// SoraCredentialBuilderError(&'static str), +// +// /// Error parsing a request URL. +// UrlParseError(url::ParseError), +// +// /// Error serializing the sentinel store token to JSON (typically for persistent storage). +// CouldNotSerializeSentinelTokenStore(serde_json::Error), +// +// /// Error deserializing the sentinel token from JSON (typically for persistent storage). +// CouldNotDeserializeSentinelTokenStore { +// error: serde_json::Error, +// raw_json: String +// }, +} + +impl Error for WorldLabsClientError {} + +impl Display for WorldLabsClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), + Self::FailedToParseJwtClaims(err) => write!(f, "Failed to parse JWT claims: {}", err), + Self::WebsocketRequestSerializationError(err) => write!(f, "Websocket request serialization error: {}", err), + Self::WebsocketLockError => write!(f, "Websocket lock error"), + Self::WebsocketReadError(err) => write!(f, "Websocket read error: {}", err), + Self::WebsocketSendError(err) => write!(f, "Websocket send error: {}", err), + Self::CannotOpenLocalFileForUpload(err) => write!(f, "Cannot open local file for upload: {}", err), + Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err), + Self::CannotOpenLocalFileForWriting(err) => write!(f, "Cannot open local file for writing: {}", err), + Self::CannotWriteLocalFile(err) => write!(f, "Cannot write local file: {}", err), + Self::FileForUploadHasInvalidPath => write!(f, "File for upload has invalid path"), + Self::HtmlParsingError => write!(f, "Html parsing error"), + Self::FailedToDecodeVerificationToken(err) => write!(f, "Failed to decode verification token: {}", err), + Self::InvalidVerificationTokenBytes => write!(f, "Invalid verification token bytes"), + Self::ScriptLogicOutOfDate => write!(f, "Script logic out of date"), + Self::ScriptSvgLogicOutOfDate => write!(f, "Script logic out of date (SVG parsing)"), + Self::Script1LogicOutOfDate => write!(f, "Script logic out of date (script 1)"), + Self::Script2LogicOutOfDate => write!(f, "Script logic out of date (script 2)"), + Self::ScriptXsidLogicOutOfDate => write!(f, "Script logic out of date (xsid script)"), + Self::BadSignatureInputs => write!(f, "Bad signature inputs"), + Self::TimeoutMathBroken => write!(f, "Timeout math is broken"), + Self::NoCookiesPresent => write!(f, "No cookies present"), + Self::ErrorGeneratingVideo => write!(f, "Error generating video"), + + //Self::FileForUploadReadError(err) => write!(f, "Error reading file for upload: {}", err), + //Self::FileForUploadHasInvalidPath => write!(f, "The file path provided for upload is invalid."), + //Self::LocalJwtClaimsParseError(msg) => write!(f, "Local JWT claims parse error: {}", msg), + //Self::MultipartFormError(err) => write!(f, "Multipart form error: {}", err), + //Self::NoBearerTokenForRequest => write!(f, "No bearer token available. The client needs a bearer token to make the request."), + //Self::NoSentinelTokenForRequest => write!(f, "No sentinel token available. The client needs a sentinel token to make the request."), + //Self::SoraCredentialBuilderError(msg) => write!(f, "Sora Credential Builder error: {}", msg), + //Self::UrlParseError(err) => write!(f, "URL parse error: {}", err), + //Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err), + //Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err), + //Self::CouldNotDeserializeSentinelTokenStore { error, raw_json} => { + // write!(f, "Could not deserialize sentinel token store from JSON: {:?}, raw JSON: {}", error, raw_json) + //}, + } + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_error.rs b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_error.rs new file mode 100644 index 0000000000..813240b08f --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_error.rs @@ -0,0 +1,59 @@ +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError; +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; + +#[derive(Debug)] +pub enum WorldLabsError { + Client(WorldLabsClientError), + ApiSpecific(WorldLabsSpecificApiError), + ApiGeneric(WorldLabsGenericApiError), +} + +impl WorldLabsError { + pub fn is_world_labs_having_downtime_issues(&self) -> bool { + match self { + Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true, + Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true, + Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true, + _ => false, + } + } +} + +impl Error for WorldLabsError {} + +impl std::fmt::Display for WorldLabsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "WorldLabsClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "WorldLabsSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "WorldLabsGenericApiError: {:?}", e), + } + } +} + +impl From for WorldLabsError { + fn from(error: WorldLabsClientError) -> Self { + Self::Client(error) + } +} + +impl From for WorldLabsError { + fn from(error: WorldLabsSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for WorldLabsError { + fn from(error: WorldLabsGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} + +impl From for WorldLabsError { + fn from(error: CloudflareError) -> Self { + Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(error)) + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_generic_api_error.rs b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_generic_api_error.rs new file mode 100644 index 0000000000..b3c6f2f8ed --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_generic_api_error.rs @@ -0,0 +1,89 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use wreq::StatusCode; + +#[derive(Debug)] +pub enum WorldLabsGenericApiError { + /// Unknown error occurred when uploading to Google GCP + GoogleUploadFailed { + status_code: StatusCode, + body: String, + }, + + /// Specific Cloudflare errors. + CloudflareError(CloudflareError), + + /// The downloaded index.html did not include the expected data. + /// This is necessary for handshakes between client and server. + IndexHtmlDidNotIncludeExpectedData { + message: String, + }, + + /// The downloaded javascript did not include the expected data. + /// This is necessary for handshakes between client and server. + ScriptBodyDidNotIncludeExpectedData { + message: String, + }, + + /// Error creating the websocket due to 401 Unauthorized, likely an authentication error with Grok. + /// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues. + LikelyWebsocketAuthentication401, + + /// We received a 403 on websocket upgrade, likely from Cloudflare. + /// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues. + LikelyWebsocketCloudflare403, + + /// Unknown status code on websocket upgrade. + UnexpectedWebsocketUpgradeStatusCode(StatusCode), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + /// Specifically, this is for non-200 (error) responses. + SerdeParseErrorWithBodyOnNon200(serde_json::Error, String), + + /// An uncategorized bad HTTP response from Grok. + UncategorizedBadResponseWithStatus(StatusCode), + + /// An uncategorized bad HTTP response from Grok. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// Our previous upload failed + UploadFailed, + + /// An error upgrading the connection to a websocket. + WreqWebsocketUpgradeError(wreq::Error), + + /// An uncaught error from the API client. + WreqError(wreq::Error), +} + +impl Error for WorldLabsGenericApiError {} + +impl Display for WorldLabsGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::GoogleUploadFailed { status_code, body } => write!(f, "Google world upload failed with HTTP {}: {:?}", status_code, body), + Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), + Self::IndexHtmlDidNotIncludeExpectedData { message } => write!(f, "IndexHtmlDidNotIncludeExpectedData ( message: {} )", message), + Self::ScriptBodyDidNotIncludeExpectedData { message } => write!(f, "ScriptBodyDidNotIncludeExpectedData ( message: {} )", message), + Self::LikelyWebsocketAuthentication401 => write!(f, "Likely websocket authentication 401 error (probably Grok authentication issue with cookies)"), + Self::LikelyWebsocketCloudflare403 => write!(f, "Likely Cloudflare 403 error on websocket upgrade"), + Self::UnexpectedWebsocketUpgradeStatusCode(status) => write!(f, "Unexpected websocket upgrade status code: {}", status), + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), + Self::UncategorizedBadResponseWithStatus(status) => write!(f, "Uncategorized with status code: {}", status), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::UploadFailed => write!(f, "Upload failed"), + Self::WreqWebsocketUpgradeError(err) => write!(f, "Websocket upgrade error: {}", err), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + } + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_specific_api_error.rs b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_specific_api_error.rs new file mode 100644 index 0000000000..aba508705e --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/error/world_labs_specific_api_error.rs @@ -0,0 +1,89 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum WorldLabsSpecificApiError { + /// If the API drops run ids (unlikely) + NoRunIdInResponse { response_body: String }, + + /// The Google GCP upload failed because the signature does not match + GoogleUploadSignatureDoesNotMatch, + + /// Grok is detecting and blocking our bot, likely due to new secrets use. + AutomationBlocked, + + /// Too many videos are being generated. + TooManyVideos, + + /// There aren't sufficient funds for generation. + InsufficientCredits, + +// /// Failed to generate a bearer token despite the `200 OK` response. +// /// This has been generated by sending old/expired session cookies or empty cookies. +// FailedToGenerateBearerToken, +// +// /// The endpoint told us we used an invalid JWT. +// InvalidJwt, +// +// /// Sora has responded with "sentinel block". +// /// This usually means we need to refresh our sentinel token. +// SentinelBlockError, +// +// /// Sora requires users to set up an account username before they can generate images. +// /// This requires the user to set up that username. +// SoraUsernameNotYetCreated, +// +// /// Sora has responded with "token expired". +// /// This (probably?) means we need to refresh our JWT bearer token. +// TokenExpiredError, +// +// /// We're sending too many tasks to Sora. +// /// Sora is telling us to cool it. +// TooManyConcurrentTasks, +// +// // TODO: Try to make concrete error types for these distinct cases. +// /// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login. +// /// +// /// Example message, e.g. from the upload endpoint: +// /// { +// /// "error": { +// /// "message": "Your authentication token has expired. Please try signing in again.", +// /// "type": "invalid_request_error", +// /// "param": null, +// /// "code": "token_expired" +// /// } +// /// } +// UnauthorizedCookieOrBearerExpired, +// +// /// The Sora sentinel token response is missing fields +// SentinelResponseIsMissingFields { +// missing_token: bool, +// missing_turnstile: bool, +// raw_response: String, +// }, +} + +impl Error for WorldLabsSpecificApiError {} + +impl Display for WorldLabsSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoRunIdInResponse { response_body } => write!(f, "no run.id found in response: {}", response_body), + Self::GoogleUploadSignatureDoesNotMatch => write!(f, "Google upload signature does not match"), + Self::AutomationBlocked => write!(f, "Automation blocked"), + Self::TooManyVideos => write!(f, "Too many videos are being generated"), + Self::InsufficientCredits => write!(f, "Insufficient credits"), + + //Self::FailedToGenerateBearerToken => write!(f, "Failed to generate bearer token. Probably invalid or expired session cookies."), + //Self::InvalidJwt => write!(f, "Invalid JWT token. Please refresh the token."), + //Self::SentinelBlockError => write!(f, "Request blocked by Sentinel Token Block. Please refresh the sentinel token."), + //Self::SoraUsernameNotYetCreated => write!(f, "Sora username not yet created. Please create a username in the Sora app or website."), + //Self::TokenExpiredError => write!(f, "JWT token has expired. Please refresh the token."), + //Self::TooManyConcurrentTasks => write!(f, "Too many concurrent tasks. Please wait."), + //Self::UnauthorizedCookieOrBearerExpired => write!(f, "Unauthorized: cookie and/or bearer token expired"), + //Self::SentinelResponseIsMissingFields { missing_token: token_missing, missing_turnstile: turnstile_missing, raw_response } => { + // write!(f, "Sentinel response is missing fields. token_missing: {}, turnstile_missing: {}. Raw response: {}", token_missing, turnstile_missing, raw_response) + //}, + } + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/lib.rs b/crates/api_clients/worldlabs_consumer_client/src/lib.rs new file mode 100644 index 0000000000..dda4bd6712 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/lib.rs @@ -0,0 +1,9 @@ + +#[cfg(test)] +pub (crate) mod test_utils; + +pub mod api; +pub mod client; +pub mod credentials; +pub mod error; +pub mod recipes; diff --git a/crates/api_clients/worldlabs_consumer_client/src/recipes/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/recipes/mod.rs new file mode 100644 index 0000000000..d17ac4bb12 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/recipes/mod.rs @@ -0,0 +1 @@ +pub mod upload_image_and_create_world_with_retry; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/recipes/upload_image_and_create_world_with_retry.rs b/crates/api_clients/worldlabs_consumer_client/src/recipes/upload_image_and_create_world_with_retry.rs new file mode 100644 index 0000000000..b841da4f95 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/recipes/upload_image_and_create_world_with_retry.rs @@ -0,0 +1,346 @@ +use crate::api::api_types::image_input_object_id::ImageInputObjectId; +use crate::api::api_types::meta_world_object_id::MetaWorldObjectId; +use crate::api::api_types::pano_object_id::PanoObjectId; +use crate::api::api_types::run_object_id::RunObjectId; +use crate::api::api_types::upload_mime_type::UploadMimeType; +use crate::api::api_types::world_id::WorldObjectId; +use crate::api::requests::google_refresh_token::google_refresh_token::{google_refresh_token, GoogleRefreshTokenArgs}; +use crate::api::requests::google_upload::google_upload_image::{google_upload_image, GoogleUploadImageArgs}; +use crate::api::requests::objects::begin_object_image_upload::{begin_object_image_upload, BeginObjectImageUploadArgs}; +use crate::api::requests::objects::create_run_object::{create_run_object, CreateRunObjectArgs}; +use crate::api::requests::objects::create_upload_object::{create_upload_object, CreateUploadObjectArgs}; +use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, FinalizeObjectImageUploadArgs}; +use crate::api::requests::objects::update_run_object_with_upload::{update_run_object_with_upload, UpdateRunObjectWithUploadArgs, UpdateRunObjectWithUploadPayloadArgs}; +use crate::api::requests::objects::update_run_object_with_world::{update_run_object_with_world, UpdateRunObjectWithWorldArgs, UpdateRunObjectWithWorldPayloadArgs}; +use crate::api::requests::recaption::recaption_image::{recaption_image, RecaptionImageArgs}; +use crate::api::requests::worlds::create_world::{create_world, CreateWorldArgs}; +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; +use crate::error::world_labs_client_error::WorldLabsClientError; +use crate::error::world_labs_error::WorldLabsError; +use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError; +use anyhow::bail; +use chrono::{TimeDelta, Utc}; +use filesys::file_read_bytes::file_read_bytes; +use log::{error, info, warn}; +use serde::Deserialize; +use std::path::PathBuf; +use std::time::Duration; +use uuid::Uuid; + +pub struct UploadImageAndCreateWorldWithRetryArgs<'a> { + pub file: FileBytesOrPath, + pub cookies: &'a WorldLabsCookies, + pub bearer_token: &'a WorldLabsBearerToken, + pub refresh_token: &'a WorldLabsRefreshToken, + pub individual_request_timeout: Option, +} + +pub enum FileBytesOrPath { + Bytes(Vec), + Path(PathBuf), +} + +pub struct UploadImageAndCreateWorldWithRetryResponse { + pub run_id: RunObjectId, + pub world_id: WorldObjectId, + pub image_upload_url: String, + + /// If the tokens were renewed, this is them. + pub maybe_new_access_tokens: Option, +} + +pub struct NewAccessTokens { + pub bearer_token: WorldLabsBearerToken, + pub refresh_token: WorldLabsRefreshToken, +} + +/// Marble Image-to-World +/// Mark the upload as complete. +/// Request #5 (of ~10) +pub async fn upload_image_and_create_world_with_retry(args: UploadImageAndCreateWorldWithRetryArgs<'_>) -> Result { + + info!("Checking to see if bearer token needs refresh (fails open) ..."); + + let mut maybe_refresh_bearer = false; + + match args.bearer_token.parse_jwt_claims() { + Err(err) => { + warn!("Failed to parse bearer_token jwt claims (failing open) : {}", err); + } + Ok(jwt) => { + let now = Utc::now(); + if now > jwt.expiration { + info!("Bearer is expired."); + maybe_refresh_bearer = true; + } + + let sooner_expiry = jwt.expiration + .checked_sub_signed(TimeDelta::minutes(30)) + .unwrap_or(jwt.expiration); + + if now > sooner_expiry { + info!("Bearer will expire soon, so we're renewing it in advance."); + maybe_refresh_bearer = true; + } + } + }; + + let mut maybe_new_access_tokens = None; + + if maybe_refresh_bearer { + info!("Refreshing bearer token..."); + + let updated = google_refresh_token(GoogleRefreshTokenArgs { + refresh_token: &args.refresh_token, + request_timeout: args.individual_request_timeout, + }).await?; + + info!("Bearer token refreshed!"); + + maybe_new_access_tokens = Some(NewAccessTokens { + bearer_token: updated.bearer_token, + refresh_token: updated.refresh_token, + }); + } + + let use_bearer_token = maybe_new_access_tokens + .as_ref() + .map(|tokens| &tokens.bearer_token) + .unwrap_or_else(|| args.bearer_token); + + info!("Checking file input..."); + + let file_bytes = match args.file { + FileBytesOrPath::Bytes(bytes) => { + info!("File bytes provided"); + bytes + } + FileBytesOrPath::Path(path) => { + info!("File path provided"); + match file_read_bytes(&path) { + Ok(bytes) => bytes, + Err(err) => { + error!("Error reading file bytes from path: {:?} - error: {:?}", path, err); + return Err(WorldLabsClientError::CannotReadLocalFileForUpload(err).into()); + } + } + } + }; + + info!("Request #1 of 10: create run object ..."); + + let response = create_run_object(CreateRunObjectArgs { + cookies: args.cookies, + bearer_token: use_bearer_token, + request_timeout: args.individual_request_timeout, + }).await?; + + let run_object_id = response.id; + let run_object = response.run_object; + + info!("Object id: {}", run_object_id.0); + + // TODO - multiple types. + let upload_mime_type= UploadMimeType::ImageJpeg; + + info!("Request #2 of 10: begin image attachment ..."); + + let response = create_upload_object(CreateUploadObjectArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + upload_mime_type, + request_timeout: args.individual_request_timeout, + }).await?; + + let upload_id = response.id; + + info!("Upload id: {}", upload_id.0); + + info!("Request #3 of 10: begin image upload ..."); + + let response = begin_object_image_upload(BeginObjectImageUploadArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + upload_mime_type, + request_timeout: args.individual_request_timeout, + upload_id: &upload_id, + }).await?; + + let google_upload_url = response.upload_url; + + info!("Request #4 of 10: upload to Google GCP ..."); + + info!("Google upload URL: {:?}", &google_upload_url); + + let _response = google_upload_image(GoogleUploadImageArgs { + upload_url: &google_upload_url, + upload_mime_type, + file_bytes, + request_timeout: args.individual_request_timeout, + }).await?; + + info!("Request #5 of 10: finalize object upload ..."); + + let response = finalize_object_image_upload(FinalizeObjectImageUploadArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + upload_id: &upload_id, + request_timeout: args.individual_request_timeout, + }).await?; + + let image_url = response.object_url; + + info!("Object/image URL: {image_url}"); + + info!("Request #6 of 10: Patch run with new info (first time) ..."); + + let image_input_id = ImageInputObjectId::generate_new(); + let pano_id = PanoObjectId::generate_new(); + let meta_world_id = MetaWorldObjectId::generate_new(); + + let response = update_run_object_with_upload(UpdateRunObjectWithUploadArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + payload_args: UpdateRunObjectWithUploadPayloadArgs { + run_id: &run_object_id, + run_created_at_timestamp: run_object.metadata.created_at, + image_upload_url: &image_url, + image_input_id: &image_input_id, + pano_id: &pano_id, + meta_world_id: &meta_world_id, + }, + request_timeout: args.individual_request_timeout, + }).await?; + + let run_first_updated_at = response.run_updated_timestamp; + + info!("Request #7 of 10: captioning with VLM ..."); + + let response = recaption_image(RecaptionImageArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + upload_id: &upload_id, + upload_mime_type, + run_id: &run_object_id, + request_timeout: args.individual_request_timeout, + }).await?; + + info!("Title: {}", response.title); + info!("Caption: {}", response.caption); + + let title = response.title; + let text_prompt = response.caption; + + info!("Request #8 of 10: create world ..."); + + let response = create_world(CreateWorldArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + text_prompt: &text_prompt, + image_upload_url: &image_url, + request_timeout: args.individual_request_timeout, + }).await?; + + let world_id = response.world_id; + + info!("World ID: {}", &world_id.0); + + info!("Request #9 of 10: patch run with new info (second time) ..."); + + let response = update_run_object_with_world(UpdateRunObjectWithWorldArgs { + cookies: &args.cookies, + bearer_token: &use_bearer_token, + payload_args: UpdateRunObjectWithWorldPayloadArgs { + run_id: &run_object_id, + run_created_at_timestamp: run_object.metadata.created_at, + first_patch_timestamp: run_first_updated_at, + image_upload_url: &image_url, + image_input_id: &image_input_id, + pano_id: &pano_id, + meta_world_id: &meta_world_id, + world_id: &world_id, + title: &title, + text_prompt: &text_prompt, + }, + request_timeout: args.individual_request_timeout, + }).await?; + + Ok(UploadImageAndCreateWorldWithRetryResponse { + run_id: run_object_id, + image_upload_url: image_url, + world_id, + maybe_new_access_tokens, + }) +} + +#[cfg(test)] +mod tests { + use crate::recipes::upload_image_and_create_world_with_retry::{upload_image_and_create_world_with_retry, FileBytesOrPath, UploadImageAndCreateWorldWithRetryArgs}; + use crate::test_utils::get_test_bearer_token::get_test_bearer_token; + use crate::test_utils::get_test_cookies::get_typed_test_cookies; + use crate::test_utils::get_test_refresh_token::get_typed_test_refresh_token; + use crate::test_utils::setup_test_logging::setup_test_logging; + use filesys::file_read_bytes::file_read_bytes; + use log::LevelFilter; + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_requests() { + setup_test_logging(LevelFilter::Debug); + + let cookies = get_typed_test_cookies().unwrap(); + let bearer_token = get_test_bearer_token().unwrap(); + let refresh_token = get_typed_test_refresh_token().unwrap(); + + //let file_path = "/home/bt/Pictures/locations/island.jpg"; + let file_path = "/Users/bt/Pictures/Midjourney/jeep.jpeg"; + let file_bytes = file_read_bytes(file_path).unwrap(); + + println!("File bytes len: {}", file_bytes.len()); + + let results = upload_image_and_create_world_with_retry(UploadImageAndCreateWorldWithRetryArgs { + cookies: &cookies, + bearer_token: &bearer_token, + refresh_token: &refresh_token, + individual_request_timeout: None, + file: FileBytesOrPath::Bytes(file_bytes), + }).await.unwrap(); + + println!("Object ID: {}", results.run_id.0); + println!("World ID: {}", results.world_id.0); + println!("Upload URL: {}", results.image_upload_url); + println!("Has new access tokens: {}", results.maybe_new_access_tokens.is_some()); + + assert_eq!(1, 2); + } + + + #[tokio::test] + #[ignore] // Client side tests only + async fn test_requests_2() { + setup_test_logging(LevelFilter::Debug); + + let cookies = get_typed_test_cookies().unwrap(); + let bearer_token = get_test_bearer_token().unwrap(); + let refresh_token = get_typed_test_refresh_token().unwrap(); + + //let file_path = "/home/bt/Pictures/locations/island.jpg"; + let file_path = "/Users/bt/Pictures/Midjourney/train_ghibli.jpeg"; + let file_bytes = file_read_bytes(file_path).unwrap(); + + println!("File bytes len: {}", file_bytes.len()); + + let results = upload_image_and_create_world_with_retry(UploadImageAndCreateWorldWithRetryArgs { + cookies: &cookies, + bearer_token: &bearer_token, + refresh_token: &refresh_token, + individual_request_timeout: None, + file: FileBytesOrPath::Bytes(file_bytes), + }).await.unwrap(); + + println!("Upload URL: {}", results.image_upload_url); + println!("Object ID: {}", results.run_id.0); + } +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_bearer_token.rs b/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_bearer_token.rs new file mode 100644 index 0000000000..93aabc641e --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_bearer_token.rs @@ -0,0 +1,17 @@ +use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub (crate) fn get_test_bearer_token_raw() -> AnyhowResult { + let cookies = read_to_string("/Users/bt/Artcraft/credentials/worldlabs_bearer.txt")?; + //let cookies = read_to_string("/home/bt/Artcraft/credentials/world_labs_bearer.txt")?; + let cookies = cookies.trim().to_string(); + Ok(cookies) +} + +#[cfg(test)] +pub (crate) fn get_test_bearer_token() -> AnyhowResult { + let cookies = get_test_bearer_token_raw()?; + Ok(WorldLabsBearerToken::new(cookies)) +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_cookies.rs b/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_cookies.rs new file mode 100644 index 0000000000..3fd53c1a9c --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_cookies.rs @@ -0,0 +1,17 @@ +use crate::credentials::world_labs_cookies::WorldLabsCookies; +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub (crate) fn get_test_cookies() -> AnyhowResult { + //let cookies = read_to_string("/home/bt/Artcraft/credentials/worldlabs_cookies.txt")?; + let cookies = read_to_string("/Users/bt/Artcraft/credentials/worldlabs_cookies.txt")?; + let cookies = cookies.trim().to_string(); + Ok(cookies) +} + +#[cfg(test)] +pub (crate) fn get_typed_test_cookies() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(WorldLabsCookies::new(cookies)) +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_refresh_token.rs b/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_refresh_token.rs new file mode 100644 index 0000000000..385a868a17 --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/test_utils/get_test_refresh_token.rs @@ -0,0 +1,17 @@ +use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub (crate) fn get_test_refresh_token() -> AnyhowResult { + //let cookies = read_to_string("/home/bt/Artcraft/credentials/worldlabs_cookies.txt")?; + let token = read_to_string("/Users/bt/Artcraft/credentials/worldlabs_refresh.txt")?; + let token = token.trim().to_string(); + Ok(token) +} + +#[cfg(test)] +pub (crate) fn get_typed_test_refresh_token() -> AnyhowResult { + let token = get_test_refresh_token()?; + Ok(WorldLabsRefreshToken::new(token)) +} diff --git a/crates/api_clients/worldlabs_consumer_client/src/test_utils/mod.rs b/crates/api_clients/worldlabs_consumer_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..ef9d0b8e3c --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/test_utils/mod.rs @@ -0,0 +1,4 @@ +pub mod get_test_cookies; +pub mod get_test_bearer_token; +pub mod setup_test_logging; +pub mod get_test_refresh_token; \ No newline at end of file diff --git a/crates/api_clients/worldlabs_consumer_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/worldlabs_consumer_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..9438da106c --- /dev/null +++ b/crates/api_clients/worldlabs_consumer_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,52 @@ +use chrono::Local; +use env_logger::Builder; +use log::{debug, error, info, trace, warn, LevelFilter}; +use std::io::Write; + +const RUST_LOG : &str = "RUST_LOG"; + +pub fn setup_test_logging(level: LevelFilter) { + let level_str = get_level_str(level); + + println!("Previous log level: {:?}", env::var(RUST_LOG)); + + let is_unsafe = env::set_var(RUST_LOG, level_str); + + if is_unsafe.is_none() { + println!("Unsafely setting Rust Log Level..."); + unsafe { std::env::set_var(RUST_LOG, level_str) } + } + + println!("Log level: {:?}", env::var(RUST_LOG)); + + Builder::new() + .is_test(true) + .format(|buf, record| { + writeln!(buf, + "{} [{}] - {}", + Local::now().format("%H:%M:%S%.6f"), + record.level(), + record.args() + ) + }) + .filter(None, level) + .filter_level(level) + .init(); + + trace!("Test trace log"); + debug!("Test debug log"); + info!("Test info log"); + warn!("Test warn log"); + error!("Test error log"); +} + +fn get_level_str(level: LevelFilter) -> &'static str { + match level { + LevelFilter::Error => "error", + LevelFilter::Warn => "warn", + LevelFilter::Info => "info", + LevelFilter::Debug => "debug", + LevelFilter::Trace => "trace", + LevelFilter::Off => "off", + } +} diff --git a/crates/desktop/artcraft/.gitignore b/crates/desktop/artcraft/.gitignore new file mode 100644 index 0000000000..502406b4ed --- /dev/null +++ b/crates/desktop/artcraft/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +/gen/schemas diff --git a/crates/desktop/artcraft/.taurignore b/crates/desktop/artcraft/.taurignore new file mode 100644 index 0000000000..ad17b2ca1c --- /dev/null +++ b/crates/desktop/artcraft/.taurignore @@ -0,0 +1,4 @@ +# Don't restart the dev server on Rust changes +crates/ +Cargo.toml +Cargo.lock \ No newline at end of file diff --git a/crates/desktop/artcraft/Cargo.toml b/crates/desktop/artcraft/Cargo.toml new file mode 100644 index 0000000000..da3b1afa89 --- /dev/null +++ b/crates/desktop/artcraft/Cargo.toml @@ -0,0 +1,100 @@ +[package] +name = "artcraft" +version = "0.0.1" +description = "Artcraft" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.77.2" +publish = false + +# [lints.rust] +# unused_imports = "forbid" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "artcraft_app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.6.2", features = [] } + +[dependencies] + +# Internal +artcraft_api_defs.workspace = true +artcraft_client.workspace = true +artcraft_router.workspace = true +build_metadata.workspace = true +cookie_store.workspace = true +enums.workspace = true +errors.workspace = true +fal_client.workspace = true +filesys.workspace = true +grok_consumer_client.workspace = true +uuid_utils.workspace = true +images.workspace = true +memory_store.workspace = true +midjourney_client.workspace = true +mimetypes.workspace = true +openai_sora_client.workspace = true +primitives.workspace = true +sqlite_tasks.workspace = true +tokens.workspace = true +url_utils.workspace = true +web_base64.workspace = true +worldlabs_consumer_client.workspace = true + +# External +anyhow.workspace = true +base64.workspace = true +bytes = "1.10.1" +chrono.workspace = true +directories = "6.0.0" +dirs = "6.0.0" +discord-rich-presence = "1.1.0" +futures = "0.3" +image = "0.25.5" +indexmap.workspace = true +log.workspace = true +once_cell.workspace = true +os_info = { version = "3.12.0", default-features = false } +rand.workspace = true +reqwest = { workspace = true, features = ["multipart", "stream"], default-features = false } # NB(bt,2025-03-10): default-features = false on Windows while I debug openssl / rustls +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +tauri-plugin-dialog.workspace = true +tauri-plugin-log.workspace = true +tauri-plugin-upload.workspace = true +tauri.workspace = true +tempdir.workspace = true +tempfile.workspace = true +tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "fs"] } +tokio-util.workspace = true +url.workspace = true + +# NB: We need to vendor this in so that we can access the cookies inside via the private API +tauri-plugin-http = { path = "../../vendor/tauri-plugin-http" } +tauri-plugin-opener = "2" + +# # Transparent title bar on MacOS +# # NB: See the notes here: +# # https://v2.tauri.app/learn/window-customization/ +# [target."cfg(target_os = \"macos\")".dependencies] +# cocoa = "0.26" + +[dev-dependencies] +serial_test = "3.2.0" + +[profile.dev] +incremental = true # Compile your binary in smaller steps. + +[profile.release] +codegen-units = 1 # Allows LLVM to perform better optimization. +lto = true # Enables link-time-optimizations. +opt-level = 3 # `s` prioritizes small binary size. Use `3` if you prefer speed. +panic = "abort" # Higher performance by disabling panic handlers. +strip = true # Ensures debug symbols are removed. diff --git a/crates/desktop/artcraft/build.rs b/crates/desktop/artcraft/build.rs new file mode 100644 index 0000000000..795b9b7c83 --- /dev/null +++ b/crates/desktop/artcraft/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/crates/desktop/artcraft/capabilities/default.json b/crates/desktop/artcraft/capabilities/default.json new file mode 100644 index 0000000000..9e286eff53 --- /dev/null +++ b/crates/desktop/artcraft/capabilities/default.json @@ -0,0 +1,34 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-start-dragging", + "core:window:allow-set-focus", + "core:window:allow-toggle-maximize", + "core:window:default", + "dialog:default", + "http:allow-fetch", + "log:default", + "upload:allow-download", + "upload:default", + { + "identifier": "http:default", + "allow": [ + { + "url": "https://*" + }, + { + "url": "http://localhost:12345" + } + ] + }, + "opener:default" + ] +} \ No newline at end of file diff --git a/crates/desktop/artcraft/icons/128x128.png b/crates/desktop/artcraft/icons/128x128.png new file mode 100644 index 0000000000..baeb9c0671 Binary files /dev/null and b/crates/desktop/artcraft/icons/128x128.png differ diff --git a/crates/desktop/artcraft/icons/128x128@2x.png b/crates/desktop/artcraft/icons/128x128@2x.png new file mode 100644 index 0000000000..97cfd0b250 Binary files /dev/null and b/crates/desktop/artcraft/icons/128x128@2x.png differ diff --git a/crates/desktop/artcraft/icons/32x32.png b/crates/desktop/artcraft/icons/32x32.png new file mode 100644 index 0000000000..e7db6b6d84 Binary files /dev/null and b/crates/desktop/artcraft/icons/32x32.png differ diff --git a/crates/desktop/artcraft/icons/64x64.png b/crates/desktop/artcraft/icons/64x64.png new file mode 100644 index 0000000000..2bfadfe089 Binary files /dev/null and b/crates/desktop/artcraft/icons/64x64.png differ diff --git a/crates/desktop/artcraft/icons/Square107x107Logo.png b/crates/desktop/artcraft/icons/Square107x107Logo.png new file mode 100644 index 0000000000..35a253d410 Binary files /dev/null and b/crates/desktop/artcraft/icons/Square107x107Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square142x142Logo.png b/crates/desktop/artcraft/icons/Square142x142Logo.png new file mode 100644 index 0000000000..caaadd7bc9 Binary files /dev/null and b/crates/desktop/artcraft/icons/Square142x142Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square150x150Logo.png b/crates/desktop/artcraft/icons/Square150x150Logo.png new file mode 100644 index 0000000000..be7aa118d1 Binary files /dev/null and b/crates/desktop/artcraft/icons/Square150x150Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square284x284Logo.png b/crates/desktop/artcraft/icons/Square284x284Logo.png new file mode 100644 index 0000000000..b6ba3c2565 Binary files /dev/null and b/crates/desktop/artcraft/icons/Square284x284Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square30x30Logo.png b/crates/desktop/artcraft/icons/Square30x30Logo.png new file mode 100644 index 0000000000..bf7894738c Binary files /dev/null and b/crates/desktop/artcraft/icons/Square30x30Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square310x310Logo.png b/crates/desktop/artcraft/icons/Square310x310Logo.png new file mode 100644 index 0000000000..c9378aeb4e Binary files /dev/null and b/crates/desktop/artcraft/icons/Square310x310Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square44x44Logo.png b/crates/desktop/artcraft/icons/Square44x44Logo.png new file mode 100644 index 0000000000..b43d72fc7a Binary files /dev/null and b/crates/desktop/artcraft/icons/Square44x44Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square71x71Logo.png b/crates/desktop/artcraft/icons/Square71x71Logo.png new file mode 100644 index 0000000000..694d402abf Binary files /dev/null and b/crates/desktop/artcraft/icons/Square71x71Logo.png differ diff --git a/crates/desktop/artcraft/icons/Square89x89Logo.png b/crates/desktop/artcraft/icons/Square89x89Logo.png new file mode 100644 index 0000000000..cb81901862 Binary files /dev/null and b/crates/desktop/artcraft/icons/Square89x89Logo.png differ diff --git a/crates/desktop/artcraft/icons/StoreLogo.png b/crates/desktop/artcraft/icons/StoreLogo.png new file mode 100644 index 0000000000..971214c0b8 Binary files /dev/null and b/crates/desktop/artcraft/icons/StoreLogo.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher.png b/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..a8f4606494 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..856fc5bdc6 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher_round.png b/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..a8f4606494 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher.png b/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..2194742ee6 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..701f818ed8 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher_round.png b/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..2194742ee6 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher.png b/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..b180db6303 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..3cd93c4331 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher_round.png b/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..b180db6303 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher.png b/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..f09690f5c4 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..25d51ed4eb Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..f09690f5c4 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher.png b/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..eb4454e694 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..963b1e48ee Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..eb4454e694 Binary files /dev/null and b/crates/desktop/artcraft/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/crates/desktop/artcraft/icons/icon.icns b/crates/desktop/artcraft/icons/icon.icns new file mode 100644 index 0000000000..1e11c08e89 Binary files /dev/null and b/crates/desktop/artcraft/icons/icon.icns differ diff --git a/crates/desktop/artcraft/icons/icon.ico b/crates/desktop/artcraft/icons/icon.ico new file mode 100644 index 0000000000..b2e7a265e0 Binary files /dev/null and b/crates/desktop/artcraft/icons/icon.ico differ diff --git a/crates/desktop/artcraft/icons/icon.png b/crates/desktop/artcraft/icons/icon.png new file mode 100644 index 0000000000..ae13d4ce65 Binary files /dev/null and b/crates/desktop/artcraft/icons/icon.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-20x20@1x.png b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000000..08d04d792a Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@1x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-20x20@2x-1.png b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000000..6d4b74f08f Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-20x20@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000000..6d4b74f08f Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@2x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-20x20@3x.png b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000000..ef88a39233 Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-20x20@3x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-29x29@1x.png b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000000..880b1300ee Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@1x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-29x29@2x-1.png b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000000..54d51c2eeb Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-29x29@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000000..54d51c2eeb Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@2x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-29x29@3x.png b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000000..20c73d70f1 Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-29x29@3x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-40x40@1x.png b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000000..6d4b74f08f Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@1x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-40x40@2x-1.png b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000000..5bd6a225fa Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-40x40@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000000..5bd6a225fa Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@2x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-40x40@3x.png b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000000..b90a6e91e0 Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-40x40@3x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-512@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000000..12aed85b0c Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-512@2x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-60x60@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000000..b90a6e91e0 Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-60x60@2x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-60x60@3x.png b/crates/desktop/artcraft/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000000..3a34b5042d Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-60x60@3x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-76x76@1x.png b/crates/desktop/artcraft/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000000..8bf184ca53 Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-76x76@1x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-76x76@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000000..6fd0a546b2 Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-76x76@2x.png differ diff --git a/crates/desktop/artcraft/icons/ios/AppIcon-83.5x83.5@2x.png b/crates/desktop/artcraft/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000000..e4d293c6bc Binary files /dev/null and b/crates/desktop/artcraft/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/common_aspect_ratio.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/common_aspect_ratio.rs new file mode 100644 index 0000000000..c16d63e159 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/common_aspect_ratio.rs @@ -0,0 +1,42 @@ +use serde_derive::{Deserialize, Serialize}; + +/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation. +/// Not every model will support every aspect ratio. +/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommonAspectRatio { + // Auto (eg. for image editing to use the source; used by Nano Banana Pro edit image, but not text-to-image) + Auto, + + // Square + Square, + + // Wide + WideThreeByTwo, + WideFourByThree, + WideFiveByFour, + WideSixteenByNine, + WideTwentyOneByNine, + + // Tall + TallTwoByThree, + TallThreeByFour, + TallFourByFive, + TallNineBySixteen, + TallNineByTwentyOne, + + // Imprecise semantic values that we probably remap to other meanings + // on a model-by-model basis. + Wide, + Tall, + + // Auto values that bake in resolution + // These are from the Seedream models + Auto2k, + Auto4k, + + // Defined aspect ratios that bake in resolution + // These are from the Seedream models + SquareHd, +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_1_dev.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_1_dev.rs new file mode 100644 index 0000000000..72f785e250 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_1_dev.rs @@ -0,0 +1,31 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageAspectRatio; + +pub fn aspect_ratio_to_artcraft_flux_1_dev(aspect_ratio: CommonAspectRatio) -> GenerateFlux1DevTextToImageAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => GenerateFlux1DevTextToImageAspectRatio::Square, + CommonAspectRatio::SquareHd => GenerateFlux1DevTextToImageAspectRatio::SquareHd, + CommonAspectRatio::WideFourByThree => GenerateFlux1DevTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideSixteenByNine => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::TallThreeByFour => GenerateFlux1DevTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::TallNineBySixteen => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen, + + // Non-matching aspect ratios, use closest neighbor + CommonAspectRatio::TallFourByFive => GenerateFlux1DevTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::TallNineByTwentyOne => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen, + CommonAspectRatio::TallTwoByThree => GenerateFlux1DevTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::WideFiveByFour => GenerateFlux1DevTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::WideThreeByTwo => GenerateFlux1DevTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideTwentyOneByNine => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine, + + // Special + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => GenerateFlux1DevTextToImageAspectRatio::Square, + + // Semantic values + CommonAspectRatio::Wide => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::Tall => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_1_schnell.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_1_schnell.rs new file mode 100644 index 0000000000..1cbe70d0b0 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_1_schnell.rs @@ -0,0 +1,31 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageAspectRatio; + +pub fn aspect_ratio_to_artcraft_flux_1_schnell(aspect_ratio: CommonAspectRatio) -> GenerateFlux1SchnellTextToImageAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => GenerateFlux1SchnellTextToImageAspectRatio::Square, + CommonAspectRatio::SquareHd => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd, + CommonAspectRatio::WideFourByThree => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideSixteenByNine => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::TallThreeByFour => GenerateFlux1SchnellTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::TallNineBySixteen => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen, + + // Non-matching aspect ratios, use closest neighbor + CommonAspectRatio::TallFourByFive => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::TallNineByTwentyOne => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen, + CommonAspectRatio::TallTwoByThree => GenerateFlux1SchnellTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::WideFiveByFour => GenerateFlux1SchnellTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::WideThreeByTwo => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideTwentyOneByNine => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine, + + // Special + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => GenerateFlux1SchnellTextToImageAspectRatio::Square, + + // Semantic values + CommonAspectRatio::Wide => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::Tall => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_pro_1p1.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_pro_1p1.rs new file mode 100644 index 0000000000..f0e6babd92 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_pro_1p1.rs @@ -0,0 +1,33 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageAspectRatio; + +pub fn aspect_ratio_to_artcraft_flux_pro_1p1(aspect_ratio: CommonAspectRatio) -> GenerateFluxPro11TextToImageAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => GenerateFluxPro11TextToImageAspectRatio::Square, + CommonAspectRatio::WideFourByThree => GenerateFluxPro11TextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideSixteenByNine => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::TallThreeByFour => GenerateFluxPro11TextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::TallNineBySixteen => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen, + + // Non-matching aspect ratios, use closest neighbor + CommonAspectRatio::TallFourByFive => GenerateFluxPro11TextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::TallNineByTwentyOne => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen, + CommonAspectRatio::TallTwoByThree => GenerateFluxPro11TextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::WideFiveByFour => GenerateFluxPro11TextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::WideThreeByTwo => GenerateFluxPro11TextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideTwentyOneByNine => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine, + + // Best match + CommonAspectRatio::SquareHd => GenerateFluxPro11TextToImageAspectRatio::Square, + + // Special + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => GenerateFluxPro11TextToImageAspectRatio::Square, + + // Semantic values + CommonAspectRatio::Wide => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::Tall => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_pro_1p1_ultra.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_pro_1p1_ultra.rs new file mode 100644 index 0000000000..f49a6208e7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_flux_pro_1p1_ultra.rs @@ -0,0 +1,33 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageAspectRatio; + +pub fn aspect_ratio_to_artcraft_flux_pro_1p1_ultra(aspect_ratio: CommonAspectRatio) -> GenerateFluxPro11UltraTextToImageAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => GenerateFluxPro11UltraTextToImageAspectRatio::Square, + CommonAspectRatio::WideFourByThree => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeFourByThree, + CommonAspectRatio::WideThreeByTwo => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeThreeByTwo, + CommonAspectRatio::WideSixteenByNine => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::WideTwentyOneByNine => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeTwentyOneByNine, + CommonAspectRatio::TallThreeByFour => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::TallTwoByThree => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitTwoByThree, + CommonAspectRatio::TallNineBySixteen => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineBySixteen, + CommonAspectRatio::TallNineByTwentyOne => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineByTwentyOne, + + // Non-matching aspect ratios, use closest neighbor + CommonAspectRatio::WideFiveByFour => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitThreeByFour, + CommonAspectRatio::TallFourByFive => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeFourByThree, + + // Best match + CommonAspectRatio::SquareHd => GenerateFluxPro11UltraTextToImageAspectRatio::Square, + + // Special + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => GenerateFluxPro11UltraTextToImageAspectRatio::Square, + + // Semantic values + CommonAspectRatio::Wide => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeSixteenByNine, + CommonAspectRatio::Tall => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineBySixteen, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_gpt_image_1.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_gpt_image_1.rs new file mode 100644 index 0000000000..1c1d4954bc --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_gpt_image_1.rs @@ -0,0 +1,7 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; + +pub fn aspect_ratio_to_artcraft_gpt_image_1(aspect_ratio: CommonAspectRatio) -> Option { + // TODO: We still make calls to two distinct endpoints for image-to-image and text-to-image. + // These need to be consolidated to benefit from this approach. + None +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_gpt_image_1p5.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_gpt_image_1p5.rs new file mode 100644 index 0000000000..e6db82431c --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_gpt_image_1p5.rs @@ -0,0 +1,33 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::GptImage1p5MultiFunctionImageGenSize; + +pub fn aspect_ratio_to_artcraft_gpt_image_1p5(aspect_ratio: CommonAspectRatio) -> GptImage1p5MultiFunctionImageGenSize { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => GptImage1p5MultiFunctionImageGenSize::Square, + CommonAspectRatio::Wide => GptImage1p5MultiFunctionImageGenSize::Wide, + CommonAspectRatio::Tall => GptImage1p5MultiFunctionImageGenSize::Tall, + + // Non-matching + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => GptImage1p5MultiFunctionImageGenSize::Square, + + // Mismatch - square + CommonAspectRatio::SquareHd => GptImage1p5MultiFunctionImageGenSize::Square, + + // Mismatch - wide + CommonAspectRatio::WideThreeByTwo + | CommonAspectRatio::WideFiveByFour + | CommonAspectRatio::WideFourByThree + | CommonAspectRatio::WideSixteenByNine + | CommonAspectRatio::WideTwentyOneByNine => GptImage1p5MultiFunctionImageGenSize::Wide, + + // Mismatch - tall + CommonAspectRatio::TallTwoByThree + | CommonAspectRatio::TallFourByFive + | CommonAspectRatio::TallThreeByFour + | CommonAspectRatio::TallNineBySixteen + | CommonAspectRatio::TallNineByTwentyOne => GptImage1p5MultiFunctionImageGenSize::Tall, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_nano_banana.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_nano_banana.rs new file mode 100644 index 0000000000..d98e910281 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_nano_banana.rs @@ -0,0 +1,29 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::NanoBananaMultiFunctionImageGenAspectRatio; + +pub fn aspect_ratio_to_artcraft_nano_banana(aspect_ratio: CommonAspectRatio) -> NanoBananaMultiFunctionImageGenAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => NanoBananaMultiFunctionImageGenAspectRatio::OneByOne, + CommonAspectRatio::WideFiveByFour => NanoBananaMultiFunctionImageGenAspectRatio::FiveByFour, + CommonAspectRatio::WideFourByThree => NanoBananaMultiFunctionImageGenAspectRatio::FourByThree, + CommonAspectRatio::WideThreeByTwo => NanoBananaMultiFunctionImageGenAspectRatio::ThreeByTwo, + CommonAspectRatio::WideSixteenByNine => NanoBananaMultiFunctionImageGenAspectRatio::SixteenByNine, + CommonAspectRatio::WideTwentyOneByNine => NanoBananaMultiFunctionImageGenAspectRatio::TwentyOneByNine, + CommonAspectRatio::TallFourByFive => NanoBananaMultiFunctionImageGenAspectRatio::FourByFive, + CommonAspectRatio::TallThreeByFour => NanoBananaMultiFunctionImageGenAspectRatio::ThreeByFour, + CommonAspectRatio::TallTwoByThree => NanoBananaMultiFunctionImageGenAspectRatio::TwoByThree, + CommonAspectRatio::TallNineBySixteen => NanoBananaMultiFunctionImageGenAspectRatio::NineBySixteen, + // Special + // NB: this is only for image-to-image editing, not text-to-image. + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => NanoBananaMultiFunctionImageGenAspectRatio::Auto, + // NB: "9:21" value does not exist. Interpolated. + CommonAspectRatio::TallNineByTwentyOne => NanoBananaMultiFunctionImageGenAspectRatio::NineBySixteen, + // Semantic values + CommonAspectRatio::Wide => NanoBananaMultiFunctionImageGenAspectRatio::SixteenByNine, + CommonAspectRatio::Tall => NanoBananaMultiFunctionImageGenAspectRatio::NineBySixteen, + CommonAspectRatio::SquareHd => NanoBananaMultiFunctionImageGenAspectRatio::OneByOne, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_nano_banana_pro.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_nano_banana_pro.rs new file mode 100644 index 0000000000..3171a8c5c1 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_nano_banana_pro.rs @@ -0,0 +1,29 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenAspectRatio; + +pub fn aspect_ratio_to_artcraft_nano_banana_pro(aspect_ratio: CommonAspectRatio) -> NanoBananaProMultiFunctionImageGenAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => NanoBananaProMultiFunctionImageGenAspectRatio::OneByOne, + CommonAspectRatio::WideFiveByFour => NanoBananaProMultiFunctionImageGenAspectRatio::FiveByFour, + CommonAspectRatio::WideFourByThree => NanoBananaProMultiFunctionImageGenAspectRatio::FourByThree, + CommonAspectRatio::WideThreeByTwo => NanoBananaProMultiFunctionImageGenAspectRatio::ThreeByTwo, + CommonAspectRatio::WideSixteenByNine => NanoBananaProMultiFunctionImageGenAspectRatio::SixteenByNine, + CommonAspectRatio::WideTwentyOneByNine => NanoBananaProMultiFunctionImageGenAspectRatio::TwentyOneByNine, + CommonAspectRatio::TallFourByFive => NanoBananaProMultiFunctionImageGenAspectRatio::FourByFive, + CommonAspectRatio::TallThreeByFour => NanoBananaProMultiFunctionImageGenAspectRatio::ThreeByFour, + CommonAspectRatio::TallTwoByThree => NanoBananaProMultiFunctionImageGenAspectRatio::TwoByThree, + CommonAspectRatio::TallNineBySixteen => NanoBananaProMultiFunctionImageGenAspectRatio::NineBySixteen, + // Special + // NB: this is only for image-to-image editing, not text-to-image. + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => NanoBananaProMultiFunctionImageGenAspectRatio::Auto, + // NB: "9:21" value does not exist. Interpolated. + CommonAspectRatio::TallNineByTwentyOne => NanoBananaProMultiFunctionImageGenAspectRatio::NineBySixteen, + // Semantic values + CommonAspectRatio::Wide => NanoBananaProMultiFunctionImageGenAspectRatio::SixteenByNine, + CommonAspectRatio::Tall => NanoBananaProMultiFunctionImageGenAspectRatio::NineBySixteen, + CommonAspectRatio::SquareHd => NanoBananaProMultiFunctionImageGenAspectRatio::OneByOne, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_seedream_4.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_seedream_4.rs new file mode 100644 index 0000000000..d09fa7f4fb --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_seedream_4.rs @@ -0,0 +1,29 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4_multi_function_image_gen::BytedanceSeedreamV4MultiFunctionImageGenImageSize; + +pub fn aspect_ratio_to_artcraft_seedream_4(aspect_ratio: CommonAspectRatio) -> BytedanceSeedreamV4MultiFunctionImageGenImageSize { + match aspect_ratio { + // Exact + CommonAspectRatio::Auto => BytedanceSeedreamV4MultiFunctionImageGenImageSize::Auto, + CommonAspectRatio::Auto2k => BytedanceSeedreamV4MultiFunctionImageGenImageSize::Auto2k, + CommonAspectRatio::Auto4k => BytedanceSeedreamV4MultiFunctionImageGenImageSize::Auto4k, + CommonAspectRatio::Square => BytedanceSeedreamV4MultiFunctionImageGenImageSize::Square, + CommonAspectRatio::SquareHd => BytedanceSeedreamV4MultiFunctionImageGenImageSize::SquareHd, + CommonAspectRatio::WideFourByThree => BytedanceSeedreamV4MultiFunctionImageGenImageSize::LandscapeFourThree, + CommonAspectRatio::WideSixteenByNine => BytedanceSeedreamV4MultiFunctionImageGenImageSize::LandscapeSixteenNine, + CommonAspectRatio::TallThreeByFour => BytedanceSeedreamV4MultiFunctionImageGenImageSize::PortraitFourThree, + CommonAspectRatio::TallNineBySixteen => BytedanceSeedreamV4MultiFunctionImageGenImageSize::PortraitSixteenNine, + + // Non-matching aspect ratios, use closest neighbor + CommonAspectRatio::WideFiveByFour => BytedanceSeedreamV4MultiFunctionImageGenImageSize::LandscapeFourThree, + CommonAspectRatio::WideThreeByTwo => BytedanceSeedreamV4MultiFunctionImageGenImageSize::LandscapeFourThree, + CommonAspectRatio::WideTwentyOneByNine => BytedanceSeedreamV4MultiFunctionImageGenImageSize::LandscapeSixteenNine, + CommonAspectRatio::TallFourByFive => BytedanceSeedreamV4MultiFunctionImageGenImageSize::PortraitFourThree, + CommonAspectRatio::TallTwoByThree => BytedanceSeedreamV4MultiFunctionImageGenImageSize::PortraitFourThree, + CommonAspectRatio::TallNineByTwentyOne => BytedanceSeedreamV4MultiFunctionImageGenImageSize::PortraitSixteenNine, + + // Semantic values + CommonAspectRatio::Wide => BytedanceSeedreamV4MultiFunctionImageGenImageSize::LandscapeSixteenNine, + CommonAspectRatio::Tall => BytedanceSeedreamV4MultiFunctionImageGenImageSize::PortraitSixteenNine, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_seedream_4p5.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_seedream_4p5.rs new file mode 100644 index 0000000000..71fae240f7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_artcraft_seedream_4p5.rs @@ -0,0 +1,29 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4p5_multi_function_image_gen::BytedanceSeedreamV4p5MultiFunctionImageGenImageSize; + +pub fn aspect_ratio_to_artcraft_seedream_4p5(aspect_ratio: CommonAspectRatio) -> BytedanceSeedreamV4p5MultiFunctionImageGenImageSize { + match aspect_ratio { + // Exact + CommonAspectRatio::Auto2k => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::Auto2k, + CommonAspectRatio::Auto4k => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::Auto4k, + CommonAspectRatio::Square => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::Square, + CommonAspectRatio::SquareHd => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::SquareHd, + CommonAspectRatio::WideFourByThree => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::LandscapeFourThree, + CommonAspectRatio::WideSixteenByNine => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::LandscapeSixteenNine, + CommonAspectRatio::TallThreeByFour => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::PortraitFourThree, + CommonAspectRatio::TallNineBySixteen => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::PortraitSixteenNine, + + // Non-matching aspect ratios, use closest neighbor + CommonAspectRatio::Auto => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::Auto4k, + CommonAspectRatio::WideFiveByFour => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::LandscapeFourThree, + CommonAspectRatio::WideThreeByTwo => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::LandscapeFourThree, + CommonAspectRatio::WideTwentyOneByNine => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::LandscapeSixteenNine, + CommonAspectRatio::TallFourByFive => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::PortraitFourThree, + CommonAspectRatio::TallTwoByThree => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::PortraitFourThree, + CommonAspectRatio::TallNineByTwentyOne => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::PortraitSixteenNine, + + // Semantic values + CommonAspectRatio::Wide => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::LandscapeSixteenNine, + CommonAspectRatio::Tall => BytedanceSeedreamV4p5MultiFunctionImageGenImageSize::PortraitSixteenNine, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_grok_image.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_grok_image.rs new file mode 100644 index 0000000000..29d361f5a1 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_grok_image.rs @@ -0,0 +1,33 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; + +pub fn aspect_ratio_to_grok_image(aspect_ratio: CommonAspectRatio) -> ClientMessageAspectRatio { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => ClientMessageAspectRatio::Square, + CommonAspectRatio::WideThreeByTwo => ClientMessageAspectRatio::WideThreeByTwo, + CommonAspectRatio::TallTwoByThree => ClientMessageAspectRatio::TallTwoByThree, + + // Close enough + CommonAspectRatio::SquareHd => ClientMessageAspectRatio::Square, + + // Non-matching + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => ClientMessageAspectRatio::Square, + + // Mismatch - wide + CommonAspectRatio::Wide + | CommonAspectRatio::WideFiveByFour + | CommonAspectRatio::WideFourByThree + | CommonAspectRatio::WideSixteenByNine + | CommonAspectRatio::WideTwentyOneByNine => ClientMessageAspectRatio::WideThreeByTwo, + + // Mismatch - tall + CommonAspectRatio::Tall + | CommonAspectRatio::TallFourByFive + | CommonAspectRatio::TallThreeByFour + | CommonAspectRatio::TallNineBySixteen + | CommonAspectRatio::TallNineByTwentyOne => ClientMessageAspectRatio::TallTwoByThree, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_sora_native_gpt_image_1.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_sora_native_gpt_image_1.rs new file mode 100644 index 0000000000..e929776e0d --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/aspect_ratio_to_sora_native_gpt_image_1.rs @@ -0,0 +1,33 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use openai_sora_client::requests::image_gen::common::ImageSize; + +pub fn aspect_ratio_to_sora_native_gpt_image_1(aspect_ratio: CommonAspectRatio) -> ImageSize { + match aspect_ratio { + // Exact + CommonAspectRatio::Square => ImageSize::Square, + CommonAspectRatio::Wide => ImageSize::Wide, + CommonAspectRatio::Tall => ImageSize::Tall, + + // Non-matching + CommonAspectRatio::Auto + | CommonAspectRatio::Auto2k + | CommonAspectRatio::Auto4k => ImageSize::Square, + + // Mismatch - square + CommonAspectRatio::SquareHd => ImageSize::Square, + + // Mismatch - wide + CommonAspectRatio::WideThreeByTwo + | CommonAspectRatio::WideFiveByFour + | CommonAspectRatio::WideFourByThree + | CommonAspectRatio::WideSixteenByNine + | CommonAspectRatio::WideTwentyOneByNine => ImageSize::Wide, + + // Mismatch - tall + CommonAspectRatio::TallTwoByThree + | CommonAspectRatio::TallFourByFive + | CommonAspectRatio::TallThreeByFour + | CommonAspectRatio::TallNineBySixteen + | CommonAspectRatio::TallNineByTwentyOne => ImageSize::Tall, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/mod.rs new file mode 100644 index 0000000000..848382b852 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/mod.rs @@ -0,0 +1,13 @@ +pub mod aspect_ratio_to_artcraft_flux_1_dev; +pub mod router_aspect_ratio_to_enums_aspect_ratio; +pub mod aspect_ratio_to_artcraft_flux_1_schnell; +pub mod aspect_ratio_to_artcraft_flux_pro_1p1; +pub mod aspect_ratio_to_artcraft_flux_pro_1p1_ultra; +pub mod aspect_ratio_to_artcraft_gpt_image_1; +pub mod aspect_ratio_to_artcraft_gpt_image_1p5; +pub mod aspect_ratio_to_artcraft_nano_banana; +pub mod aspect_ratio_to_artcraft_nano_banana_pro; +pub mod aspect_ratio_to_artcraft_seedream_4; +pub mod aspect_ratio_to_artcraft_seedream_4p5; +pub mod aspect_ratio_to_grok_image; +pub mod aspect_ratio_to_sora_native_gpt_image_1; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/router_aspect_ratio_to_enums_aspect_ratio.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/router_aspect_ratio_to_enums_aspect_ratio.rs new file mode 100644 index 0000000000..5f72b24b85 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/convert/router_aspect_ratio_to_enums_aspect_ratio.rs @@ -0,0 +1,26 @@ +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsCommonAspectRatio; + +/// Map from the router's CommonAspectRatio to the enums crate's CommonAspectRatio. +pub fn router_aspect_ratio_to_enums_aspect_ratio(ratio: RouterAspectRatio) -> EnumsCommonAspectRatio { + match ratio { + RouterAspectRatio::Auto => EnumsCommonAspectRatio::Auto, + RouterAspectRatio::Square => EnumsCommonAspectRatio::Square, + RouterAspectRatio::WideThreeByTwo => EnumsCommonAspectRatio::WideThreeByTwo, + RouterAspectRatio::WideFourByThree => EnumsCommonAspectRatio::WideFourByThree, + RouterAspectRatio::WideFiveByFour => EnumsCommonAspectRatio::WideFiveByFour, + RouterAspectRatio::WideSixteenByNine => EnumsCommonAspectRatio::WideSixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => EnumsCommonAspectRatio::WideTwentyOneByNine, + RouterAspectRatio::TallTwoByThree => EnumsCommonAspectRatio::TallTwoByThree, + RouterAspectRatio::TallThreeByFour => EnumsCommonAspectRatio::TallThreeByFour, + RouterAspectRatio::TallFourByFive => EnumsCommonAspectRatio::TallFourByFive, + RouterAspectRatio::TallNineBySixteen => EnumsCommonAspectRatio::TallNineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => EnumsCommonAspectRatio::TallNineByTwentyOne, + RouterAspectRatio::Wide => EnumsCommonAspectRatio::Wide, + RouterAspectRatio::Tall => EnumsCommonAspectRatio::Tall, + RouterAspectRatio::Auto2k => EnumsCommonAspectRatio::Auto2k, + RouterAspectRatio::Auto3k => EnumsCommonAspectRatio::Auto3k, + RouterAspectRatio::Auto4k => EnumsCommonAspectRatio::Auto4k, + RouterAspectRatio::SquareHd => EnumsCommonAspectRatio::SquareHd, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/mod.rs new file mode 100644 index 0000000000..9dbd41f347 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/aspect_ratio/mod.rs @@ -0,0 +1,2 @@ +pub mod common_aspect_ratio; +pub mod convert; diff --git a/crates/desktop/artcraft/src/core/api_adapters/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/mod.rs new file mode 100644 index 0000000000..f25f1f213e --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/mod.rs @@ -0,0 +1,4 @@ +pub mod aspect_ratio; +pub mod models; +pub mod quality; +pub mod resolution; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/image/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/models/image/mod.rs new file mode 100644 index 0000000000..3d782c1e3c --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/image/mod.rs @@ -0,0 +1,4 @@ +pub mod tauri_image_model_to_common_model_type; +pub mod tauri_image_model_to_enums_model; +pub mod tauri_image_model_to_generation_model; +pub mod tauri_image_model_to_router_model; diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_common_model_type.rs b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_common_model_type.rs new file mode 100644 index 0000000000..62578bb348 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_common_model_type.rs @@ -0,0 +1,36 @@ +use enums::common::generation::common_model_type::CommonModelType; + +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; + +/// Map TauriImageModel to the legacy CommonModelType (enums crate). +pub fn tauri_image_model_to_common_model_type(model: TauriImageModel) -> CommonModelType { + match model { + TauriImageModel::Flux1Dev => CommonModelType::Flux1Dev, + TauriImageModel::Flux1Schnell => CommonModelType::Flux1Schnell, + TauriImageModel::FluxPro11 => CommonModelType::FluxPro11, + TauriImageModel::FluxPro11Ultra => CommonModelType::FluxPro11Ultra, + TauriImageModel::GrokImage => CommonModelType::GrokImage, + TauriImageModel::Recraft3 => CommonModelType::Recraft3, + TauriImageModel::GptImage1 => CommonModelType::GptImage1, + TauriImageModel::GptImage1p5 => CommonModelType::GptImage1p5, + TauriImageModel::GptImage2 => CommonModelType::GptImage2, + TauriImageModel::GptImage2p5Flare => CommonModelType::GptImage2p5Flare, + TauriImageModel::GptImage2p5Sunburst => CommonModelType::GptImage2p5Sunburst, + TauriImageModel::Gemini25Flash => CommonModelType::NanoBanana, + TauriImageModel::NanoBanana => CommonModelType::NanoBanana, + TauriImageModel::NanoBanana2 => CommonModelType::NanoBanana2, + TauriImageModel::NanoBananaPro => CommonModelType::NanoBananaPro, + TauriImageModel::Seedream4 => CommonModelType::Seedream4, + TauriImageModel::Seedream4p5 => CommonModelType::Seedream4p5, + TauriImageModel::Seedream5Lite => CommonModelType::Seedream5Lite, + TauriImageModel::Midjourney => CommonModelType::Midjourney, + TauriImageModel::Midjourney7 => CommonModelType::Midjourney7, + TauriImageModel::Midjourney7Niji => CommonModelType::Midjourney7Niji, + TauriImageModel::Midjourney8 => CommonModelType::Midjourney8, + TauriImageModel::FluxProKontextMax => CommonModelType::FluxProKontextMax, + TauriImageModel::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles, + TauriImageModel::Flux2LoraAngles => CommonModelType::Flux2LoraAngles, + TauriImageModel::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut, + TauriImageModel::FluxPro1 => CommonModelType::FluxPro1, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs new file mode 100644 index 0000000000..8e3a3498cd --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs @@ -0,0 +1,38 @@ +use enums::common::generation::common_image_model::CommonImageModel; + +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; + +/// Map TauriImageModel to the omni endpoint's CommonImageModel (enums crate). +/// Returns None for models not yet supported by the omni endpoint. +pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option { + match model { + TauriImageModel::Flux1Dev => Some(CommonImageModel::Flux1Dev), + TauriImageModel::Flux1Schnell => Some(CommonImageModel::Flux1Schnell), + TauriImageModel::FluxPro1 => Some(CommonImageModel::FluxPro11), // TODO: Might be a slight mismatch + TauriImageModel::FluxPro11 => Some(CommonImageModel::FluxPro11), + TauriImageModel::FluxPro11Ultra => Some(CommonImageModel::FluxPro11Ultra), + TauriImageModel::GptImage1 => Some(CommonImageModel::GptImage1), + TauriImageModel::GptImage1p5 => Some(CommonImageModel::GptImage1p5), + TauriImageModel::GptImage2 => Some(CommonImageModel::GptImage2), + TauriImageModel::GptImage2p5Flare => Some(CommonImageModel::GptImage2p5Flare), + TauriImageModel::GptImage2p5Sunburst => Some(CommonImageModel::GptImage2p5Sunburst), + TauriImageModel::NanoBanana => Some(CommonImageModel::NanoBanana), + TauriImageModel::NanoBanana2 => Some(CommonImageModel::NanoBanana2), + TauriImageModel::NanoBananaPro => Some(CommonImageModel::NanoBananaPro), + TauriImageModel::Gemini25Flash => Some(CommonImageModel::NanoBanana), + TauriImageModel::Seedream4 => Some(CommonImageModel::Seedream4), + TauriImageModel::Seedream4p5 => Some(CommonImageModel::Seedream4p5), + TauriImageModel::Seedream5Lite => Some(CommonImageModel::Seedream5Lite), + TauriImageModel::QwenEdit2511Angles => Some(CommonImageModel::QwenEdit2511Angles), + TauriImageModel::Flux2LoraAngles => Some(CommonImageModel::Flux2LoraAngles), + TauriImageModel::Midjourney7 => Some(CommonImageModel::Midjourney7), + TauriImageModel::Midjourney7Niji => Some(CommonImageModel::Midjourney7Niji), + TauriImageModel::Midjourney8 => Some(CommonImageModel::Midjourney8), + // Not accounted for yet + TauriImageModel::GrokImage => None, + TauriImageModel::Recraft3 => None, + TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider. + TauriImageModel::FluxProKontextMax => None, + TauriImageModel::FluxDevJuggernaut => None, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_generation_model.rs b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_generation_model.rs new file mode 100644 index 0000000000..182e5e8453 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_generation_model.rs @@ -0,0 +1,35 @@ +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; +use crate::core::events::generation_events::common::GenerationModel; + +/// Map TauriImageModel to GenerationModel for frontend events. +pub fn tauri_image_model_to_generation_model(model: TauriImageModel) -> GenerationModel { + match model { + TauriImageModel::Flux1Dev => GenerationModel::Flux1Dev, + TauriImageModel::Flux1Schnell => GenerationModel::Flux1Schnell, + TauriImageModel::FluxPro11 => GenerationModel::FluxPro11, + TauriImageModel::FluxPro11Ultra => GenerationModel::FluxPro11Ultra, + TauriImageModel::GrokImage => GenerationModel::GrokImage, + TauriImageModel::Recraft3 => GenerationModel::Flux1Dev, // Fallback + TauriImageModel::GptImage1 => GenerationModel::GptImage1, + TauriImageModel::GptImage1p5 => GenerationModel::GptImage1p5, + TauriImageModel::GptImage2 => GenerationModel::GptImage2, + TauriImageModel::GptImage2p5Flare => GenerationModel::GptImage2p5Flare, + TauriImageModel::GptImage2p5Sunburst => GenerationModel::GptImage2p5Sunburst, + TauriImageModel::Gemini25Flash => GenerationModel::NanoBanana, + TauriImageModel::NanoBanana => GenerationModel::NanoBanana, + TauriImageModel::NanoBanana2 => GenerationModel::NanoBanana2, + TauriImageModel::NanoBananaPro => GenerationModel::NanoBananaPro, + TauriImageModel::Seedream4 => GenerationModel::Seedream4, + TauriImageModel::Seedream4p5 => GenerationModel::Seedream4p5, + TauriImageModel::Seedream5Lite => GenerationModel::Seedream5Lite, + TauriImageModel::Midjourney => GenerationModel::Midjourney, + TauriImageModel::Midjourney7 => GenerationModel::Midjourney7, + TauriImageModel::Midjourney7Niji => GenerationModel::Midjourney7Niji, + TauriImageModel::Midjourney8 => GenerationModel::Midjourney8, + TauriImageModel::FluxProKontextMax => GenerationModel::FluxProKontextMax, + TauriImageModel::QwenEdit2511Angles => GenerationModel::QwenEdit2511Angles, + TauriImageModel::Flux2LoraAngles => GenerationModel::Flux2LoraAngles, + TauriImageModel::FluxDevJuggernaut => GenerationModel::FluxDevJuggernaut, + TauriImageModel::FluxPro1 => GenerationModel::FluxPro1, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_router_model.rs b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_router_model.rs new file mode 100644 index 0000000000..a7a5d5184c --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_router_model.rs @@ -0,0 +1,38 @@ +use artcraft_router::api::router_image_model::RouterImageModel; + +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; + +/// Map TauriImageModel to the artcraft_router's RouterImageModel. +/// Returns None for models not supported by the router (Grok, Midjourney, etc.). +pub fn tauri_image_model_to_router_model(model: TauriImageModel) -> Option { + match model { + TauriImageModel::Flux1Dev => Some(RouterImageModel::Flux1Dev), // Text-to-Image + TauriImageModel::Flux1Schnell => Some(RouterImageModel::Flux1Schnell), // Text-to-Image + TauriImageModel::FluxPro1 => Some(RouterImageModel::FluxPro11), // TODO: Might be a slight mismatch + TauriImageModel::FluxPro11 => Some(RouterImageModel::FluxPro11), // Text-to-Image + TauriImageModel::FluxPro11Ultra => Some(RouterImageModel::FluxPro11Ultra), // Text-to-Image + TauriImageModel::GptImage1 => Some(RouterImageModel::GptImage1), // Text-to-Image + TauriImageModel::GptImage1p5 => Some(RouterImageModel::GptImage1p5), // Text-to-Image + TauriImageModel::GptImage2 => Some(RouterImageModel::GptImage2), // Text-to-Image + TauriImageModel::GptImage2p5Flare => Some(RouterImageModel::GptImage2p5Flare), // Text-to-Image + TauriImageModel::GptImage2p5Sunburst => Some(RouterImageModel::GptImage2p5Sunburst), // Text-to-Image + TauriImageModel::NanoBanana => Some(RouterImageModel::NanoBanana), // Text-to-Image + TauriImageModel::NanoBanana2 => Some(RouterImageModel::NanoBanana2), // Text-to-Image + TauriImageModel::NanoBananaPro => Some(RouterImageModel::NanoBananaPro), // Text-to-Image + TauriImageModel::Gemini25Flash => Some(RouterImageModel::NanoBanana), // Text-to-Image + TauriImageModel::Seedream4 => Some(RouterImageModel::Seedream4), // Text-to-Image + TauriImageModel::Seedream4p5 => Some(RouterImageModel::Seedream4p5), // Text-to-Image + TauriImageModel::Seedream5Lite => Some(RouterImageModel::Seedream5Lite), // Text-to-Image + TauriImageModel::QwenEdit2511Angles => Some(RouterImageModel::QwenEdit2511Angles), + TauriImageModel::Flux2LoraAngles => Some(RouterImageModel::Flux2LoraAngles), + TauriImageModel::Midjourney7 => Some(RouterImageModel::Midjourney7), + TauriImageModel::Midjourney7Niji => Some(RouterImageModel::Midjourney7Niji), + TauriImageModel::Midjourney8 => Some(RouterImageModel::Midjourney8), + // Not accounted for yet + TauriImageModel::GrokImage => None, + TauriImageModel::Recraft3 => None, + TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider. + TauriImageModel::FluxProKontextMax => None, + TauriImageModel::FluxDevJuggernaut => None, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/models/mod.rs new file mode 100644 index 0000000000..c10a6ca15e --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/mod.rs @@ -0,0 +1,2 @@ +pub mod image; +pub mod video; diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/video/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/models/video/mod.rs new file mode 100644 index 0000000000..b2235723ed --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/video/mod.rs @@ -0,0 +1,2 @@ +pub mod tauri_video_model_to_generation_model; +pub mod tauri_video_model_to_router_model; diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/video/tauri_video_model_to_generation_model.rs b/crates/desktop/artcraft/src/core/api_adapters/models/video/tauri_video_model_to_generation_model.rs new file mode 100644 index 0000000000..dad373a5de --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/video/tauri_video_model_to_generation_model.rs @@ -0,0 +1,31 @@ +use crate::core::commands::generate::generate_video::request::TauriVideoModel; +use crate::core::events::generation_events::common::GenerationModel; + +/// Map TauriVideoModel to the GenerationModel used in events and the task database. +/// NB: GenerationModel serializations are stored in the tasks database — they keep +/// their legacy spellings for compatibility. +pub fn tauri_video_model_to_generation_model(model: TauriVideoModel) -> GenerationModel { + match model { + TauriVideoModel::GrokVideo => GenerationModel::GrokVideo, + TauriVideoModel::GrokImagineVideo1p5 => GenerationModel::GrokImagineVideo1p5, + TauriVideoModel::HappyHorse1p0 => GenerationModel::HappyHorse1p0, + TauriVideoModel::Kling16Pro => GenerationModel::Kling1_6, + TauriVideoModel::Kling21Master => GenerationModel::Kling21Master, + TauriVideoModel::Kling21Pro => GenerationModel::Kling21Pro, + TauriVideoModel::Kling2p5TurboPro => GenerationModel::Kling2p5TurboPro, + TauriVideoModel::Kling2p6Pro => GenerationModel::Kling2p6Pro, + TauriVideoModel::Kling3p0Pro => GenerationModel::Kling3p0Pro, + TauriVideoModel::Kling3p0Standard => GenerationModel::Kling3p0Standard, + TauriVideoModel::Seedance10Lite => GenerationModel::Seedance10Lite, + TauriVideoModel::Seedance1p5Pro => GenerationModel::Seedance1p5Pro, + TauriVideoModel::Seedance2p0 => GenerationModel::Seedance2p0, + TauriVideoModel::Seedance2p0Fast => GenerationModel::Seedance2p0Fast, + TauriVideoModel::Sora2 => GenerationModel::Sora2, + TauriVideoModel::Sora2Pro => GenerationModel::Sora2Pro, + TauriVideoModel::Veo2 => GenerationModel::Veo2, + TauriVideoModel::Veo3 => GenerationModel::Veo3, + TauriVideoModel::Veo3Fast => GenerationModel::Veo3Fast, + TauriVideoModel::Veo3p1 => GenerationModel::Veo3p1, + TauriVideoModel::Veo3p1Fast => GenerationModel::Veo3p1Fast, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/video/tauri_video_model_to_router_model.rs b/crates/desktop/artcraft/src/core/api_adapters/models/video/tauri_video_model_to_router_model.rs new file mode 100644 index 0000000000..008c41690a --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/models/video/tauri_video_model_to_router_model.rs @@ -0,0 +1,30 @@ +use artcraft_router::api::router_video_model::RouterVideoModel; + +use crate::core::commands::generate::generate_video::request::TauriVideoModel; + +/// Map TauriVideoModel to the artcraft_router's RouterVideoModel. +pub fn tauri_video_model_to_router_model(model: TauriVideoModel) -> RouterVideoModel { + match model { + TauriVideoModel::GrokVideo => RouterVideoModel::GrokImagineVideo, + TauriVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5, + TauriVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0, + TauriVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro, + TauriVideoModel::Kling21Master => RouterVideoModel::Kling21Master, + TauriVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro, + TauriVideoModel::Kling2p5TurboPro => RouterVideoModel::Kling2p5TurboPro, + TauriVideoModel::Kling2p6Pro => RouterVideoModel::Kling2p6Pro, + TauriVideoModel::Kling3p0Pro => RouterVideoModel::Kling3p0Pro, + TauriVideoModel::Kling3p0Standard => RouterVideoModel::Kling3p0Standard, + TauriVideoModel::Seedance10Lite => RouterVideoModel::Seedance10Lite, + TauriVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro, + TauriVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0, + TauriVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast, + TauriVideoModel::Sora2 => RouterVideoModel::Sora2, + TauriVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro, + TauriVideoModel::Veo2 => RouterVideoModel::Veo2, + TauriVideoModel::Veo3 => RouterVideoModel::Veo3, + TauriVideoModel::Veo3Fast => RouterVideoModel::Veo3Fast, + TauriVideoModel::Veo3p1 => RouterVideoModel::Veo3p1, + TauriVideoModel::Veo3p1Fast => RouterVideoModel::Veo3p1Fast, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs b/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs new file mode 100644 index 0000000000..a28d9522d6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs @@ -0,0 +1,21 @@ +use serde_derive::{Deserialize, Serialize}; +use artcraft_router::api::router_quality::RouterQuality; + +/// Just cargo culting this. might not be necessary for standalone types anymore +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommonQuality { + High, + Medium, + Low, +} + +impl CommonQuality { + pub fn to_artcraft_router_type(&self) -> RouterQuality { + match self { + CommonQuality::High => RouterQuality::High, + CommonQuality::Medium => RouterQuality::Medium, + CommonQuality::Low => RouterQuality::Low, + } + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/quality/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/quality/mod.rs new file mode 100644 index 0000000000..1f4f0aaad0 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/quality/mod.rs @@ -0,0 +1 @@ +pub mod common_quality; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/api_adapters/resolution/common_resolution.rs b/crates/desktop/artcraft/src/core/api_adapters/resolution/common_resolution.rs new file mode 100644 index 0000000000..3b401c02e4 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/resolution/common_resolution.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; + +/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation. +/// Not every model will support every resolution +/// In the case a model doesn't support the resolution, gracefully pick the nearest option. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommonResolution { + /// Models: Nano Banana Pro + OneK, + /// Models: Nano Banana Pro + TwoK, + /// Models: (None) + ThreeK, + /// Models: Nano Banana Pro + FourK, + + /// Used by Nano Banana 2 (and others) + HalfK, + + /// Used by the Seedance family (and others) + FourEightyP, + + /// Used by the Seedance family (and others) + SevenTwentyP, + + /// Used by the Seedance family (and others) + TenEightyP, +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/mod.rs new file mode 100644 index 0000000000..332209b3cb --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/mod.rs @@ -0,0 +1,2 @@ +pub mod resolution_to_artcraft_nano_banana_pro; +pub mod router_resolution_to_enums_resolution; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/resolution_to_artcraft_nano_banana_pro.rs b/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/resolution_to_artcraft_nano_banana_pro.rs new file mode 100644 index 0000000000..a880943bcc --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/resolution_to_artcraft_nano_banana_pro.rs @@ -0,0 +1,17 @@ +use crate::core::api_adapters::resolution::common_resolution::CommonResolution; +use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenImageResolution; + +pub fn resolution_to_artcraft_nano_banana_pro(resolution: CommonResolution) -> NanoBananaProMultiFunctionImageGenImageResolution { + match resolution { + CommonResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK, + CommonResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK, + // NB: ThreeK doesn't exist in Nano Banana Pro, use FourK as nearest higher resolution + CommonResolution::ThreeK => NanoBananaProMultiFunctionImageGenImageResolution::FourK, + CommonResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK, + // Fall back to nearest supported resolution + CommonResolution::HalfK => NanoBananaProMultiFunctionImageGenImageResolution::OneK, + CommonResolution::FourEightyP => NanoBananaProMultiFunctionImageGenImageResolution::OneK, + CommonResolution::SevenTwentyP => NanoBananaProMultiFunctionImageGenImageResolution::OneK, + CommonResolution::TenEightyP => NanoBananaProMultiFunctionImageGenImageResolution::OneK, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/router_resolution_to_enums_resolution.rs b/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/router_resolution_to_enums_resolution.rs new file mode 100644 index 0000000000..3dbbce6766 --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/resolution/convert/router_resolution_to_enums_resolution.rs @@ -0,0 +1,16 @@ +use artcraft_router::api::router_resolution::RouterResolution; +use enums::common::generation::common_resolution::CommonResolution as EnumsCommonResolution; + +/// Map from the router's CommonResolution to the enums crate's CommonResolution. +pub fn router_resolution_to_enums_resolution(res: RouterResolution) -> EnumsCommonResolution { + match res { + RouterResolution::OneK => EnumsCommonResolution::OneK, + RouterResolution::TwoK => EnumsCommonResolution::TwoK, + RouterResolution::ThreeK => EnumsCommonResolution::ThreeK, + RouterResolution::FourK => EnumsCommonResolution::FourK, + RouterResolution::HalfK => EnumsCommonResolution::HalfK, + RouterResolution::FourEightyP => EnumsCommonResolution::FourEightyP, + RouterResolution::SevenTwentyP => EnumsCommonResolution::SevenTwentyP, + RouterResolution::TenEightyP => EnumsCommonResolution::TenEightyP, + } +} diff --git a/crates/desktop/artcraft/src/core/api_adapters/resolution/mod.rs b/crates/desktop/artcraft/src/core/api_adapters/resolution/mod.rs new file mode 100644 index 0000000000..f1197a3d7b --- /dev/null +++ b/crates/desktop/artcraft/src/core/api_adapters/resolution/mod.rs @@ -0,0 +1,2 @@ +pub mod common_resolution; +pub mod convert; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/artcraft_error.rs b/crates/desktop/artcraft/src/core/artcraft_error.rs new file mode 100644 index 0000000000..e42ebff92f --- /dev/null +++ b/crates/desktop/artcraft/src/core/artcraft_error.rs @@ -0,0 +1,80 @@ +use grok_consumer_client::error::grok_error::GrokError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; +use artcraft_client::error::storyteller_error::StorytellerError; + +#[derive(Debug)] +pub enum ArtcraftError { + AnyhowError(anyhow::Error), + DecodeError(base64::DecodeError), + IoError(std::io::Error), + ReqwestError(reqwest::Error), + // Service errors + GrokError(GrokError), + StorytellerError(StorytellerError), + // Lock errors + RwLockReadError, + RwLockWriteError, + MutexLockError, + // Download errors + BadDownloadFilename { path: PathBuf }, + CannotDownloadFilePathAlreadyExists { path: PathBuf }, +} + +impl Error for ArtcraftError {} + +impl Display for ArtcraftError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::AnyhowError(e) => write!(f, "AnyhowError: {:?}", e), + Self::DecodeError(e) => write!(f, "DecodeError: {:?}", e), + Self::IoError(e) => write!(f, "IoError: {:?}", e), + Self::ReqwestError(e) => write!(f, "ReqwestError: {:?}", e), + Self::GrokError(e) => write!(f, "GrokError: {:?}", e), + Self::StorytellerError(e) => write!(f, "StorytellerError: {:?}", e), + Self::RwLockReadError => write!(f, "RwLockReadError"), + Self::RwLockWriteError => write!(f, "RwLockWriteError"), + Self::MutexLockError => write!(f, "MutexLockError"), + Self::BadDownloadFilename { path } => write!(f, "BadDownloadFilename: {:?}", path), + Self::CannotDownloadFilePathAlreadyExists { path } => write!(f, "CannotDownloadFilePathAlreadyExists {:?}", path), + } + } +} + +impl From for ArtcraftError { + fn from(value: anyhow::Error) -> Self { + Self::AnyhowError(value) + } +} + +impl From for ArtcraftError { + fn from(value: base64::DecodeError) -> Self { + Self::DecodeError(value) + } +} + +impl From for ArtcraftError { + fn from(value: std::io::Error) -> Self { + Self::IoError(value) + } +} + +impl From for ArtcraftError { + fn from(value: reqwest::Error) -> Self { + Self::ReqwestError(value) + } +} + +impl From for ArtcraftError { + fn from(value: GrokError) -> Self { + Self::GrokError(value) + } +} + +impl From for ArtcraftError { + fn from(value: StorytellerError) -> Self { + Self::StorytellerError(value) + } +} + diff --git a/crates/desktop/artcraft/src/core/commands/app_preferences/get_app_preferences_command.rs b/crates/desktop/artcraft/src/core/commands/app_preferences/get_app_preferences_command.rs new file mode 100644 index 0000000000..a42668a668 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/app_preferences/get_app_preferences_command.rs @@ -0,0 +1,74 @@ +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct GetAppPreferencesResponse { + pub preferences: AppPreferencesPayload, +} + +#[derive(Serialize)] +pub struct AppPreferencesPayload { + /// The downloads directory to use when a user downloads a file. + pub preferred_download_directory: PreferredDownloadDirectory, + + /// Play sounds on events. + pub play_sounds: bool, + + /// Key pointing to file; defined in the frontend code. + pub delete_file_sound: Option, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[deprecated] + pub generation_enqueue_sound: Option, +} + + +#[tauri::command] +pub async fn get_app_preferences_command( + app_prefs: State<'_, AppPreferencesManager>, +) -> Result { + info!("get_app_preferences_command called"); + + let result = get_prefs(&app_prefs) + .await + .map_err(|err| { + error!("Error getting app preferences: {:?}", err); + format!("Error getting app preferences: {:?}", err) + })?; + + Ok(GetAppPreferencesResponse { + preferences: result, + }) +} + +async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult { + let prefs = app_prefs.get_clone()?; + Ok(AppPreferencesPayload { + preferred_download_directory: prefs.preferred_download_directory, + play_sounds: prefs.play_sounds, + delete_file_sound: prefs.delete_file_sound, + enqueue_success_sound: prefs.enqueue_success_sound, + enqueue_failure_sound: prefs.enqueue_failure_sound, + generation_success_sound: prefs.generation_success_sound, + generation_failure_sound: prefs.generation_failure_sound, + generation_enqueue_sound: prefs.generation_enqueue_sound, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/app_preferences/mod.rs b/crates/desktop/artcraft/src/core/commands/app_preferences/mod.rs new file mode 100644 index 0000000000..d032ba902b --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/app_preferences/mod.rs @@ -0,0 +1,2 @@ +pub mod get_app_preferences_command; +pub mod update_app_preference_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/app_preferences/update_app_preference_command.rs b/crates/desktop/artcraft/src/core/commands/app_preferences/update_app_preference_command.rs new file mode 100644 index 0000000000..0e8a8ee2c9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/app_preferences/update_app_preference_command.rs @@ -0,0 +1,127 @@ +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::State; + +/// For now, we'll only update a single preference at a time. +#[derive(Deserialize)] +pub struct UpdateAppPreferencesRequest { + pub preference: PreferenceName, + /// We'll decode this with respect to the preference value. + pub value: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum ValueType { + Bool(bool), + String(String), + DownloadDirectory(PreferredDownloadDirectory), +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PreferenceName { + PreferredDownloadDirectory, + PlaySounds, + DeleteFileSound, + EnqueueSuccessSound, + EnqueueFailureSound, + GenerationSuccessSound, + GenerationFailureSound, +} + +#[derive(Serialize)] +pub struct UpdateAppPreferencesResponse { + pub success: bool, +} + +#[tauri::command] +pub async fn update_app_preferences_command( + request: UpdateAppPreferencesRequest, + app_prefs: State<'_, AppPreferencesManager>, + app_data_root: State<'_, AppDataRoot>, +) -> Result { + info!("update_app_preferences_command called"); + + update_prefs(request, &app_prefs, &app_data_root) + .await + .map_err(|err| { + error!("Error getting app preferences: {:?}", err); + format!("Error getting app preferences: {:?}", err) + })?; + + Ok(UpdateAppPreferencesResponse { + success: true, + }) +} + +async fn update_prefs( + request: UpdateAppPreferencesRequest, + app_prefs: &AppPreferencesManager, + app_data_root: &AppDataRoot, +) -> AnyhowResult<()> { + let mut prefs = app_prefs.get_clone()?; + + info!("Value is: {:?}", request.value); + + match request.preference { + PreferenceName::PreferredDownloadDirectory => { + match request.value { + Some(ValueType::DownloadDirectory(dir)) => + prefs.preferred_download_directory = dir, + _ => + return Err(anyhow!("Invalid value: {:?}", request.value)), + } + } + PreferenceName::PlaySounds => { + match request.value { + Some(ValueType::Bool(val)) => + prefs.play_sounds = val, + _ => + return Err(anyhow!("Invalid value: {:?}", request.value)), + } + } + PreferenceName::DeleteFileSound => { + prefs.delete_file_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + PreferenceName::EnqueueSuccessSound => { + prefs.enqueue_success_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + PreferenceName::EnqueueFailureSound => { + prefs.enqueue_failure_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + PreferenceName::GenerationSuccessSound => { + prefs.generation_success_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + PreferenceName::GenerationFailureSound => { + prefs.generation_failure_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + } + + app_prefs.set_clone(&prefs)?; + app_data_root.settings_dir().write_app_preferences(&prefs)?; + + Ok(()) +} + +fn string_value(value: &ValueType) -> AnyhowResult { + match value { + ValueType::String(val) => Ok(val.to_string()), + _ => Err(anyhow!("Invalid value type: {:?}", value)), + } +} diff --git a/crates/desktop/artcraft/src/core/commands/app_state/mod.rs b/crates/desktop/artcraft/src/core/commands/app_state/mod.rs new file mode 100644 index 0000000000..33df1ad78c --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/app_state/mod.rs @@ -0,0 +1 @@ +mod reset_artcraft_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/app_state/reset_artcraft_command.rs b/crates/desktop/artcraft/src/core/commands/app_state/reset_artcraft_command.rs new file mode 100644 index 0000000000..b60bd65666 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/app_state/reset_artcraft_command.rs @@ -0,0 +1,34 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::EnqueueEditImageSuccessResponse; +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::clear_main_webview_window_storyteller_cookies::clear_main_webview_window_storyteller_cookies; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +#[derive(Serialize)] +pub struct ResetArtcraftCommandResponse { + pub success: bool, +} + +impl SerializeMarker for ResetArtcraftCommandResponse {} + +#[tauri::command] +pub async fn reset_artcraft_command( + app: AppHandle, +) -> ResponseOrErrorMessage { + info!("reset_artcraft_command called"); + + // TODO: Reset local vs. production + + Ok(ResetArtcraftCommandResponse { + success: true, + }.into()) +} + diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs new file mode 100644 index 0000000000..8ac6d6671d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs @@ -0,0 +1,11 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::local_error; +use tauri::AppHandle; + +#[tauri::command] +pub async fn estimate_audio_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if !request.uses_artcraft() { + return Err(local_error("No direct provider adapter is configured for this modality.")); + } + omni::estimate(request, Modality::Audio, &app).await +} diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs new file mode 100644 index 0000000000..68a6deca14 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs @@ -0,0 +1,52 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native}; +use tauri::{AppHandle, Manager}; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::ResponseOrError; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{ + EstimateImageCostError, EstimateImageCostErrorType, EstimateImageCostRequest, + EstimateImageCostResponse, +}; +use artcraft_client::endpoints::generate::cost_estimate::image::estimate_image_cost::estimate_image_cost; +use log::{debug, info}; +use tauri::State; + +impl SerializeMarker for EstimateImageCostResponse {} + +#[tauri::command] +pub async fn estimate_image_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if request.uses_artcraft() && !request.uses_legacy_image_endpoint() { + return omni::estimate(request, Modality::Image, &app).await; + } + adapt_legacy_response(estimate_image_cost_native(decode_native(request)?, app.state()).await) +} + +async fn estimate_image_cost_native( + request: EstimateImageCostRequest, + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrError { + debug!("estimate_image_cost_command called: {:?}", request); + + let result = estimate_image_cost( + &app_env_configs.storyteller_host, + None, // Credentials are not required for this endpoint. + request, + ) + .await; + + match result { + Ok(response) => Ok(response.into()), + Err(err) => Err(CommandErrorResponseWrapper { + status: CommandErrorStatus::BadRequest, + error_message: None, + error_type: None, + error_details: Some(EstimateImageCostError { + success: false, + error_type: EstimateImageCostErrorType::InvalidInput, + error_message: err.to_string(), + }), + }), + } +} diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs new file mode 100644 index 0000000000..1af43fa031 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs @@ -0,0 +1,11 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::local_error; +use tauri::AppHandle; + +#[tauri::command] +pub async fn estimate_mesh_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if !request.uses_artcraft() { + return Err(local_error("No direct provider adapter is configured for this modality.")); + } + omni::estimate(request, Modality::Mesh, &app).await +} diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs new file mode 100644 index 0000000000..fef3df75b5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs @@ -0,0 +1,52 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native}; +use tauri::{AppHandle, Manager}; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::ResponseOrError; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use artcraft_api_defs::generate::cost_estimate::estimate_splat_cost::{ + EstimateSplatCostError, EstimateSplatCostErrorType, EstimateSplatCostRequest, + EstimateSplatCostResponse, +}; +use artcraft_client::endpoints::generate::cost_estimate::splat::estimate_splat_cost::estimate_splat_cost; +use log::debug; +use tauri::State; + +impl SerializeMarker for EstimateSplatCostResponse {} + +#[tauri::command] +pub async fn estimate_splat_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if request.uses_artcraft() { + return omni::estimate(request, Modality::Splat, &app).await; + } + adapt_legacy_response(estimate_splat_cost_native(decode_native(request)?, app.state()).await) +} + +async fn estimate_splat_cost_native( + request: EstimateSplatCostRequest, + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrError { + debug!("estimate_splat_cost_command called"); + + let result = estimate_splat_cost( + &app_env_configs.storyteller_host, + None, // Credentials are not required for this endpoint. + request, + ) + .await; + + match result { + Ok(response) => Ok(response.into()), + Err(err) => Err(CommandErrorResponseWrapper { + status: CommandErrorStatus::BadRequest, + error_message: None, + error_type: None, + error_details: Some(EstimateSplatCostError { + success: false, + error_type: EstimateSplatCostErrorType::InvalidInput, + error_message: err.to_string(), + }), + }), + } +} diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs new file mode 100644 index 0000000000..7bf4d83fa5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs @@ -0,0 +1,53 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native}; +use tauri::{AppHandle, Manager}; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::ResponseOrError; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{ + EstimateVideoCostError, EstimateVideoCostErrorType, EstimateVideoCostRequest, + EstimateVideoCostResponse, +}; +use artcraft_client::endpoints::generate::cost_estimate::video::estimate_video_cost::estimate_video_cost; +use log::{debug, info}; +use tauri::State; + +impl SerializeMarker for EstimateVideoCostResponse {} + +#[tauri::command] +pub async fn estimate_video_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if request.uses_artcraft() { + return omni::estimate(request, Modality::Video, &app).await; + } + adapt_legacy_response(estimate_video_cost_native(decode_native(request)?, app.state()).await) +} + +async fn estimate_video_cost_native( + request: EstimateVideoCostRequest, + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrError { + debug!("estimate_video_cost_command called: {:?}", request); + + let result = estimate_video_cost( + &app_env_configs.storyteller_host, + None, // Credentials are not required for this endpoint. + request, + ) + .await; + + match result { + Ok(response) => Ok(response.into()), + Err(err) => Err(CommandErrorResponseWrapper { + status: CommandErrorStatus::BadRequest, + error_message: None, + error_type: None, + error_details: Some(EstimateVideoCostError { + success: false, + error_type: EstimateVideoCostErrorType::InvalidInput, + error_message: err.to_string(), + }), + }), + } +} diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/mod.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/mod.rs new file mode 100644 index 0000000000..4edb9bdbb3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/mod.rs @@ -0,0 +1,6 @@ +pub mod estimate_image_cost_command; +pub mod estimate_splat_cost_command; +pub mod estimate_video_cost_command; + +pub mod estimate_mesh_cost_command; +pub mod estimate_audio_cost_command; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_artcraft_flux_kontext_edit.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_artcraft_flux_kontext_edit.rs new file mode 100644 index 0000000000..5293adb024 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_artcraft_flux_kontext_edit.rs @@ -0,0 +1,131 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageNumImages, FluxProKontextMaxEditImageRequest}; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use artcraft_client::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use tauri::AppHandle; + +pub async fn handle_artcraft_flux_kontext_edit( + request: &EnqueueEditImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + info!("Calling Artcraft flux kontext max edit (edit) ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let num_images = match request.image_count { + None => None, + Some(1) => Some(FluxProKontextMaxEditImageNumImages::One), + Some(2) => Some(FluxProKontextMaxEditImageNumImages::Two), + Some(3) => Some(FluxProKontextMaxEditImageNumImages::Three), + Some(4) => Some(FluxProKontextMaxEditImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + }, + }; + + let mut maybe_media_token = None; + + let list_count = request.image_media_tokens + .as_ref() + .map(|tokens| tokens.len()) + .unwrap_or(0); + + if request.scene_image_media_token.is_some() && list_count > 0 { + return Err(GenerateError::BadInput(BadInputReason::WrongImageArguments( + "Cannot specify both scene_image_media_token and image_media_tokens for Flux Kontext Max".to_string()))); + } + + if list_count > 1 { + return Err(GenerateError::BadInput(BadInputReason::WrongImageArguments( + "Cannot specify more than one image in image_media_tokens for Flux Kontext Max".to_string()))); + } + + if let Some(media_token) = &request.scene_image_media_token { + maybe_media_token = Some(media_token.clone()); + } + + let maybe_list_media_token = request.image_media_tokens + .as_ref() + .map(|tokens| tokens.first()) + .flatten(); + + if let Some(media_token) = maybe_list_media_token { + maybe_media_token = Some(media_token.clone()); + } + + let media_token = match maybe_media_token { + Some(token) => token, + None => { + return Err(GenerateError::BadInput(BadInputReason::WrongImageArguments( + "No input image specified for Flux Kontext Max edit".to_string()))); + }, + }; + + let request = FluxProKontextMaxEditImageRequest { + uuid_idempotency_token, + prompt: Some(request.prompt.clone()), + image_media_token: media_token, + num_images, + }; + + let result = flux_pro_kontext_max_edit_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft gpt-image-1. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft gpt-image-1: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::FluxProKontextMax), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_artcraft_gpt_image_1_edit.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_artcraft_gpt_image_1_edit.rs new file mode 100644 index 0000000000..a7b7cda139 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_artcraft_gpt_image_1_edit.rs @@ -0,0 +1,129 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use tauri::AppHandle; + +const MAX_IMAGES: usize = 4; + +pub async fn handle_artcraft_gpt_image_1_edit( + request: &EnqueueEditImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + info!("Calling Artcraft gpt-image-1 (edit) ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let image_quality = request.image_quality + .map(|quality| match quality { + EditImageQuality::Auto => GptImage1EditImageImageQuality::Auto, + EditImageQuality::High => GptImage1EditImageImageQuality::High, + EditImageQuality::Medium => GptImage1EditImageImageQuality::Medium, + EditImageQuality::Low => GptImage1EditImageImageQuality::Low, + }); + + let image_size = request.aspect_ratio + .map(|size| match size { + EditImageSize::Auto => GptImage1EditImageImageSize::Square, + EditImageSize::Square => GptImage1EditImageImageSize::Square, + EditImageSize::Tall => GptImage1EditImageImageSize::Vertical, + EditImageSize::Wide => GptImage1EditImageImageSize::Horizontal, + }); + + let num_images = match request.image_count { + None => None, + Some(1) => Some(GptImage1EditImageNumImages::One), + Some(2) => Some(GptImage1EditImageNumImages::Two), + Some(3) => Some(GptImage1EditImageNumImages::Three), + Some(4) => Some(GptImage1EditImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + }, + }; + + let mut media_tokens = Vec::with_capacity(10); + + if let Some(scene_image_media_token) = request.scene_image_media_token.clone() { + media_tokens.push(scene_image_media_token); + } + + if let Some(image_media_tokens) = request.image_media_tokens.as_ref() { + media_tokens.extend_from_slice(image_media_tokens); + } + + if media_tokens.len() > MAX_IMAGES { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { + min: 1, + max: MAX_IMAGES as u32, + provided: media_tokens.len() as u32, + })); + } + + let request = GptImage1EditImageRequest { + uuid_idempotency_token, + prompt: Some(request.prompt.clone()), + image_media_tokens: Some(media_tokens), + image_size, + num_images, + image_quality, + }; + + let result = gpt_image_1_edit_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft gpt-image-1. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft gpt-image-1: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::GptImage1), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft.rs new file mode 100644 index 0000000000..0cf70d6d05 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft.rs @@ -0,0 +1,38 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::deprecated::image_edit::artcraft::handle_artcraft_flux_kontext_edit::handle_artcraft_flux_kontext_edit; +use crate::core::commands::deprecated::image_edit::artcraft::handle_artcraft_gpt_image_1_edit::handle_artcraft_gpt_image_1_edit; +use crate::core::commands::deprecated::image_edit::artcraft::handle_image_edit_artcraft_via_router::handle_image_edit_artcraft_via_router; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EnqueueEditImageCommand, ImageEditModel}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_router::api::router_image_model::RouterImageModel; +use tauri::AppHandle; + +pub async fn handle_image_edit_artcraft( + model: ImageEditModel, + request: &EnqueueEditImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + match model { + ImageEditModel::FluxProKontextMax => handle_artcraft_flux_kontext_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await, + ImageEditModel::Gemini25Flash | ImageEditModel::NanoBanana => { + handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::NanoBanana, GenerationModel::NanoBanana).await + }, + ImageEditModel::NanoBanana2 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::NanoBanana2, GenerationModel::NanoBanana2).await, + ImageEditModel::NanoBananaPro => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::NanoBananaPro, GenerationModel::NanoBananaPro).await, + ImageEditModel::GptImage1 => handle_artcraft_gpt_image_1_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await, + ImageEditModel::GptImage1p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::GptImage1p5, GenerationModel::GptImage1p5).await, + ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Seedream4, GenerationModel::Seedream4).await, + ImageEditModel::Seedream4p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Seedream4p5, GenerationModel::Seedream4p5).await, + ImageEditModel::Seedream5Lite => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await, + ImageEditModel::QwenEdit2511Angles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::QwenEdit2511Angles, GenerationModel::QwenEdit2511Angles).await, + ImageEditModel::Flux2LoraAngles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Flux2LoraAngles, GenerationModel::Flux2LoraAngles).await, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs new file mode 100644 index 0000000000..3b9895339d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs @@ -0,0 +1,172 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio as DesktopCommonAspectRatio; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ + EditImageResolution, EditImageSize, EnqueueEditImageCommand, +}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_image_model::RouterImageModel; +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::client::router_artcraft_client::RouterArtcraftClient; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; + +pub(super) const MAX_EDIT_IMAGES: usize = 10; + +pub(super) async fn handle_image_edit_artcraft_via_router( + request: &EnqueueEditImageCommand, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, + model: RouterImageModel, + generation_model: GenerationModel, +) -> Result { + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials)), + }; + + let client = RouterClient::Artcraft(RouterArtcraftClient::new( + app_env_configs.storyteller_host.clone(), + creds, + )); + + let mut media_tokens = Vec::with_capacity(MAX_EDIT_IMAGES); + + if let Some(scene_token) = request.scene_image_media_token.clone() { + media_tokens.push(scene_token); + } + if let Some(tokens) = request.image_media_tokens.as_ref() { + media_tokens.extend_from_slice(tokens); + } + + if media_tokens.len() > MAX_EDIT_IMAGES { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { + min: 1, + max: MAX_EDIT_IMAGES as u32, + provided: media_tokens.len() as u32, + })); + } + + let image_inputs = if media_tokens.is_empty() { + None + } else { + Some(ImageListRef::MediaFileTokens(media_tokens)) + }; + + let aspect_ratio = get_aspect_ratio_edit(request); + let resolution = get_resolution_edit(request); + + let router_request = GenerateImageRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: Some(request.prompt.clone()), + image_inputs, + resolution, + aspect_ratio, + quality: None, // TODO(bt): Add quality + image_batch_count: request.image_count.map(|n| n as u16), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, + }; + + let dor = router_request.build2()?; + + info!("Image Generation Request: {:?}", dor); + + let request = match dor { + ImageGenerationDraftOrRequest::Request(req) => req, + // Artcraft never returns a Draft — image-token resolution happens server-side. + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft router should never produce a draft for image edits" + ), + }; + + let response = match request.send_request(&client).await { + Ok(resp) => { + info!("Successfully enqueued."); + resp + } + Err(err) => { + error!("Failed to enqueue: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + let job_id = response + .get_artcraft_payload() + .map(|p| p.inference_job_token.to_string()) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + info!("Job token: {}", job_id); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(generation_model), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_id), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_aspect_ratio_edit(request: &EnqueueEditImageCommand) -> Option { + if let Some(ar) = request.common_aspect_ratio { + return Some(convert_desktop_aspect_ratio(ar)); + } + if let Some(ar) = request.aspect_ratio { + return Some(match ar { + EditImageSize::Auto => RouterAspectRatio::Auto, + EditImageSize::Square => RouterAspectRatio::Square, + EditImageSize::Wide => RouterAspectRatio::Wide, + EditImageSize::Tall => RouterAspectRatio::Tall, + }); + } + None +} + +fn get_resolution_edit(request: &EnqueueEditImageCommand) -> Option { + request.image_resolution.map(|res| match res { + EditImageResolution::OneK => RouterResolution::OneK, + EditImageResolution::TwoK => RouterResolution::TwoK, + EditImageResolution::FourK => RouterResolution::FourK, + }) +} + +fn convert_desktop_aspect_ratio(ar: DesktopCommonAspectRatio) -> RouterAspectRatio { + match ar { + DesktopCommonAspectRatio::Auto => RouterAspectRatio::Auto, + DesktopCommonAspectRatio::Square => RouterAspectRatio::Square, + DesktopCommonAspectRatio::WideThreeByTwo => RouterAspectRatio::WideThreeByTwo, + DesktopCommonAspectRatio::WideFourByThree => RouterAspectRatio::WideFourByThree, + DesktopCommonAspectRatio::WideFiveByFour => RouterAspectRatio::WideFiveByFour, + DesktopCommonAspectRatio::WideSixteenByNine => RouterAspectRatio::WideSixteenByNine, + DesktopCommonAspectRatio::WideTwentyOneByNine => RouterAspectRatio::WideTwentyOneByNine, + DesktopCommonAspectRatio::TallTwoByThree => RouterAspectRatio::TallTwoByThree, + DesktopCommonAspectRatio::TallThreeByFour => RouterAspectRatio::TallThreeByFour, + DesktopCommonAspectRatio::TallFourByFive => RouterAspectRatio::TallFourByFive, + DesktopCommonAspectRatio::TallNineBySixteen => RouterAspectRatio::TallNineBySixteen, + DesktopCommonAspectRatio::TallNineByTwentyOne => RouterAspectRatio::TallNineByTwentyOne, + DesktopCommonAspectRatio::Wide => RouterAspectRatio::Wide, + DesktopCommonAspectRatio::Tall => RouterAspectRatio::Tall, + DesktopCommonAspectRatio::Auto2k => RouterAspectRatio::Auto2k, + DesktopCommonAspectRatio::Auto4k => RouterAspectRatio::Auto4k, + DesktopCommonAspectRatio::SquareHd => RouterAspectRatio::SquareHd, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/mod.rs new file mode 100644 index 0000000000..49b2aaf980 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/mod.rs @@ -0,0 +1,5 @@ +mod handle_image_edit_artcraft_via_router; +mod handle_artcraft_flux_kontext_edit; +mod handle_artcraft_gpt_image_1_edit; + +pub (super) mod handle_image_edit_artcraft; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/enqueue_edit_image_command.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/enqueue_edit_image_command.rs new file mode 100644 index 0000000000..c23b0db205 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/enqueue_edit_image_command.rs @@ -0,0 +1,424 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use crate::core::api_adapters::quality::common_quality::CommonQuality; +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::deprecated::image_edit::artcraft::handle_image_edit_artcraft::handle_image_edit_artcraft; +use crate::core::commands::deprecated::image_edit::image_edit_models::image_edit_model_to_model_type; +use crate::core::commands::deprecated::image_edit::sora::handle_image_edit_sora::handle_image_edit_sora; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::utils::api_host::ApiHost; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowError; +use log::{error, info, warn}; +use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest}; +use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use serde_derive::{Deserialize, Serialize}; +use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs}; +use std::time::Duration; +use tauri::{AppHandle, Manager, State}; +use tokens::tokens::media_files::MediaFileToken; + +/// This is used in the Tauri command bridge. +/// Don't change the serializations without coordinating with the frontend. +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ImageEditModel { + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + + #[serde(rename = "nano_banana")] + NanoBanana, + + #[serde(rename = "nano_banana_2")] + NanoBanana2, + + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + + #[serde(rename = "gpt_image_1")] + GptImage1, + + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + + #[serde(rename = "seedream_4")] + Seedream4, + + #[serde(rename = "seedream_4p5")] + Seedream4p5, + + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + +// #[serde(rename = "qwen")] +// Qwen, +// +// #[serde(rename = "seededit_3")] +// SeedEdit3, +} + +#[derive(Deserialize, Debug)] +pub struct EnqueueEditImageCommand { + /// The provider to use (defaults to Artcraft/Storyteller). + /// Not all (provider, model) combinations are valid. + pub provider: Option, + + /// The model to use. + pub model: Option, + + /// Images to use for the image edit. + /// The first image is typically a 2D canvas or 3D stage, but doesn't have to be. + /// There must be at least one image. + pub image_media_tokens: Option>, + + /// If set, this becomes the first image in the image media tokens (pushing back + /// each of the `image_media_tokens` by one). + /// This is useful if we want to do prompt engineering. + pub scene_image_media_token: Option, + + /// The user's image generation prompt. + pub prompt: String, + + /// Turn off the system prompt. + pub disable_system_prompt: Option, + + /// Number of images to generate. + pub image_count: Option, + + /// Aspect ratio. + #[deprecated(note="use common_aspect_ratio")] + pub aspect_ratio: Option, + + /// New field for aspect ratio. + /// Not all models support each of these aspect ratios, but we can choose or interpolate sensibly. + pub common_aspect_ratio: Option, + + /// Image quality. + pub image_quality: Option, + + /// Image resolution + pub image_resolution: Option, + + /// Quality. + /// Tends to be used by OpenAI models. + pub quality: Option, + + /// OPTIONAL. + /// Only for image angle edit models. + pub horizontal_angle: Option, + + /// OPTIONAL. + /// Only for image angle edit models. + pub vertical_angle: Option, + + /// OPTIONAL. + /// Only for image angle edit models. + pub zoom: Option, + + /// OPTIONAL. + /// Name of the frontend caller. + /// We'll use this to selectively trigger events. + pub frontend_caller: Option, + + /// OPTIONAL. + /// A frontend-defined identifier that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// OPTIONAL. + /// A frontend-defined payload that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum EditImageSize { + Auto, + Square, + Wide, + Tall, +} + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum EditImageQuality { + Auto, + High, + Medium, + Low, +} + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum EditImageResolution { + /// 1K - nano banana pro + OneK, + /// 2K - nano banana pro + TwoK, + /// 4K - nano banana pro + FourK, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueEditImageErrorType { + /// Caller didn't specify a model + ModelNotSpecified, + + /// No model available for image generation + NoProviderAvailable, + + /// Generic bad request error + BadRequest, + + /// Generic server error + ServerError, + + /// The user is sending too many requests + TooManyConcurrentTasks, + /// User is not logged into Sora! + SoraLoginRequired, + /// The user needs to create a Sora account + SoraUsernameNotYetCreated, + /// The Sora service is having problems. Try again soon. + SoraIsHavingProblems, +} + +#[derive(Serialize)] +pub struct EnqueueEditImageSuccessResponse { +} + +impl SerializeMarker for EnqueueEditImageSuccessResponse {} + +#[tauri::command] +pub async fn enqueue_edit_image_command( + app: AppHandle, + request: EnqueueEditImageCommand, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + provider_priority_store: State<'_, ProviderPriorityStore>, + task_database: State<'_, TaskDatabase>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + sora_creds_manager: State<'_, SoraCredentialManager>, + sora_task_queue: State<'_, SoraTaskQueue>, +) -> ResponseOrErrorType { + + info!("enqueue_edit_image_command called; image media tokens : {:?}, full request: {:?}", + &request.image_media_tokens, &request); + + let result = handle_request( + &request, + &app, + &app_data_root, + &app_env_configs, + &artcraft_usage_tracker, + &provider_priority_store, + &task_database, + &storyteller_creds_manager, + &sora_creds_manager, + &sora_task_queue, + ).await; + + match result { + Err(err) => { + error!("Error enqueuing contextual edit image: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + // TODO: Derive from err. Make service provider optional. + let event = GenerationEnqueueFailureEvent { + action: GenerationAction::GenerateImage, + service: GenerationServiceProvider::Artcraft, // FIXME: This is wrong. + model: None, + reason: None, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + Err(error_to_tauri_response(err)) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: event.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(EnqueueEditImageSuccessResponse {}.into()) + } + } +} + +pub async fn handle_request( + request: &EnqueueEditImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + artcraft_usage_tracker: &ArtcraftUsageTracker, + provider_priority_store: &ProviderPriorityStore, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let model = match request.model { + Some(model) => model, + None => { + return Err(GenerateError::no_model_specified()) + } + }; + + let provider = request.provider + .unwrap_or(GenerationProvider::Artcraft); + + info!("edit image with {:?} via provider {:?}", &model, &provider); + + let mut success_event = match provider { + GenerationProvider::Artcraft => { + handle_image_edit_artcraft( + model, + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await? + } + GenerationProvider::Sora => { + handle_image_edit_sora( + model, + request, + app, + app_data_root, + app_env_configs, + sora_creds_manager, + sora_task_queue, + ).await? + } + _ => { + return Err(GenerateError::BadProviderForModel { + provider, + model: image_edit_model_to_model_type(model), + }) + } + }; + + match request.frontend_caller { + Some(TauriCommandCaller::ImageEditor) => { + // NB: Certain image generations enqueued in the inpainting editor are classed + // as "image_generation" and won't update the inpainting UI. This is a hack to + // temporarily fix the UI event binding. + // TODO: The frontend shouldn't require us to change the type to inpainting. + success_event.task_type = TaskType::ImageInpaintEdit; + } + _ => {} // fall-through + } + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller.clone(), + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref(), + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + // NB: Fail open, but find a way to flag this. + } + + // TODO: We don't know that this is strictly the edit page. It could be the stage. It might also be nice to distinguish inpainting. + let num_images = request.image_count.map(|count| count as u16).unwrap_or(1); + if let Err(err) = artcraft_usage_tracker.record_image_generation(num_images, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::EditPage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} + +fn error_to_tauri_response(error: GenerateError) -> CommandErrorResponseWrapper { + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueEditImageErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.".to_string(); + + match error { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueEditImageErrorType::ModelNotSpecified; + error_message = "No model specified for image generation".to_string(); + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueEditImageErrorType::NoProviderAvailable; + error_message = "No configured provider available for image generation".to_string(); + } + GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { min, max, requested }) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueEditImageErrorType::BadRequest; + error_message = format!("Invalid number of images requested ({}). Must be between {} and {}", requested, min, max); + + } + GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages{ min, max, provided }) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueEditImageErrorType::BadRequest; + error_message = format!("Invalid number of input images ({}). Must be between {} and {}", provided, min, max); + } + _ => {} // Fall-through for now + } + + CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/image_edit_models.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/image_edit_models.rs new file mode 100644 index 0000000000..6a0753e5b7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/image_edit_models.rs @@ -0,0 +1,19 @@ +use enums::common::generation::common_model_type::CommonModelType; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::ImageEditModel; + +pub fn image_edit_model_to_model_type(model: ImageEditModel) -> CommonModelType { + match model { + ImageEditModel::FluxProKontextMax => CommonModelType::FluxProKontextMax, + ImageEditModel::Gemini25Flash => CommonModelType::NanoBanana, + ImageEditModel::NanoBanana => CommonModelType::NanoBanana, + ImageEditModel::NanoBanana2 => CommonModelType::NanoBanana2, + ImageEditModel::NanoBananaPro => CommonModelType::NanoBananaPro, + ImageEditModel::GptImage1 => CommonModelType::GptImage1, + ImageEditModel::GptImage1p5 => CommonModelType::GptImage1p5, + ImageEditModel::Seedream4 => CommonModelType::Seedream4, + ImageEditModel::Seedream4p5 => CommonModelType::Seedream4p5, + ImageEditModel::Seedream5Lite => CommonModelType::Seedream5Lite, + ImageEditModel::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles, + ImageEditModel::Flux2LoraAngles => CommonModelType::Flux2LoraAngles, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/mod.rs new file mode 100644 index 0000000000..146d166383 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/mod.rs @@ -0,0 +1,5 @@ +pub (super) mod artcraft; +pub (super) mod sora; +pub mod enqueue_edit_image_command; +pub mod image_edit_models; + diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/handle_image_edit_sora.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/handle_image_edit_sora.rs new file mode 100644 index 0000000000..91799a77f9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/handle_image_edit_sora.rs @@ -0,0 +1,40 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EnqueueEditImageCommand, ImageEditModel}; +use crate::core::commands::deprecated::image_edit::image_edit_models::image_edit_model_to_model_type; +use crate::core::commands::deprecated::image_edit::sora::handle_sora_gpt_image_1_edit::handle_sora_gpt_image_1_edit; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use enums::common::generation_provider::GenerationProvider; +use tauri::AppHandle; + +pub async fn handle_image_edit_sora( + model: ImageEditModel, + request: &EnqueueEditImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + match model { + ImageEditModel::GptImage1 => { + handle_sora_gpt_image_1_edit( + request, + app, + app_data_root, + app_env_configs, + sora_creds_manager, + sora_task_queue, + ).await + }, + _ => { + Err(GenerateError::BadProviderForModel { + provider: GenerationProvider::Sora, + model: image_edit_model_to_model_type(model), + }) + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/handle_sora_gpt_image_1_edit.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/handle_sora_gpt_image_1_edit.rs new file mode 100644 index 0000000000..f5d8927feb --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/handle_sora_gpt_image_1_edit.rs @@ -0,0 +1,183 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageSize, EnqueueEditImageCommand, EnqueueEditImageErrorType}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info, warn}; +use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest}; +use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use std::time::Duration; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use tauri::AppHandle; + +const MAX_IMAGES: usize = 4; +const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +const DEFAULT_ASPECT_RATIO : EditImageSize = EditImageSize::Square; + +pub async fn handle_sora_gpt_image_1_edit( + request: &EnqueueEditImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let mut creds = match sora_creds_manager.get_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + warn!("No Sora credentials found."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + } + Err(err) => { + error!("Failed to get Sora credentials: {:?}", err); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + } + }; + + let mut media_tokens = Vec::with_capacity(10); + + if let Some(scene_image_media_token) = request.scene_image_media_token.clone() { + media_tokens.push(scene_image_media_token); + } + + if let Some(image_media_tokens) = request.image_media_tokens.as_ref() { + media_tokens.extend_from_slice(image_media_tokens); + } + + if media_tokens.len() > MAX_IMAGES { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { + min: 1, + max: MAX_IMAGES as u32, + provided: media_tokens.len() as u32, + })); + } + + info!("Calling get media file API: {:?}", app_env_configs.storyteller_host); + + let mut files_to_upload_to_sora = Vec::with_capacity(10); + + // TODO(bt,2025-07-07): This is inefficient. Cache and parallelize this. + for media_token in media_tokens.iter() { + info!("Using media token: {:?}", media_token); + + let response = get_media_file( + &app_env_configs.storyteller_host, + media_token + ).await?; + + let media_file_url = &response.media_file.media_links.cdn_url; + let extension_with_dot = get_url_file_extension(media_file_url) + .map(|ext| format!(".{}", ext)) + .unwrap_or_else(|| ".png".to_string()); + + let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot); + let filename = app_data_root.downloads_dir().path().join(&filename); + + simple_http_download(&media_file_url, &filename).await?; + + files_to_upload_to_sora.push(filename); + } + + let credential_updated = maybe_upgrade_or_renew_session(&mut creds) + .await + .map_err(|err| { + error!("Failed to upgrade or renew session: {:?}", err); + err + })?; + + if credential_updated { + info!("Storing updated credentials"); + sora_creds_manager.set_credentials(&creds)?; + } + + let mut sora_media_tokens = Vec::with_capacity(files_to_upload_to_sora.len()); + + for (i, file_path) in files_to_upload_to_sora.iter().enumerate() { + info!("Uploading image {} of {}...", (i+1), files_to_upload_to_sora.len()); + + let (response, maybe_new_credentials) = + image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest { + file_path, + credentials: &creds, + request_timeout: Some(SORA_IMAGE_UPLOAD_TIMEOUT), // TODO: Centralize and make configurable. + }).await?; + + if let Some(new_creds) = maybe_new_credentials { + info!("Storing updated credentials."); + sora_creds_manager.set_credentials(&new_creds)?; + creds = new_creds; + } + + sora_media_tokens.push(response.id); + } + + let aspect_ratio = request.aspect_ratio.unwrap_or(DEFAULT_ASPECT_RATIO); + + let aspect_ratio = match aspect_ratio { + EditImageSize::Auto => ImageSize::Square, + EditImageSize::Square => ImageSize::Square, + EditImageSize::Wide => ImageSize::Wide, + EditImageSize::Tall => ImageSize::Tall, + }; + + info!("Calling Sora image generation..."); + + // TODO(bt,2025-04-21): Download media tokens. + // Note: This is incredibly inefficient. We should keep a local cache. + // Also, if they've already been uploaded to OpenAI, we shouldn't continue to re-upload. + + let (response, maybe_new_creds) = + image_remix_with_session_auto_renew(ImageRemixAutoRenewRequest { + prompt: request.prompt.to_string(), + num_images: NumImages::One, + image_size: aspect_ratio, + sora_media_tokens: sora_media_tokens.clone(), + credentials: &creds, + request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT), + }).await?; + + if let Some(new_creds) = maybe_new_creds { + info!("Storing updated credentials."); + sora_creds_manager.set_credentials(&new_creds)?; + } + + info!("New Sora Task ID: {:?} ", response.task_id); + + sora_task_queue.insert(&response.task_id)?; + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(GenerationModel::GptImage1), + provider: GenerationProvider::Sora, + provider_job_id: Some(response.task_id.to_string()), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/mod.rs new file mode 100644 index 0000000000..f7b715be9d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/sora/mod.rs @@ -0,0 +1,2 @@ +mod handle_sora_gpt_image_1_edit; +pub (super) mod handle_image_edit_sora; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_dev_juggernaut_inpaint.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_dev_juggernaut_inpaint.rs new file mode 100644 index 0000000000..7908f1bf9c --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_dev_juggernaut_inpaint.rs @@ -0,0 +1,161 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest}; +use artcraft_api_defs::generate::image::inpaint::flux_dev_juggernaut_inpaint_image::{FluxDevJuggernautInpaintImageNumImages, FluxDevJuggernautInpaintImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes; +use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions; +use images::mask_images::normalize_image_bytes_to_flux_mask::normalize_image_bytes_to_flux_mask; +use log::{error, info}; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use artcraft_client::endpoints::generate::image::inpaint::flux_dev_juggernaut_inpaint_image::flux_dev_juggernaut_inpaint_image; +use artcraft_client::endpoints::generate::image::inpaint::flux_pro_1_inpaint_image::flux_pro_1_inpaint_image; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_artcraft_flux_dev_juggernaut_inpaint( + request: &EnqueueInpaintImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + let image_media_token = match &request.image_media_token { + Some(token) => token.clone(), + None => { + return Err(GenerateError::required_source_image_not_provided()); + }, + }; + + let mask_media_token = get_mask( + request, + app_env_configs, + &creds, + ).await?; + + info!("Calling Artcraft flux dev juggernaut inpaint ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let num_images = match request.image_count { + None => None, + Some(1) => Some(FluxDevJuggernautInpaintImageNumImages::One), + Some(2) => Some(FluxDevJuggernautInpaintImageNumImages::Two), + Some(3) => Some(FluxDevJuggernautInpaintImageNumImages::Three), + Some(4) => Some(FluxDevJuggernautInpaintImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + }, + }; + + let request = FluxDevJuggernautInpaintImageRequest { + uuid_idempotency_token, + prompt: Some(request.prompt.clone()), + image_media_token, + mask_media_token, + num_images, + }; + + let result = flux_dev_juggernaut_inpaint_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft flux dev juggernaut inpaint. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft flux dev juggernaut inpaint: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::FluxDevJuggernaut), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageInpaintEdit, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +async fn get_mask( + request: &EnqueueInpaintImageCommand, + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, +) -> Result { + + if request.mask_image_media_token.is_some() && request.mask_image_raw_bytes.is_some() { + return Err(GenerateError::both_image_mask_media_token_and_bytes_supplied()); + } + + if let Some(token) = request.mask_image_media_token.as_ref() { + return Ok(token.clone()); + }; + + let image_bytes = request.mask_image_raw_bytes.as_ref() + .ok_or(GenerateError::required_source_image_mask_not_provided())?; + + let image_bytes = normalize_image_bytes_to_flux_mask(image_bytes) + .map_err(|err| { + error!("Failed to convert image bytes to png: {:?}", err); + GenerateError::AnyhowError(anyhow!("Failed to convert image bytes to png mask")) + })?; + + info!("Uploading image media file from bytes..."); + + let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&storyteller_creds), + image_bytes: image_bytes.0, + image_type: ImageType::Png, + is_intermediate_system_file: true, + maybe_generation_provider: None, + }).await + .map_err(|err| { + error!("Failed to upload image media file: {:?}", err); + GenerateError::from(err) + })?; + + Ok(result.media_file_token) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_pro_1_inpaint.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_pro_1_inpaint.rs new file mode 100644 index 0000000000..d7653b87a9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_pro_1_inpaint.rs @@ -0,0 +1,160 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest}; +use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes; +use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions; +use images::mask_images::normalize_image_bytes_to_flux_mask::normalize_image_bytes_to_flux_mask; +use log::{error, info}; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use artcraft_client::endpoints::generate::image::inpaint::flux_pro_1_inpaint_image::flux_pro_1_inpaint_image; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_artcraft_flux_pro_1_inpaint( + request: &EnqueueInpaintImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + let image_media_token = match &request.image_media_token { + Some(token) => token.clone(), + None => { + return Err(GenerateError::required_source_image_not_provided()); + }, + }; + + let mask_media_token = get_mask( + request, + app_env_configs, + &creds, + ).await?; + + info!("Calling Artcraft flux pro 1 inpaint ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let num_images = match request.image_count { + None => None, + Some(1) => Some(FluxPro1InpaintImageNumImages::One), + Some(2) => Some(FluxPro1InpaintImageNumImages::Two), + Some(3) => Some(FluxPro1InpaintImageNumImages::Three), + Some(4) => Some(FluxPro1InpaintImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + }, + }; + + let request = FluxPro1InpaintImageRequest { + uuid_idempotency_token, + prompt: Some(request.prompt.clone()), + image_media_token, + mask_media_token, + num_images, + }; + + let result = flux_pro_1_inpaint_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft flux pro 1 inpaint. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft flux pro 1 inpaint: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::FluxPro1), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageInpaintEdit, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +async fn get_mask( + request: &EnqueueInpaintImageCommand, + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, +) -> Result { + + if request.mask_image_media_token.is_some() && request.mask_image_raw_bytes.is_some() { + return Err(GenerateError::both_image_mask_media_token_and_bytes_supplied()); + } + + if let Some(token) = request.mask_image_media_token.as_ref() { + return Ok(token.clone()); + }; + + let image_bytes = request.mask_image_raw_bytes.as_ref() + .ok_or(GenerateError::required_source_image_mask_not_provided())?; + + let image_bytes = normalize_image_bytes_to_flux_mask(image_bytes) + .map_err(|err| { + error!("Failed to convert image bytes to png: {:?}", err); + GenerateError::AnyhowError(anyhow!("could not encode image mask to png")) + })?; + + info!("Uploading image media file from bytes..."); + + let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&storyteller_creds), + image_bytes: image_bytes.0, + image_type: ImageType::Png, + is_intermediate_system_file: true, + maybe_generation_provider: None, + }).await + .map_err(|err| { + error!("Failed to upload image media file: {:?}", err); + GenerateError::from(err) + })?; + + Ok(result.media_file_token) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_pro_kontext_inpaint.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_pro_kontext_inpaint.rs new file mode 100644 index 0000000000..c999e9ad5f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_flux_pro_kontext_inpaint.rs @@ -0,0 +1,132 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageNumImages, FluxProKontextMaxEditImageRequest}; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest}; +use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes; +use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions; +use images::mask_images::normalize_image_bytes_to_flux_mask::normalize_image_bytes_to_flux_mask; +use log::{error, info}; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use artcraft_client::endpoints::generate::image::inpaint::flux_pro_1_inpaint_image::flux_pro_1_inpaint_image; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_artcraft_flux_pro_kontext_inpaint( + request: &EnqueueInpaintImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + let image_media_token = match &request.image_media_token { + Some(token) => token.clone(), + None => { + return Err(GenerateError::required_source_image_not_provided()); + }, + }; + + //let image_media_file = get_media_file( + // &app_env_configs.storyteller_host, + // &image_media_token, + //).await.map_err(|err| { + // error!("Failed to get media file for image: {:?}", err); + // InternalImageInpaintError::StorytellerError(err) + //})?; + + // TODO: Experiment in seeing if we can use the mask in any useful way. + + // let mask_media_token = get_mask( + // request, + // app_env_configs, + // &creds, + // ).await?; + + info!("Calling Artcraft flux pro kontext inpaint ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let num_images = match request.image_count { + None => None, + Some(1) => Some(FluxProKontextMaxEditImageNumImages::One), + Some(2) => Some(FluxProKontextMaxEditImageNumImages::Two), + Some(3) => Some(FluxProKontextMaxEditImageNumImages::Three), + Some(4) => Some(FluxProKontextMaxEditImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + }, + }; + + + let request = FluxProKontextMaxEditImageRequest { + uuid_idempotency_token, + prompt: Some(request.prompt.clone()), + image_media_token, + num_images, + }; + + let result = flux_pro_kontext_max_edit_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft flux pro kontext inpaint. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft flux pro kontext inpaint: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::FluxProKontextMax), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageInpaintEdit, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_nano_banana_inpaint.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_nano_banana_inpaint.rs new file mode 100644 index 0000000000..213c04d12a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_artcraft_nano_banana_inpaint.rs @@ -0,0 +1,117 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageNumImages, FluxProKontextMaxEditImageRequest}; +use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::{Gemini25FlashEditImageNumImages, Gemini25FlashEditImageRequest}; +use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest}; +use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes; +use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions; +use images::mask_images::normalize_image_bytes_to_flux_mask::normalize_image_bytes_to_flux_mask; +use log::{error, info}; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image; +use artcraft_client::endpoints::generate::image::edit::gemini_25_flash_edit_image::gemini_25_flash_edit_image; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use artcraft_client::endpoints::generate::image::inpaint::flux_pro_1_inpaint_image::flux_pro_1_inpaint_image; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_artcraft_nano_banana_inpaint( + request: &EnqueueInpaintImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + let image_media_token = match &request.image_media_token { + Some(token) => token.clone(), + None => { + return Err(GenerateError::required_source_image_not_provided()); + }, + }; + + info!("Calling Artcraft gemini 2.5 flash inpaint ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let num_images = match request.image_count { + None => None, + Some(1) => Some(Gemini25FlashEditImageNumImages::One), + Some(2) => Some(Gemini25FlashEditImageNumImages::Two), + Some(3) => Some(Gemini25FlashEditImageNumImages::Three), + Some(4) => Some(Gemini25FlashEditImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + }, + }; + + let request = Gemini25FlashEditImageRequest { + uuid_idempotency_token, + prompt: Some(request.prompt.clone()), + image_media_tokens: Some(vec![image_media_token]), + num_images, + image_quality: None, + }; + + let result = gemini_25_flash_edit_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft gemini 2.5 flash inpaint. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft gemini 2.5 flash inpaint: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::Gemini25Flash), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageInpaintEdit, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_inpaint_artcraft.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_inpaint_artcraft.rs new file mode 100644 index 0000000000..66deb9afa5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/handle_inpaint_artcraft.rs @@ -0,0 +1,60 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::deprecated::image_inpaint::artcraft::handle_artcraft_flux_dev_juggernaut_inpaint::handle_artcraft_flux_dev_juggernaut_inpaint; +use crate::core::commands::deprecated::image_inpaint::artcraft::handle_artcraft_flux_pro_1_inpaint::handle_artcraft_flux_pro_1_inpaint; +use crate::core::commands::deprecated::image_inpaint::artcraft::handle_artcraft_flux_pro_kontext_inpaint::handle_artcraft_flux_pro_kontext_inpaint; +use crate::core::commands::deprecated::image_inpaint::artcraft::handle_artcraft_nano_banana_inpaint::handle_artcraft_nano_banana_inpaint; +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::{EnqueueInpaintImageCommand, ImageInpaintModel}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use tauri::AppHandle; + +pub async fn handle_inpaint_artcraft( + model: ImageInpaintModel, + request: &EnqueueInpaintImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + match model { + ImageInpaintModel::FluxDevJuggernaut => { + handle_artcraft_flux_dev_juggernaut_inpaint( + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await + } + ImageInpaintModel::FluxPro1 => { + handle_artcraft_flux_pro_1_inpaint( + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await + } + ImageInpaintModel::FluxProKontextMax => { + handle_artcraft_flux_pro_kontext_inpaint( + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await + } + ImageInpaintModel::Gemini25Flash => { + handle_artcraft_nano_banana_inpaint( + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/mod.rs new file mode 100644 index 0000000000..b0b915f5da --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/artcraft/mod.rs @@ -0,0 +1,6 @@ +pub (super) mod handle_inpaint_artcraft; + +mod handle_artcraft_flux_pro_1_inpaint; +mod handle_artcraft_flux_dev_juggernaut_inpaint; +mod handle_artcraft_flux_pro_kontext_inpaint; +mod handle_artcraft_nano_banana_inpaint; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/enqueue_image_inpaint_command.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/enqueue_image_inpaint_command.rs new file mode 100644 index 0000000000..6ab4469dcc --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/enqueue_image_inpaint_command.rs @@ -0,0 +1,348 @@ +use crate::core::api_adapters::quality::common_quality::CommonQuality; +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::deprecated::image_edit::image_edit_models::image_edit_model_to_model_type; +use crate::core::commands::deprecated::image_inpaint::artcraft::handle_inpaint_artcraft::handle_inpaint_artcraft; +use crate::core::commands::deprecated::image_inpaint::inpaint_models::image_inpaint_model_to_model_type; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::utils::api_host::ApiHost; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowError; +use log::{error, info, warn}; +use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest}; +use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use serde_derive::{Deserialize, Serialize}; +use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs}; +use std::time::Duration; +use tauri::{AppHandle, Manager, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ImageInpaintModel { + /* + * Mask-based inpainting models + */ + + #[serde(rename = "flux_dev_juggernaut")] + FluxDevJuggernaut, + + #[serde(rename = "flux_pro_1")] + FluxPro1, + + /* + * Non-inpainting, instructive editing models + * NB: We're supporting these to keep the app simple and not over-complicate the javascript + */ + + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, +} + +#[derive(Deserialize, Debug)] +pub struct EnqueueInpaintImageCommand { + /// The provider to use (defaults to Artcraft/Storyteller). + /// Not all (provider, model) combinations are valid. + pub provider: Option, + + /// REQUIRED (Option is just for error messages). + /// The model to use. + pub model: Option, + + /// REQUIRED (Option is just for error messages). + /// The source image to edit. + pub image_media_token: Option, + + /// REQUIRED: Supply this *XOR* `mask_image_raw_bytes`. + /// The mask to focus the edit (already uploaded). + pub mask_image_media_token: Option, + + /// REQUIRED: Supply this *XOR* `mask_image_media_token`. + /// The mask to focus the edit (raw bytes). + pub mask_image_raw_bytes: Option>, + + /// REQUIRED. + /// The user's image generation prompt. + pub prompt: String, + + /// Number of images to generate. + pub image_count: Option, + + /// Quality. + /// Tends to be used by OpenAI models. + pub quality: Option, + + /// If true, force the dimensions of the source image and mask image to match. + pub require_matching_dimensions: Option, + + /// OPTIONAL. + /// Name of the frontend caller. + /// We'll use this to selectively trigger events. + pub frontend_caller: Option, + + /// OPTIONAL. + /// A frontend-defined identifier that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// OPTIONAL. + /// A frontend-defined payload that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueInpaintImageErrorType { + /// Caller didn't specify a model + ModelNotSpecified, + + /// No model available for image generation + NoProviderAvailable, + + /// No source image was supplied. + NoSourceImageSpecified, + + /// No mask image was supplied. + NoMaskImageSpecified, + + /// Too many mask images were supplied. + MultipleMaskImagesSpecified, + + /// Bad mask image was supplied. + BadMaskImage, + + /// Generic bad request error + BadRequest, + + /// Generic server error + ServerError, +} + +#[derive(Serialize)] +pub struct EnqueueImageInpaintSuccessResponse { +} + +impl SerializeMarker for EnqueueImageInpaintSuccessResponse {} + +#[tauri::command] +pub async fn enqueue_image_inpaint_command( + app: AppHandle, + request: EnqueueInpaintImageCommand, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + provider_priority_store: State<'_, ProviderPriorityStore>, + task_database: State<'_, TaskDatabase>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + sora_creds_manager: State<'_, SoraCredentialManager>, + sora_task_queue: State<'_, SoraTaskQueue>, +) -> ResponseOrErrorType { + + info!("enqueue_image_inpaint_command called; model: {:?}, image_media_token: {:?}, prompt: {:?}, image_count: {:?}", + &request.model, &request.image_media_token, &request.prompt, &request.image_count); + + let result = handle_request( + &request, + &app, + &app_data_root, + &app_env_configs, + &artcraft_usage_tracker, + &provider_priority_store, + &task_database, + &storyteller_creds_manager, + &sora_creds_manager, + &sora_task_queue, + ).await; + + match result { + Err(err) => { + error!("Error enqueuing inpaint image: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + // TODO: Derive from err. Make service provider optional. + let event = GenerationEnqueueFailureEvent { + action: GenerationAction::GenerateImage, + service: GenerationServiceProvider::Artcraft, // FIXME: This is wrong. + model: None, + reason: None, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + Err(error_to_tauri_response(err)) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: event.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(EnqueueImageInpaintSuccessResponse {}.into()) + } + } +} + +pub async fn handle_request( + request: &EnqueueInpaintImageCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + artcraft_usage_tracker: &ArtcraftUsageTracker, + provider_priority_store: &ProviderPriorityStore, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let model = match request.model { + Some(model) => model, + None => { + return Err(GenerateError::no_model_specified()) + } + }; + + let provider = request.provider + .unwrap_or(GenerationProvider::Artcraft); + + info!("image inpaint with {:?} via provider {:?}", &model, &provider); + + let success_event = match provider { + GenerationProvider::Artcraft => { + handle_inpaint_artcraft( + model, + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await? + } + _ => { + return Err(GenerateError::BadProviderForModel { + provider, + model: image_inpaint_model_to_model_type(model), + }) + } + }; + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller, + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref() + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + // NB: Fail open, but find a way to flag this. + } + + // TODO: We don't know that this is strictly the edit page. It could be the stage. It might also be nice to distinguish inpainting. + let num_images = request.image_count.map(|count| count as u16).unwrap_or(1); + if let Err(err) = artcraft_usage_tracker.record_image_generation(num_images, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::EditPage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} + +pub fn error_to_tauri_response(error: GenerateError) -> CommandErrorResponseWrapper { + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueInpaintImageErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.".to_string(); + + match error { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::ModelNotSpecified; + error_message = "No model specified for image generation".to_string(); + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueInpaintImageErrorType::NoProviderAvailable; + error_message = "No configured provider available for image generation".to_string(); + } + GenerateError::BadInput(BadInputReason::RequiredSourceImageNotProvided) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::NoSourceImageSpecified; + error_message = "No source image was provided".to_string(); + } + GenerateError::BadInput(BadInputReason::RequiredSourceImageMaskNotProvided) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::NoMaskImageSpecified; + error_message = "No mask image was provided".to_string(); + } + GenerateError::BadInput(BadInputReason::BothImageMaskMediaTokenAndBytesSupplied) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::MultipleMaskImagesSpecified; + error_message = "multiple mask images provided".to_string(); + } + GenerateError::BadInput(BadInputReason::CannotDetermineImageMimeType) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::BadMaskImage; + error_message = "bad mask image (mime)".to_string(); + } + GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { min, max, requested }) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::BadRequest; + error_message = format!("Invalid number of images requested ({}). Must be between {} and {}", requested, min, max); + } + GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages{ min, max, provided }) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueInpaintImageErrorType::BadRequest; + error_message = format!("Invalid number of input images ({}). Must be between {} and {}", provided, min, max); + } + _ => {}, // Other cases fall through. + } + + CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/inpaint_models.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/inpaint_models.rs new file mode 100644 index 0000000000..30fa56b0c2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/inpaint_models.rs @@ -0,0 +1,11 @@ +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::ImageInpaintModel; +use enums::common::generation::common_model_type::CommonModelType; + +pub fn image_inpaint_model_to_model_type(model: ImageInpaintModel) -> CommonModelType { + match model { + ImageInpaintModel::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut, + ImageInpaintModel::FluxPro1 => CommonModelType::FluxPro1, + ImageInpaintModel::FluxProKontextMax => CommonModelType::FluxProKontextMax, + ImageInpaintModel::Gemini25Flash => CommonModelType::NanoBanana, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/mod.rs new file mode 100644 index 0000000000..f57a278228 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_inpaint/mod.rs @@ -0,0 +1,4 @@ +pub (super) mod inpaint_models; +pub mod enqueue_image_inpaint_command; + +mod artcraft; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/mod.rs new file mode 100644 index 0000000000..7302986596 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/mod.rs @@ -0,0 +1,3 @@ +pub mod image_edit; +pub mod image_inpaint; +pub mod text_to_image; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_artcraft_gpt_image_1_text_to_image.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_artcraft_gpt_image_1_text_to_image.rs new file mode 100644 index 0000000000..5dc9601fb3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_artcraft_gpt_image_1_text_to_image.rs @@ -0,0 +1,115 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest}; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use std::time::Duration; +use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image; +use artcraft_client::endpoints::generate::image::text::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image; +use tauri::AppHandle; + +pub async fn handle_artcraft_gpt_image_1_text_to_image( + request: &EnqueueTextToImageRequest, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + info!("Calling Artcraft gpt-image-1 ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let image_quality = Some(GenerateGptImage1TextToImageImageQuality::High); + + //let image_quality = request.image_quality + // .map(|quality| match quality { + // EditImageQuality::Auto => GenerateGptImage1TextToImageImageQuality::Auto, + // EditImageQuality::High => GenerateGptImage1TextToImageImageQuality::High, + // EditImageQuality::Medium => GenerateGptImage1TextToImageImageQuality::Medium, + // EditImageQuality::Low => GenerateGptImage1TextToImageImageQuality::Low, + // }); + + let image_size = request.aspect_ratio + .map(|size| match size { + TextToImageSize::Auto => GenerateGptImage1TextToImageImageSize::Square, + TextToImageSize::Square => GenerateGptImage1TextToImageImageSize::Square, + TextToImageSize::Tall => GenerateGptImage1TextToImageImageSize::Vertical, + TextToImageSize::Wide => GenerateGptImage1TextToImageImageSize::Horizontal, + }); + + let num_images = match request.number_images { + None => None, + Some(1) => Some(GenerateGptImage1TextToImageNumImages::One), + Some(2) => Some(GenerateGptImage1TextToImageNumImages::Two), + Some(3) => Some(GenerateGptImage1TextToImageNumImages::Three), + Some(4) => Some(GenerateGptImage1TextToImageNumImages::Four), + // TODO: Error + //Some(other) => { + // return Err(InternalImageError::InvalidNumberOfRequestedImages { + // min: 1, + // max: 4, + // requested: other, + // }); + //}, + _ => Some(GenerateGptImage1TextToImageNumImages::One), // Default to one image if invalid number + }; + + let request = GenerateGptImage1TextToImageRequest { + uuid_idempotency_token, + prompt: request.prompt.clone(), + image_size, + num_images, + image_quality, + }; + + let result = generate_gpt_image_1_text_to_image( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let job_id = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft gpt-image-1. Job token: {}", + enqueued.inference_job_token); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft gpt-image-1: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + model: Some(GenerationModel::GptImage1), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::ImageGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft.rs new file mode 100644 index 0000000000..09feb45a79 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft.rs @@ -0,0 +1,50 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::artcraft::handle_artcraft_gpt_image_1_text_to_image::handle_artcraft_gpt_image_1_text_to_image; +use crate::core::commands::deprecated::text_to_image::artcraft::handle_text_to_image_artcraft_via_router::handle_text_to_image_artcraft_via_router; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageModel}; +use crate::core::commands::deprecated::text_to_image::text_to_image_models::text_to_image_model_to_model_type; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_router::api::router_image_model::RouterImageModel; +use enums::common::generation_provider::GenerationProvider; +use tauri::AppHandle; + +pub async fn handle_text_to_image_artcraft( + model: TextToImageModel, + request: &EnqueueTextToImageRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + match model { + TextToImageModel::Midjourney | TextToImageModel::GrokImage => { + Err(GenerateError::BadProviderForModel { + provider: GenerationProvider::Artcraft, + model: text_to_image_model_to_model_type(model), + }) + } + TextToImageModel::Recraft3 => { + Err(GenerateError::NotYetImplemented(format!("not yet implemented in Artcraft"))) + } + TextToImageModel::Flux1Dev => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Flux1Dev, GenerationModel::Flux1Dev).await, + TextToImageModel::Flux1Schnell => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Flux1Schnell, GenerationModel::Flux1Schnell).await, + TextToImageModel::FluxPro11 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::FluxPro11, GenerationModel::FluxPro11).await, + TextToImageModel::FluxPro11Ultra => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::FluxPro11Ultra, GenerationModel::FluxPro11Ultra).await, + TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await, + TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::GptImage1p5, GenerationModel::GptImage1p5).await, + TextToImageModel::GptImage2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::GptImage2, GenerationModel::GptImage2).await, + TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => { + handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::NanoBanana, GenerationModel::NanoBanana).await + }, + TextToImageModel::NanoBanana2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::NanoBanana2, GenerationModel::NanoBanana2).await, + TextToImageModel::NanoBananaPro => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::NanoBananaPro, GenerationModel::NanoBananaPro).await, + TextToImageModel::Seedream4 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Seedream4, GenerationModel::Seedream4).await, + TextToImageModel::Seedream4p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Seedream4p5, GenerationModel::Seedream4p5).await, + TextToImageModel::Seedream5Lite => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, RouterImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs new file mode 100644 index 0000000000..66f98e5c3a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs @@ -0,0 +1,173 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio as CommonAspectRatio2; +use crate::core::api_adapters::resolution::common_resolution::CommonResolution as CommonResolution2; +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{ + EnqueueTextToImageRequest, TextToImageResolution, TextToImageSize, +}; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_image_model::RouterImageModel; +use artcraft_router::api::router_resolution::RouterResolution; +use artcraft_router::api::router_quality::RouterQuality; +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::client::router_artcraft_client::RouterArtcraftClient; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; + +pub(super) async fn handle_text_to_image_artcraft_via_router( + request: &EnqueueTextToImageRequest, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, + model: RouterImageModel, + generation_model: GenerationModel, +) -> Result { + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials)), + }; + + let client = RouterClient::Artcraft(RouterArtcraftClient::new( + app_env_configs.storyteller_host.clone(), + creds, + )); + + let image_inputs = request.image_media_tokens.clone().map(ImageListRef::MediaFileTokens); + + let aspect_ratio = get_aspect_ratio_t2i(request); + let resolution = get_resolution_t2i(request); + let quality = get_quality_t2i(request); + + let router_request = GenerateImageRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + image_inputs, + resolution, + aspect_ratio, + quality, + image_batch_count: request.number_images.map(|n| n as u16), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + }; + + let dor = router_request.build2()?; + + info!("Image Generation Request: {:?}", dor); + + let request = match dor { + ImageGenerationDraftOrRequest::Request(req) => req, + // Artcraft never returns a Draft — image-token resolution happens server-side. + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft router should never produce a draft for text-to-image" + ), + }; + + let response = match request.send_request(&client).await { + Ok(resp) => { + info!("Successfully enqueued."); + resp + } + Err(err) => { + error!("Failed to enqueue: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + let job_id = response + .get_artcraft_payload() + .map(|p| p.inference_job_token.to_string()) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(generation_model), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_id), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_aspect_ratio_t2i(request: &EnqueueTextToImageRequest) -> Option { + if let Some(ar) = request.common_aspect_ratio { + return Some(convert_desktop_aspect_ratio(ar)); + } + if let Some(ar) = request.aspect_ratio { + return Some(match ar { + TextToImageSize::Auto => RouterAspectRatio::Auto, + TextToImageSize::Square => RouterAspectRatio::Square, + TextToImageSize::Wide => RouterAspectRatio::Wide, + TextToImageSize::Tall => RouterAspectRatio::Tall, + }); + } + None +} + +fn get_resolution_t2i(request: &EnqueueTextToImageRequest) -> Option { + if let Some(res) = request.common_resolution { + return Some(convert_desktop_resolution(res)); + } + if let Some(res) = request.image_resolution { + return Some(match res { + TextToImageResolution::OneK => RouterResolution::OneK, + TextToImageResolution::TwoK => RouterResolution::TwoK, + TextToImageResolution::FourK => RouterResolution::FourK, + }); + } + None +} + +fn get_quality_t2i(request: &EnqueueTextToImageRequest) -> Option { + request.quality.map(|quality| quality.to_artcraft_router_type()) +} + +fn convert_desktop_aspect_ratio(ar: CommonAspectRatio2) -> RouterAspectRatio { + match ar { + CommonAspectRatio2::Auto => RouterAspectRatio::Auto, + CommonAspectRatio2::Square => RouterAspectRatio::Square, + CommonAspectRatio2::WideThreeByTwo => RouterAspectRatio::WideThreeByTwo, + CommonAspectRatio2::WideFourByThree => RouterAspectRatio::WideFourByThree, + CommonAspectRatio2::WideFiveByFour => RouterAspectRatio::WideFiveByFour, + CommonAspectRatio2::WideSixteenByNine => RouterAspectRatio::WideSixteenByNine, + CommonAspectRatio2::WideTwentyOneByNine => RouterAspectRatio::WideTwentyOneByNine, + CommonAspectRatio2::TallTwoByThree => RouterAspectRatio::TallTwoByThree, + CommonAspectRatio2::TallThreeByFour => RouterAspectRatio::TallThreeByFour, + CommonAspectRatio2::TallFourByFive => RouterAspectRatio::TallFourByFive, + CommonAspectRatio2::TallNineBySixteen => RouterAspectRatio::TallNineBySixteen, + CommonAspectRatio2::TallNineByTwentyOne => RouterAspectRatio::TallNineByTwentyOne, + CommonAspectRatio2::Wide => RouterAspectRatio::Wide, + CommonAspectRatio2::Tall => RouterAspectRatio::Tall, + CommonAspectRatio2::Auto2k => RouterAspectRatio::Auto2k, + CommonAspectRatio2::Auto4k => RouterAspectRatio::Auto4k, + CommonAspectRatio2::SquareHd => RouterAspectRatio::SquareHd, + } +} + +fn convert_desktop_resolution(res: CommonResolution2) -> RouterResolution { + match res { + CommonResolution2::OneK => RouterResolution::OneK, + CommonResolution2::TwoK => RouterResolution::TwoK, + CommonResolution2::ThreeK => RouterResolution::ThreeK, + CommonResolution2::FourK => RouterResolution::FourK, + CommonResolution2::HalfK => RouterResolution::HalfK, + CommonResolution2::FourEightyP => RouterResolution::FourEightyP, + CommonResolution2::SevenTwentyP => RouterResolution::SevenTwentyP, + CommonResolution2::TenEightyP => RouterResolution::TenEightyP, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/mod.rs new file mode 100644 index 0000000000..572b124965 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/mod.rs @@ -0,0 +1,4 @@ +pub (super) mod handle_text_to_image_artcraft; + +mod handle_text_to_image_artcraft_via_router; +mod handle_artcraft_gpt_image_1_text_to_image; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/enqueue_text_to_image_command.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/enqueue_text_to_image_command.rs new file mode 100644 index 0000000000..9b7310b819 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/enqueue_text_to_image_command.rs @@ -0,0 +1,425 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use crate::core::api_adapters::quality::common_quality::CommonQuality; +use crate::core::api_adapters::resolution::common_resolution::CommonResolution; +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason}; +use crate::core::commands::deprecated::image_edit::image_edit_models::image_edit_model_to_model_type; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::artcraft::handle_text_to_image_artcraft::handle_text_to_image_artcraft; +use crate::core::commands::deprecated::text_to_image::grok::handle_grok::handle_grok; +use crate::core::commands::deprecated::text_to_image::midjourney::handle_midjourney::handle_midjourney; +use crate::core::commands::deprecated::text_to_image::sora::handle_text_to_image_sora::handle_text_to_image_sora; +use crate::core::commands::deprecated::text_to_image::text_to_image_models::text_to_image_model_to_model_type; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::Response; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::GrokImagePromptQueue; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs}; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +/// This is used in the Tauri command bridge. +/// Don't change the serializations without coordinating with the frontend. +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TextToImageModel { + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_pro_11")] + FluxPro11, + #[serde(rename = "flux_pro_11_ultra")] + FluxPro11Ultra, + #[serde(rename = "grok_image")] + GrokImage, + #[serde(rename = "recraft_3")] + Recraft3, + + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + + // Generic Midjourney model, version unknown. + #[serde(rename = "midjourney")] + Midjourney, +} + +#[derive(Deserialize, Debug)] +pub struct EnqueueTextToImageRequest { + /// The provider to use (defaults to Artcraft/Storyteller). + /// Not all (provider, model) combinations are valid. + pub provider: Option, + + /// The model to use. + pub model: Option, + + /// Text prompt for the image generation. Required. + pub prompt: Option, + + /// New field for aspect ratio. + /// Not all models support each of these aspect ratios, but we can choose or interpolate sensibly. + pub common_aspect_ratio: Option, + + /// New field for resolution. + /// Not all models support each of these resolutions, but we can choose or interpolate sensibly. + pub common_resolution: Option, + + /// Quality. + /// Tends to be used by OpenAI models. + pub quality: Option, + + /// Aspect ratio. + #[deprecated(note="use common_aspect_ratio")] + pub aspect_ratio: Option, + + /// Aspect ratio. + #[deprecated(note="use common_resolution")] + pub image_resolution: Option, + + /// The number of images to generate. + pub number_images: Option, + + /// OPTIONAL. + /// Reference images (without semantics) + /// The purpose varies on a model-by-model basis, but they + /// are not semantically treated as "style reference", + /// "character/object reference", etc. + pub image_media_tokens: Option>, + + /// OPTIONAL. + /// Name of the frontend caller. + /// We'll use this to selectively trigger events. + pub frontend_caller: Option, + + /// OPTIONAL. + /// A frontend-defined identifier that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// OPTIONAL. + /// A frontend-defined payload that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +// TODO(bt,2025-07-14): Support other aspect ratios / resolutions - +// Flux Dev has: 4:3, 16:9, 3:4, 9:16, and custom. +// Flux Schnell has: 4:3, 16:9, 3:4, 9:16, and custom. +// Flux Pro has: 4:3, 16:9, 3:4, 9:16, and custom. +// Flux Pro Ulra has: 4:3, 16:9, 3:4, 9:16, and custom. + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TextToImageSize { + Auto, + Square, + Wide, + Tall, +} + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TextToImageResolution { + /// 1K - nano banana pro + OneK, + /// 2K - nano banana pro + TwoK, + /// 4K - nano banana pro + FourK, +} + +#[derive(Serialize)] +pub struct EnqueueTextToImageSuccessResponse { +} + +impl SerializeMarker for EnqueueTextToImageSuccessResponse {} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueTextToImageErrorType { + /// Caller didn't specify a model + ModelNotSpecified, + /// No model available for image generation + NoProviderAvailable, + /// Generic server error + ServerError, + /// No Fal API key available + NeedsFalApiKey, + /// Fal had an API error + FalError, + /// Needs to be logged into Artcraft + NeedsStorytellerCredentials, +} + +#[tauri::command] +pub async fn enqueue_text_to_image_command( + request: EnqueueTextToImageRequest, + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + provider_priority_store: State<'_, ProviderPriorityStore>, + task_database: State<'_, TaskDatabase>, + mj_creds_manager: State<'_, MidjourneyCredentialManager>, + grok_creds_manager: State<'_, GrokCredentialManager>, + grok_image_prompt_queue: State<'_, GrokImagePromptQueue>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + sora_creds_manager: State<'_, SoraCredentialManager>, + sora_task_queue: State<'_, SoraTaskQueue>, +) -> Response { + + info!("enqueue_text_to_image called"); + + info!("request: {:?}", request); + + let result = handle_request( + request, + &app, + &app_data_root, + &artcraft_usage_tracker, + &provider_priority_store, + &task_database, + &mj_creds_manager, + &grok_creds_manager, + &grok_image_prompt_queue, + &storyteller_creds_manager, + &app_env_configs, + &sora_creds_manager, + &sora_task_queue, + ).await; + + match result { + Err(err) => { + error!("error: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueTextToImageErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem."; + + match err { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueTextToImageErrorType::ModelNotSpecified; + error_message = "No model specified for image generation"; + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueTextToImageErrorType::NoProviderAvailable; + error_message = "No configured provider available for image generation"; + } + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => { + status = CommandErrorStatus::Unauthorized; + error_type = EnqueueTextToImageErrorType::NeedsFalApiKey; + error_message = "You need to set a FAL api key"; + }, + _ => {}, // Fall-through + } + + Err(CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + }) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: event.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(EnqueueTextToImageSuccessResponse {}.into()) + } + } +} + +pub async fn handle_request( + request: EnqueueTextToImageRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + artcraft_usage_tracker: &ArtcraftUsageTracker, + provider_priority_store: &ProviderPriorityStore, + task_database: &TaskDatabase, + mj_creds_manager: &MidjourneyCredentialManager, + grok_creds_manager: &GrokCredentialManager, + grok_image_prompt_queue: &GrokImagePromptQueue, + storyteller_creds_manager: &StorytellerCredentialManager, + app_env_configs: &AppEnvConfigs, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let result = dispatch_request( + &request, + &app, + &app_data_root, + &provider_priority_store, + &storyteller_creds_manager, + &app_env_configs, + &mj_creds_manager, + &grok_creds_manager, + &grok_image_prompt_queue, + &sora_creds_manager, + &sora_task_queue, + ).await; + + let success_event = match result { + Err(err) => return Err(err), + Ok(event) => event, + }; + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller, + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref(), + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + // NB: Fail open, but find a way to flag this. + } + + let num_images = request.number_images.map(|n| n as u16).unwrap_or(1); + + let is_image_to_image = request.image_media_tokens + .as_ref() + .map(|tokens| !tokens.is_empty()) + .unwrap_or(false); + + let usage_type = if is_image_to_image { + ArtcraftUsageType::ImageToResult + } else { + ArtcraftUsageType::TextToResult + }; + + if let Err(err) = artcraft_usage_tracker.record_image_generation(num_images, usage_type, ArtcraftUsagePage::ImagePage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} + +pub async fn dispatch_request( + request: &EnqueueTextToImageRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + provider_priority_store: &ProviderPriorityStore, + storyteller_creds_manager: &StorytellerCredentialManager, + app_env_configs: &AppEnvConfigs, + mj_creds_manager: &MidjourneyCredentialManager, + grok_creds_manager: &GrokCredentialManager, + grok_image_prompt_queue: &GrokImagePromptQueue, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let model = match request.model { + Some(model) => model, + None => { + return Err(GenerateError::no_model_specified()) + } + }; + + let provider = match (model, request.provider) { + (TextToImageModel::GrokImage, _) => GenerationProvider::Grok, + (TextToImageModel::Midjourney, _) => GenerationProvider::Midjourney, + _ => request.provider.unwrap_or(GenerationProvider::Artcraft), + }; + + info!("generate image with {:?} via provider {:?}", &model, &provider); + + match provider { + GenerationProvider::Artcraft => { + handle_text_to_image_artcraft( + model, + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await + } + GenerationProvider::Grok => { + handle_grok( + app, + request, + app_env_configs, + grok_creds_manager, + grok_image_prompt_queue, + ).await + } + GenerationProvider::Midjourney => { + handle_midjourney( + app, + request, + app_env_configs, + mj_creds_manager, + ).await + } + GenerationProvider::Sora => { + handle_text_to_image_sora( + request, + app, + sora_creds_manager, + sora_task_queue, + ).await + } + _ => { + Err(GenerateError::BadProviderForModel { + provider, + model: text_to_image_model_to_model_type(model), + }) + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/grok/handle_grok.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/grok/handle_grok.rs new file mode 100644 index 0000000000..b2b1b17223 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/grok/handle_grok.rs @@ -0,0 +1,95 @@ +use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_grok_image::aspect_ratio_to_grok_image; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::{GrokImagePromptQueue, PromptItem}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; +use uuid_utils::uuid::generate_random_uuid; +use tauri::AppHandle; + +pub async fn handle_grok( + app: &AppHandle, + request: &EnqueueTextToImageRequest, + app_env_configs: &AppEnvConfigs, + creds_manager: &GrokCredentialManager, + grok_image_prompt_queue: &GrokImagePromptQueue, +) -> Result { + + + //// TODO: We can request population of the user info if absent or expired. + //let websocket = get_websocket( + // app, + // creds_manager, + // websocket_manager, + //).await?; + // + //let _result = prompt_websocket_image(PromptWebsocketImageArgs { + // websocket_wrapped: &websocket, + // prompt: request.prompt.as_deref().unwrap_or_else(||""), + //}).await?; + // + //let request_id = listen_for_websocket_request_id(ListenForWebsocketRequestIdArgs { + // websocket: &websocket, + // timeout: Duration::from_millis(10_000), + //}).await?; + // + //let request_id = match request_id.request_id { + // Some(request_id) => request_id.to_string(), + // None => { + // warn!("No request id from Grok image generation (websocket)"); + // return Err(GenerateError::ProviderFailure(ProviderFailureReason::GrokJobEnqueueFailed)); + // } + //}; + // + //info!("Successfully enqueued MidJourney. Job token: {}", request_id); + + let job_id = generate_random_uuid(); + + let prompt = request.prompt + .as_deref() + .map(|prompt| prompt.trim().to_string()) + .unwrap_or_else(|| "".to_string()); + + let aspect_ratio = get_aspect_ratio(request); + + grok_image_prompt_queue.enqueue(PromptItem { + task_id: job_id.clone(), + prompt, + aspect_ratio: aspect_ratio.unwrap_or(ClientMessageAspectRatio::Square), + })?; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Grok, + model: Some(GenerationModel::GrokImage), + provider_job_id: Some(job_id), + task_type: TaskType::ImageGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option { + if let Some(common_aspect_ratio) = request.common_aspect_ratio { + // Handle modern aspect ratio + let aspect = aspect_ratio_to_grok_image(common_aspect_ratio); + return Some(aspect); + } + + if let Some(aspect_ratio) = request.aspect_ratio { + // Handle deprecated aspect ratio + return match aspect_ratio { + TextToImageSize::Auto => Some(ClientMessageAspectRatio::Square), + TextToImageSize::Square => Some(ClientMessageAspectRatio::Square), + TextToImageSize::Wide => Some(ClientMessageAspectRatio::WideThreeByTwo), + TextToImageSize::Tall => Some(ClientMessageAspectRatio::TallTwoByThree), + } + } + + None +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/grok/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/grok/mod.rs new file mode 100644 index 0000000000..181e756f1a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/grok/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_grok; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/midjourney/handle_midjourney.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/midjourney/handle_midjourney.rs new file mode 100644 index 0000000000..aefc881ed5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/midjourney/handle_midjourney.rs @@ -0,0 +1,143 @@ +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason, ProviderFailureReason}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::warning_events::flash_user_input_error_event::FlashUserInputErrorEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::endpoints::submit_job::{submit_job, SubmitJobRequest}; +use midjourney_client::error::midjourney_api_error::MidjourneyApiError; +use midjourney_client::recipes::channel_id::ChannelId; +use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageError, TextToImageRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest}; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use std::time::Duration; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_midjourney( + app: &AppHandle, + request: &EnqueueTextToImageRequest, + app_env_configs: &AppEnvConfigs, + mj_creds_manager: &MidjourneyCredentialManager, +) -> Result { + + let creds = match mj_creds_manager.maybe_copy_cookie_store() { + Ok(Some(creds)) => creds, + Ok(None) => { + error!("Midjourney credentials not found."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app); + return Err(GenerateError::needs_midjourney_credentials()); + } + Err(err) => { + error!("Error reading Midjourney credentials: {:?}", err); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app); + return Err(GenerateError::needs_midjourney_credentials()); + }, + }; + + // TODO: We can request population of the user info if absent or expired. + + let user_info = match mj_creds_manager.maybe_copy_user_info() { + Ok(Some(user_info)) => user_info, + Ok(None) => { + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo)); + } + Err(err) => { + error!("Error reading Midjourney user info: {:?}", err); + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo)); + }, + }; + + let channel_id = match user_info.user_id { + Some(user_id) => ChannelId::UserId(user_id), + None => { + error!("Midjourney user info does not contain a user ID."); + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserId)); + } + }; + + info!("Calling midjourney ..."); + + let cookie_header = creds.to_cookie_string(); + + let prompt = request.prompt + .as_deref() + .unwrap_or(""); + + let result = text_to_image(TextToImageRequest { + prompt, + channel_id: &channel_id, + hostname: MidjourneyHostname::Standard, + cookie_header, + }).await; + + let result = match result { + Ok(result) => result, + Err(err) => { + error!("Failed to use MidJourney: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + let job_id = match result.maybe_job_id { + Some(job_id) => job_id, + None => { + error!("Failed to enqueue MidJourney: No job ID returned."); + return handle_midjourney_errors(app, result.maybe_errors); + } + }; + + info!("Successfully enqueued MidJourney. Job token: {}", job_id); + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Midjourney, + model: Some(GenerationModel::Midjourney), + provider_job_id: Some(job_id), + task_type: TaskType::ImageGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn handle_midjourney_errors( + app: &AppHandle, + maybe_errors: Option> +) -> Result { + if let Some(errors) = maybe_errors { + if !errors.is_empty() { + let messages: Vec = errors.iter() + .map(|e| format!("{:?}", e)) + .collect(); + + let combined_message = messages.join("; "); + + let event = FlashUserInputErrorEvent { + message: format!("Midjourney Error: {}", combined_message), + }; + + if let Err(err) = event.send(&app) { + error!("Failed to send FlashUserInputErrorEvent: {:?}", err); // Fail open + } + } + } + + Err(GenerateError::ProviderFailure(ProviderFailureReason::MidjourneyJobEnqueueFailed)) +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/midjourney/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/midjourney/mod.rs new file mode 100644 index 0000000000..18048875b6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/midjourney/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_midjourney; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/mod.rs new file mode 100644 index 0000000000..e3a7d42879 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/mod.rs @@ -0,0 +1,7 @@ +pub (super) mod text_to_image_models; +pub mod enqueue_text_to_image_command; + +mod artcraft; +mod grok; +mod midjourney; +mod sora; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/handle_gpt_image_1_sora_text_to_image.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/handle_gpt_image_1_sora_text_to_image.rs new file mode 100644 index 0000000000..d6a0e2c92d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/handle_gpt_image_1_sora_text_to_image.rs @@ -0,0 +1,138 @@ +use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_grok_image::aspect_ratio_to_grok_image; +use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_sora_native_gpt_image_1::aspect_ratio_to_sora_native_gpt_image_1; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use errors::AnyhowResult; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; +use log::{error, info}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest}; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use std::time::Duration; +use tauri::AppHandle; + +const SORA_SIMPLE_IMAGE_GEN_TIMEOUT : Duration = Duration::from_millis(1000 * 30); // 30 seconds + +pub async fn handle_gpt_image_1_sora_text_to_image( + request: &EnqueueTextToImageRequest, + app: &AppHandle, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let mut creds = match sora_creds_manager.get_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + error!("Sora credentials not found."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + } + Err(err) => { + error!("Error reading Sora credentials: {:?}", err); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + } + }; + + let credential_updated = maybe_upgrade_or_renew_session(&mut creds) + .await + .map_err(|err| { + error!("Failed to upgrade or renew session: {:?}", err); + err + })?; + + if credential_updated { + info!("Storing updated credentials"); + sora_creds_manager.set_credentials(&creds)?; + } + + let prompt = request.prompt.as_deref().unwrap_or(""); + + let aspect_ratio = get_aspect_ratio(request) + .unwrap_or(ImageSize::Square); + + let num_images = match request.number_images { + None => NumImages::One, + Some(1) => NumImages::One, + Some(2) => NumImages::Two, + Some(3) => NumImages::Two, // NB: There is no option for three images! + Some(4) => NumImages::Four, + _ => NumImages::One, + }; + + let result = + simple_image_gen_with_session_auto_renew(SimpleImageGenAutoRenewRequest { + prompt: prompt.to_string(), + num_images, + image_size: aspect_ratio, + credentials: &creds, + request_timeout: Some(SORA_SIMPLE_IMAGE_GEN_TIMEOUT), + }).await; + + let (response, maybe_new_creds) = + match result { + Ok((response, maybe_new_creds)) => (response, maybe_new_creds), + Err(err) => { + let event = GenerationEnqueueFailureEvent { + action: GenerationAction::GenerateImage, + service: GenerationServiceProvider::Sora, + model: None, + reason: None, + }; + + if let Err(err) = event.send(app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + return Err(GenerateError::from(err)); + } + }; + + if let Some(new_creds) = maybe_new_creds { + info!("Storing updated credentials."); + sora_creds_manager.set_credentials(&new_creds)?; + } + + info!("New Sora Task ID: {:?} ", response.task_id); + + sora_task_queue.insert(&response.task_id)?; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Sora, + task_type: TaskType::ImageGeneration, + model: Some(GenerationModel::GptImage1), + provider_job_id: Some(response.task_id.to_string()), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option { + if let Some(common_aspect_ratio) = request.common_aspect_ratio { + // Handle modern aspect ratio + let aspect = aspect_ratio_to_sora_native_gpt_image_1(common_aspect_ratio); + return Some(aspect); + } + + if let Some(aspect_ratio) = request.aspect_ratio { + // Handle deprecated aspect ratio + return match aspect_ratio { + TextToImageSize::Auto => Some(ImageSize::Square), + TextToImageSize::Square => Some(ImageSize::Square), + TextToImageSize::Wide => Some(ImageSize::Wide), + TextToImageSize::Tall => Some(ImageSize::Tall), + } + } + + None +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/handle_text_to_image_sora.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/handle_text_to_image_sora.rs new file mode 100644 index 0000000000..b776d84f97 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/handle_text_to_image_sora.rs @@ -0,0 +1,21 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::EnqueueTextToImageRequest; +use crate::core::commands::deprecated::text_to_image::sora::handle_gpt_image_1_sora_text_to_image::handle_gpt_image_1_sora_text_to_image; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use tauri::AppHandle; + +pub async fn handle_text_to_image_sora( + request: &EnqueueTextToImageRequest, + app: &AppHandle, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + handle_gpt_image_1_sora_text_to_image( + request, + app, + sora_creds_manager, + sora_task_queue, + ).await +} diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/mod.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/mod.rs new file mode 100644 index 0000000000..9a9529d300 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/sora/mod.rs @@ -0,0 +1,2 @@ +pub (super) mod handle_text_to_image_sora; +mod handle_gpt_image_1_sora_text_to_image; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/text_to_image_models.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/text_to_image_models.rs new file mode 100644 index 0000000000..48c16d5676 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/text_to_image_models.rs @@ -0,0 +1,24 @@ +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::TextToImageModel; +use enums::common::generation::common_model_type::CommonModelType; + +pub fn text_to_image_model_to_model_type(model: TextToImageModel) -> CommonModelType { + match model { + TextToImageModel::Flux1Dev => CommonModelType::Flux1Dev, + TextToImageModel::Flux1Schnell => CommonModelType::Flux1Schnell, + TextToImageModel::FluxPro11 => CommonModelType::FluxPro11, + TextToImageModel::FluxPro11Ultra => CommonModelType::FluxPro11Ultra, + TextToImageModel::GrokImage => CommonModelType::GrokImage, + TextToImageModel::Recraft3 => CommonModelType::Recraft3, + TextToImageModel::GptImage1 => CommonModelType::GptImage1, + TextToImageModel::GptImage1p5 => CommonModelType::GptImage1p5, + TextToImageModel::GptImage2 => CommonModelType::GptImage2, + TextToImageModel::Gemini25Flash => CommonModelType::NanoBanana, + TextToImageModel::NanoBanana => CommonModelType::NanoBanana, + TextToImageModel::NanoBanana2 => CommonModelType::NanoBanana2, + TextToImageModel::NanoBananaPro => CommonModelType::NanoBananaPro, + TextToImageModel::Seedream4 => CommonModelType::Seedream4, + TextToImageModel::Seedream4p5 => CommonModelType::Seedream4p5, + TextToImageModel::Seedream5Lite => CommonModelType::Seedream5Lite, + TextToImageModel::Midjourney => CommonModelType::Midjourney, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/download/download_directory_reveal_command.rs b/crates/desktop/artcraft/src/core/commands/download/download_directory_reveal_command.rs new file mode 100644 index 0000000000..793527b5ac --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/download/download_directory_reveal_command.rs @@ -0,0 +1,70 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use crate::core::artcraft_error::ArtcraftError; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage, ResponseOrErrorType}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir; +use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir; +use anyhow::anyhow; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::str::FromStr; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use tauri::{AppHandle, State}; +use tauri_plugin_opener::OpenerExt; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Serialize)] +pub struct DownloadDirectoryRevealSuccessResponse { +} + +impl SerializeMarker for DownloadDirectoryRevealSuccessResponse {} + +#[tauri::command] +pub async fn download_directory_reveal_command( + app: AppHandle, + app_prefs: State<'_, AppPreferencesManager>, + app_data_root: State<'_, AppDataRoot>, +) -> ResponseOrErrorMessage { + + info!("download_directory_reveal_command called"); + + let result = handle_request( + &app, + &app_prefs, + &app_data_root, + ).await; + + if let Err(err) = result { + format!("Error revealing download dir: {:?}", err); + return Err("error revealing download dir".into()) + } + + Ok(DownloadDirectoryRevealSuccessResponse {}.into()) +} + + +pub async fn handle_request( + app: &AppHandle, + app_prefs: &AppPreferencesManager, + app_data_root: &AppDataRoot, +) -> Result<(), ArtcraftError> { + + let app_prefs = app_prefs.get_clone()?; + + let download_directory = app_prefs + .preferred_download_directory + .download_directory(app_data_root); + + info!("Revealing item in directory: {:?}", download_directory); + + app.opener().reveal_item_in_dir(download_directory) + .map_err(|err| anyhow!("Failed to open directory: {:?}", err))?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/commands/download/download_media_file_command.rs b/crates/desktop/artcraft/src/core/commands/download/download_media_file_command.rs new file mode 100644 index 0000000000..fb9e685814 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/download/download_media_file_command.rs @@ -0,0 +1,104 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use crate::core::artcraft_error::ArtcraftError; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir; +use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir; +use anyhow::anyhow; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::str::FromStr; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; + +#[derive(Deserialize, Debug)] +pub struct DownloadMediaFileRequest { + pub media_token: MediaFileToken, +} + +#[derive(Serialize)] +pub struct DownloadMediaFileSuccessResponse { +} + +impl SerializeMarker for DownloadMediaFileSuccessResponse {} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum DownloadMediaFileErrorType { + FilesystemError, + NetworkError, + UnknownError, +} + + +#[tauri::command] +pub async fn download_media_file_command( + request: DownloadMediaFileRequest, + app: AppHandle, + app_prefs: State<'_, AppPreferencesManager>, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrErrorType { + + info!("download_media_file_command called"); + + info!("request: {:?}", request); + + let result = handle_request( + request, + &app, + &app_prefs, + &app_data_root, + &app_env_configs, + ).await; + + if let Err(err) = result { + error!("Error downloading media: {:?}", err); + // TODO: This error is semantically incorrect - just trying to get the code done + return Err(CommandErrorResponseWrapper { + status: CommandErrorStatus::ServerError, + error_message: Some("error downloading file".to_string()), + error_type: Some(DownloadMediaFileErrorType::UnknownError), + error_details: None, + }); + } + + Ok(DownloadMediaFileSuccessResponse {}.into()) +} + + +pub async fn handle_request( + request: DownloadMediaFileRequest, + app: &AppHandle, + app_prefs: &AppPreferencesManager, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs +) -> Result<(), ArtcraftError> { + + let app_prefs = app_prefs.get_clone()?; + + // TODO: Api should return the extension and suggested filename so we can better construct something. + let media_file = get_media_file( + &app_env_configs.storyteller_host, + &request.media_token, + ).await?; + + let asset_url = media_file.media_file.media_links.cdn_url; + + let download_path = download_url_to_user_download_dir( + &asset_url, + app_data_root, + &app_prefs + ).await?; + + info!("downloaded to: {:?}", download_path); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/commands/download/download_url_command.rs b/crates/desktop/artcraft/src/core/commands/download/download_url_command.rs new file mode 100644 index 0000000000..cfcb4c071c --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/download/download_url_command.rs @@ -0,0 +1,125 @@ +use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio; +use crate::core::artcraft_error::ArtcraftError; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::warning_events::flash_file_download_error_event::{FlashFileDownloadErrorType, FlashFileDownloadErrorEvent}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir; +use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir; +use anyhow::anyhow; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::str::FromStr; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use tauri::{AppHandle, State}; +use url::Url; + +#[derive(Deserialize, Debug)] +pub struct DownloadUrlRequest { + pub url: Url, +} + +#[derive(Serialize)] +pub struct DownloadUrlSuccessResponse { +} + +impl SerializeMarker for DownloadUrlSuccessResponse {} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum DownloadUrlErrorType { + FilesystemError, + NetworkError, + UnknownError, +} + + +#[tauri::command] +pub async fn download_url_command( + request: DownloadUrlRequest, + app: AppHandle, + app_prefs: State<'_, AppPreferencesManager>, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrErrorType { + + info!("download_url_command called"); + + info!("request: {:?}", request); + + let result = handle_request( + request, + &app, + &app_prefs, + &app_data_root, + &app_env_configs, + ).await; + + if let Err(err) = result { + error!("Error downloading media: {:?}", err); + + let mut endpoint_message = "unknown error when downloading file"; + let mut error_type = DownloadUrlErrorType::UnknownError; + + let mut flash_error_type = FlashFileDownloadErrorType::UnknownError; + let mut flash_filename = None; + let mut flash_message = Some("Failed to download file".to_string()); + + match err { + ArtcraftError::CannotDownloadFilePathAlreadyExists { path } => { + endpoint_message = "file already downloaded"; + error_type = DownloadUrlErrorType::FilesystemError; + + flash_error_type = FlashFileDownloadErrorType::FileAlreadyDownloaded; + flash_message = Some(format!("File already downloaded: {:?}", path)); + flash_filename = Some(path); + } + _ => {}, // NB: Fall-through + } + + let event = FlashFileDownloadErrorEvent { + filename: flash_filename, + message: flash_message, + error_type: flash_error_type, + }; + + event.send_infallible(&app); + + return Err(CommandErrorResponseWrapper { + status: CommandErrorStatus::ServerError, + error_message: Some(endpoint_message.to_string()), + error_type: Some(error_type), + error_details: None, + }); + } + + Ok(DownloadUrlSuccessResponse {}.into()) +} + + +pub async fn handle_request( + request: DownloadUrlRequest, + app: &AppHandle, + app_prefs: &AppPreferencesManager, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs +) -> Result<(), ArtcraftError> { + + let app_prefs = app_prefs.get_clone()?; + + let download_path = download_url_to_user_download_dir( + &request.url, + app_data_root, + &app_prefs + ).await?; + + info!("downloaded to: {:?}", download_path); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/commands/download/mod.rs b/crates/desktop/artcraft/src/core/commands/download/mod.rs new file mode 100644 index 0000000000..ad9c6330d3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/download/mod.rs @@ -0,0 +1,3 @@ +pub mod download_directory_reveal_command; +pub mod download_media_file_command; +pub mod download_url_command; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs new file mode 100644 index 0000000000..5259fefdcf --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs @@ -0,0 +1,58 @@ +use crate::core::commands::enqueue::generate_error::{BillingIssueReason, BillingProvider, GenerateError, ProviderFailureReason}; +use crate::core::events::functional_events::show_provider_billing_modal_event::ShowProviderBillingModalEvent; +use enums::common::generation_provider::GenerationProvider; +use log::warn; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use tauri::AppHandle; + +pub async fn maybe_notify_frontend_of_billing_errors( + app: &AppHandle, + error: &GenerateError, +) { + match error { + GenerateError::BillingIssue(reason) => { + billing_error(app, reason); + } + GenerateError::ProviderFailure(reason) => { + provider_billing_error(app, reason); + } + _ => { + // Do nothing for other types of errors + } + } +} + +fn billing_error( + app: &AppHandle, + reason: &BillingIssueReason, +) { + let provider = match reason.provider { + BillingProvider::Artcraft => GenerationProvider::Artcraft, + BillingProvider::Fal => GenerationProvider::Fal, + BillingProvider::Kinovi => GenerationProvider::Artcraft, // NB: We don't support Kinovi yet. + BillingProvider::Midjourney => GenerationProvider::Midjourney, + BillingProvider::Sora => GenerationProvider::Sora, + }; + warn!("Billing issue with: {:?}", provider); + ShowProviderBillingModalEvent::send_for_provider(provider, app); +} + +fn provider_billing_error( + app: &AppHandle, + error: &ProviderFailureReason, +) { + let provider; + + match error { + ProviderFailureReason::StorytellerError(StorytellerError::Api(ApiError::PaymentRequired(reason))) => { + warn!("Billing issue with Artcraft/Storyteller: {}", reason); + provider = GenerationProvider::Artcraft; + } + _ => { + return; + } + } + + ShowProviderBillingModalEvent::send_for_provider(provider, app); +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_grok_errors.rs b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_grok_errors.rs new file mode 100644 index 0000000000..1ff72f807d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_grok_errors.rs @@ -0,0 +1,41 @@ +use crate::core::commands::enqueue::generate_error::{GenerateError, ProviderFailureReason}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use grok_consumer_client::error::grok_error::GrokError; +use grok_consumer_client::error::grok_specific_api_error::GrokSpecificApiError; +use tauri::AppHandle; + +pub async fn maybe_notify_frontend_of_grok_errors( + app: &AppHandle, + error: &GenerateError, +) { + match error { + GenerateError::ProviderFailure(ProviderFailureReason::GrokError(error)) => { + grok_error(app, error); + } + _ => { + // Do nothing for other types of errors + } + } +} + +fn grok_error( + app: &AppHandle, + error: &GrokError, +) { + match error { + GrokError::ApiSpecific(GrokSpecificApiError::TooManyVideos) => { + let event = GenerationEnqueueFailureEvent { + action: GenerationAction::GenerateVideo, + service: GenerationServiceProvider::Grok, + model: None, + reason: Some("You've generated too many Grok videos. Please wait for more quota from Grok.".to_string()), + }; + event.send_infallible(&app); + } + GrokError::ApiSpecific(_) => {} + GrokError::Client(_) => {} + GrokError::ApiGeneric(_) => {} + } +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/common/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/common/mod.rs new file mode 100644 index 0000000000..feab773fad --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/common/mod.rs @@ -0,0 +1,3 @@ +pub mod maybe_notify_frontend_of_billing_errors; +pub mod maybe_notify_frontend_of_grok_errors; +pub mod notify_frontend_of_errors; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/common/notify_frontend_of_errors.rs b/crates/desktop/artcraft/src/core/commands/enqueue/common/notify_frontend_of_errors.rs new file mode 100644 index 0000000000..1f05e104c3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/common/notify_frontend_of_errors.rs @@ -0,0 +1,12 @@ +use crate::core::commands::enqueue::common::maybe_notify_frontend_of_billing_errors::maybe_notify_frontend_of_billing_errors; +use crate::core::commands::enqueue::common::maybe_notify_frontend_of_grok_errors::maybe_notify_frontend_of_grok_errors; +use crate::core::commands::enqueue::generate_error::GenerateError; +use tauri::AppHandle; + +pub async fn notify_frontend_of_errors( + app: &AppHandle, + error: &GenerateError, +) { + maybe_notify_frontend_of_billing_errors(app, error).await; + maybe_notify_frontend_of_grok_errors(app, error).await; +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs new file mode 100644 index 0000000000..17123ee6b5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs @@ -0,0 +1,257 @@ +use crate::core::artcraft_error::ArtcraftError; +use anyhow::anyhow; +use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError; +use artcraft_router::errors::provider_error::ProviderError; +use base64::DecodeError; +use enums::common::generation_provider::GenerationProvider; +use enums::common::generation::common_model_type::CommonModelType; +use errors::AnyhowError; +use grok_consumer_client::error::grok_error::GrokError; +use midjourney_client::error::midjourney_error::MidjourneyError; +use openai_sora_client::error::sora_error::SoraError; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_router::errors::download_error::DownloadError; +use worldlabs_consumer_client::error::world_labs_error::WorldLabsError; +//use fal_client::error::fal_error_plus::FalErrorPlus; + +#[derive(Debug)] +pub enum GenerateError { + BadInput(BadInputReason), + MissingCredentials(MissingCredentialsReason), + ProviderFailure(ProviderFailureReason), + + /// We couldn't find a provider to dispatch the request to. + NoProviderAvailable, + + /// We pulled out Fal (for now) - it's impacting build speeds. We'll add it in the future. + FalNoLongerSupported, + + /// Wrong provider for the model + BadProviderForModel { + provider: GenerationProvider, + model: CommonModelType, + }, + + /// If the response didn't contain job tokens to track. + ResponseHadNoJobTokens, + + /// Issue with Artcraft router downloads + ArtcraftRouterDownloadError(DownloadError), + + /// A provider we don't support yet in ArtCraft "the Tauri app" part. + ArtcraftRouterNotYetSupportedProvider(&'static str), + + /// There was a billing, credits, or payments issue. + BillingIssue(BillingIssueReason), + + /// The feature is not yet implemented. + NotYetImplemented(String), + + // Misc error buckets + AnyhowError(AnyhowError), + DecodeError(DecodeError), + IoError(std::io::Error), +} + +#[derive(Debug)] +pub enum BadInputReason { + Base64DecodeError, + BothImageMaskMediaTokenAndBytesSupplied, + CannotDetermineImageMimeType, + InvalidNumberOfInputImages { + provided: u32, + min: u32, + max: u32, + }, + InvalidNumberOfRequestedImages { + requested: u32, + min: u32, + max: u32, + }, + NoModelSpecified, + RequiredSourceImageMaskNotProvided, + RequiredSourceImageNotProvided, + WrongImageArguments(String), +} + +#[derive(Debug)] +pub enum MissingCredentialsReason { + NeedsGrokCredentials, + NeedsFalApiKey, + NeedsMidjourneyCredentials, + NeedsMidjourneyUserId, + NeedsMidjourneyUserInfo, + NeedsSoraCredentials, + NeedsStorytellerCredentials, + NeedsWorldLabsCredentials, +} + +#[derive(Debug)] +pub struct BillingIssueReason { + pub provider: BillingProvider, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum BillingProvider { + Artcraft, + Fal, + Kinovi, + Midjourney, + Sora, +} + +#[derive(Debug)] +pub enum ProviderFailureReason { + GrokError(GrokError), + /// NB: The Grok client doesn't say why certain errors (eg. missing fields) happen, so we synthesize this. + GrokJobEnqueueFailed, + //Fal(FalErrorPlus), + MidjourneyError(MidjourneyError), + /// NB: The midjourney client doesn't categorize all errors, so we have to do so on our end. + MidjourneyJobEnqueueFailed, + SoraError(SoraError), + StorytellerError(StorytellerError), + WorldLabsError(WorldLabsError), +} + +impl GenerateError { + + // + // Bad input + // + + pub fn both_image_mask_media_token_and_bytes_supplied() -> Self { + Self::BadInput(BadInputReason::BothImageMaskMediaTokenAndBytesSupplied) + } + + pub fn no_model_specified() -> Self { + Self::BadInput(BadInputReason::NoModelSpecified) + } + + pub fn required_source_image_mask_not_provided() -> Self { + Self::BadInput(BadInputReason::RequiredSourceImageMaskNotProvided) + } + + pub fn required_source_image_not_provided() -> Self { + Self::BadInput(BadInputReason::RequiredSourceImageNotProvided) + } + + // + // Missing credentials + // + + pub fn needs_fal_api_key() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) + } + + pub fn needs_grok_credentials() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) + } + + pub fn needs_midjourney_credentials() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials) + } + + pub fn needs_sora_credentials() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsSoraCredentials) + } + + pub fn needs_storyteller_credentials() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials) + } + + pub fn needs_worldlabs_credentials() -> Self { + Self::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials) + } +} + +impl From for GenerateError { + fn from(value: AnyhowError) -> Self { + Self::AnyhowError(value) + } +} + +impl From for GenerateError { + fn from(value: ArtcraftError) -> Self { + match value { + ArtcraftError::AnyhowError(e) => Self::AnyhowError(e), + ArtcraftError::DecodeError(e) => Self::DecodeError(e), + ArtcraftError::IoError(e) => Self::IoError(e), + ArtcraftError::GrokError(e) => Self::ProviderFailure(ProviderFailureReason::GrokError(e)), + ArtcraftError::StorytellerError(e) => Self::ProviderFailure(ProviderFailureReason::StorytellerError(e)), + ArtcraftError::RwLockReadError => Self::AnyhowError(anyhow!("Lock read error")), + ArtcraftError::RwLockWriteError => Self::AnyhowError(anyhow!("Lock write error")), + ArtcraftError::MutexLockError => Self::AnyhowError(anyhow!("Mutex lock error")), + ArtcraftError::ReqwestError(err) => Self::AnyhowError(anyhow!("Reqwest error: {:?}", err)), + ArtcraftError::BadDownloadFilename { path } => Self::AnyhowError(anyhow!("Bad download file name: {:?}", path)), + ArtcraftError::CannotDownloadFilePathAlreadyExists { path } => Self::AnyhowError(anyhow!("Cannot download already existing file: {:?}", path)), + } + } +} + +//impl From for GenerateError { +// fn from(value: FalErrorPlus) -> Self { +// Self::ProviderFailure(ProviderFailureReason::Fal(value)) +// } +//} + +impl From for GenerateError { + fn from(value: GrokError) -> Self { + Self::ProviderFailure(ProviderFailureReason::GrokError(value)) + } +} + +impl From for GenerateError { + fn from(value: MidjourneyError) -> Self { + Self::ProviderFailure(ProviderFailureReason::MidjourneyError(value)) + } +} + +impl From for GenerateError { + fn from(value: SoraError) -> Self { + Self::ProviderFailure(ProviderFailureReason::SoraError(value)) + } +} + +impl From for GenerateError { + fn from(value: StorytellerError) -> Self { + Self::ProviderFailure(ProviderFailureReason::StorytellerError(value)) + } +} + +impl From for GenerateError { + fn from(value: WorldLabsError) -> Self { + Self::ProviderFailure(ProviderFailureReason::WorldLabsError(value)) + } +} + +impl From for GenerateError { + fn from(value: ArtcraftRouterError) -> Self { + match value { + ArtcraftRouterError::Client(e) => Self::BadInput(BadInputReason::WrongImageArguments(e.to_string())), + ArtcraftRouterError::Download(e) => Self::ArtcraftRouterDownloadError(e), + ArtcraftRouterError::InvalidInput(msg) => Self::BadInput(BadInputReason::WrongImageArguments(msg)), + ArtcraftRouterError::ProviderBillingError(err) => { + let provider = match err { + ProviderError::Fal(_) => BillingProvider::Fal, + ProviderError::KinoviWeb(_) => BillingProvider::Kinovi, + ProviderError::Storyteller(_) => BillingProvider::Artcraft, + ProviderError::GmiCloud(_) => BillingProvider::Artcraft, + ProviderError::Grok(_) => BillingProvider::Artcraft, + ProviderError::WorldLabs(_) => BillingProvider::Artcraft, + }; + Self::BillingIssue(BillingIssueReason { provider }) + }, + ArtcraftRouterError::Provider(ProviderError::Storyteller(e)) => Self::ProviderFailure(ProviderFailureReason::StorytellerError(e)), + ArtcraftRouterError::Provider(ProviderError::Fal(_)) => Self::FalNoLongerSupported, + ArtcraftRouterError::Provider(ProviderError::GmiCloud(_)) => Self::ArtcraftRouterNotYetSupportedProvider("gmicloud"), + ArtcraftRouterError::Provider(ProviderError::Grok(_)) => Self::ArtcraftRouterNotYetSupportedProvider("grok_api"), + ArtcraftRouterError::Provider(ProviderError::KinoviWeb(_)) => Self::ArtcraftRouterNotYetSupportedProvider("kinovi_web"), + // NB: the desktop app reaches World Labs through the Artcraft backend; the router's + // direct World Labs provider isn't used here. + ArtcraftRouterError::Provider(ProviderError::WorldLabs(_)) => Self::ArtcraftRouterNotYetSupportedProvider("world_labs"), + ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)), + ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(message) => Self::ArtcraftRouterNotYetSupportedProvider("unsupported model for new router API"), + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/artcraft/handle_generic_bg_removal_artcraft.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/artcraft/handle_generic_bg_removal_artcraft.rs new file mode 100644 index 0000000000..b6ed87edbe --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/artcraft/handle_generic_bg_removal_artcraft.rs @@ -0,0 +1,126 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason}; +use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::generate::image::bg_removal::remove_image_background::RemoveImageBackgroundRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::bg_removal::remove_image_background::remove_image_background; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_generic_bg_removal_artcraft( + request: &EnqueueImageBgRemovalCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials)); + }, + }; + + info!("Calling Artcraft bg removal ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let image_media_token = match &request.image_media_token { + Some(image_media_token) => image_media_token.clone(), + None => { + upload_image_from_base64_bytes(&request, &app_data_root, &app_env_configs, &creds).await? + } + }; + + let request = RemoveImageBackgroundRequest { + uuid_idempotency_token, + media_file_token: Some(image_media_token), + }; + + let result = remove_image_background( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let success_result = match result { + Ok(enqueued) => { + // TODO(bt,2025-07-05): Enqueue job token? + info!("Successfully enqueued Artcraft background removal. Job token: {}", + enqueued.inference_job_token); + enqueued + } + Err(err) => { + error!("Failed to use Artcraft background removal: {:?}", err); + return Err(GenerateError::ProviderFailure(ProviderFailureReason::StorytellerError(err))); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Artcraft, + task_type: TaskType::BackgroundRemoval, + provider_job_id: Some(success_result.inference_job_token.to_string()), + model: None, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +async fn upload_image_from_base64_bytes( + request: &EnqueueImageBgRemovalCommand, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + creds: &StorytellerCredentialSet +) -> Result { + let temp_file; + + if let Some(base64_bytes) = &request.base64_image { + info!("Saving base64 image to temp dir ..."); + temp_file = save_base64_image_to_temp_dir(&app_data_root, base64_bytes) + .await + .map_err(|err| { + error!("Failed to save base64 image to temp dir: {:?}", err); + GenerateError::BadInput(BadInputReason::Base64DecodeError) + })?; + } else { + return Err(GenerateError::BadInput(BadInputReason::RequiredSourceImageNotProvided)); + }; + + info!("Uploading image media file from temp file: {:?}", temp_file.path()); + + let result = + upload_image_media_file_from_file(UploadImageFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&creds), + path: temp_file, + is_intermediate_system_file: true, // NB: Probably not essential to keep this. + maybe_prompt_token: None, // NB: Not used for bg removal. + maybe_batch_token: None, // NB: Not used for bg removal. + maybe_generation_provider: None, + }).await + .map_err(|err| { + error!("Failed to upload image media file: {:?}", err); + GenerateError::ProviderFailure(ProviderFailureReason::StorytellerError(err)) + })?; + + Ok(result.media_file_token) +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/artcraft/mod.rs new file mode 100644 index 0000000000..a02c2e249a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/artcraft/mod.rs @@ -0,0 +1 @@ +pub mod handle_generic_bg_removal_artcraft; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/enqueue_image_bg_removal_command.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/enqueue_image_bg_removal_command.rs new file mode 100644 index 0000000000..d18b85217b --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/enqueue_image_bg_removal_command.rs @@ -0,0 +1,230 @@ +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::image_bg_removal::artcraft::handle_generic_bg_removal_artcraft::handle_generic_bg_removal_artcraft; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowError; +use log::{error, info, warn}; +use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest}; +use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use serde_derive::{Deserialize, Serialize}; +use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs}; +use std::time::Duration; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::utils::api_host::ApiHost; +use tauri::{AppHandle, Manager, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Deserialize, Debug)] +pub struct EnqueueImageBgRemovalCommand { + // /// The bg removal model to use (optional). + // pub model: Option, + + /// Image media file; the image to remove the background from. + pub image_media_token: Option, + + /// Base64-encoded image + pub base64_image: Option, + + /// OPTIONAL. + /// Name of the frontend caller. + /// We'll use this to selectively trigger events. + pub frontend_caller: Option, + + /// OPTIONAL. + /// A frontend-defined identifier that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// OPTIONAL. + /// A frontend-defined payload that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueImageBgRemovalErrorType { + /// No model available for image generation + NoProviderAvailable, + + /// No image was provided for background removal + MissingImage, + + /// Bad base64 image data + Base64DecodeError, + + /// Generic bad request error + BadRequest, + + /// Generic server error + ServerError, +} + +#[derive(Serialize)] +pub struct EnqueueImageBgRemovalSuccessResponse { +} + +impl SerializeMarker for EnqueueImageBgRemovalSuccessResponse {} + +#[tauri::command] +pub async fn enqueue_image_bg_removal_command( + app: AppHandle, + request: EnqueueImageBgRemovalCommand, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + provider_priority_store: State<'_, ProviderPriorityStore>, + task_database: State<'_, TaskDatabase>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> ResponseOrErrorType { + + info!("enqueue_image_bg_removal_command called"); + + let result = handle_request( + &request, + &app, + &app_data_root, + &app_env_configs, + &artcraft_usage_tracker, + &provider_priority_store, + &task_database, + &storyteller_creds_manager, + ).await; + + match result { + Err(err) => { + error!("Error removing background: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + // TODO: Derive from err. Make service provider optional. + let event = GenerationEnqueueFailureEvent { + action: GenerationAction::RemoveBackground, + service: GenerationServiceProvider::Artcraft, // FIXME: This is wrong. + model: None, + reason: None, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + Err(error_to_tauri_response(err)) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: None, // FIXME: This isn't right, though we probably don't care for simple bg removal. + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(EnqueueImageBgRemovalSuccessResponse {}.into()) + } + } +} + +pub async fn handle_request( + request: &EnqueueImageBgRemovalCommand, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + artcraft_usage_tracker: &ArtcraftUsageTracker, + provider_priority_store: &ProviderPriorityStore, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + // TODO(bt,2025-07-07): Other model/provider routing... + + let success_event = handle_generic_bg_removal_artcraft( + request, + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + ).await?; + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller, + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref() + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + // NB: Fail open, but find a way to flag this. + } + + if let Err(err) = artcraft_usage_tracker.record_image_generation(1, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::EditPage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} + +fn error_to_tauri_response(error: GenerateError) -> CommandErrorResponseWrapper { + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueImageBgRemovalErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.".to_string(); + + match error { + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueImageBgRemovalErrorType::NoProviderAvailable; + error_message = "No configured provider available for background removal".to_string(); + } + GenerateError::BadInput(BadInputReason::RequiredSourceImageNotProvided) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueImageBgRemovalErrorType::MissingImage; + error_message = "No image provided for background removal".to_string(); + } + GenerateError::BadInput(BadInputReason::Base64DecodeError) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueImageBgRemovalErrorType::Base64DecodeError; + error_message = "Failed to decode base64 image data".to_string(); + } + _ => {}, // Other cases fall through. + } + + CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/mod.rs new file mode 100644 index 0000000000..31900208a8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_bg_removal/mod.rs @@ -0,0 +1,2 @@ +pub mod enqueue_image_bg_removal_command; +pub (super) mod artcraft; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/get_storyteller_creds_or_error.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/get_storyteller_creds_or_error.rs new file mode 100644 index 0000000000..f4bee82220 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/get_storyteller_creds_or_error.rs @@ -0,0 +1,26 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::GenerationAction; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use log::error; +use tauri::AppHandle; + +pub(super) fn get_storyteller_creds_or_error( + app: &AppHandle, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + match storyteller_creds_manager.get_credentials()? { + Some(creds) => Ok(creds), + None => { + error!("No Artcraft credentials are set. Can't perform action."); + let event = + GenerationEnqueueFailureEvent::no_artcraft_credentials(GenerationAction::GenerateGaussian); + + event.send_infallible(app); + + Err(GenerateError::needs_storyteller_credentials()) + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/handle_artcraft_gaussian.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/handle_artcraft_gaussian.rs new file mode 100644 index 0000000000..0854e8af70 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/handle_artcraft_gaussian.rs @@ -0,0 +1,32 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::image_to_gaussian::artcraft::get_storyteller_creds_or_error::get_storyteller_creds_or_error; +use crate::core::commands::enqueue::image_to_gaussian::artcraft::handle_artcraft_splat_via_router::handle_artcraft_splat_via_router; +use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::{EnqueueImageToGaussianRequest, GaussianModel}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_router::api::router_splat_model::RouterSplatModel; +use tauri::AppHandle; + +pub async fn handle_gaussian_artcraft( + request: &EnqueueImageToGaussianRequest, + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + let creds = get_storyteller_creds_or_error(app, storyteller_creds_manager)?; + + match request.model { + None => Err(GenerateError::no_model_specified()), + Some(GaussianModel::Marble0p1Mini) => { + handle_artcraft_splat_via_router(request, app_env_configs, &creds, RouterSplatModel::Marble0p1Mini, GenerationModel::WorldlabsMarble0p1Mini).await + } + Some( + GaussianModel::Marble0p1Plus | + GaussianModel::WorldLabsMarble // Legacy variant — route to Plus + ) => { + handle_artcraft_splat_via_router(request, app_env_configs, &creds, RouterSplatModel::Marble0p1Plus, GenerationModel::WorldlabsMarble0p1Plus).await + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/handle_artcraft_splat_via_router.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/handle_artcraft_splat_via_router.rs new file mode 100644 index 0000000000..0ddb799eb5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/handle_artcraft_splat_via_router.rs @@ -0,0 +1,65 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::EnqueueImageToGaussianRequest; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_router::api::router_splat_model::RouterSplatModel; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::client::router_artcraft_client::RouterArtcraftClient; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::generate::generate_splat::generate_splat_request::GenerateSplatRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; + +pub(super) async fn handle_artcraft_splat_via_router( + request: &EnqueueImageToGaussianRequest, + app_env_configs: &AppEnvConfigs, + creds: &StorytellerCredentialSet, + model: RouterSplatModel, + generation_model: GenerationModel, +) -> Result { + let client = RouterClient::Artcraft(RouterArtcraftClient::new( + app_env_configs.storyteller_host.clone(), + creds.clone(), + )); + + let router_request = GenerateSplatRequest { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + reference_images: request.image_media_tokens.clone().map(ImageListRef::MediaFileTokens), + idempotency_token: None, + }; + + let plan = router_request.build()?; + + info!("Splat Generation Plan: {:?}", plan); + + let response = match plan.generate_splat(&client).await { + Ok(resp) => { + info!("Successfully enqueued splat generation."); + resp + } + Err(err) => { + error!("Failed to enqueue splat generation: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + let job_id = response.get_artcraft_payload() + .map(|p| p.inference_job_token.to_string()) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(generation_model), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_id), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/mod.rs new file mode 100644 index 0000000000..b4eb5153e3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/artcraft/mod.rs @@ -0,0 +1,3 @@ +mod get_storyteller_creds_or_error; +mod handle_artcraft_splat_via_router; +pub(super) mod handle_artcraft_gaussian; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/enqueue_image_to_gaussian_command.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/enqueue_image_to_gaussian_command.rs new file mode 100644 index 0000000000..3e0c1f9404 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/enqueue_image_to_gaussian_command.rs @@ -0,0 +1,262 @@ +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::image_to_gaussian::artcraft::handle_artcraft_gaussian::handle_gaussian_artcraft; +use crate::core::commands::enqueue::image_to_gaussian::worldlabs::handle_worldlabs_marble::handle_worldlabs_marble; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::Response; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::GrokImagePromptQueue; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs}; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; + +/// This is used in the Tauri command bridge. +/// Don't change the serializations without coordinating with the frontend. +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum GaussianModel { + #[serde(rename = "world_labs_marble")] + WorldLabsMarble, + + #[serde(rename = "marble_0p1_mini")] + Marble0p1Mini, + + #[serde(rename = "marble_0p1_plus")] + Marble0p1Plus, +} + +#[derive(Deserialize)] +pub struct EnqueueImageToGaussianRequest { + /// REQUIRED. + /// The model to use. + pub model: Option, + + /// OPTIONAL. + /// The provider to use (defaults to Artcraft/Storyteller). + /// Not all (provider, model) combinations are valid. + pub provider: Option, + + /// OPTIONAL. + /// Text prompt for the gaussian generation. Required. + pub prompt: Option, + + /// OPTIONAL. + /// Images for the gaussian generation + pub image_media_tokens: Option>, + + /// OPTIONAL. + /// Name of the frontend caller. + /// We'll use this to selectively trigger events. + pub frontend_caller: Option, + + /// OPTIONAL. + /// A frontend-defined identifier that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// OPTIONAL. + /// A frontend-defined payload that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +#[derive(Serialize)] +pub struct EnqueueImageToGaussianSuccessResponse { +} + +impl SerializeMarker for EnqueueImageToGaussianSuccessResponse {} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueImageToGaussianErrorType { + /// Caller didn't specify a model + ModelNotSpecified, + /// No model available for image generation + NoProviderAvailable, + /// Generic server error + ServerError, + /// Needs to be logged into Artcraft + NeedsStorytellerCredentials, +} + +#[tauri::command] +pub async fn enqueue_image_to_gaussian_command( + request: EnqueueImageToGaussianRequest, + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + task_database: State<'_, TaskDatabase>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + worldlabs_creds_manager: State<'_, WorldlabsCredentialManager>, +) -> Response { + + info!("enqueue_image_to_gaussian called"); + + let result = handle_request( + request, + &app, + &app_data_root, + &artcraft_usage_tracker, + &task_database, + &storyteller_creds_manager, + &worldlabs_creds_manager, + &app_env_configs, + ).await; + + match result { + Err(err) => { + error!("error: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueImageToGaussianErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem."; + + match err { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueImageToGaussianErrorType::ModelNotSpecified; + error_message = "No model specified for image generation"; + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueImageToGaussianErrorType::NoProviderAvailable; + error_message = "No configured provider available for image generation"; + } + _ => {}, // Fall-through + } + + Err(CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + }) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: event.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(EnqueueImageToGaussianSuccessResponse {}.into()) + } + } +} + +pub async fn handle_request( + request: EnqueueImageToGaussianRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + artcraft_usage_tracker: &ArtcraftUsageTracker, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + worldlabs_creds_manager: &WorldlabsCredentialManager, + app_env_configs: &AppEnvConfigs, +) -> Result { + + let result = dispatch_request( + &request, + &app, + &app_data_root, + &storyteller_creds_manager, + &worldlabs_creds_manager, + &app_env_configs, + ).await; + + let success_event = match result { + Err(err) => return Err(err), + Ok(event) => event, + }; + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller, + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref(), + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + // NB: Fail open, but find a way to flag this. + } + + if let Err(err) = artcraft_usage_tracker.record_object_generation(1, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::OtherPage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} + +pub async fn dispatch_request( + request: &EnqueueImageToGaussianRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + storyteller_creds_manager: &StorytellerCredentialManager, + worldlabs_creds_manager: &WorldlabsCredentialManager, + app_env_configs: &AppEnvConfigs, +) -> Result { + + if request.model.is_none() { + return Err(GenerateError::BadInput(BadInputReason::NoModelSpecified)); + } + + let provider = request.provider.unwrap_or(GenerationProvider::Artcraft); + + match provider { + GenerationProvider::WorldLabs => { + handle_worldlabs_marble( + app, + app_data_root, + app_env_configs, + request, + worldlabs_creds_manager, + ).await + } + GenerationProvider::Artcraft => { + handle_gaussian_artcraft( + request, + app, + app_env_configs, + storyteller_creds_manager, + ).await + } + _ => { + Err(GenerateError::NoProviderAvailable) + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/mod.rs new file mode 100644 index 0000000000..59dc5bdcb9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/mod.rs @@ -0,0 +1,3 @@ +mod artcraft; +pub mod enqueue_image_to_gaussian_command; +mod worldlabs; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/worldlabs/handle_worldlabs_marble.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/worldlabs/handle_worldlabs_marble.rs new file mode 100644 index 0000000000..1b588af4ec --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/worldlabs/handle_worldlabs_marble.rs @@ -0,0 +1,240 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::EnqueueImageToGaussianRequest; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use std::path::PathBuf; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; +use worldlabs_consumer_client::credentials::world_labs_cookies::WorldLabsCookies; +use worldlabs_consumer_client::recipes::upload_image_and_create_world_with_retry::{upload_image_and_create_world_with_retry, FileBytesOrPath, UploadImageAndCreateWorldWithRetryArgs}; + +pub(super) const MAX_IMAGES: usize = 10; + +#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")] +pub async fn handle_worldlabs_marble( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + request: &EnqueueImageToGaussianRequest, + worldlabs_creds_manager: &WorldlabsCredentialManager, +) -> Result { + + let world_labs_cookies = match worldlabs_creds_manager.maybe_copy_typed_cookies()? { + Some(cookies) => cookies, + None => { + error!("No WorldLabs cookies!"); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::WorldLabs, &app); + return Err(GenerateError::needs_worldlabs_credentials()); + } + }; + + let world_labs_bearer = match worldlabs_creds_manager.maybe_copy_bearer_token()? { + Some(bearer) => bearer, + None => { + error!("No WorldLabs bearer token!"); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::WorldLabs, &app); + return Err(GenerateError::needs_worldlabs_credentials()); + } + }; + + let world_labs_refresh = match worldlabs_creds_manager.maybe_copy_refresh_token()? { + Some(bearer) => bearer, + None => { + error!("No WorldLabs refresh token!"); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::WorldLabs, &app); + return Err(GenerateError::needs_worldlabs_credentials()); + } + }; + + let maybe_prompt = request.prompt + .as_deref() + .map(|prompt| prompt.trim().to_string()); + + info!("Downloading image file..."); + + let file_path = download_file( + app_data_root, + app_env_configs, + request, + ).await?; + + info!("Enqueueing WorldLabs request..."); + + let response = upload_image_and_create_world_with_retry(UploadImageAndCreateWorldWithRetryArgs { + cookies: &world_labs_cookies, + bearer_token: &world_labs_bearer, + refresh_token: &world_labs_refresh, + individual_request_timeout: None, + file: FileBytesOrPath::Path(file_path), + }).await?; + + let run_id = response.run_id; + let world_id = response.world_id; + + info!("Run ID: {:?}", run_id); + info!("World ID: {:?}", world_id); + + if let Some(new_access) = response.maybe_new_access_tokens { + info!("New access tokens were generated; saving."); + worldlabs_creds_manager.replace_bearer_and_refresh_token( + new_access.bearer_token, + new_access.refresh_token + )?; + + worldlabs_creds_manager.persist_to_disk()?; + } + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::WorldLabs, + model: Some(GenerationModel::WorldlabsMarble), + provider_job_id: Some(world_id.0), + task_type: TaskType::ImageGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +async fn download_file( + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + request: &EnqueueImageToGaussianRequest, +) -> Result { + + let maybe_media_token = request.image_media_tokens + .as_ref() + .and_then(|tokens| tokens.get(0)); + + let media_token = match maybe_media_token { + Some(token) => token, + None => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { + min: 1, + max: MAX_IMAGES as u32, + provided: 0, + })); + } + }; + + info!("Calling get media file API: {:?}", app_env_configs.storyteller_host); + + // TODO(bt,2025-12-18): Add a cache. + info!("Using media token: {:?}", media_token); + + let response = get_media_file( + &app_env_configs.storyteller_host, + media_token + ).await?; + + let media_file_url = &response.media_file.media_links.cdn_url; + let extension_with_dot = get_url_file_extension(media_file_url) + .map(|ext| format!(".{}", ext)) + .unwrap_or_else(|| ".png".to_string()); + + let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot); + let filename = app_data_root.downloads_dir().path().join(&filename); + + simple_http_download(&media_file_url, &filename).await?; + + Ok(filename) +} + +//async fn download_files( +// app_data_root: &AppDataRoot, +// app_env_configs: &AppEnvConfigs, +// request: &EnqueueImageToGaussianRequest, +//) -> Result, GenerateError> { +// +// let maybe_media_token = request.image_media_tokens +// .as_ref() +// .and_then(|tokens| tokens.get(0)); +// +// +// let media_tokens = match request.image_media_tokens.as_ref() { +// Some(media_tokens) => media_tokens.to_vec(), +// None => { +// return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { +// min: 1, +// max: MAX_IMAGES as u32, +// provided: 0, +// })); +// } +// }; +// +// if media_tokens.len() > MAX_IMAGES { +// return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { +// min: 1, +// max: MAX_IMAGES as u32, +// provided: media_tokens.len() as u32, +// })); +// } +// +// info!("Calling get media file API: {:?}", app_env_configs.storyteller_host); +// +// let mut local_files_to_upload = Vec::with_capacity(10); +// +// // TODO(bt,2025-07-07): This is inefficient. Cache and parallelize this. +// // TODO(bt,2025-12-18): Add a cache. +// for media_token in media_tokens.iter() { +// info!("Using media token: {:?}", media_token); +// +// let response = get_media_file( +// &app_env_configs.storyteller_host, +// media_token +// ).await?; +// +// let media_file_url = &response.media_file.media_links.cdn_url; +// let extension_with_dot = get_url_file_extension(media_file_url) +// .map(|ext| format!(".{}", ext)) +// .unwrap_or_else(|| ".png".to_string()); +// +// let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot); +// let filename = app_data_root.downloads_dir().path().join(&filename); +// +// simple_http_download(&media_file_url, &filename).await?; +// +// local_files_to_upload.push(filename); +// } +// +// Ok(local_files_to_upload) +//} + +// TODO: +// fn handle_midjourney_errors( +// app: &AppHandle, +// maybe_errors: Option> +// ) -> Result { +// if let Some(errors) = maybe_errors { +// if !errors.is_empty() { +// let messages: Vec = errors.iter() +// .map(|e| format!("{:?}", e)) +// .collect(); +// +// let combined_message = messages.join("; "); +// +// let event = FlashUserInputErrorEvent { +// message: format!("Midjourney Error: {}", combined_message), +// }; +// +// if let Err(err) = event.send(&app) { +// error!("Failed to send FlashUserInputErrorEvent: {:?}", err); // Fail open +// } +// } +// } +// +// Err(GenerateError::ProviderFailure(ProviderFailureReason::MidjourneyJobEnqueueFailed)) +// } diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/worldlabs/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/worldlabs/mod.rs new file mode 100644 index 0000000000..73ec59edec --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_gaussian/worldlabs/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_worldlabs_marble; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/artcraft/handle_artcraft_object.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/artcraft/handle_artcraft_object.rs new file mode 100644 index 0000000000..d8775aee20 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/artcraft/handle_artcraft_object.rs @@ -0,0 +1,159 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::image_to_object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest; +use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use artcraft_client::endpoints::generate::object::generate_hunyuan_3d_2_0_image_to_3d::generate_hunyuan3d_2_0_image_to_3d; +use artcraft_client::endpoints::generate::object::generate_hunyuan_3d_2_1_image_to_3d::generate_hunyuan3d_2_1_image_to_3d; +use tauri::AppHandle; +use artcraft_api_defs::generate::object::multi_function::hunyuan3d_v3_multi_function_object_gen::Hunyuan3dV3MultiFunctionObjectGenRequest; +use artcraft_client::endpoints::generate::object::multi_function::hunyuan3d_v3_multi_function_object_gen::hunyuan3d_v3_multi_function_object_gen; + +pub async fn handle_artcraft_object( + request: &EnqueueImageTo3dObjectRequest, + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + error!("No Artcraft credentials are set. Can't perform action."); + let event = + GenerationEnqueueFailureEvent::no_artcraft_credentials(GenerationAction::ImageTo3d); + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + return Err(GenerateError::needs_storyteller_credentials()); + }, + }; + + info!("Calling Artcraft image to 3d ..."); + + let uuid_idempotency_token = generate_random_uuid(); + + let mut used_model = None; + + let job_token = match request.model { + None => { + return Err(GenerateError::no_model_specified()); + } + Some( + EnqueueImageTo3dObjectModel::Hunyuan3d2 | + EnqueueImageTo3dObjectModel::Hunyuan3d2_0 + ) => { + info!("enqueue Artcraft Hunyuan 3D 2.0"); + used_model = Some(GenerationModel::Hunyuan3d2_0); + let request = GenerateHunyuan20ImageTo3dRequest { + uuid_idempotency_token, + media_file_token: request.image_media_token.clone(), + }; + let result = generate_hunyuan3d_2_0_image_to_3d( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + match result { + Ok(enqueued) => { + info!("Successfully enqueued Artcraft Hunyuan 3D 2.0"); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft Hunyuan 3D 2.0: {:?}", err); + return Err(GenerateError::from(err)); + } + } + } + Some(EnqueueImageTo3dObjectModel::Hunyuan3d2_1) => { + info!("enqueue Artcraft Hunyuan 3D 2.1"); + used_model = Some(GenerationModel::Hunyuan3d2_1); + let request = GenerateHunyuan21ImageTo3dRequest { + uuid_idempotency_token, + media_file_token: request.image_media_token.clone(), + }; + let result = generate_hunyuan3d_2_1_image_to_3d( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + match result { + Ok(enqueued) => { + info!("Successfully enqueued Artcraft Hunyuan 3D 2.1"); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft Hunyuan 3D 2.1: {:?}", err); + return Err(GenerateError::from(err)); + } + } + } + Some(EnqueueImageTo3dObjectModel::Hunyuan3d3) => { + info!("enqueue Artcraft Hunyuan 3D 3"); + used_model = Some(GenerationModel::Hunyuan3d3); + let request = Hunyuan3dV3MultiFunctionObjectGenRequest { + uuid_idempotency_token, + image_media_token: request.image_media_token.clone(), + prompt: None, + back_image_media_token: None, + left_image_media_token: None, + right_image_media_token: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + let result = hunyuan3d_v3_multi_function_object_gen( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + match result { + Ok(enqueued) => { + info!("Successfully enqueued Artcraft Hunyuan 3D 3"); + enqueued.inference_job_token + } + Err(err) => { + error!("Failed to use Artcraft Hunyuan 3D 3: {:?}", err); + return Err(GenerateError::from(err)); + } + } + } + }; + + info!("Successfully enqueued 3D generation"); + + //let event = GenerationEnqueueSuccessEvent { + // action: GenerationAction::ImageTo3d, + // service: GenerationServiceProvider::Artcraft, + // model: None, + //}; + // + //if let Err(err) = event.send(app) { + // error!("Failed to emit event: {:?}", err); // Fail open. + //} + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ObjectGeneration, + model: used_model, + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_token.to_string()), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/artcraft/mod.rs new file mode 100644 index 0000000000..f623ebcbe4 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/artcraft/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_artcraft_object; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/enqueue_image_to_3d_object_command.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/enqueue_image_to_3d_object_command.rs new file mode 100644 index 0000000000..39bcf076d3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/enqueue_image_to_3d_object_command.rs @@ -0,0 +1,217 @@ +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::image_to_object::artcraft::handle_artcraft_object::handle_artcraft_object; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::Response; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use log::{error, info, warn}; +use serde_derive::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; + +#[derive(Deserialize)] +pub struct EnqueueImageTo3dObjectRequest { + /// Image media file; the image to remove the background from. + /// TODO: In the future we may support base64 images, URLs, or file paths here. + pub image_media_token: Option, + + /// The model to use. + pub model: Option, + + /// OPTIONAL. + /// Name of the frontend caller. + /// We'll use this to selectively trigger events. + pub frontend_caller: Option, + + /// OPTIONAL. + /// A frontend-defined identifier that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// OPTIONAL. + /// A frontend-defined payload that we'll send back to the frontend + /// as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueImageTo3dObjectModel { + #[deprecated(note="Use `hunyuan_3d_2_0` instead")] + #[serde(rename = "hunyuan_3d_2")] + Hunyuan3d2, + #[serde(rename = "hunyuan_3d_2_0")] + Hunyuan3d2_0, + #[serde(rename = "hunyuan_3d_2_1")] + Hunyuan3d2_1, + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, +} + +#[derive(Serialize)] +pub struct EnqueueImageTo3dObjectSuccessResponse { +} + +impl SerializeMarker for EnqueueImageTo3dObjectSuccessResponse {} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum EnqueueImageTo3dObjectErrorType { + /// Caller didn't specify a model + ModelNotSpecified, + /// No model available for object generation + NoProviderAvailable, + /// Generic server error + ServerError, + /// No Fal API key available + NeedsFalApiKey, + /// Fal had an API error + FalError, + /// Needs to be logged into Artcraft + NeedsStorytellerCredentials, +} + +#[tauri::command] +pub async fn enqueue_image_to_3d_object_command( + app: AppHandle, + request: EnqueueImageTo3dObjectRequest, + app_env_configs: State<'_, AppEnvConfigs>, + app_data_root: State<'_, AppDataRoot>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + provider_priority_store: State<'_, ProviderPriorityStore>, + task_database: State<'_, TaskDatabase>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + sora_task_queue: State<'_, SoraTaskQueue>, +) -> Response { + + info!("enqueue_image_to_3d_object_command called"); + + let result = handle_request( + request, + &app, + &app_env_configs, + &app_data_root, + &artcraft_usage_tracker, + &provider_priority_store, + &task_database, + &storyteller_creds_manager, + ).await; + + match result { + Err(err) => { + error!("error: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueImageTo3dObjectErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem."; + + match err { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueImageTo3dObjectErrorType::ModelNotSpecified; + error_message = "No model specified for object generation"; + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueImageTo3dObjectErrorType::NoProviderAvailable; + error_message = "No configured provider available for object generation"; + } + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => { + status = CommandErrorStatus::Unauthorized; + error_type = EnqueueImageTo3dObjectErrorType::NeedsFalApiKey; + error_message = "You need to set a FAL api key"; + }, + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials) => { + status = CommandErrorStatus::Unauthorized; + error_type = EnqueueImageTo3dObjectErrorType::NeedsStorytellerCredentials; + error_message = "You need to be logged into Artcraft."; + } + _ => {}, // Fall-through + } + + Err(CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + }) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: event.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(EnqueueImageTo3dObjectSuccessResponse {}.into()) + } + } +} + + +pub async fn handle_request( + request: EnqueueImageTo3dObjectRequest, + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + artcraft_usage_tracker: &ArtcraftUsageTracker, + provider_priority_store: &ProviderPriorityStore, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let result = handle_artcraft_object( + &request, + &app, + &app_env_configs, + &app_data_root, + &storyteller_creds_manager, + ).await; + + let success_event = match result { + Err(err) => return Err(err), + Ok(event) => event, + }; + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller.clone(), + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref(), + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + // NB: Fail open, but find a way to flag this. + } + + if let Err(err) = artcraft_usage_tracker.record_object_generation(1, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::ObjectPage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/mod.rs new file mode 100644 index 0000000000..de940c5c8b --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_to_object/mod.rs @@ -0,0 +1,2 @@ +pub mod enqueue_image_to_3d_object_command; +mod artcraft; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/mod.rs b/crates/desktop/artcraft/src/core/commands/enqueue/mod.rs new file mode 100644 index 0000000000..73ce2476cc --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/mod.rs @@ -0,0 +1,6 @@ +pub mod common; +pub mod generate_error; +pub mod image_bg_removal; +pub mod image_to_gaussian; +pub mod image_to_object; +pub mod task_enqueue_success; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs new file mode 100644 index 0000000000..7d964c2abe --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs @@ -0,0 +1,151 @@ +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::state::task_database::TaskDatabase; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use sqlite_tasks::error::SqliteTasksError; +use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs}; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct TaskEnqueueSuccess { + pub task_type: TaskType, + pub model: Option, + pub provider: GenerationProvider, + pub provider_job_id: Option, + pub maybe_queue_status_url: Option, + pub maybe_queue_response_url: Option, + pub maybe_prompt_token: Option, +} + +impl TaskEnqueueSuccess{ + pub fn to_frontend_event_action(&self) -> GenerationAction { + match self.task_type { + TaskType::ImageGeneration => GenerationAction::GenerateImage, + TaskType::GaussianGeneration => GenerationAction::GenerateGaussian, + TaskType::VideoGeneration => GenerationAction::GenerateVideo, + TaskType::AudioGeneration => GenerationAction::GenerateAudio, + TaskType::BackgroundRemoval => GenerationAction::RemoveBackground, + TaskType::ObjectGeneration => GenerationAction::ImageTo3d, + TaskType::ImageInpaintEdit => GenerationAction::ImageInpaintEdit, + } + } + + pub fn to_frontend_event_service(&self) -> GenerationServiceProvider { + match self.provider { + GenerationProvider::Artcraft => GenerationServiceProvider::Artcraft, + GenerationProvider::Fal => GenerationServiceProvider::Fal, + GenerationProvider::Grok => GenerationServiceProvider::Grok, + GenerationProvider::Midjourney => GenerationServiceProvider::Midjourney, + GenerationProvider::Sora => GenerationServiceProvider::Sora, + GenerationProvider::WorldLabs => GenerationServiceProvider::WorldLabs, + GenerationProvider::Higgsfield => GenerationServiceProvider::Higgsfield, + GenerationProvider::Krea => GenerationServiceProvider::Krea, + GenerationProvider::Leonardo => GenerationServiceProvider::Leonardo, + GenerationProvider::Magnific => GenerationServiceProvider::Magnific, + GenerationProvider::Openart => GenerationServiceProvider::Openart, + GenerationProvider::Picsart => GenerationServiceProvider::Picsart, + GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse, + GenerationProvider::Runway => GenerationServiceProvider::Runway, + } + } + + pub async fn insert_into_task_database(&self, task_database: &TaskDatabase) -> Result { + self.insert_into_task_database_with_frontend_payload( + task_database, + None, + None, + None, + ).await + } + + // TODO: This belongs somewhere else, not as a method of an event struct. + pub async fn insert_into_task_database_with_frontend_payload( + &self, + task_database: &TaskDatabase, + frontend_caller: Option, + frontend_subscriber_id: Option<&str>, + frontend_subscriber_payload: Option<&str>, + ) -> Result { + // TODO: Move this mapping elsewhere, or remove the other models. + let model_type = match &self.model { + None => None, + Some(GenerationModel::Unknown(model)) => Some(TaskModelType::Unknown(model.clone())), + Some(GenerationModel::Flux1Dev) => Some(TaskModelType::Flux1Dev), + Some(GenerationModel::FluxDevJuggernaut) => Some(TaskModelType::FluxDevJuggernaut), + Some(GenerationModel::Flux1Schnell) => Some(TaskModelType::Flux1Schnell), + Some(GenerationModel::FluxPro1) => Some(TaskModelType::FluxPro1), // NB: This is for inpainting. + Some(GenerationModel::FluxPro11) => Some(TaskModelType::FluxPro11), + Some(GenerationModel::FluxPro11Ultra) => Some(TaskModelType::FluxPro11Ultra), + Some(GenerationModel::FluxProKontextMax) => Some(TaskModelType::FluxProKontextMax), + Some(GenerationModel::Gemini25Flash) => Some(TaskModelType::Gemini25Flash), + Some(GenerationModel::NanoBanana) => Some(TaskModelType::NanoBanana), + Some(GenerationModel::NanoBanana2) => Some(TaskModelType::NanoBanana2), + Some(GenerationModel::NanoBananaPro) => Some(TaskModelType::NanoBananaPro), + Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1), + Some(GenerationModel::GptImage1p5) => Some(TaskModelType::GptImage1p5), + Some(GenerationModel::GptImage2) => Some(TaskModelType::GptImage2), + Some(GenerationModel::GptImage2p5Flare) => Some(TaskModelType::GptImage2p5Flare), + Some(GenerationModel::GptImage2p5Sunburst) => Some(TaskModelType::GptImage2p5Sunburst), + Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4), + Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5), + Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite), + Some(GenerationModel::QwenEdit2511Angles) => Some(TaskModelType::QwenEdit2511Angles), + Some(GenerationModel::Flux2LoraAngles) => Some(TaskModelType::Flux2LoraAngles), + Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage), + Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3), + Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo), + Some(GenerationModel::GrokImagineVideo1p5) => Some(TaskModelType::GrokImagineVideo1p5), + Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro), + Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master), + Some(GenerationModel::Kling2p5TurboPro) => Some(TaskModelType::Kling2p5TurboPro), + Some(GenerationModel::Kling2p6Pro) => Some(TaskModelType::Kling2p6Pro), + Some(GenerationModel::Kling3p0Standard) => Some(TaskModelType::Kling3p0Standard), + Some(GenerationModel::Kling3p0Pro) => Some(TaskModelType::Kling3p0Pro), + Some(GenerationModel::HappyHorse1p0) => Some(TaskModelType::HappyHorse1p0), + Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite), + Some(GenerationModel::Seedance1p5Pro) => Some(TaskModelType::Seedance1p5Pro), + Some(GenerationModel::Seedance2p0) => Some(TaskModelType::Seedance2p0), + Some(GenerationModel::Seedance2p0Fast) => Some(TaskModelType::Seedance2p0Fast), + Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2), + Some(GenerationModel::Sora2Pro) => Some(TaskModelType::Sora2Pro), + Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2), + Some(GenerationModel::Veo3) => Some(TaskModelType::Veo3), + Some(GenerationModel::Veo3p1) => Some(TaskModelType::Veo3p1), + Some(GenerationModel::Veo3p1Fast) => Some(TaskModelType::Veo3p1Fast), + Some(GenerationModel::Veo3Fast) => Some(TaskModelType::Veo3Fast), + Some(GenerationModel::Hunyuan3d2_0) => Some(TaskModelType::Hunyuan3d2_0), + Some(GenerationModel::Hunyuan3d2_1) => Some(TaskModelType::Hunyuan3d2_1), + Some(GenerationModel::Hunyuan3d3) => Some(TaskModelType::Hunyuan3d3), + Some(GenerationModel::WorldlabsMarble) => Some(TaskModelType::WorldlabsMarble), + Some(GenerationModel::WorldlabsMarble0p1Mini) => Some(TaskModelType::WorldlabsMarble0p1Mini), + Some(GenerationModel::WorldlabsMarble0p1Plus) => Some(TaskModelType::WorldlabsMarble0p1Plus), + Some(GenerationModel::Midjourney) => Some(TaskModelType::Midjourney), // NB: This is a generic Midjourney model, version unknown. + Some(GenerationModel::Midjourney7) => Some(TaskModelType::Midjourney7), + Some(GenerationModel::Midjourney7Niji) => Some(TaskModelType::Midjourney7Niji), + Some(GenerationModel::Midjourney8) => Some(TaskModelType::Midjourney8), + + // TODO: These seem wrong - + Some(GenerationModel::Kling1_6) => Some(TaskModelType::Kling16Pro), // NB: `VideoModel::Kling16Pro`. + Some(GenerationModel::Kling2_0) => None, // TODO: unused elsewhere? + Some(GenerationModel::Sora) => None, // TODO: unused elsewhere? + }; + + create_task(CreateTaskArgs { + db: task_database.get_connection(), + status: TaskStatus::Pending, + task_type: self.task_type, + model_type, + provider: self.provider, + provider_job_id: self.provider_job_id.as_deref(), + queue_status_url: self.maybe_queue_status_url.as_deref(), + queue_response_url: self.maybe_queue_response_url.as_deref(), + prompt_token: self.maybe_prompt_token.as_ref(), + frontend_caller, + frontend_subscriber_id, + frontend_subscriber_payload, + }).await + } +} diff --git a/crates/desktop/artcraft/src/core/commands/flip_image.rs b/crates/desktop/artcraft/src/core/commands/flip_image.rs new file mode 100644 index 0000000000..cc6b2a7fea --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/flip_image.rs @@ -0,0 +1,36 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use image::codecs::png::{CompressionType, FilterType, PngEncoder}; +use image::ImageReader; +use std::io::Cursor; + +#[tauri::command] +pub fn flip_image(image: &str) -> Result { + println!("infer_image called; processing image..."); + + let bytes = BASE64_STANDARD.decode(image) + .map_err(|err| format!("Base64 decode error: {}", err))?; + + let image = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|err| format!("Image format error: {}", err))? + .decode() + .map_err(|err| format!("Image decode error: {}", err))?; + + let rotated_image = image.rotate180(); + + let mut buffer = Vec::new(); // TODO: Preallocate, better data structure + let mut writer = Cursor::new(&mut buffer); + + let encoder = PngEncoder::new_with_quality + (&mut writer, CompressionType::Fast, FilterType::Adaptive); + + rotated_image.write_with_encoder(encoder) + .map_err(|err| format!("Image encoding error: {}", err))?; + + let encoded = BASE64_STANDARD.encode(&buffer); + + println!("Encoded: {:?}", encoded.split_at(10).0); + + Ok(encoded) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/common/mod.rs new file mode 100644 index 0000000000..7f7bebef4d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/common/mod.rs @@ -0,0 +1,2 @@ +pub mod router_image_request_to_artcraft_prompt; +pub mod router_video_request_to_artcraft_prompt; diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs new file mode 100644 index 0000000000..ef7bb5742a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs @@ -0,0 +1,222 @@ +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_image_model::RouterImageModel; +use artcraft_router::api::router_resolution::RouterResolution; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation::common_resolution::CommonResolution as EnumsResolution; +use enums::common::generation_provider::GenerationProvider; +use uuid_utils::uuid::generate_random_uuid; + +pub fn router_image_request_to_artcraft_prompt( + request: &GenerateImageRequestBuilder, +) -> CreatePromptRequest { + CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: request.prompt.clone(), + negative_prompt: None, + model_type: image_model_to_common_model_type(request.model), + generation_provider: Some(provider_to_generation_provider(request.provider)), + maybe_generation_mode: Some(determine_image_generation_mode(request)), + maybe_aspect_ratio: request.aspect_ratio.map(router_aspect_ratio_to_enums), + maybe_resolution: request.resolution.map(router_resolution_to_enums), + maybe_batch_count: request.image_batch_count.map(|n| n.min(255) as u8), + maybe_generate_audio: None, + maybe_duration_seconds: None, + } +} + +// ── Converters ── + +fn determine_image_generation_mode(request: &GenerateImageRequestBuilder) -> CommonGenerationMode { + if request.image_inputs.is_some() { + CommonGenerationMode::Edit + } else { + CommonGenerationMode::Text + } +} + +fn image_model_to_common_model_type(model: RouterImageModel) -> Option { + match model { + RouterImageModel::Flux1Dev => Some(CommonModelType::Flux1Dev), + RouterImageModel::Flux1Schnell => Some(CommonModelType::Flux1Schnell), + RouterImageModel::FluxPro11 => Some(CommonModelType::FluxPro11), + RouterImageModel::FluxPro11Ultra => Some(CommonModelType::FluxPro11Ultra), + RouterImageModel::GptImage1 => Some(CommonModelType::GptImage1), + RouterImageModel::GptImage1p5 => Some(CommonModelType::GptImage1p5), + RouterImageModel::GptImage2 => Some(CommonModelType::GptImage2), + RouterImageModel::GptImage2p5Flare => Some(CommonModelType::GptImage2p5Flare), + RouterImageModel::GptImage2p5Sunburst => Some(CommonModelType::GptImage2p5Sunburst), + RouterImageModel::NanoBanana => Some(CommonModelType::NanoBanana), + RouterImageModel::NanoBanana2 => Some(CommonModelType::NanoBanana2), + RouterImageModel::NanoBananaPro => Some(CommonModelType::NanoBananaPro), + RouterImageModel::Seedream4 => Some(CommonModelType::Seedream4), + RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5), + RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite), + RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro), + RouterImageModel::Seedream5p0ProUltra => Some(CommonModelType::Seedream5p0ProUltra), + RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles), + RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles), + RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage), + RouterImageModel::GrokImagineImageQuality => Some(CommonModelType::GrokImagineImageQuality), + RouterImageModel::Midjourney7 => Some(CommonModelType::Midjourney7), + RouterImageModel::Midjourney7Niji => Some(CommonModelType::Midjourney7Niji), + RouterImageModel::Midjourney8 => Some(CommonModelType::Midjourney8), + } +} + +fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvider { + match provider { + RouterProvider::Artcraft => GenerationProvider::Artcraft, + RouterProvider::Fal => GenerationProvider::Fal, + // Unused providers -> ArtCraft + RouterProvider::KinoviWeb => GenerationProvider::Artcraft , + RouterProvider::GmiCloud => GenerationProvider::Artcraft, + RouterProvider::GrokApi => GenerationProvider::Artcraft, + RouterProvider::WorldLabs => GenerationProvider::Artcraft, + } +} + +fn router_aspect_ratio_to_enums(ar: RouterAspectRatio) -> EnumsAspectRatio { + match ar { + RouterAspectRatio::Auto => EnumsAspectRatio::Auto, + RouterAspectRatio::Square => EnumsAspectRatio::Square, + RouterAspectRatio::WideThreeByTwo => EnumsAspectRatio::WideThreeByTwo, + RouterAspectRatio::WideFourByThree => EnumsAspectRatio::WideFourByThree, + RouterAspectRatio::WideFiveByFour => EnumsAspectRatio::WideFiveByFour, + RouterAspectRatio::WideSixteenByNine => EnumsAspectRatio::WideSixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => EnumsAspectRatio::WideTwentyOneByNine, + RouterAspectRatio::TallTwoByThree => EnumsAspectRatio::TallTwoByThree, + RouterAspectRatio::TallThreeByFour => EnumsAspectRatio::TallThreeByFour, + RouterAspectRatio::TallFourByFive => EnumsAspectRatio::TallFourByFive, + RouterAspectRatio::TallNineBySixteen => EnumsAspectRatio::TallNineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => EnumsAspectRatio::TallNineByTwentyOne, + RouterAspectRatio::Wide => EnumsAspectRatio::Wide, + RouterAspectRatio::Tall => EnumsAspectRatio::Tall, + RouterAspectRatio::Auto2k => EnumsAspectRatio::Auto2k, + RouterAspectRatio::Auto3k => EnumsAspectRatio::Auto3k, + RouterAspectRatio::Auto4k => EnumsAspectRatio::Auto4k, + RouterAspectRatio::SquareHd => EnumsAspectRatio::SquareHd, + } +} + +fn router_resolution_to_enums(res: RouterResolution) -> EnumsResolution { + match res { + RouterResolution::OneK => EnumsResolution::OneK, + RouterResolution::TwoK => EnumsResolution::TwoK, + RouterResolution::ThreeK => EnumsResolution::ThreeK, + RouterResolution::FourK => EnumsResolution::FourK, + RouterResolution::HalfK => EnumsResolution::HalfK, + RouterResolution::FourEightyP => EnumsResolution::FourEightyP, + RouterResolution::SevenTwentyP => EnumsResolution::SevenTwentyP, + RouterResolution::TenEightyP => EnumsResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::NanoBananaPro, + provider: RouterProvider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + #[test] + fn basic_conversion() { + let builder = base_builder(); + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.positive_prompt.as_deref(), Some("a cat in space")); + assert_eq!(prompt.model_type, Some(CommonModelType::NanoBananaPro)); + assert_eq!(prompt.generation_provider, Some(GenerationProvider::Fal)); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Text)); + assert!(prompt.negative_prompt.is_none()); + assert!(prompt.maybe_aspect_ratio.is_none()); + assert!(prompt.maybe_resolution.is_none()); + assert!(prompt.maybe_batch_count.is_none()); + assert!(!prompt.uuid_idempotency_token.is_empty()); + } + + #[test] + fn text_mode_when_no_images() { + let builder = base_builder(); + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Text)); + } + + #[test] + fn edit_mode_when_images_present() { + use artcraft_router::api::image_list_ref::ImageListRef; + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Edit)); + } + + #[test] + fn with_aspect_ratio_and_resolution() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::TwoK), + image_batch_count: Some(3), + ..base_builder() + }; + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_aspect_ratio, Some(EnumsAspectRatio::WideSixteenByNine)); + assert_eq!(prompt.maybe_resolution, Some(EnumsResolution::TwoK)); + assert_eq!(prompt.maybe_batch_count, Some(3)); + } + + #[test] + fn no_prompt() { + let builder = GenerateImageRequestBuilder { + prompt: None, + ..base_builder() + }; + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert!(prompt.positive_prompt.is_none()); + } + + #[test] + fn all_image_models_map() { + let models = [ + (RouterImageModel::Flux1Dev, CommonModelType::Flux1Dev), + (RouterImageModel::Flux1Schnell, CommonModelType::Flux1Schnell), + (RouterImageModel::NanoBananaPro, CommonModelType::NanoBananaPro), + (RouterImageModel::GptImage1, CommonModelType::GptImage1), + (RouterImageModel::Seedream4, CommonModelType::Seedream4), + ]; + for (router_model, expected) in models { + let builder = GenerateImageRequestBuilder { model: router_model, ..base_builder() }; + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.model_type, Some(expected)); + } + } + + #[test] + fn provider_mapping() { + let builder = GenerateImageRequestBuilder { provider: RouterProvider::Artcraft, ..base_builder() }; + let prompt = router_image_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.generation_provider, Some(GenerationProvider::Artcraft)); + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs new file mode 100644 index 0000000000..9420763e78 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs @@ -0,0 +1,276 @@ +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +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::router_provider::RouterProvider; +use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation::common_resolution::CommonResolution as EnumsResolution; +use enums::common::generation_provider::GenerationProvider; +use uuid_utils::uuid::generate_random_uuid; + +pub fn router_video_request_to_artcraft_prompt( + request: &GenerateVideoRequestBuilder, +) -> CreatePromptRequest { + CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: request.prompt.clone(), + negative_prompt: request.negative_prompt.clone(), + model_type: video_model_to_common_model_type(request.model), + generation_provider: Some(provider_to_generation_provider(request.provider)), + maybe_generation_mode: Some(determine_video_generation_mode(request)), + maybe_aspect_ratio: request.aspect_ratio.map(router_aspect_ratio_to_enums), + maybe_resolution: request.resolution.map(router_resolution_to_enums), + maybe_batch_count: request.video_batch_count.map(|n| n.min(255) as u8), + maybe_generate_audio: request.generate_audio, + maybe_duration_seconds: request.duration_seconds.map(|d| d as u32), + } +} + +// ── Converters ── + +fn determine_video_generation_mode(request: &GenerateVideoRequestBuilder) -> CommonGenerationMode { + let has_keyframes = request.start_frame.is_some() || request.end_frame.is_some(); + let has_references = request.reference_images.is_some() + || request.reference_videos.is_some() + || request.reference_audio.is_some(); + + if has_keyframes { + CommonGenerationMode::Keyframe + } else if has_references { + CommonGenerationMode::Reference + } else { + CommonGenerationMode::Text + } +} + +fn video_model_to_common_model_type(model: RouterVideoModel) -> Option { + match model { + RouterVideoModel::GrokVideo => Some(CommonModelType::GrokVideo), + RouterVideoModel::Kling16Pro => Some(CommonModelType::Kling16Pro), + RouterVideoModel::Kling21Pro => Some(CommonModelType::Kling21Pro), + RouterVideoModel::Kling21Master => Some(CommonModelType::Kling21Master), + RouterVideoModel::Kling2p5TurboPro => Some(CommonModelType::Kling2p5TurboPro), + RouterVideoModel::Kling2p6Pro => Some(CommonModelType::Kling2p6Pro), + RouterVideoModel::Kling3p0Standard => Some(CommonModelType::Kling3p0Standard), + RouterVideoModel::Kling3p0Pro => Some(CommonModelType::Kling3p0Pro), + RouterVideoModel::Seedance10Lite => Some(CommonModelType::Seedance10Lite), + RouterVideoModel::Seedance1p5Pro => Some(CommonModelType::Seedance1p5Pro), + RouterVideoModel::Seedance2p0 => Some(CommonModelType::Seedance2p0), + RouterVideoModel::Seedance2p0Fast => Some(CommonModelType::Seedance2p0Fast), + RouterVideoModel::HappyHorse1p0 => Some(CommonModelType::HappyHorse1p0), + RouterVideoModel::MinimaxH3 => Some(CommonModelType::MinimaxH3), + RouterVideoModel::MinimaxH3Turbo => Some(CommonModelType::MinimaxH3Turbo), + RouterVideoModel::MinimaxH3Ultra => Some(CommonModelType::MinimaxH3Ultra), + RouterVideoModel::Flux3 => Some(CommonModelType::Flux3), + RouterVideoModel::Flux3Draft => Some(CommonModelType::Flux3Draft), + RouterVideoModel::Sora2 => Some(CommonModelType::Sora2), + RouterVideoModel::Sora2Pro => Some(CommonModelType::Sora2Pro), + RouterVideoModel::Veo2 => Some(CommonModelType::Veo2), + RouterVideoModel::Veo3 => Some(CommonModelType::Veo3), + RouterVideoModel::Veo3Fast => Some(CommonModelType::Veo3Fast), + RouterVideoModel::Veo3p1 => Some(CommonModelType::Veo3p1), + RouterVideoModel::Veo3p1Fast => Some(CommonModelType::Veo3p1Fast), + RouterVideoModel::Veo3p1Lite => Some(CommonModelType::Veo3p1Lite), + RouterVideoModel::ViduQ3 => Some(CommonModelType::ViduQ3), + RouterVideoModel::ViduQ3Turbo => Some(CommonModelType::ViduQ3Turbo), + RouterVideoModel::Seedance2p0BytePlus => Some(CommonModelType::Seedance2p0BytePlus), + RouterVideoModel::Seedance2p0BytePlusFast => Some(CommonModelType::Seedance2p0BytePlusFast), + RouterVideoModel::Seedance2p0BytePlusUltra => Some(CommonModelType::Seedance2p0BytePlusUltra), + RouterVideoModel::Seedance2p0BytePlusUltraFast => Some(CommonModelType::Seedance2p0BytePlusUltraFast), + RouterVideoModel::Seedance2p0Mini => Some(CommonModelType::Seedance2p0Mini), + RouterVideoModel::Seedance2p0BytePlusMini => Some(CommonModelType::Seedance2p0BytePlusMini), + RouterVideoModel::Seedance2p0BytePlusUltraMini => Some(CommonModelType::Seedance2p0BytePlusUltraMini), + RouterVideoModel::PreviewModel => Some(CommonModelType::PreviewModel), + RouterVideoModel::PreviewModelFast => Some(CommonModelType::PreviewModelFast), + RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo), + RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5), + RouterVideoModel::Seedance2p5Preview => Some(CommonModelType::Seedance2p5Preview), + RouterVideoModel::Seedance2p5 => Some(CommonModelType::Seedance2p5), + RouterVideoModel::Seedance2p5Ultra => Some(CommonModelType::Seedance2p5Ultra), + } +} + +fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvider { + match provider { + RouterProvider::Artcraft => GenerationProvider::Artcraft, + RouterProvider::Fal => GenerationProvider::Fal, + // Unused providers -> ArtCraft + RouterProvider::KinoviWeb => GenerationProvider::Artcraft , + RouterProvider::GmiCloud => GenerationProvider::Artcraft, + RouterProvider::GrokApi => GenerationProvider::Artcraft, + RouterProvider::WorldLabs => GenerationProvider::Artcraft, + } +} + +fn router_aspect_ratio_to_enums(ar: RouterAspectRatio) -> EnumsAspectRatio { + match ar { + RouterAspectRatio::Auto => EnumsAspectRatio::Auto, + RouterAspectRatio::Square => EnumsAspectRatio::Square, + RouterAspectRatio::WideThreeByTwo => EnumsAspectRatio::WideThreeByTwo, + RouterAspectRatio::WideFourByThree => EnumsAspectRatio::WideFourByThree, + RouterAspectRatio::WideFiveByFour => EnumsAspectRatio::WideFiveByFour, + RouterAspectRatio::WideSixteenByNine => EnumsAspectRatio::WideSixteenByNine, + RouterAspectRatio::WideTwentyOneByNine => EnumsAspectRatio::WideTwentyOneByNine, + RouterAspectRatio::TallTwoByThree => EnumsAspectRatio::TallTwoByThree, + RouterAspectRatio::TallThreeByFour => EnumsAspectRatio::TallThreeByFour, + RouterAspectRatio::TallFourByFive => EnumsAspectRatio::TallFourByFive, + RouterAspectRatio::TallNineBySixteen => EnumsAspectRatio::TallNineBySixteen, + RouterAspectRatio::TallNineByTwentyOne => EnumsAspectRatio::TallNineByTwentyOne, + RouterAspectRatio::Wide => EnumsAspectRatio::Wide, + RouterAspectRatio::Tall => EnumsAspectRatio::Tall, + RouterAspectRatio::Auto2k => EnumsAspectRatio::Auto2k, + RouterAspectRatio::Auto3k => EnumsAspectRatio::Auto3k, + RouterAspectRatio::Auto4k => EnumsAspectRatio::Auto4k, + RouterAspectRatio::SquareHd => EnumsAspectRatio::SquareHd, + } +} + +fn router_resolution_to_enums(res: RouterResolution) -> EnumsResolution { + match res { + RouterResolution::OneK => EnumsResolution::OneK, + RouterResolution::TwoK => EnumsResolution::TwoK, + RouterResolution::ThreeK => EnumsResolution::ThreeK, + RouterResolution::FourK => EnumsResolution::FourK, + RouterResolution::HalfK => EnumsResolution::HalfK, + RouterResolution::FourEightyP => EnumsResolution::FourEightyP, + RouterResolution::SevenTwentyP => EnumsResolution::SevenTwentyP, + RouterResolution::TenEightyP => EnumsResolution::TenEightyP, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Kling3p0Standard, + provider: RouterProvider::Fal, + prompt: Some("a dog running on the beach".to_string()), + negative_prompt: None, + start_frame: None, + end_frame: None, + reference_images: None, + reference_videos: None, + reference_audio: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + bitrate: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + total_reference_video_input_seconds: None, + request_mismatch_mitigation_strategy: + artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy::ErrorOut, + idempotency_token: None, + } + } + + #[test] + fn basic_conversion() { + let builder = base_builder(); + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.positive_prompt.as_deref(), Some("a dog running on the beach")); + assert_eq!(prompt.model_type, Some(CommonModelType::Kling3p0Standard)); + assert_eq!(prompt.generation_provider, Some(GenerationProvider::Fal)); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Text)); + assert!(prompt.negative_prompt.is_none()); + assert!(prompt.maybe_aspect_ratio.is_none()); + assert!(prompt.maybe_resolution.is_none()); + assert!(prompt.maybe_batch_count.is_none()); + assert!(prompt.maybe_generate_audio.is_none()); + assert!(prompt.maybe_duration_seconds.is_none()); + } + + #[test] + fn with_video_fields() { + let builder = GenerateVideoRequestBuilder { + negative_prompt: Some("blurry".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::TenEightyP), + duration_seconds: Some(10), + video_batch_count: Some(2), + generate_audio: Some(true), + ..base_builder() + }; + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.negative_prompt.as_deref(), Some("blurry")); + assert_eq!(prompt.maybe_aspect_ratio, Some(EnumsAspectRatio::WideSixteenByNine)); + assert_eq!(prompt.maybe_resolution, Some(EnumsResolution::TenEightyP)); + assert_eq!(prompt.maybe_duration_seconds, Some(10)); + assert_eq!(prompt.maybe_batch_count, Some(2)); + assert_eq!(prompt.maybe_generate_audio, Some(true)); + } + + #[test] + fn text_mode_when_no_references() { + let builder = base_builder(); + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Text)); + } + + #[test] + fn keyframe_mode_with_start_frame() { + use artcraft_router::api::image_ref::ImageRef; + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/frame.jpg".to_string())), + ..base_builder() + }; + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Keyframe)); + } + + #[test] + fn keyframe_mode_with_end_frame() { + use artcraft_router::api::image_ref::ImageRef; + let builder = GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("https://example.com/frame.jpg".to_string())), + ..base_builder() + }; + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Keyframe)); + } + + #[test] + fn reference_mode_with_reference_images() { + use artcraft_router::api::image_list_ref::ImageListRef; + let builder = GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + ..base_builder() + }; + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Reference)); + } + + #[test] + fn keyframe_takes_priority_over_reference() { + use artcraft_router::api::image_ref::ImageRef; + use artcraft_router::api::image_list_ref::ImageListRef; + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/frame.jpg".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + ..base_builder() + }; + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.maybe_generation_mode, Some(CommonGenerationMode::Keyframe)); + } + + #[test] + fn video_model_mapping() { + let models = [ + (RouterVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard), + (RouterVideoModel::Veo3, CommonModelType::Veo3), + (RouterVideoModel::Seedance2p0, CommonModelType::Seedance2p0), + (RouterVideoModel::Sora2, CommonModelType::Sora2), + ]; + for (router_model, expected) in models { + let builder = GenerateVideoRequestBuilder { model: router_model, ..base_builder() }; + let prompt = router_video_request_to_artcraft_prompt(&builder); + assert_eq!(prompt.model_type, Some(expected)); + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_audio_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_audio_command.rs new file mode 100644 index 0000000000..23edcf18c7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_audio_command.rs @@ -0,0 +1,11 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::local_error; +use tauri::AppHandle; + +#[tauri::command] +pub async fn generate_audio_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if !request.uses_artcraft() { + return Err(local_error("No direct provider adapter is configured for this modality.")); + } + omni::generate(request, Modality::Audio, &app).await +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs new file mode 100644 index 0000000000..4dff8b6c68 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs @@ -0,0 +1,190 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native}; +use tauri::Manager; +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft; +use crate::core::commands::generate::generate_image::providers::artcraft_router::handle_router::handle_router; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::{ + TauriGenerateImageErrorType, TauriGenerateImageRequest, TauriGenerateImageResponse, +}; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::Response; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::task_database::TaskDatabase; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use log::{error, info}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn generate_image_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if request.uses_artcraft() && !request.uses_legacy_image_endpoint() { + return omni::generate(request, Modality::Image, &app).await; + } + adapt_legacy_response(generate_image_native( + decode_native(request)?, app.clone(), + app.state(), app.state(), app.state(), app.state(), + ).await) +} + +async fn generate_image_native( + request: TauriGenerateImageRequest, + app: AppHandle, + app_env_configs: State<'_, AppEnvConfigs>, + task_database: State<'_, TaskDatabase>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + credential_cache: State<'_, ProviderCredentialLoadingCache>, +) -> Response { + + info!("generate_image_command called, request: {:?}", request); + + let provider = request.provider.unwrap_or(GenerationProvider::Artcraft); + + let result = match provider { + GenerationProvider::Artcraft => { + handle_artcraft( + &request, + &app_env_configs, + &storyteller_creds_manager, + ).await + } + // Midjourney uses its own legacy command path, not this one. + GenerationProvider::Midjourney => { + Err(GenerateError::NotYetImplemented( + "Midjourney should use its dedicated command".to_string(), + )) + } + // All other providers go through the router. + other => { + handle_router( + &request, + other, + &app_env_configs, + &credential_cache, + &storyteller_creds_manager, + ).await + } + }; + + match result { + Ok(success) => handle_success_behavior(&app, &task_database, &request, success).await, + Err(err) => handle_error_behavior(&app, err).await, + } +} + +// ── Result mapping ── + +async fn handle_success_behavior( + app: &AppHandle, + task_database: &TaskDatabase, + request: &TauriGenerateImageRequest, + success: TaskEnqueueSuccess, +) -> Response { + + + // Insert into task database + let db_result = success + .insert_into_task_database_with_frontend_payload( + &task_database, + request.frontend_caller, + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref(), + ) + .await; + + if let Err(err) = db_result { + error!("Failed to create task in database: {:?}", err); + } + + /* + let usage_type = if is_image_to_image { + ArtcraftUsageType::ImageToResult + } else { + ArtcraftUsageType::TextToResult + }; + + if let Err(err) = artcraft_usage_tracker.record_image_generation(num_images, usage_type, ArtcraftUsagePage::ImagePage) { + // NB: Fail open. + warn!("Failed to report usage: {:?}", err); + } + */ + + let event = GenerationEnqueueSuccessEvent { + action: success.to_frontend_event_action(), + service: success.to_frontend_event_service(), + model: success.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(TauriGenerateImageResponse {}.into()) +} + +async fn handle_error_behavior( + app: &AppHandle, + err: GenerateError, +) -> Response { + error!("generate_image_command error: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + let error_type = match &err { + GenerateError::BadInput(_) => TauriGenerateImageErrorType::BadInput, + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => TauriGenerateImageErrorType::NeedsFalApiKey, + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) => TauriGenerateImageErrorType::NeedsGrokCredentials, + GenerateError::MissingCredentials(_) => TauriGenerateImageErrorType::NeedsStorytellerCredentials, + GenerateError::NoProviderAvailable => TauriGenerateImageErrorType::NoProviderAvailable, + GenerateError::BillingIssue(_) => TauriGenerateImageErrorType::BillingIssue, + _ => TauriGenerateImageErrorType::ServerError, + }; + + // TODO: + /* + let mut status = CommandErrorStatus::ServerError; + let mut error_type = EnqueueTextToImageErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem."; + + match err { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = EnqueueTextToImageErrorType::ModelNotSpecified; + error_message = "No model specified for image generation"; + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = EnqueueTextToImageErrorType::NoProviderAvailable; + error_message = "No configured provider available for image generation"; + } + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => { + status = CommandErrorStatus::Unauthorized; + error_type = EnqueueTextToImageErrorType::NeedsFalApiKey; + error_message = "You need to set a FAL api key"; + }, + _ => {}, // Fall-through + } + + Err(CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + }) + */ + + Err(CommandErrorResponseWrapper { + status: CommandErrorStatus::ServerError, + error_message: Some(format!("{:?}", err)), + error_type: Some(error_type), + error_details: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/mod.rs new file mode 100644 index 0000000000..568b848fe2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/mod.rs @@ -0,0 +1,5 @@ +pub mod generate_image_command; +pub mod providers; +pub mod tauri_generate_image_request; +pub mod tauri_image_model; +pub mod utils; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs new file mode 100644 index 0000000000..3dbd41876d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs @@ -0,0 +1,63 @@ +use log::info; + +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft_via_legacy::handle_artcraft_via_legacy; +use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft_via_omni_endpoint::handle_artcraft_via_omni_endpoint; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::parse_semantic_media_files; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + + +/// Dispatch an image generation request to Artcraft. +pub async fn handle_artcraft( + request: &TauriGenerateImageRequest, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + // Resolve credentials. + let creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials)), + }; + + // Resolve semantic media files (upload raw bytes if provided). + let semantic_media_files = parse_semantic_media_files( + request, + &creds, + &app_env_configs.storyteller_host, + ).await?; + + let use_legacy = request.model + .is_some_and(|m| is_legacy_only_model(m)); + + if use_legacy { + info!("Model {:?} is legacy-only, routing to artcraft_router path.", request.model); + handle_artcraft_via_legacy( + request, + &semantic_media_files, + &creds, + app_env_configs, + ).await + } else { + handle_artcraft_via_omni_endpoint( + request, + &semantic_media_files, + &creds, + app_env_configs, + ).await + } +} + +fn is_legacy_only_model(model: TauriImageModel) -> bool { + matches!( + model, + TauriImageModel::GrokImage + | TauriImageModel::Recraft3 + | TauriImageModel::Midjourney + | TauriImageModel::FluxProKontextMax + | TauriImageModel::FluxDevJuggernaut + ) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs new file mode 100644 index 0000000000..e4ea97ba75 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs @@ -0,0 +1,49 @@ +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::providers::artcraft::legacy::artcraft_flux_2_lora_angles::handle_flux_2_lora_angles; +use crate::core::commands::generate::generate_image::providers::artcraft::legacy::artcraft_flux_dev_juggernaut_inpaint::handle_flux_dev_juggernaut_inpaint; +use crate::core::commands::generate::generate_image::providers::artcraft::legacy::artcraft_flux_pro_kontext_edit::handle_flux_pro_kontext_edit; +use crate::core::commands::generate::generate_image::providers::artcraft::legacy::artcraft_qwen_edit_2511_angles::handle_qwen_edit_2511_angles; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +/// Handle image generation via legacy model-specific paths. +/// +/// Dispatches to dedicated handlers for each supported legacy model. +pub async fn handle_artcraft_via_legacy( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, +) -> Result { + let model = request.model.ok_or(GenerateError::no_model_specified())?; + + match model { + TauriImageModel::FluxDevJuggernaut => { + handle_flux_dev_juggernaut_inpaint(request, semantic_media_files, creds, app_env_configs).await + } + TauriImageModel::FluxProKontextMax => { + handle_flux_pro_kontext_edit(request, semantic_media_files, creds, app_env_configs).await + } + TauriImageModel::QwenEdit2511Angles => { + handle_qwen_edit_2511_angles(request, semantic_media_files, creds, app_env_configs).await + } + TauriImageModel::Flux2LoraAngles => { + handle_flux_2_lora_angles(request, semantic_media_files, creds, app_env_configs).await + } + TauriImageModel::Midjourney | TauriImageModel::Recraft3 => { + Err(GenerateError::NotYetImplemented( + format!("Model {:?} is not supported by provider Artcraft", model), + )) + } + other => { + Err(GenerateError::NotYetImplemented( + format!("Model {:?} was incorrectly handled", other), + )) + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_omni_endpoint.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_omni_endpoint.rs new file mode 100644 index 0000000000..f18fbb399f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_omni_endpoint.rs @@ -0,0 +1,101 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; +use tokens::tokens::media_files::MediaFileToken; +use uuid_utils::uuid::generate_random_uuid; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::api_adapters::models::image::tauri_image_model_to_enums_model::tauri_image_model_to_enums_model; +use crate::core::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +pub async fn handle_artcraft_via_omni_endpoint( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, +) -> Result { + let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?; + + let omni_api_model = tauri_image_model_to_enums_model(tauri_model) + .ok_or(GenerateError::NotYetImplemented( + format!("Model {:?} is not supported via the omni endpoint", tauri_model), + ))?; + + let uuid_idempotency_token = generate_random_uuid(); + + let image_media_tokens = get_image_media_tokens(request, semantic_media_files); + + let omni_request = OmniGenImageCostAndGenerateRequest { + idempotency_token: Some(uuid_idempotency_token), + model: Some(omni_api_model), + prompt: request.prompt.clone(), + image_media_tokens, + resolution: request.resolution, + aspect_ratio: request.aspect_ratio, + quality: request.quality, + image_batch_count: request.batch_size.map(|n| n as u16), + adjust_horizontal_angle: request.adjust_horizontal_angle, + adjust_vertical_angle: request.adjust_vertical_angle, + adjust_zoom: request.adjust_zoom, + }; + + info!("Sending image generation via omni endpoint: model={:?}", omni_api_model); + + let response = omni_gen_image_generate( + &app_env_configs.storyteller_host, + Some(creds), + omni_request, + ).await.map_err(|err| { + error!("Omni image generation failed: {:?}", err); + GenerateError::from(err) + })?; + + info!("Omni image generation succeeded: job_token={}", response.inference_job_token.as_str()); + + let generation_model = tauri_image_model_to_generation_model(tauri_model); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(generation_model), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(response.inference_job_token.to_string()), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_image_media_tokens( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, +) -> Option> { + let num_images = request.image_media_tokens.as_deref() + .map(|t| t.len()) + .unwrap_or(0); + + let mut image_media_tokens = Vec::with_capacity(num_images + 2); + + if let Some(canvas_token) = &semantic_media_files.canvas_image_media_token { + image_media_tokens.push(canvas_token.clone()); + } + + if let Some(scene_token) = &semantic_media_files.scene_image_media_token { + image_media_tokens.push(scene_token.clone()); + } + + if let Some(media_tokens) = &request.image_media_tokens { + image_media_tokens.extend(media_tokens.clone()); + } + + if image_media_tokens.is_empty() { + None + } else { + Some(image_media_tokens) + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs new file mode 100644 index 0000000000..929df4474f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs @@ -0,0 +1,107 @@ +use log::{error, info}; + +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_router::api::router_image_model::RouterImageModel; +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::client::router_artcraft_client::RouterArtcraftClient; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; + +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +/// Handle Flux2LoraAngles image editing via the artcraft_router. +pub async fn handle_flux_2_lora_angles( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, +) -> Result { + let client = RouterClient::Artcraft(RouterArtcraftClient::new( + app_env_configs.storyteller_host.clone(), + creds.clone(), + )); + + let image_inputs = build_image_inputs(request, semantic_media_files); + + let router_request = GenerateImageRequestBuilder { + model: RouterImageModel::Flux2LoraAngles, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + image_inputs, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: request.batch_size.map(|n| n as u16), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: request.adjust_horizontal_angle, + vertical_angle: request.adjust_vertical_angle, + zoom: request.adjust_zoom, + }; + + let dor = router_request.build2()?; + + info!("Flux2LoraAngles request: {:?}", dor); + + let request = match dor { + ImageGenerationDraftOrRequest::Request(req) => req, + // Artcraft-side angle models always return a Request — never a Draft. + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft Flux2LoraAngles should never produce a draft" + ), + }; + + let response = request.send_request(&client).await.map_err(|err| { + error!("Flux2LoraAngles generation failed: {:?}", err); + GenerateError::from(err) + })?; + + let job_id = response + .get_artcraft_payload() + .map(|p| p.inference_job_token.to_string()) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + info!("Flux2LoraAngles succeeded: job_id={}", job_id); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(GenerationModel::Flux2LoraAngles), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_id), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn build_image_inputs( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, +) -> Option { + let mut tokens = Vec::new(); + + if let Some(scene_token) = &semantic_media_files.scene_image_media_token { + tokens.push(scene_token.clone()); + } + if let Some(canvas_token) = &semantic_media_files.canvas_image_media_token { + tokens.push(canvas_token.clone()); + } + if let Some(media_tokens) = &request.image_media_tokens { + tokens.extend(media_tokens.clone()); + } + + if tokens.is_empty() { None } else { Some(ImageListRef::MediaFileTokens(tokens)) } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_dev_juggernaut_inpaint.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_dev_juggernaut_inpaint.rs new file mode 100644 index 0000000000..a6b6dff483 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_dev_juggernaut_inpaint.rs @@ -0,0 +1,101 @@ +use log::{error, info}; + +use artcraft_api_defs::generate::image::inpaint::flux_dev_juggernaut_inpaint_image::{ + FluxDevJuggernautInpaintImageNumImages, FluxDevJuggernautInpaintImageRequest, +}; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::inpaint::flux_dev_juggernaut_inpaint_image::flux_dev_juggernaut_inpaint_image; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +/// Handle FluxDevJuggernaut inpainting via the legacy dedicated endpoint. +/// +/// Requires a source image (from scene, canvas, or image_media_tokens) and an inpainting mask. +pub async fn handle_flux_dev_juggernaut_inpaint( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, +) -> Result { + let image_media_token = get_first_image_token(request, semantic_media_files) + .ok_or(GenerateError::required_source_image_not_provided())?; + + let mask_media_token = semantic_media_files.inpainting_mask_image_media_token.clone() + .ok_or(GenerateError::required_source_image_mask_not_provided())?; + + let num_images = match request.batch_size { + None => None, + Some(1) => Some(FluxDevJuggernautInpaintImageNumImages::One), + Some(2) => Some(FluxDevJuggernautInpaintImageNumImages::Two), + Some(3) => Some(FluxDevJuggernautInpaintImageNumImages::Three), + Some(4) => Some(FluxDevJuggernautInpaintImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + } + }; + + let uuid_idempotency_token = generate_random_uuid(); + + let inpaint_request = FluxDevJuggernautInpaintImageRequest { + uuid_idempotency_token, + prompt: request.prompt.clone(), + image_media_token, + mask_media_token, + num_images, + }; + + info!("Calling FluxDevJuggernaut inpaint..."); + + let response = flux_dev_juggernaut_inpaint_image( + &app_env_configs.storyteller_host, + Some(creds), + inpaint_request, + ).await.map_err(|err| { + error!("FluxDevJuggernaut inpaint failed: {:?}", err); + GenerateError::from(err) + })?; + + info!("FluxDevJuggernaut inpaint succeeded: job_token={}", response.inference_job_token); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageInpaintEdit, + model: Some(GenerationModel::FluxDevJuggernaut), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(response.inference_job_token.to_string()), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_first_image_token( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, +) -> Option { + if let Some(scene_token) = &semantic_media_files.scene_image_media_token { + return Some(scene_token.clone()); + } + if let Some(canvas_token) = &semantic_media_files.canvas_image_media_token { + return Some(canvas_token.clone()); + } + if let Some(media_tokens) = &request.image_media_tokens { + if let Some(token) = media_tokens.first() { + return Some(token.clone()); + } + } + None +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_kontext_edit.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_kontext_edit.rs new file mode 100644 index 0000000000..131d4a588f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_kontext_edit.rs @@ -0,0 +1,99 @@ +use log::{error, info}; + +use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{ + FluxProKontextMaxEditImageNumImages, FluxProKontextMaxEditImageRequest, +}; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use tokens::tokens::media_files::MediaFileToken; +use uuid_utils::uuid::generate_random_uuid; + +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +/// Handle FluxProKontextMax image editing via the legacy dedicated endpoint. +/// +/// Consolidates both the "edit" and "inpaint" legacy paths — both call the same +/// Kontext Max edit endpoint with a single source image. +pub async fn handle_flux_pro_kontext_edit( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, +) -> Result { + let image_media_token = get_first_image_token(request, semantic_media_files) + .ok_or(GenerateError::BadInput(BadInputReason::WrongImageArguments( + "No input image specified for Flux Kontext Max edit".to_string(), + )))?; + + let num_images = match request.batch_size { + None => None, + Some(1) => Some(FluxProKontextMaxEditImageNumImages::One), + Some(2) => Some(FluxProKontextMaxEditImageNumImages::Two), + Some(3) => Some(FluxProKontextMaxEditImageNumImages::Three), + Some(4) => Some(FluxProKontextMaxEditImageNumImages::Four), + Some(other) => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { + min: 1, + max: 4, + requested: other, + })); + } + }; + + let uuid_idempotency_token = generate_random_uuid(); + + let kontext_request = FluxProKontextMaxEditImageRequest { + uuid_idempotency_token, + prompt: request.prompt.clone(), + image_media_token, + num_images, + }; + + info!("Calling FluxProKontextMax edit..."); + + let response = flux_pro_kontext_max_edit_image( + &app_env_configs.storyteller_host, + Some(creds), + kontext_request, + ).await.map_err(|err| { + error!("FluxProKontextMax edit failed: {:?}", err); + GenerateError::from(err) + })?; + + info!("FluxProKontextMax edit succeeded: job_token={}", response.inference_job_token); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(GenerationModel::FluxProKontextMax), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(response.inference_job_token.to_string()), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn get_first_image_token( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, +) -> Option { + if let Some(scene_token) = &semantic_media_files.scene_image_media_token { + return Some(scene_token.clone()); + } + if let Some(canvas_token) = &semantic_media_files.canvas_image_media_token { + return Some(canvas_token.clone()); + } + if let Some(media_tokens) = &request.image_media_tokens { + if let Some(token) = media_tokens.first() { + return Some(token.clone()); + } + } + None +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs new file mode 100644 index 0000000000..30a9b0880e --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs @@ -0,0 +1,107 @@ +use log::{error, info}; + +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_router::api::router_image_model::RouterImageModel; +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::client::router_artcraft_client::RouterArtcraftClient; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; + +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +/// Handle QwenEdit2511Angles image editing via the artcraft_router. +pub async fn handle_qwen_edit_2511_angles( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, +) -> Result { + let client = RouterClient::Artcraft(RouterArtcraftClient::new( + app_env_configs.storyteller_host.clone(), + creds.clone(), + )); + + let image_inputs = build_image_inputs(request, semantic_media_files); + + let router_request = GenerateImageRequestBuilder { + model: RouterImageModel::QwenEdit2511Angles, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + image_inputs, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: request.batch_size.map(|n| n as u16), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + horizontal_angle: request.adjust_horizontal_angle, + vertical_angle: request.adjust_vertical_angle, + zoom: request.adjust_zoom, + }; + + let dor = router_request.build2()?; + + info!("QwenEdit2511Angles request: {:?}", dor); + + let request = match dor { + ImageGenerationDraftOrRequest::Request(req) => req, + // Artcraft-side angle models always return a Request — never a Draft. + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft QwenEdit2511Angles should never produce a draft" + ), + }; + + let response = request.send_request(&client).await.map_err(|err| { + error!("QwenEdit2511Angles generation failed: {:?}", err); + GenerateError::from(err) + })?; + + let job_id = response + .get_artcraft_payload() + .map(|p| p.inference_job_token.to_string()) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + info!("QwenEdit2511Angles succeeded: job_id={}", job_id); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(GenerationModel::QwenEdit2511Angles), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_id), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +fn build_image_inputs( + request: &TauriGenerateImageRequest, + semantic_media_files: &SemanticMediaFiles, +) -> Option { + let mut tokens = Vec::new(); + + if let Some(scene_token) = &semantic_media_files.scene_image_media_token { + tokens.push(scene_token.clone()); + } + if let Some(canvas_token) = &semantic_media_files.canvas_image_media_token { + tokens.push(canvas_token.clone()); + } + if let Some(media_tokens) = &request.image_media_tokens { + tokens.extend(media_tokens.clone()); + } + + if tokens.is_empty() { None } else { Some(ImageListRef::MediaFileTokens(tokens)) } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs new file mode 100644 index 0000000000..83dc104d10 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs @@ -0,0 +1,4 @@ +pub mod artcraft_flux_2_lora_angles; +pub mod artcraft_flux_dev_juggernaut_inpaint; +pub mod artcraft_flux_pro_kontext_edit; +pub mod artcraft_qwen_edit_2511_angles; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/mod.rs new file mode 100644 index 0000000000..af34f55def --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/mod.rs @@ -0,0 +1,6 @@ +mod handle_artcraft; +mod handle_artcraft_via_legacy; +mod handle_artcraft_via_omni_endpoint; +mod legacy; + +pub use handle_artcraft::handle_artcraft; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_api_providers.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_api_providers.rs new file mode 100644 index 0000000000..ce7c59854e --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_api_providers.rs @@ -0,0 +1,203 @@ +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use artcraft_client::utils::api_host::ApiHost; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::client::router_client::RouterClient; +use artcraft_router::client::router_fal_client::RouterFalClient; +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_or_request::ImageGenerationDraftOrRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info, warn}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::core::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model; +use crate::core::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::common::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt; +use crate::core::commands::generate::generate_image::providers::artcraft_router::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution}; +use crate::core::commands::generate::generate_image::providers::artcraft_router::utils::map_media_files_to_urls::map_media_file_tokens_to_cdn_urls; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +/// Handle image generation for providers that authenticate via API key. +pub async fn handle_api_key_provider( + request: &TauriGenerateImageRequest, + provider: GenerationProvider, + api_key: &str, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + match provider { + GenerationProvider::Fal => { + handle_fal(request, api_key, app_env_configs, storyteller_creds_manager).await + } + _ => { + Err(GenerateError::NotYetImplemented( + format!("API key provider {:?} is not yet supported via the router path", provider), + )) + } + } +} + +// ── FAL ── + +async fn handle_fal( + request: &TauriGenerateImageRequest, + api_key: &str, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?; + let api_host = &app_env_configs.storyteller_host; + + let router_model = tauri_image_model_to_router_model(tauri_model) + .ok_or(GenerateError::NotYetImplemented( + format!("Model {:?} is not supported via the FAL router path", tauri_model), + ))?; + + // Collect all media file tokens that need resolving. + let image_inputs = resolve_image_inputs(request, api_host).await?; + + let router_request = GenerateImageRequestBuilder { + model: router_model, + provider: RouterProvider::Fal, + prompt: request.prompt.clone(), + image_inputs, + resolution: request.resolution.map(convert_resolution), + aspect_ratio: request.aspect_ratio.map(convert_aspect_ratio), + quality: request.quality.map(convert_quality), + image_batch_count: request.batch_size.map(|n| n as u16), + 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: Some(GenerationModeMismatchStrategy::GenerateAnyway), + idempotency_token: None, + }; + + // Create a prompt record before sending the generation request. + let maybe_prompt_token = create_prompt_record( + &router_request, + api_host, + storyteller_creds_manager, + ).await; + + let fal_client = RouterFalClient::new_polling_only_from_raw_key(api_key); + let client = RouterClient::Fal(fal_client); + + info!("Building FAL image generation plan: model={:?}", router_model); + + let request = match router_request.build2() { + Ok(ImageGenerationDraftOrRequest::Request(request)) => request, + Ok(ImageGenerationDraftOrRequest::Draft(draft)) => { + warn!("Fal is trying to send draft request: {:?}", draft); + return Err(GenerateError::NotYetImplemented("Fal should not be sending draft requests".to_string())); + }, + Err(err) => { + warn!("Could not use FAL: {:?}", err); + return Err(GenerateError::NotYetImplemented("Error Message: TODO".to_string())); + } + }; + + info!("Executing FAL image generation. Request: {:?}", request); + + match request.send_request(&client).await { + Ok(response) => { + build_task_enqueue_success(tauri_model, response, maybe_prompt_token) + }, + Err(err) => { + warn!("Fal image generation failed: {:?}", err); + Err(err.into()) + } + } +} + +// ── Helpers ── + +async fn resolve_image_inputs( + request: &TauriGenerateImageRequest, + api_host: &ApiHost, +) -> Result, GenerateError> { + let mut tokens: Vec = Vec::new(); + + if let Some(canvas_token) = &request.canvas_image_media_token { + tokens.push(canvas_token.clone()); + } + + if let Some(scene_token) = &request.scene_image_media_token { + tokens.push(scene_token.clone()); + } + + if let Some(media_tokens) = &request.image_media_tokens { + tokens.extend(media_tokens.clone()); + } + + if tokens.is_empty() { + return Ok(None); + } + + let urls = map_media_file_tokens_to_cdn_urls(&tokens, api_host).await?; + Ok(Some(ImageListRef::Urls(urls))) +} + +/// Create a prompt record in the Artcraft backend before sending the generation request. +/// Fails open: if prompt creation fails, we log and return None rather than blocking generation. +async fn create_prompt_record( + router_request: &GenerateImageRequestBuilder, + api_host: &ApiHost, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Option { + let creds = match storyteller_creds_manager.get_credentials() { + Ok(Some(creds)) => creds, + _ => { + warn!("[FalRouter] No Storyteller credentials available, skipping prompt creation"); + return None; + } + }; + + let prompt_request = router_image_request_to_artcraft_prompt(router_request); + + match create_prompt(api_host, Some(&creds), prompt_request).await { + Ok(response) => { + info!("[FalRouter] Created prompt: {:?}", response.prompt_token); + Some(response.prompt_token) + } + Err(err) => { + error!("[FalRouter] Failed to create prompt (continuing anyway): {:?}", err); + None + } + } +} + +fn build_task_enqueue_success( + tauri_model: TauriImageModel, + response: GenerateImageResponse, + maybe_prompt_token: Option, +) -> Result { + let fal_payload = response.get_fal_payload() + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + let provider_job_id = fal_payload.request_id + .or(fal_payload.gateway_request_id) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + let generation_model = tauri_image_model_to_generation_model(tauri_model); + + Ok(TaskEnqueueSuccess { + task_type: TaskType::ImageGeneration, + model: Some(generation_model), + provider: GenerationProvider::Fal, + provider_job_id: Some(provider_job_id), + maybe_queue_status_url: fal_payload.maybe_status_url, + maybe_queue_response_url: fal_payload.maybe_response_url, + maybe_prompt_token, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs new file mode 100644 index 0000000000..56a19dd5a2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs @@ -0,0 +1,68 @@ +use enums::common::generation_provider::GenerationProvider; +use log::info; + +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::providers::artcraft_router::handle_api_providers::handle_api_key_provider; +use crate::core::commands::generate::generate_image::providers::artcraft_router::handle_web_login_providers::handle_web_login_provider; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +/// Dispatch an image generation request to a third-party provider via the router. +/// +/// This handles providers that authenticate with the user's own credentials +/// (API key or web login) rather than going through the Artcraft backend. +pub async fn handle_router( + request: &TauriGenerateImageRequest, + provider: GenerationProvider, + app_env_configs: &AppEnvConfigs, + credential_cache: &ProviderCredentialLoadingCache, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + let credential_key = map_provider_to_credential_key(provider)?; + + info!("handle_router: provider={:?}, credential_key={:?}", provider, credential_key); + + let payload = credential_cache.get_credentials(credential_key) + .map_err(|err| { + GenerateError::AnyhowError(anyhow::anyhow!("Failed to load credentials: {:?}", err)) + })? + .ok_or_else(|| map_provider_to_missing_credentials_error(provider))?; + + match payload { + ProviderCredentialPayload::ApiKey(api_key_data) => { + handle_api_key_provider(request, provider, api_key_data.as_str(), app_env_configs, storyteller_creds_manager).await + } + ProviderCredentialPayload::WebLogin(web_login_data) => { + handle_web_login_provider(request, provider, &web_login_data).await + } + } +} + +// ── Helpers ── + +fn map_provider_to_credential_key( + provider: GenerationProvider, +) -> Result { + match provider { + GenerationProvider::Fal => Ok(ProviderCredentialKey::FalApiKey), + _ => Err(GenerateError::NotYetImplemented( + format!("Provider {:?} does not have a mapped credential key yet", provider), + )), + } +} + +fn map_provider_to_missing_credentials_error(provider: GenerationProvider) -> GenerateError { + match provider { + GenerationProvider::Fal => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey), + GenerationProvider::Grok => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials), + GenerationProvider::Midjourney => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials), + GenerationProvider::Sora => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsSoraCredentials), + GenerationProvider::WorldLabs => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials), + _ => GenerateError::NoProviderAvailable, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_web_login_providers.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_web_login_providers.rs new file mode 100644 index 0000000000..d9359f325f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_web_login_providers.rs @@ -0,0 +1,17 @@ +use enums::common::generation_provider::GenerationProvider; + +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::providers::credentials::payload::web_login::WebLoginData; + +/// Handle image generation for providers that authenticate via web login. +/// +/// Not yet implemented — placeholder for Grok, Runway, Higgsfield, etc. +pub async fn handle_web_login_provider( + _request: &TauriGenerateImageRequest, + _provider: GenerationProvider, + _web_login: &WebLoginData, +) -> Result { + unimplemented!("Web login provider image generation is not yet supported via the router path") +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/mod.rs new file mode 100644 index 0000000000..ea74c39d9a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/mod.rs @@ -0,0 +1,4 @@ +pub mod handle_router; +mod handle_api_providers; +mod handle_web_login_providers; +mod utils; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs new file mode 100644 index 0000000000..33c23040fa --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs @@ -0,0 +1,50 @@ +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_quality::RouterQuality; +use artcraft_router::api::router_resolution::RouterResolution; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio; +use enums::common::generation::common_quality::CommonQuality as EnumsQuality; +use enums::common::generation::common_resolution::CommonResolution as EnumsResolution; + +pub fn convert_aspect_ratio(value: EnumsAspectRatio) -> RouterAspectRatio { + match value { + EnumsAspectRatio::Auto => RouterAspectRatio::Auto, + EnumsAspectRatio::Square => RouterAspectRatio::Square, + EnumsAspectRatio::WideThreeByTwo => RouterAspectRatio::WideThreeByTwo, + EnumsAspectRatio::WideFourByThree => RouterAspectRatio::WideFourByThree, + EnumsAspectRatio::WideFiveByFour => RouterAspectRatio::WideFiveByFour, + EnumsAspectRatio::WideSixteenByNine => RouterAspectRatio::WideSixteenByNine, + EnumsAspectRatio::WideTwentyOneByNine => RouterAspectRatio::WideTwentyOneByNine, + EnumsAspectRatio::TallTwoByThree => RouterAspectRatio::TallTwoByThree, + EnumsAspectRatio::TallThreeByFour => RouterAspectRatio::TallThreeByFour, + EnumsAspectRatio::TallFourByFive => RouterAspectRatio::TallFourByFive, + EnumsAspectRatio::TallNineBySixteen => RouterAspectRatio::TallNineBySixteen, + EnumsAspectRatio::TallNineByTwentyOne => RouterAspectRatio::TallNineByTwentyOne, + EnumsAspectRatio::Wide => RouterAspectRatio::Wide, + EnumsAspectRatio::Tall => RouterAspectRatio::Tall, + EnumsAspectRatio::Auto2k => RouterAspectRatio::Auto2k, + EnumsAspectRatio::Auto3k => RouterAspectRatio::Auto3k, + EnumsAspectRatio::Auto4k => RouterAspectRatio::Auto4k, + EnumsAspectRatio::SquareHd => RouterAspectRatio::SquareHd, + } +} + +pub fn convert_quality(value: EnumsQuality) -> RouterQuality { + match value { + EnumsQuality::High => RouterQuality::High, + EnumsQuality::Medium => RouterQuality::Medium, + EnumsQuality::Low => RouterQuality::Low, + } +} + +pub fn convert_resolution(value: EnumsResolution) -> RouterResolution { + match value { + EnumsResolution::OneK => RouterResolution::OneK, + EnumsResolution::TwoK => RouterResolution::TwoK, + EnumsResolution::ThreeK => RouterResolution::ThreeK, + EnumsResolution::FourK => RouterResolution::FourK, + EnumsResolution::HalfK => RouterResolution::HalfK, + EnumsResolution::FourEightyP => RouterResolution::FourEightyP, + EnumsResolution::SevenTwentyP => RouterResolution::SevenTwentyP, + EnumsResolution::TenEightyP => RouterResolution::TenEightyP, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/map_media_files_to_urls.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/map_media_files_to_urls.rs new file mode 100644 index 0000000000..81f47670e7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/map_media_files_to_urls.rs @@ -0,0 +1,27 @@ +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::utils::api_host::ApiHost; +use log::info; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::enqueue::generate_error::GenerateError; + +/// Resolve a list of media file tokens to their CDN URLs. +/// +/// FAL (and other third-party providers) can fetch images by URL directly, +/// so we just need the CDN link — no need to download the bytes locally. +pub async fn map_media_file_tokens_to_cdn_urls( + tokens: &[MediaFileToken], + api_host: &ApiHost, +) -> Result, GenerateError> { + let mut urls = Vec::with_capacity(tokens.len()); + + for token in tokens { + info!("Resolving media file token {} to CDN URL...", token.as_str()); + let response = get_media_file(api_host, token).await?; + let cdn_url = response.media_file.media_links.cdn_url.to_string(); + info!("Resolved {} -> {}", token.as_str(), &cdn_url); + urls.push(cdn_url); + } + + Ok(urls) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/mod.rs new file mode 100644 index 0000000000..d9e43ac9c4 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod convert_enums_to_router; +pub mod map_media_files_to_urls; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/grok/handle_grok.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/grok/handle_grok.rs new file mode 100644 index 0000000000..dc8f00476f --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/grok/handle_grok.rs @@ -0,0 +1,92 @@ +use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_grok_image::aspect_ratio_to_grok_image; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::{GrokImagePromptQueue, PromptItem}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; +use uuid_utils::uuid::generate_random_uuid; +use tauri::AppHandle; + +pub async fn handle_grok( + app: &AppHandle, + request: &EnqueueTextToImageRequest, + app_env_configs: &AppEnvConfigs, + creds_manager: &GrokCredentialManager, + grok_image_prompt_queue: &GrokImagePromptQueue, +) -> Result { + + + //// TODO: We can request population of the user info if absent or expired. + //let websocket = get_websocket( + // app, + // creds_manager, + // websocket_manager, + //).await?; + // + //let _result = prompt_websocket_image(PromptWebsocketImageArgs { + // websocket_wrapped: &websocket, + // prompt: request.prompt.as_deref().unwrap_or_else(||""), + //}).await?; + // + //let request_id = listen_for_websocket_request_id(ListenForWebsocketRequestIdArgs { + // websocket: &websocket, + // timeout: Duration::from_millis(10_000), + //}).await?; + // + //let request_id = match request_id.request_id { + // Some(request_id) => request_id.to_string(), + // None => { + // warn!("No request id from Grok image generation (websocket)"); + // return Err(GenerateError::ProviderFailure(ProviderFailureReason::GrokJobEnqueueFailed)); + // } + //}; + // + //info!("Successfully enqueued MidJourney. Job token: {}", request_id); + + let job_id = generate_random_uuid(); + + let prompt = request.prompt + .as_deref() + .map(|prompt| prompt.trim().to_string()) + .unwrap_or_else(|| "".to_string()); + + let aspect_ratio = get_aspect_ratio(request); + + grok_image_prompt_queue.enqueue(PromptItem { + task_id: job_id.clone(), + prompt, + aspect_ratio: aspect_ratio.unwrap_or(ClientMessageAspectRatio::Square), + })?; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Grok, + model: Some(GenerationModel::GrokImage), + provider_job_id: Some(job_id), + task_type: TaskType::ImageGeneration, + }) +} + +fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option { + if let Some(common_aspect_ratio) = request.common_aspect_ratio { + // Handle modern aspect ratio + let aspect = aspect_ratio_to_grok_image(common_aspect_ratio); + return Some(aspect); + } + + if let Some(aspect_ratio) = request.aspect_ratio { + // Handle deprecated aspect ratio + return match aspect_ratio { + TextToImageSize::Auto => Some(ClientMessageAspectRatio::Square), + TextToImageSize::Square => Some(ClientMessageAspectRatio::Square), + TextToImageSize::Wide => Some(ClientMessageAspectRatio::WideThreeByTwo), + TextToImageSize::Tall => Some(ClientMessageAspectRatio::TallTwoByThree), + } + } + + None +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/grok/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/grok/mod.rs new file mode 100644 index 0000000000..181e756f1a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/grok/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_grok; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/midjourney/handle_midjourney.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/midjourney/handle_midjourney.rs new file mode 100644 index 0000000000..4eceda6d30 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/midjourney/handle_midjourney.rs @@ -0,0 +1,140 @@ +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason, ProviderFailureReason}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::core::events::warning_events::flash_user_input_error_event::FlashUserInputErrorEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::endpoints::submit_job::{submit_job, SubmitJobRequest}; +use midjourney_client::error::midjourney_api_error::MidjourneyApiError; +use midjourney_client::recipes::channel_id::ChannelId; +use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageError, TextToImageRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest}; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use std::time::Duration; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_midjourney( + app: &AppHandle, + request: &EnqueueTextToImageRequest, + app_env_configs: &AppEnvConfigs, + mj_creds_manager: &MidjourneyCredentialManager, +) -> Result { + + let creds = match mj_creds_manager.maybe_copy_cookie_store() { + Ok(Some(creds)) => creds, + Ok(None) => { + error!("Midjourney credentials not found."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app); + return Err(GenerateError::needs_midjourney_credentials()); + } + Err(err) => { + error!("Error reading Midjourney credentials: {:?}", err); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app); + return Err(GenerateError::needs_midjourney_credentials()); + }, + }; + + // TODO: We can request population of the user info if absent or expired. + + let user_info = match mj_creds_manager.maybe_copy_user_info() { + Ok(Some(user_info)) => user_info, + Ok(None) => { + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo)); + } + Err(err) => { + error!("Error reading Midjourney user info: {:?}", err); + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo)); + }, + }; + + let channel_id = match user_info.user_id { + Some(user_id) => ChannelId::UserId(user_id), + None => { + error!("Midjourney user info does not contain a user ID."); + return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserId)); + } + }; + + info!("Calling midjourney ..."); + + let cookie_header = creds.to_cookie_string(); + + let prompt = request.prompt + .as_deref() + .unwrap_or(""); + + let result = text_to_image(TextToImageRequest { + prompt, + channel_id: &channel_id, + hostname: MidjourneyHostname::Standard, + cookie_header, + }).await; + + let result = match result { + Ok(result) => result, + Err(err) => { + error!("Failed to use MidJourney: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + let job_id = match result.maybe_job_id { + Some(job_id) => job_id, + None => { + error!("Failed to enqueue MidJourney: No job ID returned."); + return handle_midjourney_errors(app, result.maybe_errors); + } + }; + + info!("Successfully enqueued MidJourney. Job token: {}", job_id); + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Midjourney, + model: Some(GenerationModel::Midjourney), + provider_job_id: Some(job_id), + task_type: TaskType::ImageGeneration, + }) +} + +fn handle_midjourney_errors( + app: &AppHandle, + maybe_errors: Option> +) -> Result { + if let Some(errors) = maybe_errors { + if !errors.is_empty() { + let messages: Vec = errors.iter() + .map(|e| format!("{:?}", e)) + .collect(); + + let combined_message = messages.join("; "); + + let event = FlashUserInputErrorEvent { + message: format!("Midjourney Error: {}", combined_message), + }; + + if let Err(err) = event.send(&app) { + error!("Failed to send FlashUserInputErrorEvent: {:?}", err); // Fail open + } + } + } + + Err(GenerateError::ProviderFailure(ProviderFailureReason::MidjourneyJobEnqueueFailed)) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/midjourney/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/midjourney/mod.rs new file mode 100644 index 0000000000..18048875b6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/midjourney/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_midjourney; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs new file mode 100644 index 0000000000..c260241ca6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/mod.rs @@ -0,0 +1,2 @@ +pub mod artcraft; +pub mod artcraft_router; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/handle_gpt_image_1_sora_text_to_image.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/handle_gpt_image_1_sora_text_to_image.rs new file mode 100644 index 0000000000..d7e1a25875 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/handle_gpt_image_1_sora_text_to_image.rs @@ -0,0 +1,136 @@ +use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_grok_image::aspect_ratio_to_grok_image; +use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_sora_native_gpt_image_1::aspect_ratio_to_sora_native_gpt_image_1; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use errors::AnyhowResult; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; +use log::{error, info}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest}; +use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; +use std::time::Duration; +use tauri::AppHandle; + +const SORA_SIMPLE_IMAGE_GEN_TIMEOUT : Duration = Duration::from_millis(1000 * 30); // 30 seconds + +#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")] +pub async fn handle_gpt_image_1_sora_text_to_image( + request: &EnqueueTextToImageRequest, + app: &AppHandle, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + + let mut creds = match sora_creds_manager.get_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + error!("Sora credentials not found."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + } + Err(err) => { + error!("Error reading Sora credentials: {:?}", err); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + } + }; + + let credential_updated = maybe_upgrade_or_renew_session(&mut creds) + .await + .map_err(|err| { + error!("Failed to upgrade or renew session: {:?}", err); + err + })?; + + if credential_updated { + info!("Storing updated credentials"); + sora_creds_manager.set_credentials(&creds)?; + } + + let prompt = request.prompt.as_deref().unwrap_or(""); + + let aspect_ratio = get_aspect_ratio(request) + .unwrap_or(ImageSize::Square); + + let num_images = match request.number_images { + None => NumImages::One, + Some(1) => NumImages::One, + Some(2) => NumImages::Two, + Some(3) => NumImages::Two, // NB: There is no option for three images! + Some(4) => NumImages::Four, + _ => NumImages::One, + }; + + let result = + simple_image_gen_with_session_auto_renew(SimpleImageGenAutoRenewRequest { + prompt: prompt.to_string(), + num_images, + image_size: aspect_ratio, + credentials: &creds, + request_timeout: Some(SORA_SIMPLE_IMAGE_GEN_TIMEOUT), + }).await; + + let (response, maybe_new_creds) = + match result { + Ok((response, maybe_new_creds)) => (response, maybe_new_creds), + Err(err) => { + let event = GenerationEnqueueFailureEvent { + action: GenerationAction::GenerateImage, + service: GenerationServiceProvider::Sora, + model: None, + reason: None, + }; + + if let Err(err) = event.send(app) { + error!("Failed to emit event: {:?}", err); // Fail open. + } + + return Err(GenerateError::from(err)); + } + }; + + if let Some(new_creds) = maybe_new_creds { + info!("Storing updated credentials."); + sora_creds_manager.set_credentials(&new_creds)?; + } + + info!("New Sora Task ID: {:?} ", response.task_id); + + sora_task_queue.insert(&response.task_id)?; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Sora, + task_type: TaskType::ImageGeneration, + model: Some(GenerationModel::GptImage1), + provider_job_id: Some(response.task_id.to_string()), + }) +} + +fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option { + if let Some(common_aspect_ratio) = request.common_aspect_ratio { + // Handle modern aspect ratio + let aspect = aspect_ratio_to_sora_native_gpt_image_1(common_aspect_ratio); + return Some(aspect); + } + + if let Some(aspect_ratio) = request.aspect_ratio { + // Handle deprecated aspect ratio + return match aspect_ratio { + TextToImageSize::Auto => Some(ImageSize::Square), + TextToImageSize::Square => Some(ImageSize::Square), + TextToImageSize::Wide => Some(ImageSize::Wide), + TextToImageSize::Tall => Some(ImageSize::Tall), + } + } + + None +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/handle_text_to_image_sora.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/handle_text_to_image_sora.rs new file mode 100644 index 0000000000..f7bad13a58 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/handle_text_to_image_sora.rs @@ -0,0 +1,22 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::EnqueueTextToImageRequest; +use crate::core::commands::generate::generate_image::sora::handle_gpt_image_1_sora_text_to_image::handle_gpt_image_1_sora_text_to_image; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use tauri::AppHandle; + +#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")] +pub async fn handle_text_to_image_sora( + request: &EnqueueTextToImageRequest, + app: &AppHandle, + sora_creds_manager: &SoraCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> Result { + handle_gpt_image_1_sora_text_to_image( + request, + app, + sora_creds_manager, + sora_task_queue, + ).await +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/mod.rs new file mode 100644 index 0000000000..9a9529d300 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/sora/mod.rs @@ -0,0 +1,2 @@ +pub (super) mod handle_text_to_image_sora; +mod handle_gpt_image_1_sora_text_to_image; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs new file mode 100644 index 0000000000..1e37568ff6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_generate_image_request.rs @@ -0,0 +1,125 @@ +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::common_quality::CommonQuality; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; + +// ── Request ── + +#[derive(Deserialize, Debug)] +pub struct TauriGenerateImageRequest { + /// The provider to use (defaults to Artcraft/Storyteller). + /// Not all (provider, model) combinations are valid. + pub provider: Option, + + /// The model to use. + pub model: Option, + + /// Text prompt for the image generation. + pub prompt: Option, + + /// Aspect ratio. + pub aspect_ratio: Option, + + /// Resolution. + pub resolution: Option, + + /// Quality (used by OpenAI models). + pub quality: Option, + + /// The number of images to generate. + pub batch_size: Option, + + /// Reference images (without semantics). + /// The purpose varies on a model-by-model basis. + pub image_media_tokens: Option>, + + // ── Canvas / scene images ── + + /// Supply this *XOR* `canvas_image_raw_bytes`. + /// Becomes the first image reference (pushing back `image_media_tokens` by one). + pub canvas_image_media_token: Option, + + /// Supply this *XOR* `canvas_image_media_token`. + /// Raw bytes of a canvas image. + pub canvas_image_raw_bytes: Option>, + + /// Supply this *XOR* `scene_image_raw_bytes`. + /// Becomes a scene reference image. + pub scene_image_media_token: Option, + + /// Supply this *XOR* `scene_image_media_token`. + /// Raw bytes of a scene image. + pub scene_image_raw_bytes: Option>, + + // ── Inpainting ── + + /// Supply this *XOR* `inpainting_mask_image_raw_bytes`. + /// The mask to focus the edit (already uploaded). + pub inpainting_mask_image_media_token: Option, + + /// Supply this *XOR* `inpainting_mask_image_media_token`. + /// The mask to focus the edit (raw bytes). + pub inpainting_mask_image_raw_bytes: Option>, + + // ── Angle adjustment (for edit models like QwenEdit, Flux2LoraAngles) ── + + /// Horizontal angle adjustment. + pub adjust_horizontal_angle: Option, + + /// Vertical angle adjustment. + pub adjust_vertical_angle: Option, + + /// Zoom adjustment. + pub adjust_zoom: Option, + + /// Turn on the system prompt. + pub enable_system_prompt: Option, + + // ── Frontend metadata ── + + /// Name of the frontend caller. + pub frontend_caller: Option, + + /// A frontend-defined identifier sent back as a Tauri event on task completion. + pub frontend_subscriber_id: Option, + + /// A frontend-defined payload sent back as a Tauri event on task completion. + pub frontend_subscriber_payload: Option, +} + +// ── Response ── + +#[derive(Serialize)] +pub struct TauriGenerateImageResponse { +} + +impl SerializeMarker for TauriGenerateImageResponse {} + +// ── Error ── + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum TauriGenerateImageErrorType { + /// Caller didn't specify a model + ModelNotSpecified, + /// Bad input + BadInput, + /// No provider available + NoProviderAvailable, + /// Generic server error + ServerError, + /// Needs to be logged into Artcraft + NeedsStorytellerCredentials, + /// Needs a FAL API key + NeedsFalApiKey, + /// Needs Grok credentials + NeedsGrokCredentials, + /// Billing issue + BillingIssue, +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_image_model.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_image_model.rs new file mode 100644 index 0000000000..88c53c1078 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/tauri_image_model.rs @@ -0,0 +1,81 @@ +use serde_derive::Deserialize; + +/// Unified image model enum covering text-to-image, image edit, and inpainting. +/// +/// This is used in the Tauri command bridge. +/// Don't change the serializations without coordinating with the frontend. +/// +/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni +/// identifiers (`CommonImageModel` serde strings). Variants whose legacy Tauri +/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize. +/// Once the frontend is 100% omni ids, the legacy renames can be dropped. +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TauriImageModel { + // Text-to-image models + + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_pro_11", alias = "flux_pro_1p1")] + FluxPro11, + #[serde(rename = "flux_pro_11_ultra", alias = "flux_pro_1p1_ultra")] + FluxPro11Ultra, + #[serde(rename = "grok_image", alias = "grok_imagine_image")] + GrokImage, + #[serde(rename = "recraft_3")] + Recraft3, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + // Generic Midjourney experience, served via the native Midjourney provider. + #[serde(rename = "midjourney")] + Midjourney, + + // Versioned Midjourney models, served by storyteller-web via the + // Artcraft provider (dispatched through artcraft_router). + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, + + // Image edit models + + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + + // Inpainting models + + #[serde(rename = "flux_dev_juggernaut")] + FluxDevJuggernaut, + #[serde(rename = "flux_pro_1")] + FluxPro1, +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/utils/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/utils/mod.rs new file mode 100644 index 0000000000..1734914faa --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/utils/mod.rs @@ -0,0 +1 @@ +pub mod parse_semantic_media_files; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/utils/parse_semantic_media_files.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/utils/parse_semantic_media_files.rs new file mode 100644 index 0000000000..2b0402a82a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/utils/parse_semantic_media_files.rs @@ -0,0 +1,123 @@ +use log::info; + +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::utils::api_host::ApiHost; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest; +use crate::core::utils::upload_bytes_to_media_file::upload_image_bytes_as_media_file::upload_image_bytes_as_media_file; +use crate::core::utils::upload_bytes_to_media_file::upload_mask_image_bytes_as_media_file::upload_mask_image_bytes_as_media_file; + +/// Resolved semantic media file tokens for canvas, scene, and inpainting mask. +/// +/// Each field is populated either from an already-uploaded token on the request +/// or by uploading raw bytes on the fly. +pub struct SemanticMediaFiles { + pub canvas_image_media_token: Option, + pub scene_image_media_token: Option, + pub inpainting_mask_image_media_token: Option, +} + +/// Resolve semantic media files from the request. +/// +/// For each of (canvas, scene, mask): the request may supply a pre-uploaded +/// token XOR raw bytes. If both are supplied, returns an error. If raw bytes +/// are supplied, uploads them and returns the resulting token. +pub async fn parse_semantic_media_files( + request: &TauriGenerateImageRequest, + creds: &StorytellerCredentialSet, + api_host: &ApiHost, +) -> Result { + // Canvas image + let canvas_image_media_token = resolve_image_field( + "canvas_image", + request.canvas_image_media_token.as_ref(), + request.canvas_image_raw_bytes.as_deref(), + creds, + api_host, + ).await?; + + // Scene image + let scene_image_media_token = resolve_image_field( + "scene_image", + request.scene_image_media_token.as_ref(), + request.scene_image_raw_bytes.as_deref(), + creds, + api_host, + ).await?; + + // Inpainting mask + let inpainting_mask_image_media_token = resolve_mask_field( + request.inpainting_mask_image_media_token.as_ref(), + request.inpainting_mask_image_raw_bytes.as_deref(), + creds, + api_host, + ).await?; + + Ok(SemanticMediaFiles { + canvas_image_media_token, + scene_image_media_token, + inpainting_mask_image_media_token, + }) +} + +// ── Helpers ── + +pub(crate) async fn resolve_image_field( + field_name: &str, + maybe_token: Option<&MediaFileToken>, + maybe_raw_bytes: Option<&[u8]>, + creds: &StorytellerCredentialSet, + api_host: &ApiHost, +) -> Result, GenerateError> { + if maybe_token.is_some() && maybe_raw_bytes.is_some() { + return Err(GenerateError::BadInput(BadInputReason::WrongImageArguments(format!( + "Cannot supply both {}_media_token and {}_raw_bytes", + field_name, field_name + )))); + } + + if let Some(token) = maybe_token { + return Ok(Some(token.clone())); + } + + if let Some(raw_bytes) = maybe_raw_bytes { + info!("Uploading {} raw bytes ({} bytes)...", field_name, raw_bytes.len()); + let token = upload_image_bytes_as_media_file( + creds, + api_host, + raw_bytes.to_vec(), + ).await?; + return Ok(Some(token)); + } + + Ok(None) +} + +pub(crate) async fn resolve_mask_field( + maybe_token: Option<&MediaFileToken>, + maybe_raw_bytes: Option<&[u8]>, + creds: &StorytellerCredentialSet, + api_host: &ApiHost, +) -> Result, GenerateError> { + if maybe_token.is_some() && maybe_raw_bytes.is_some() { + return Err(GenerateError::BadInput(BadInputReason::BothImageMaskMediaTokenAndBytesSupplied)); + } + + if let Some(token) = maybe_token { + return Ok(Some(token.clone())); + } + + if let Some(raw_bytes) = maybe_raw_bytes { + info!("Uploading inpainting mask raw bytes ({} bytes)...", raw_bytes.len()); + let token = upload_mask_image_bytes_as_media_file( + creds, + api_host, + raw_bytes, + ).await?; + return Ok(Some(token)); + } + + Ok(None) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_mesh_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_mesh_command.rs new file mode 100644 index 0000000000..b3a626a442 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_mesh_command.rs @@ -0,0 +1,11 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::local_error; +use tauri::AppHandle; + +#[tauri::command] +pub async fn generate_mesh_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if !request.uses_artcraft() { + return Err(local_error("No direct provider adapter is configured for this modality.")); + } + omni::generate(request, Modality::Mesh, &app).await +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_splat_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_splat_command.rs new file mode 100644 index 0000000000..85b86ef839 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_splat_command.rs @@ -0,0 +1,15 @@ +use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::enqueue_image_to_gaussian_command; +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native}; +use tauri::{AppHandle, Manager}; + +#[tauri::command] +pub async fn generate_splat_command(mut request: OmniRequest, app: AppHandle) -> OmniResult { + if request.uses_artcraft() { + return omni::generate(request, Modality::Splat, &app).await; + } + if let Some(images) = request.fields.remove("reference_image_media_tokens") { + request.fields.insert("image_media_tokens".into(), images); + } + adapt_legacy_response(enqueue_image_to_gaussian_command(decode_native(request)?, app.clone(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state()).await) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/get_storyteller_creds_or_error.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/get_storyteller_creds_or_error.rs new file mode 100644 index 0000000000..3fdd114d5e --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/get_storyteller_creds_or_error.rs @@ -0,0 +1,27 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::GenerationAction; +use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use log::error; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use tauri::AppHandle; + +pub (super) fn get_storyteller_creds_or_error( + app: &AppHandle, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + match storyteller_creds_manager.get_credentials()? { + Some(creds) => Ok(creds), + None => { + error!("No Artcraft credentials are set. Can't perform action."); + let event = + GenerationEnqueueFailureEvent::no_artcraft_credentials(GenerationAction::GenerateVideo); + + event.send_infallible(app); + + Err(GenerateError::needs_storyteller_credentials()) + }, + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video.rs new file mode 100644 index 0000000000..cb526557bc --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video.rs @@ -0,0 +1,30 @@ +use crate::core::api_adapters::models::video::tauri_video_model_to_generation_model::tauri_video_model_to_generation_model; +use crate::core::api_adapters::models::video::tauri_video_model_to_router_model::tauri_video_model_to_router_model; +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_video::artcraft::get_storyteller_creds_or_error::get_storyteller_creds_or_error; +use crate::core::commands::generate::generate_video::artcraft::handle_artcraft_video_via_router::handle_artcraft_video_via_router; +use crate::core::commands::generate::generate_video::request::TauriGenerateVideoRequest; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use tauri::AppHandle; + +pub async fn handle_video_artcraft( + request: &TauriGenerateVideoRequest, + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let creds = get_storyteller_creds_or_error(app, storyteller_creds_manager)?; + + let model = match request.model { + None => return Err(GenerateError::no_model_specified()), + Some(model) => model, + }; + + let router_model = tauri_video_model_to_router_model(model); + let generation_model = tauri_video_model_to_generation_model(model); + + handle_artcraft_video_via_router(request, app_env_configs, &creds, router_model, generation_model).await +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs new file mode 100644 index 0000000000..78bb76d9d8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/handle_artcraft_video_via_router.rs @@ -0,0 +1,107 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::generate::generate_video::request::TauriGenerateVideoRequest; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_router::api::audio_list_ref::AudioListRef; +use artcraft_router::api::character_list_ref::CharacterListRef; +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::client::router_artcraft_client::RouterArtcraftClient; +use artcraft_router::client::router_client::RouterClient; +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_or_request::VideoGenerationDraftOrRequest; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; + +pub(super) async fn handle_artcraft_video_via_router( + request: &TauriGenerateVideoRequest, + app_env_configs: &AppEnvConfigs, + creds: &StorytellerCredentialSet, + model: RouterVideoModel, + generation_model: GenerationModel, +) -> Result { + let client = RouterClient::Artcraft(RouterArtcraftClient::new( + app_env_configs.storyteller_host.clone(), + creds.clone(), + )); + + let start_frame = request.image_media_token.clone().map(ImageRef::MediaFileToken); + let end_frame = request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken); + + let reference_images = request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens); + let reference_videos = request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens); + let reference_audio = request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens); + + let reference_character_tokens = request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens); + + let router_request = GenerateVideoRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + start_frame, + end_frame, + reference_images, + reference_videos, + reference_audio, + reference_character_tokens, + resolution: request.resolution, + aspect_ratio: request.aspect_ratio, + bitrate: None, + duration_seconds: request.duration_seconds, + video_batch_count: request.video_batch_count, + generate_audio: request.generate_audio, + total_reference_video_input_seconds: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + idempotency_token: None, + negative_prompt: None, + }; + + info!("Building request for artcraft_router (v2 pipeline)..."); + let response = generate_via_v2(router_request, &client).await?; + + let job_id = response.get_artcraft_payload() + .map(|p| p.inference_job_token.to_string()) + .ok_or(GenerateError::ResponseHadNoJobTokens)?; + + Ok(TaskEnqueueSuccess { + task_type: TaskType::VideoGeneration, + model: Some(generation_model), + provider: GenerationProvider::Artcraft, + provider_job_id: Some(job_id), + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +/// V2 pipeline: build2 → send_request (Artcraft skips draft phase). +async fn generate_via_v2( + router_request: GenerateVideoRequestBuilder, + client: &RouterClient, +) -> Result { + let draft_or_request = router_request.build2()?; + + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + VideoGenerationDraftOrRequest::Draft(_) => { + error!("Unexpected Draft variant for Artcraft provider"); + return Err(GenerateError::NotYetImplemented("Artcraft provider should not produce a draft request".to_string())); + } + }; + + let response = request.send_request(client).await.map_err(|err| { + error!("V2 failed to enqueue: {:?}", err); + GenerateError::from(err) + })?; + + info!("V2 successfully enqueued."); + Ok(response) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/mod.rs new file mode 100644 index 0000000000..5dd8e99770 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/artcraft/mod.rs @@ -0,0 +1,3 @@ +mod get_storyteller_creds_or_error; +mod handle_artcraft_video_via_router; +pub (super) mod handle_artcraft_video; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/generate_video_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/generate_video_command.rs new file mode 100644 index 0000000000..ebee2daad7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/generate_video_command.rs @@ -0,0 +1,227 @@ +use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult}; +use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native}; +use tauri::Manager; +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_video::artcraft::handle_artcraft_video::handle_video_artcraft; +use crate::core::commands::generate::generate_video::grok::handle_grok_video::handle_grok_video; +use crate::core::commands::generate::generate_video::request::{ + TauriGenerateVideoErrorType, TauriGenerateVideoRequest, TauriGenerateVideoResponse, + TauriVideoModel, +}; +use crate::core::commands::generate::generate_video::sora2::handle_sora_sora2::handle_sora_sora2; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::Response; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use log::{error, info, warn}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn generate_video_command(request: OmniRequest, app: AppHandle) -> OmniResult { + if request.uses_artcraft() { + return omni::generate(request, Modality::Video, &app).await; + } + adapt_legacy_response(generate_video_native( + decode_native(request)?, app.clone(), + app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), + ).await) +} + +async fn generate_video_native( + mut request: TauriGenerateVideoRequest, + app: AppHandle, + app_env_configs: State<'_, AppEnvConfigs>, + app_data_root: State<'_, AppDataRoot>, + artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>, + provider_priority_store: State<'_, ProviderPriorityStore>, + task_database: State<'_, TaskDatabase>, + grok_creds_manager: State<'_, GrokCredentialManager>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, + sora_task_queue: State<'_, SoraTaskQueue>, + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> Response { + + info!("generate_video_command called, request: {:?}", request); + + if request.image_media_token.is_none() + && request.start_frame_image_media_token.is_some() { + // NB: This is to fix the legacy handlers + request.image_media_token = request.start_frame_image_media_token.clone(); + } + + if request.image_media_token.is_some() + && request.start_frame_image_media_token.is_none() { + // NB: This is to fix the modern handlers + request.start_frame_image_media_token = request.image_media_token.clone(); + } + + let result = handle_request( + request, + &app, + &app_env_configs, + &app_data_root, + &artcraft_usage_tracker, + &provider_priority_store, + &task_database, + &grok_creds_manager, + &sora_creds_manager, + &storyteller_creds_manager, + ).await; + + match result { + Err(err) => { + error!("error: {:?}", err); + + notify_frontend_of_errors(&app, &err).await; + + let mut status = CommandErrorStatus::ServerError; + let mut error_type = TauriGenerateVideoErrorType::ServerError; + let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem."; + + match err { + GenerateError::BadInput(BadInputReason::NoModelSpecified) => { + status = CommandErrorStatus::BadRequest; + error_type = TauriGenerateVideoErrorType::ModelNotSpecified; + error_message = "No model specified for video generation"; + } + GenerateError::NoProviderAvailable => { + status = CommandErrorStatus::ServerError; + error_type = TauriGenerateVideoErrorType::NoProviderAvailable; + error_message = "No configured provider available for video generation"; + } + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => { + status = CommandErrorStatus::Unauthorized; + error_type = TauriGenerateVideoErrorType::NeedsFalApiKey; + error_message = "You need to set a FAL api key"; + }, + GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials) => { + status = CommandErrorStatus::Unauthorized; + error_type = TauriGenerateVideoErrorType::NeedsStorytellerCredentials; + error_message = "You need to be logged into Artcraft."; + } + _ => {}, // Fall-through + } + + Err(CommandErrorResponseWrapper { + status, + error_message: Some(error_message.to_string()), + error_type: Some(error_type), + error_details: None, + }) + } + Ok(event) => { + let event = GenerationEnqueueSuccessEvent { + action: event.to_frontend_event_action(), + service: event.to_frontend_event_service(), + model: event.model, + }; + + if let Err(err) = event.send(&app) { + error!("Failed to emit event: {:?}", err); + } + + CreditsBalanceChangedEvent{}.send_infallible(&app); + + Ok(TauriGenerateVideoResponse {}.into()) + } + } +} + +async fn handle_request( + request: TauriGenerateVideoRequest, + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + artcraft_usage_tracker: &ArtcraftUsageTracker, + provider_priority_store: &ProviderPriorityStore, + task_database: &TaskDatabase, + grok_creds_manager: &GrokCredentialManager, + sora_creds_manager: &SoraCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> Result { + + let model = match request.model { + Some(model) => model, + None => { + return Err(GenerateError::no_model_specified()) + } + }; + + let provider = match (model, request.provider) { + (TauriVideoModel::GrokVideo, _) => GenerationProvider::Grok, + // NB: Grok Imagine 1.5 is served via Artcraft (storyteller-web / artcraft_router) + // for now — force it regardless of the requested provider. In the future we + // may serve it natively via the Grok provider. + (TauriVideoModel::GrokImagineVideo1p5, _) => GenerationProvider::Artcraft, + _ => request.provider.unwrap_or(GenerationProvider::Artcraft), + }; + + info!("generate video with {:?} via provider {:?}", &model, &provider); + + let result = match provider { + GenerationProvider::Grok => { + handle_grok_video( + &request, + app, + app_data_root, + app_env_configs, + grok_creds_manager, + ).await + } + GenerationProvider::Sora => { + handle_sora_sora2( + &request, + app, + app_data_root, + app_env_configs, + sora_creds_manager, + ).await + } + _ => { + handle_video_artcraft( + &request, + app, + app_env_configs, + storyteller_creds_manager, + ).await + } + }; + + let success_event = match result { + Err(err) => return Err(err), + Ok(event) => event, + }; + + let result = success_event + .insert_into_task_database_with_frontend_payload( + task_database, + request.frontend_caller, + request.frontend_subscriber_id.as_deref(), + request.frontend_subscriber_payload.as_deref() + ) + .await; + + if let Err(err) = result { + error!("Failed to create task in database: {:?}", err); + } + + if let Err(err) = artcraft_usage_tracker.record_video_generation(1, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::VideoPage) { + warn!("Failed to report usage: {:?}", err); + } + + Ok(success_event) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/grok/handle_grok_video.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/grok/handle_grok_video.rs new file mode 100644 index 0000000000..871c487903 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/grok/handle_grok_video.rs @@ -0,0 +1,152 @@ +use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::EnqueueEditImageCommand; +use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand; +use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel, GrokAspectRatio, SoraOrientation}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::util::upload_image_media_file_to_grok::{upload_image_media_file_to_grok, UploadImageMediaFileToGrok}; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::utils::upload_images_to_sora::{upload_images_to_sora, UploadImagesToSoraArgs}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use grok_consumer_client::credentials::grok_cookies::GrokCookies; +use grok_consumer_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_consumer_client::datatypes::api::aspect_ratio::AspectRatio; +use grok_consumer_client::datatypes::file_upload_spec::FileUploadSpec; +use grok_consumer_client::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; +use grok_consumer_client::recipes::upload_image_and_generate_video::{upload_image_and_generate_video, UploadImageAndGenerateVideo}; +use grok_consumer_client::recipes::upload_image_and_generate_video_with_retry::{upload_image_and_generate_video_with_retry, UploadImageAndGenerateVideoWithRetry}; +use log::{error, info, warn}; +use openai_sora_client::recipes::generate_sora2_video::generate_sora2_video_with_session_auto_renew::generate_sora2_video_with_session_auto_renew; +use openai_sora_client::requests::generate_sora2_video::generate_sora2_video::{GenerateSora2VideoArgs, Orientation}; +use std::time::Duration; +use tauri::AppHandle; + +const GROK_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +pub async fn handle_grok_video( + request: &TauriGenerateVideoRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + grok_credential_manager: &GrokCredentialManager, +) -> Result { + + let creds = get_grok_creds(app, grok_credential_manager).await?; + + let image_token = match request.image_media_token.as_ref() { + Some(token) => token, + None => { + return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages { min: 1, max: 1, provided: 0 })) + } + }; + + info!("Downloading image media file..."); + + let local_image_file = download_media_file_to_temp_dir( + &app_env_configs, + &app_data_root, + image_token, + ).await?; + + let aspect_ratio = match request.grok_aspect_ratio { + None => AspectRatio::WideThreeByTwo, + Some(GrokAspectRatio::Landscape) => AspectRatio::WideThreeByTwo, + Some(GrokAspectRatio::Portrait) => AspectRatio::TallTwoByThree, + Some(GrokAspectRatio::Square) => AspectRatio::Square, + }; + + info!("Calling Grok Video generate..."); + + let upload_result = upload_image_and_generate_video_with_retry(UploadImageAndGenerateVideoWithRetry { + credentials: &creds, + file: FileUploadSpec::Path(local_image_file.path()), + prompt: request.prompt.as_deref(), + aspect_ratio: Some(aspect_ratio), + wait_for_generation: false, + individual_request_timeout: Some(GROK_IMAGE_UPLOAD_TIMEOUT), + mode: None, + }).await; + + let post_id = match upload_result { + Err(err) => { + error!("Failed to use Grok video: {:?}", err); + return Err(GenerateError::from(err)); + } + Ok(result) => { + if let Some(secrets) = result.maybe_new_client_secrets { + info!("New Grok secrets generated! Storing them in the credential manager..."); + grok_credential_manager.replace_client_secrets_only(secrets)?; + } + info!("Successfully enqueued Grok Video: post_id = {:?}", result.upload_result.post_id); + result.upload_result.post_id + } + }; + + // NB: Grok's consumer API doesn't take a version selector; this only affects + // which model is recorded for the task queue / events. + let generation_model = match request.model { + Some(TauriVideoModel::GrokImagineVideo1p5) => GenerationModel::GrokImagineVideo1p5, + _ => GenerationModel::GrokVideo, + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Grok, + model: Some(generation_model), + provider_job_id: Some(post_id.to_string()), + task_type: TaskType::VideoGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} + +async fn get_grok_creds(app: &AppHandle, grok_credential_manager: &GrokCredentialManager) -> Result { + if let Some(creds) = grok_credential_manager.maybe_copy_full_credentials()? { + return Ok(creds); + } + + let cookies = match grok_credential_manager.maybe_copy_cookie_store()? { + Some(cookies) => cookies.to_cookie_string(), + None => { + warn!("No Grok Cookie stored. Must login."); + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Grok, &app); + return Err(GenerateError::needs_grok_credentials()) + } + }; + + let cookies = GrokCookies::new(cookies); + + info!("Requesting Grok client secrets..."); + + let upgraded = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await; + + match upgraded { + Err(err) => { + error!("Failed to fetch Grok client secrets: {}", err); // NB: Fall-through + } + Ok(secrets) => { + info!("Grok client secrets successfully upgraded..."); + let full_creds = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets); + grok_credential_manager.replace_full_credentials(full_creds.clone())?; + info!("Persisting grok credentials to disk..."); + grok_credential_manager.persist_to_disk()?; + return Ok(full_creds) + } + } + + warn!("Grok upgrade failed. Try logging in again..."); + + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Grok, &app); + Err(GenerateError::needs_grok_credentials()) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/grok/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/grok/mod.rs new file mode 100644 index 0000000000..045dcc9439 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/grok/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_grok_video; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/mod.rs new file mode 100644 index 0000000000..f7a6a5a7f9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/mod.rs @@ -0,0 +1,5 @@ +pub mod generate_video_command; +pub mod request; +mod artcraft; +mod grok; +pub mod sora2; diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/request.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/request.rs new file mode 100644 index 0000000000..4640bf4530 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/request.rs @@ -0,0 +1,152 @@ +use artcraft_router::api::router_aspect_ratio::RouterAspectRatio; +use artcraft_router::api::router_resolution::RouterResolution; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::response::success_response_wrapper::SerializeMarker; + +/// This is used in the Tauri command bridge. +/// Don't change the serializations without coordinating with the frontend. +/// +/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni +/// identifiers (`CommonVideoModel` serde strings). Variants whose legacy Tauri +/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize. +/// Once the frontend is 100% omni ids, the legacy renames can be dropped. +#[derive(Deserialize, Debug, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TauriVideoModel { + #[serde(rename = "grok_video", alias = "grok_imagine_video")] + GrokVideo, + + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + + #[serde(rename = "kling_1.6_pro", alias = "kling_1p6_pro")] + Kling16Pro, + + #[serde(rename = "kling_2.1_pro", alias = "kling_2p1_pro")] + Kling21Pro, + + #[serde(rename = "kling_2.1_master", alias = "kling_2p1_master")] + Kling21Master, + + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + + #[serde(rename = "seedance_1.0_lite", alias = "seedance_1p0_lite")] + Seedance10Lite, + + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + + #[serde(rename = "seedance_2p0")] + Seedance2p0, + + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + + #[serde(rename = "sora_2")] + Sora2, + + #[serde(rename = "sora_2_pro")] + Sora2Pro, + + #[serde(rename = "veo_2")] + Veo2, + + #[serde(rename = "veo_3")] + Veo3, + + #[serde(rename = "veo_3_fast")] + Veo3Fast, + + #[serde(rename = "veo_3p1")] + Veo3p1, + + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct TauriGenerateVideoRequest { + pub provider: Option, + pub model: Option, + + pub prompt: Option, + pub negative_prompt: Option, + + #[deprecated(note = "Use start_frame_image_media_token instead")] + pub image_media_token: Option, + + pub start_frame_image_media_token: Option, + pub end_frame_image_media_token: Option, + + pub reference_image_media_tokens: Option>, + pub reference_video_media_tokens: Option>, + pub reference_audio_media_tokens: Option>, + pub reference_character_tokens: Option>, + + pub aspect_ratio: Option, + pub resolution: Option, + + pub duration_seconds: Option, + pub generate_audio: Option, + pub video_batch_count: Option, + + #[deprecated(note = "Use start_frame_image_media_token instead")] + pub sora_orientation: Option, + + #[deprecated(note = "Use start_frame_image_media_token instead")] + pub grok_aspect_ratio: Option, + + pub frontend_caller: Option, + pub frontend_subscriber_id: Option, + pub frontend_subscriber_payload: Option, +} + +#[derive(Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum SoraOrientation { + Portrait, + Landscape, +} + +#[derive(Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum GrokAspectRatio { + Portrait, + Landscape, + Square, +} + +#[derive(Serialize)] +pub struct TauriGenerateVideoResponse { +} + +impl SerializeMarker for TauriGenerateVideoResponse {} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum TauriGenerateVideoErrorType { + ModelNotSpecified, + NoProviderAvailable, + ServerError, + NeedsFalApiKey, + FalError, + NeedsStorytellerCredentials, +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/sora2/handle_sora_sora2.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/sora2/handle_sora_sora2.rs new file mode 100644 index 0000000000..48321dd2fa --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/sora2/handle_sora_sora2.rs @@ -0,0 +1,106 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, SoraOrientation}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::utils::upload_images_to_sora::{upload_images_to_sora, UploadImagesToSoraArgs, UploadImagesToSoraResult}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; +use openai_sora_client::recipes::generate_sora2_video::generate_sora2_video_with_session_auto_renew::generate_sora2_video_with_session_auto_renew; +use openai_sora_client::recipes::list_sora2_drafts::list_sora2_drafts_with_session_auto_renew::list_sora2_drafts_with_session_auto_renew; +use openai_sora_client::requests::generate_sora2_video::generate_sora2_video::{GenerateSora2VideoArgs, Orientation}; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")] +pub async fn handle_sora_sora2( + request: &TauriGenerateVideoRequest, + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + sora_creds_manager: &SoraCredentialManager, +) -> Result { + + let mut sora_creds = match sora_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Sora, &app); + return Err(GenerateError::needs_sora_credentials()); + }, + }; + + // TODO: Handle multiple input images. + let image_media_tokens = match &request.image_media_token { + Some(token) => vec![token.clone()], + None => vec![], + }; + + let mut image_reference_media_ids = None; + + if !image_media_tokens.is_empty() { + let result = upload_images_to_sora(UploadImagesToSoraArgs { + storyteller_host: &app_env_configs.storyteller_host, + image_media_tokens: &image_media_tokens, + app_data_root, + sora_creds_manager, + }).await?; + + if let Some(creds) = result.maybe_new_sora_credentials { + sora_creds = creds; + } + + image_reference_media_ids = Some(result.sora_media_tokens); + } + + let orientation = match request.sora_orientation { + Some(SoraOrientation::Landscape) => Orientation::Landscape, + Some(SoraOrientation::Portrait) => Orientation::Portrait, + None => Orientation::Landscape, + }; + + info!("Calling Sora 2 generate..."); + + let result = generate_sora2_video_with_session_auto_renew( + GenerateSora2VideoArgs { + prompt: request.prompt.as_deref().unwrap_or(""), + credentials: &sora_creds, + request_timeout: None, + orientation, + image_reference_media_ids: image_reference_media_ids.as_ref(), + } + ).await; + + let job_id = match result { + Ok((response, maybe_new_session)) => { + info!("Successfully enqueued Sora2 Video: {}", response.task_id); + + if let Some(new_creds) = maybe_new_session { + if let Err(err) = sora_creds_manager.set_credentials(&new_creds) { + error!("Failed to save renewed Sora credentials: {:?}", err); + } + } + + response.task_id + } + Err(err) => { + error!("Failed to use Sora2 video: {:?}", err); + return Err(GenerateError::from(err)); + } + }; + + Ok(TaskEnqueueSuccess { + provider: GenerationProvider::Sora, + model: Some(GenerationModel::Sora2), + provider_job_id: Some(job_id.to_string()), + task_type: TaskType::VideoGeneration, + maybe_queue_status_url: None, + maybe_prompt_token: None, + maybe_queue_response_url: None, + }) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_video/sora2/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_video/sora2/mod.rs new file mode 100644 index 0000000000..8a58d2038c --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_video/sora2/mod.rs @@ -0,0 +1 @@ +pub (super) mod handle_sora_sora2; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/generate/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/mod.rs new file mode 100644 index 0000000000..b7caf159b3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/mod.rs @@ -0,0 +1,9 @@ +pub mod generate_image; +pub mod generate_video; +pub mod common; +pub mod models; +pub mod omni; + +pub mod generate_mesh_command; +pub mod generate_splat_command; +pub mod generate_audio_command; diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/image/list_image_models_command.rs b/crates/desktop/artcraft/src/core/commands/generate/models/image/list_image_models_command.rs new file mode 100644 index 0000000000..214c8ffcb1 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/image/list_image_models_command.rs @@ -0,0 +1,98 @@ +//! Tauri command: list available image models. +//! +//! Maps the API-client response into command-specific types (see `response_types`), +//! retries the request up to `MAX_ATTEMPTS` times, and caches successful responses +//! in-memory for `CACHE_TTL`. + +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +use log::{debug, warn}; +use once_cell::sync::Lazy; +use tauri::State; + +use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{omni_gen_list_image_models, OmniGenListImageModelsArgs, OmniGenImageModelsResponse}; +use artcraft_client::utils::api_host::ApiHost; + +use crate::core::commands::generate::models::image::response_types::ListImageModelsResponse; +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +const MAX_ATTEMPTS: u32 = 3; +const RETRY_BACKOFF: Duration = Duration::from_millis(250); +const CACHE_TTL: Duration = Duration::from_secs(60); + +struct CacheEntry { + response: ListImageModelsResponse, + loaded_at: Instant, +} + +static CACHE: Lazy>> = Lazy::new(|| RwLock::new(None)); + + +#[tauri::command] +pub async fn list_image_models_command( + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrErrorMessage { + if let Some(cached) = cached_response() { + debug!("list_image_models_command: serving cached response"); + return Ok(cached.into()); + } + + match fetch_with_retry(&app_env_configs.storyteller_host).await { + Ok(api_response) => { + let response: ListImageModelsResponse = api_response.into(); + store_response(response.clone()); + Ok(response.into()) + } + Err(error_message) => { + warn!("list_image_models_command failed after {} attempts: {}", MAX_ATTEMPTS, error_message); + if let Some(stale) = any_cached_response() { + warn!("list_image_models_command: refresh failed; serving stale cached response"); + return Ok(stale.into()); + } + Err(error_message.into()) + } + } +} + +fn cached_response() -> Option { + let guard = CACHE.read().ok()?; + let entry = guard.as_ref()?; + if entry.loaded_at.elapsed() < CACHE_TTL { + Some(entry.response.clone()) + } else { + None + } +} + +/// The cached response regardless of age. Used as a fallback so that a failed +/// refresh still returns the last-known-good data instead of an error. +fn any_cached_response() -> Option { + let guard = CACHE.read().ok()?; + guard.as_ref().map(|entry| entry.response.clone()) +} + +fn store_response(response: ListImageModelsResponse) { + if let Ok(mut guard) = CACHE.write() { + *guard = Some(CacheEntry { response, loaded_at: Instant::now() }); + } +} + +/// Fetch the models list, retrying up to `MAX_ATTEMPTS` times on failure. +async fn fetch_with_retry(api_host: &ApiHost) -> Result { + let mut last_error = "unknown error".to_string(); + for attempt in 1..=MAX_ATTEMPTS { + match omni_gen_list_image_models(OmniGenListImageModelsArgs { api_host, maybe_creds: None, provider: None }).await { + Ok(response) => return Ok(response), + Err(err) => { + last_error = err.to_string(); + warn!("list_image_models attempt {}/{} failed: {}", attempt, MAX_ATTEMPTS, last_error); + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(RETRY_BACKOFF).await; + } + } + } + } + Err(last_error) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/image/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/models/image/mod.rs new file mode 100644 index 0000000000..fe780e0bea --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/image/mod.rs @@ -0,0 +1,2 @@ +pub mod list_image_models_command; +pub mod response_types; diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs b/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs new file mode 100644 index 0000000000..dfd7ac7321 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs @@ -0,0 +1,554 @@ +//! Command-specific response types for `list_image_models_command`. +//! +//! These are duplicated (never `artcraft_client` types), and every enum has an +//! `Unknown(String)` catch-all so newer server variants are preserved instead of +//! breaking an older desktop build. `From` impls map the API-client response over. + +use serde_derive::Serialize; + +use artcraft_client::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider; +use artcraft_client::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator; +use artcraft_client::datatypes::common_enums::generation::api_client_image_model::ApiClientImageModel; +use artcraft_client::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio; +use artcraft_client::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution; +use artcraft_client::datatypes::common_enums::generation::api_client_quality::ApiClientQuality; +use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{ + OmniGenImageModelDetails, OmniGenImageModelProviderDetails, OmniGenImageProviderModelDetails, OmniGenImageModelsResponse, +}; + +use crate::core::commands::response::success_response_wrapper::SerializeMarker; + +impl SerializeMarker for ListImageModelsResponse {} + +// ============================ Enums ============================ + +/// Command-specific copy of `ApiClientGenerationProvider` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListImageModelsProvider { + Artcraft, + Fal, + Grok, + Midjourney, + Sora, + WorldLabs, + Higgsfield, + Krea, + Leonardo, + Magnific, + Openart, + Picsart, + Pixverse, + Runway, + Unknown(String), +} + +impl From for ListImageModelsProvider { + fn from(value: ApiClientGenerationProvider) -> Self { + match value { + ApiClientGenerationProvider::Artcraft => Self::Artcraft, + ApiClientGenerationProvider::Fal => Self::Fal, + ApiClientGenerationProvider::Grok => Self::Grok, + ApiClientGenerationProvider::Midjourney => Self::Midjourney, + ApiClientGenerationProvider::Sora => Self::Sora, + ApiClientGenerationProvider::WorldLabs => Self::WorldLabs, + ApiClientGenerationProvider::Higgsfield => Self::Higgsfield, + ApiClientGenerationProvider::Krea => Self::Krea, + ApiClientGenerationProvider::Leonardo => Self::Leonardo, + ApiClientGenerationProvider::Magnific => Self::Magnific, + ApiClientGenerationProvider::Openart => Self::Openart, + ApiClientGenerationProvider::Picsart => Self::Picsart, + ApiClientGenerationProvider::Pixverse => Self::Pixverse, + ApiClientGenerationProvider::Runway => Self::Runway, + ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListImageModelsProvider) -> Self { + match value { + ListImageModelsProvider::Artcraft => "artcraft".to_string(), + ListImageModelsProvider::Fal => "fal".to_string(), + ListImageModelsProvider::Grok => "grok".to_string(), + ListImageModelsProvider::Midjourney => "midjourney".to_string(), + ListImageModelsProvider::Sora => "sora".to_string(), + ListImageModelsProvider::WorldLabs => "world_labs".to_string(), + ListImageModelsProvider::Higgsfield => "higgsfield".to_string(), + ListImageModelsProvider::Krea => "krea".to_string(), + ListImageModelsProvider::Leonardo => "leonardo".to_string(), + ListImageModelsProvider::Magnific => "magnific".to_string(), + ListImageModelsProvider::Openart => "openart".to_string(), + ListImageModelsProvider::Picsart => "picsart".to_string(), + ListImageModelsProvider::Pixverse => "pixverse".to_string(), + ListImageModelsProvider::Runway => "runway".to_string(), + ListImageModelsProvider::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientModelCreator` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListImageModelsModelCreator { + Alibaba, + ArtCraft, + BlackForestLabs, + Bytedance, + Fal, + Google, + Grok, + Hailuo, + Higgsfield, + Kling, + Krea, + Midjourney, + OpenAi, + OpenArt, + Recraft, + Replicate, + Runway, + Stability, + Tencent, + TensorArt, + Vidu, + WorldLabs, + Unknown(String), +} + +impl From for ListImageModelsModelCreator { + fn from(value: ApiClientModelCreator) -> Self { + match value { + ApiClientModelCreator::Alibaba => Self::Alibaba, + ApiClientModelCreator::ArtCraft => Self::ArtCraft, + ApiClientModelCreator::BlackForestLabs => Self::BlackForestLabs, + ApiClientModelCreator::Bytedance => Self::Bytedance, + ApiClientModelCreator::Fal => Self::Fal, + ApiClientModelCreator::Google => Self::Google, + ApiClientModelCreator::Grok => Self::Grok, + ApiClientModelCreator::Hailuo => Self::Hailuo, + ApiClientModelCreator::Higgsfield => Self::Higgsfield, + ApiClientModelCreator::Kling => Self::Kling, + ApiClientModelCreator::Krea => Self::Krea, + ApiClientModelCreator::Midjourney => Self::Midjourney, + ApiClientModelCreator::OpenAi => Self::OpenAi, + ApiClientModelCreator::OpenArt => Self::OpenArt, + ApiClientModelCreator::Recraft => Self::Recraft, + ApiClientModelCreator::Replicate => Self::Replicate, + ApiClientModelCreator::Runway => Self::Runway, + ApiClientModelCreator::Stability => Self::Stability, + ApiClientModelCreator::Tencent => Self::Tencent, + ApiClientModelCreator::TensorArt => Self::TensorArt, + ApiClientModelCreator::Vidu => Self::Vidu, + ApiClientModelCreator::WorldLabs => Self::WorldLabs, + ApiClientModelCreator::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListImageModelsModelCreator) -> Self { + match value { + ListImageModelsModelCreator::Alibaba => "alibaba".to_string(), + ListImageModelsModelCreator::ArtCraft => "artcraft".to_string(), + ListImageModelsModelCreator::BlackForestLabs => "black_forest_labs".to_string(), + ListImageModelsModelCreator::Bytedance => "bytedance".to_string(), + ListImageModelsModelCreator::Fal => "fal".to_string(), + ListImageModelsModelCreator::Google => "google".to_string(), + ListImageModelsModelCreator::Grok => "grok".to_string(), + ListImageModelsModelCreator::Hailuo => "hailuo".to_string(), + ListImageModelsModelCreator::Higgsfield => "higgsfield".to_string(), + ListImageModelsModelCreator::Kling => "kling".to_string(), + ListImageModelsModelCreator::Krea => "krea".to_string(), + ListImageModelsModelCreator::Midjourney => "midjourney".to_string(), + ListImageModelsModelCreator::OpenAi => "open_ai".to_string(), + ListImageModelsModelCreator::OpenArt => "open_art".to_string(), + ListImageModelsModelCreator::Recraft => "recraft".to_string(), + ListImageModelsModelCreator::Replicate => "replicate".to_string(), + ListImageModelsModelCreator::Runway => "runway".to_string(), + ListImageModelsModelCreator::Stability => "stability".to_string(), + ListImageModelsModelCreator::Tencent => "tencent".to_string(), + ListImageModelsModelCreator::TensorArt => "tensor_art".to_string(), + ListImageModelsModelCreator::Vidu => "vidu".to_string(), + ListImageModelsModelCreator::WorldLabs => "world_labs".to_string(), + ListImageModelsModelCreator::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientImageModel` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListImageModelsImageModel { + Flux1Dev, + Flux1Schnell, + FluxPro11, + FluxPro11Ultra, + GptImage1, + GptImage1p5, + GptImage2, + GrokImagineImage, + GptImage2p5Flare, + GptImage2p5Sunburst, + GrokImagineImageQuality, + Midjourney7, + Midjourney7Niji, + Midjourney8, + NanoBanana, + NanoBanana2, + NanoBananaPro, + Seedream4, + Seedream4p5, + Seedream5Lite, + QwenEdit2511Angles, + Flux2LoraAngles, + Unknown(String), +} + +impl From for ListImageModelsImageModel { + fn from(value: ApiClientImageModel) -> Self { + match value { + ApiClientImageModel::Flux1Dev => Self::Flux1Dev, + ApiClientImageModel::Flux1Schnell => Self::Flux1Schnell, + ApiClientImageModel::FluxPro11 => Self::FluxPro11, + ApiClientImageModel::FluxPro11Ultra => Self::FluxPro11Ultra, + ApiClientImageModel::GptImage1 => Self::GptImage1, + ApiClientImageModel::GptImage1p5 => Self::GptImage1p5, + ApiClientImageModel::GptImage2 => Self::GptImage2, + ApiClientImageModel::GptImage2p5Flare => Self::GptImage2p5Flare, + ApiClientImageModel::GptImage2p5Sunburst => Self::GptImage2p5Sunburst, + ApiClientImageModel::GrokImagineImage => Self::GrokImagineImage, + ApiClientImageModel::GrokImagineImageQuality => Self::GrokImagineImageQuality, + ApiClientImageModel::Midjourney7 => Self::Midjourney7, + ApiClientImageModel::Midjourney7Niji => Self::Midjourney7Niji, + ApiClientImageModel::Midjourney8 => Self::Midjourney8, + ApiClientImageModel::NanoBanana => Self::NanoBanana, + ApiClientImageModel::NanoBanana2 => Self::NanoBanana2, + ApiClientImageModel::NanoBananaPro => Self::NanoBananaPro, + ApiClientImageModel::Seedream4 => Self::Seedream4, + ApiClientImageModel::Seedream4p5 => Self::Seedream4p5, + ApiClientImageModel::Seedream5Lite => Self::Seedream5Lite, + ApiClientImageModel::QwenEdit2511Angles => Self::QwenEdit2511Angles, + ApiClientImageModel::Flux2LoraAngles => Self::Flux2LoraAngles, + ApiClientImageModel::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListImageModelsImageModel) -> Self { + match value { + ListImageModelsImageModel::Flux1Dev => "flux_1_dev".to_string(), + ListImageModelsImageModel::Flux1Schnell => "flux_1_schnell".to_string(), + ListImageModelsImageModel::FluxPro11 => "flux_pro_1p1".to_string(), + ListImageModelsImageModel::FluxPro11Ultra => "flux_pro_1p1_ultra".to_string(), + ListImageModelsImageModel::GptImage1 => "gpt_image_1".to_string(), + ListImageModelsImageModel::GptImage1p5 => "gpt_image_1p5".to_string(), + ListImageModelsImageModel::GptImage2 => "gpt_image_2".to_string(), + ListImageModelsImageModel::GptImage2p5Flare => "gpt_image_2p5_flare".to_string(), + ListImageModelsImageModel::GptImage2p5Sunburst => "gpt_image_2p5_sunburst".to_string(), + ListImageModelsImageModel::GrokImagineImage => "grok_imagine_image".to_string(), + ListImageModelsImageModel::GrokImagineImageQuality => "grok_imagine_image_q".to_string(), + ListImageModelsImageModel::Midjourney7 => "midjourney_7".to_string(), + ListImageModelsImageModel::Midjourney7Niji => "midjourney_7_niji".to_string(), + ListImageModelsImageModel::Midjourney8 => "midjourney_8".to_string(), + ListImageModelsImageModel::NanoBanana => "nano_banana".to_string(), + ListImageModelsImageModel::NanoBanana2 => "nano_banana_2".to_string(), + ListImageModelsImageModel::NanoBananaPro => "nano_banana_pro".to_string(), + ListImageModelsImageModel::Seedream4 => "seedream_4".to_string(), + ListImageModelsImageModel::Seedream4p5 => "seedream_4p5".to_string(), + ListImageModelsImageModel::Seedream5Lite => "seedream_5_lite".to_string(), + ListImageModelsImageModel::QwenEdit2511Angles => "qwen_edit_2511_angles".to_string(), + ListImageModelsImageModel::Flux2LoraAngles => "flux_2_lora_angles".to_string(), + ListImageModelsImageModel::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientAspectRatio` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListImageModelsAspectRatio { + Auto, + Square, + WideThreeByTwo, + WideFourByThree, + WideFiveByFour, + WideSixteenByNine, + WideTwentyOneByNine, + TallTwoByThree, + TallThreeByFour, + TallFourByFive, + TallNineBySixteen, + TallNineByTwentyOne, + Wide, + Tall, + Auto2k, + Auto3k, + Auto4k, + SquareHd, + Unknown(String), +} + +impl From for ListImageModelsAspectRatio { + fn from(value: ApiClientAspectRatio) -> Self { + match value { + ApiClientAspectRatio::Auto => Self::Auto, + ApiClientAspectRatio::Square => Self::Square, + ApiClientAspectRatio::WideThreeByTwo => Self::WideThreeByTwo, + ApiClientAspectRatio::WideFourByThree => Self::WideFourByThree, + ApiClientAspectRatio::WideFiveByFour => Self::WideFiveByFour, + ApiClientAspectRatio::WideSixteenByNine => Self::WideSixteenByNine, + ApiClientAspectRatio::WideTwentyOneByNine => Self::WideTwentyOneByNine, + ApiClientAspectRatio::TallTwoByThree => Self::TallTwoByThree, + ApiClientAspectRatio::TallThreeByFour => Self::TallThreeByFour, + ApiClientAspectRatio::TallFourByFive => Self::TallFourByFive, + ApiClientAspectRatio::TallNineBySixteen => Self::TallNineBySixteen, + ApiClientAspectRatio::TallNineByTwentyOne => Self::TallNineByTwentyOne, + ApiClientAspectRatio::Wide => Self::Wide, + ApiClientAspectRatio::Tall => Self::Tall, + ApiClientAspectRatio::Auto2k => Self::Auto2k, + ApiClientAspectRatio::Auto3k => Self::Auto3k, + ApiClientAspectRatio::Auto4k => Self::Auto4k, + ApiClientAspectRatio::SquareHd => Self::SquareHd, + ApiClientAspectRatio::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListImageModelsAspectRatio) -> Self { + match value { + ListImageModelsAspectRatio::Auto => "auto".to_string(), + ListImageModelsAspectRatio::Square => "square".to_string(), + ListImageModelsAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(), + ListImageModelsAspectRatio::WideFourByThree => "wide_four_by_three".to_string(), + ListImageModelsAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(), + ListImageModelsAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(), + ListImageModelsAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(), + ListImageModelsAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(), + ListImageModelsAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(), + ListImageModelsAspectRatio::TallFourByFive => "tall_four_by_five".to_string(), + ListImageModelsAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(), + ListImageModelsAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(), + ListImageModelsAspectRatio::Wide => "wide".to_string(), + ListImageModelsAspectRatio::Tall => "tall".to_string(), + ListImageModelsAspectRatio::Auto2k => "auto_2k".to_string(), + ListImageModelsAspectRatio::Auto3k => "auto_3k".to_string(), + ListImageModelsAspectRatio::Auto4k => "auto_4k".to_string(), + ListImageModelsAspectRatio::SquareHd => "square_hd".to_string(), + ListImageModelsAspectRatio::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientResolution` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListImageModelsResolution { + OneK, + TwoK, + ThreeK, + FourK, + HalfK, + FourEightyP, + SevenTwentyP, + TenEightyP, + Unknown(String), +} + +impl From for ListImageModelsResolution { + fn from(value: ApiClientResolution) -> Self { + match value { + ApiClientResolution::OneK => Self::OneK, + ApiClientResolution::TwoK => Self::TwoK, + ApiClientResolution::ThreeK => Self::ThreeK, + ApiClientResolution::FourK => Self::FourK, + ApiClientResolution::HalfK => Self::HalfK, + ApiClientResolution::FourEightyP => Self::FourEightyP, + ApiClientResolution::SevenTwentyP => Self::SevenTwentyP, + ApiClientResolution::TenEightyP => Self::TenEightyP, + ApiClientResolution::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListImageModelsResolution) -> Self { + match value { + ListImageModelsResolution::OneK => "one_k".to_string(), + ListImageModelsResolution::TwoK => "two_k".to_string(), + ListImageModelsResolution::ThreeK => "three_k".to_string(), + ListImageModelsResolution::FourK => "four_k".to_string(), + ListImageModelsResolution::HalfK => "half_k".to_string(), + ListImageModelsResolution::FourEightyP => "four_eighty_p".to_string(), + ListImageModelsResolution::SevenTwentyP => "seven_twenty_p".to_string(), + ListImageModelsResolution::TenEightyP => "ten_eighty_p".to_string(), + ListImageModelsResolution::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientQuality` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListImageModelsQuality { + High, + Medium, + Low, + Unknown(String), +} + +impl From for ListImageModelsQuality { + fn from(value: ApiClientQuality) -> Self { + match value { + ApiClientQuality::High => Self::High, + ApiClientQuality::Medium => Self::Medium, + ApiClientQuality::Low => Self::Low, + ApiClientQuality::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListImageModelsQuality) -> Self { + match value { + ListImageModelsQuality::High => "high".to_string(), + ListImageModelsQuality::Medium => "medium".to_string(), + ListImageModelsQuality::Low => "low".to_string(), + ListImageModelsQuality::Unknown(other) => other, + } + } +} + +// ============================ Structs ============================ + +#[derive(Clone, Debug, Serialize)] +pub struct ListImageModelsResponse { + pub success: bool, + pub models: Vec, + pub providers: Vec, +} + +impl From for ListImageModelsResponse { + fn from(v: OmniGenImageModelsResponse) -> Self { + Self { + success: v.success, + models: v.models.into_iter().map(Into::into).collect(), + providers: v.providers.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ListImageModelsProviderDetails { + pub provider: ListImageModelsProvider, + pub models: Vec, +} + +impl From for ListImageModelsProviderDetails { + fn from(v: OmniGenImageModelProviderDetails) -> Self { + Self { + provider: v.provider.into(), + models: v.models.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ListImageModelsProviderModelDetails { + pub model: ListImageModelsImageModel, + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +impl From for ListImageModelsProviderModelDetails { + fn from(v: OmniGenImageProviderModelDetails) -> Self { + Self { + model: v.model.into(), + overrides: v.overrides.map(Into::into), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ListImageModelsModelDetails { + pub model: ListImageModelsImageModel, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_refs_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_refs_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_fixed_editing_aspect_ratio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_default_when_editing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl From for ListImageModelsModelDetails { + fn from(v: OmniGenImageModelDetails) -> Self { + Self { + model: v.model.into(), + model_creator: v.model_creator.map(Into::into), + full_name: v.full_name, + text_prompt_supported: v.text_prompt_supported, + text_prompt_max_length: v.text_prompt_max_length, + negative_text_prompt_supported: v.negative_text_prompt_supported, + negative_text_prompt_max_length: v.negative_text_prompt_max_length, + image_refs_supported: v.image_refs_supported, + image_refs_max: v.image_refs_max, + has_fixed_editing_aspect_ratio: v.has_fixed_editing_aspect_ratio, + aspect_ratio_options: v.aspect_ratio_options.map(|items| items.into_iter().map(Into::into).collect()), + aspect_ratio_default: v.aspect_ratio_default.map(Into::into), + aspect_ratio_default_when_editing: v.aspect_ratio_default_when_editing.map(Into::into), + resolution_options: v.resolution_options.map(|items| items.into_iter().map(Into::into).collect()), + resolution_default: v.resolution_default.map(Into::into), + quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()), + default_quality: v.default_quality.map(Into::into), + batch_size_min: v.batch_size_min, + batch_size_max: v.batch_size_max, + batch_size_options: v.batch_size_options, + batch_size_default: v.batch_size_default, + is_disabled: v.is_disabled, + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/models/mod.rs new file mode 100644 index 0000000000..c10a6ca15e --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/mod.rs @@ -0,0 +1,2 @@ +pub mod image; +pub mod video; diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/video/list_video_models_command.rs b/crates/desktop/artcraft/src/core/commands/generate/models/video/list_video_models_command.rs new file mode 100644 index 0000000000..79dfde8cda --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/video/list_video_models_command.rs @@ -0,0 +1,98 @@ +//! Tauri command: list available video models. +//! +//! Maps the API-client response into command-specific types (see `response_types`), +//! retries the request up to `MAX_ATTEMPTS` times, and caches successful responses +//! in-memory for `CACHE_TTL`. + +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +use log::{debug, warn}; +use once_cell::sync::Lazy; +use tauri::State; + +use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{omni_gen_list_video_models, OmniGenListVideoModelsArgs, OmniGenVideoModelsResponse}; +use artcraft_client::utils::api_host::ApiHost; + +use crate::core::commands::generate::models::video::response_types::ListVideoModelsResponse; +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; + +const MAX_ATTEMPTS: u32 = 3; +const RETRY_BACKOFF: Duration = Duration::from_millis(250); +const CACHE_TTL: Duration = Duration::from_secs(60); + +struct CacheEntry { + response: ListVideoModelsResponse, + loaded_at: Instant, +} + +static CACHE: Lazy>> = Lazy::new(|| RwLock::new(None)); + + +#[tauri::command] +pub async fn list_video_models_command( + app_env_configs: State<'_, AppEnvConfigs>, +) -> ResponseOrErrorMessage { + if let Some(cached) = cached_response() { + debug!("list_video_models_command: serving cached response"); + return Ok(cached.into()); + } + + match fetch_with_retry(&app_env_configs.storyteller_host).await { + Ok(api_response) => { + let response: ListVideoModelsResponse = api_response.into(); + store_response(response.clone()); + Ok(response.into()) + } + Err(error_message) => { + warn!("list_video_models_command failed after {} attempts: {}", MAX_ATTEMPTS, error_message); + if let Some(stale) = any_cached_response() { + warn!("list_video_models_command: refresh failed; serving stale cached response"); + return Ok(stale.into()); + } + Err(error_message.into()) + } + } +} + +fn cached_response() -> Option { + let guard = CACHE.read().ok()?; + let entry = guard.as_ref()?; + if entry.loaded_at.elapsed() < CACHE_TTL { + Some(entry.response.clone()) + } else { + None + } +} + +/// The cached response regardless of age. Used as a fallback so that a failed +/// refresh still returns the last-known-good data instead of an error. +fn any_cached_response() -> Option { + let guard = CACHE.read().ok()?; + guard.as_ref().map(|entry| entry.response.clone()) +} + +fn store_response(response: ListVideoModelsResponse) { + if let Ok(mut guard) = CACHE.write() { + *guard = Some(CacheEntry { response, loaded_at: Instant::now() }); + } +} + +/// Fetch the models list, retrying up to `MAX_ATTEMPTS` times on failure. +async fn fetch_with_retry(api_host: &ApiHost) -> Result { + let mut last_error = "unknown error".to_string(); + for attempt in 1..=MAX_ATTEMPTS { + match omni_gen_list_video_models(OmniGenListVideoModelsArgs { api_host, maybe_creds: None, provider: None }).await { + Ok(response) => return Ok(response), + Err(err) => { + last_error = err.to_string(); + warn!("list_video_models attempt {}/{} failed: {}", attempt, MAX_ATTEMPTS, last_error); + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(RETRY_BACKOFF).await; + } + } + } + } + Err(last_error) +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/video/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/models/video/mod.rs new file mode 100644 index 0000000000..e7de36c168 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/video/mod.rs @@ -0,0 +1,2 @@ +pub mod list_video_models_command; +pub mod response_types; diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs b/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs new file mode 100644 index 0000000000..755c5e60eb --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs @@ -0,0 +1,672 @@ +//! Command-specific response types for `list_video_models_command`. +//! +//! These are duplicated (never `artcraft_client` types), and every enum has an +//! `Unknown(String)` catch-all so newer server variants are preserved instead of +//! breaking an older desktop build. `From` impls map the API-client response over. + +use serde_derive::Serialize; + +use artcraft_client::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider; +use artcraft_client::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator; +use artcraft_client::datatypes::common_enums::generation::api_client_video_model::ApiClientVideoModel; +use artcraft_client::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio; +use artcraft_client::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution; +use artcraft_client::datatypes::common_enums::generation::api_client_bitrate::ApiClientBitrate; +use artcraft_client::datatypes::common_enums::generation::api_client_quality::ApiClientQuality; +use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{ + OmniGenVideoModelDetails, OmniGenVideoModelProviderDetails, OmniGenVideoProviderModelDetails, OmniGenVideoModelsResponse, +}; + +use crate::core::commands::response::success_response_wrapper::SerializeMarker; + +impl SerializeMarker for ListVideoModelsResponse {} + +// ============================ Enums ============================ + +/// Command-specific copy of `ApiClientGenerationProvider` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsProvider { + Artcraft, + Fal, + Grok, + Midjourney, + Sora, + WorldLabs, + Higgsfield, + Krea, + Leonardo, + Magnific, + Openart, + Picsart, + Pixverse, + Runway, + Unknown(String), +} + +impl From for ListVideoModelsProvider { + fn from(value: ApiClientGenerationProvider) -> Self { + match value { + ApiClientGenerationProvider::Artcraft => Self::Artcraft, + ApiClientGenerationProvider::Fal => Self::Fal, + ApiClientGenerationProvider::Grok => Self::Grok, + ApiClientGenerationProvider::Midjourney => Self::Midjourney, + ApiClientGenerationProvider::Sora => Self::Sora, + ApiClientGenerationProvider::WorldLabs => Self::WorldLabs, + ApiClientGenerationProvider::Higgsfield => Self::Higgsfield, + ApiClientGenerationProvider::Krea => Self::Krea, + ApiClientGenerationProvider::Leonardo => Self::Leonardo, + ApiClientGenerationProvider::Magnific => Self::Magnific, + ApiClientGenerationProvider::Openart => Self::Openart, + ApiClientGenerationProvider::Picsart => Self::Picsart, + ApiClientGenerationProvider::Pixverse => Self::Pixverse, + ApiClientGenerationProvider::Runway => Self::Runway, + ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsProvider) -> Self { + match value { + ListVideoModelsProvider::Artcraft => "artcraft".to_string(), + ListVideoModelsProvider::Fal => "fal".to_string(), + ListVideoModelsProvider::Grok => "grok".to_string(), + ListVideoModelsProvider::Midjourney => "midjourney".to_string(), + ListVideoModelsProvider::Sora => "sora".to_string(), + ListVideoModelsProvider::WorldLabs => "world_labs".to_string(), + ListVideoModelsProvider::Higgsfield => "higgsfield".to_string(), + ListVideoModelsProvider::Krea => "krea".to_string(), + ListVideoModelsProvider::Leonardo => "leonardo".to_string(), + ListVideoModelsProvider::Magnific => "magnific".to_string(), + ListVideoModelsProvider::Openart => "openart".to_string(), + ListVideoModelsProvider::Picsart => "picsart".to_string(), + ListVideoModelsProvider::Pixverse => "pixverse".to_string(), + ListVideoModelsProvider::Runway => "runway".to_string(), + ListVideoModelsProvider::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientModelCreator` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsModelCreator { + Alibaba, + ArtCraft, + BlackForestLabs, + Bytedance, + Fal, + Google, + Grok, + Hailuo, + Higgsfield, + Kling, + Krea, + Midjourney, + OpenAi, + OpenArt, + Recraft, + Replicate, + Runway, + Stability, + Tencent, + TensorArt, + Vidu, + WorldLabs, + Unknown(String), +} + +impl From for ListVideoModelsModelCreator { + fn from(value: ApiClientModelCreator) -> Self { + match value { + ApiClientModelCreator::Alibaba => Self::Alibaba, + ApiClientModelCreator::ArtCraft => Self::ArtCraft, + ApiClientModelCreator::BlackForestLabs => Self::BlackForestLabs, + ApiClientModelCreator::Bytedance => Self::Bytedance, + ApiClientModelCreator::Fal => Self::Fal, + ApiClientModelCreator::Google => Self::Google, + ApiClientModelCreator::Grok => Self::Grok, + ApiClientModelCreator::Hailuo => Self::Hailuo, + ApiClientModelCreator::Higgsfield => Self::Higgsfield, + ApiClientModelCreator::Kling => Self::Kling, + ApiClientModelCreator::Krea => Self::Krea, + ApiClientModelCreator::Midjourney => Self::Midjourney, + ApiClientModelCreator::OpenAi => Self::OpenAi, + ApiClientModelCreator::OpenArt => Self::OpenArt, + ApiClientModelCreator::Recraft => Self::Recraft, + ApiClientModelCreator::Replicate => Self::Replicate, + ApiClientModelCreator::Runway => Self::Runway, + ApiClientModelCreator::Stability => Self::Stability, + ApiClientModelCreator::Tencent => Self::Tencent, + ApiClientModelCreator::TensorArt => Self::TensorArt, + ApiClientModelCreator::Vidu => Self::Vidu, + ApiClientModelCreator::WorldLabs => Self::WorldLabs, + ApiClientModelCreator::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsModelCreator) -> Self { + match value { + ListVideoModelsModelCreator::Alibaba => "alibaba".to_string(), + ListVideoModelsModelCreator::ArtCraft => "artcraft".to_string(), + ListVideoModelsModelCreator::BlackForestLabs => "black_forest_labs".to_string(), + ListVideoModelsModelCreator::Bytedance => "bytedance".to_string(), + ListVideoModelsModelCreator::Fal => "fal".to_string(), + ListVideoModelsModelCreator::Google => "google".to_string(), + ListVideoModelsModelCreator::Grok => "grok".to_string(), + ListVideoModelsModelCreator::Hailuo => "hailuo".to_string(), + ListVideoModelsModelCreator::Higgsfield => "higgsfield".to_string(), + ListVideoModelsModelCreator::Kling => "kling".to_string(), + ListVideoModelsModelCreator::Krea => "krea".to_string(), + ListVideoModelsModelCreator::Midjourney => "midjourney".to_string(), + ListVideoModelsModelCreator::OpenAi => "open_ai".to_string(), + ListVideoModelsModelCreator::OpenArt => "open_art".to_string(), + ListVideoModelsModelCreator::Recraft => "recraft".to_string(), + ListVideoModelsModelCreator::Replicate => "replicate".to_string(), + ListVideoModelsModelCreator::Runway => "runway".to_string(), + ListVideoModelsModelCreator::Stability => "stability".to_string(), + ListVideoModelsModelCreator::Tencent => "tencent".to_string(), + ListVideoModelsModelCreator::TensorArt => "tensor_art".to_string(), + ListVideoModelsModelCreator::Vidu => "vidu".to_string(), + ListVideoModelsModelCreator::WorldLabs => "world_labs".to_string(), + ListVideoModelsModelCreator::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientVideoModel` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsVideoModel { + GrokVideo, + GrokImagineVideo, + GrokImagineVideo1p5, + Kling16Pro, + Kling21Pro, + Kling21Master, + Kling2p5TurboPro, + Kling2p6Pro, + Kling3p0Standard, + Kling3p0Pro, + HappyHorse1p0, + Seedance10Lite, + Seedance1p5Pro, + Seedance2p0, + Seedance2p0Fast, + Seedance2p0BytePlus, + Seedance2p0BytePlusFast, + Seedance2p0BytePlusUltra, + Seedance2p0BytePlusUltraFast, + Seedance2p0Mini, + Seedance2p0BytePlusMini, + Seedance2p0BytePlusUltraMini, + Sora2, + Sora2Pro, + Veo2, + Veo3, + Veo3Fast, + Veo3p1, + Veo3p1Fast, + PreviewModel, + PreviewModelFast, + Unknown(String), +} + +impl From for ListVideoModelsVideoModel { + fn from(value: ApiClientVideoModel) -> Self { + match value { + ApiClientVideoModel::GrokVideo => Self::GrokVideo, + ApiClientVideoModel::GrokImagineVideo => Self::GrokImagineVideo, + ApiClientVideoModel::GrokImagineVideo1p5 => Self::GrokImagineVideo1p5, + ApiClientVideoModel::Kling16Pro => Self::Kling16Pro, + ApiClientVideoModel::Kling21Pro => Self::Kling21Pro, + ApiClientVideoModel::Kling21Master => Self::Kling21Master, + ApiClientVideoModel::Kling2p5TurboPro => Self::Kling2p5TurboPro, + ApiClientVideoModel::Kling2p6Pro => Self::Kling2p6Pro, + ApiClientVideoModel::Kling3p0Standard => Self::Kling3p0Standard, + ApiClientVideoModel::Kling3p0Pro => Self::Kling3p0Pro, + ApiClientVideoModel::HappyHorse1p0 => Self::HappyHorse1p0, + ApiClientVideoModel::Seedance10Lite => Self::Seedance10Lite, + ApiClientVideoModel::Seedance1p5Pro => Self::Seedance1p5Pro, + ApiClientVideoModel::Seedance2p0 => Self::Seedance2p0, + ApiClientVideoModel::Seedance2p0Fast => Self::Seedance2p0Fast, + ApiClientVideoModel::Seedance2p0BytePlus => Self::Seedance2p0BytePlus, + ApiClientVideoModel::Seedance2p0BytePlusFast => Self::Seedance2p0BytePlusFast, + ApiClientVideoModel::Seedance2p0BytePlusUltra => Self::Seedance2p0BytePlusUltra, + ApiClientVideoModel::Seedance2p0BytePlusUltraFast => Self::Seedance2p0BytePlusUltraFast, + ApiClientVideoModel::Seedance2p0Mini => Self::Seedance2p0Mini, + ApiClientVideoModel::Seedance2p0BytePlusMini => Self::Seedance2p0BytePlusMini, + ApiClientVideoModel::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini, + ApiClientVideoModel::Sora2 => Self::Sora2, + ApiClientVideoModel::Sora2Pro => Self::Sora2Pro, + ApiClientVideoModel::Veo2 => Self::Veo2, + ApiClientVideoModel::Veo3 => Self::Veo3, + ApiClientVideoModel::Veo3Fast => Self::Veo3Fast, + ApiClientVideoModel::Veo3p1 => Self::Veo3p1, + ApiClientVideoModel::Veo3p1Fast => Self::Veo3p1Fast, + ApiClientVideoModel::PreviewModel => Self::PreviewModel, + ApiClientVideoModel::PreviewModelFast => Self::PreviewModelFast, + ApiClientVideoModel::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsVideoModel) -> Self { + match value { + ListVideoModelsVideoModel::GrokVideo => "grok_video".to_string(), + ListVideoModelsVideoModel::GrokImagineVideo => "grok_imagine_video".to_string(), + ListVideoModelsVideoModel::GrokImagineVideo1p5 => "grok_imagine_video_1p5".to_string(), + ListVideoModelsVideoModel::Kling16Pro => "kling_1p6_pro".to_string(), + ListVideoModelsVideoModel::Kling21Pro => "kling_2p1_pro".to_string(), + ListVideoModelsVideoModel::Kling21Master => "kling_2p1_master".to_string(), + ListVideoModelsVideoModel::Kling2p5TurboPro => "kling_2p5_turbo_pro".to_string(), + ListVideoModelsVideoModel::Kling2p6Pro => "kling_2p6_pro".to_string(), + ListVideoModelsVideoModel::Kling3p0Standard => "kling_3p0_standard".to_string(), + ListVideoModelsVideoModel::Kling3p0Pro => "kling_3p0_pro".to_string(), + ListVideoModelsVideoModel::HappyHorse1p0 => "happy_horse_1p0".to_string(), + ListVideoModelsVideoModel::Seedance10Lite => "seedance_1p0_lite".to_string(), + ListVideoModelsVideoModel::Seedance1p5Pro => "seedance_1p5_pro".to_string(), + ListVideoModelsVideoModel::Seedance2p0 => "seedance_2p0".to_string(), + ListVideoModelsVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(), + ListVideoModelsVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(), + ListVideoModelsVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(), + ListVideoModelsVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(), + ListVideoModelsVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(), + ListVideoModelsVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(), + ListVideoModelsVideoModel::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini".to_string(), + ListVideoModelsVideoModel::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini".to_string(), + ListVideoModelsVideoModel::Sora2 => "sora_2".to_string(), + ListVideoModelsVideoModel::Sora2Pro => "sora_2_pro".to_string(), + ListVideoModelsVideoModel::Veo2 => "veo_2".to_string(), + ListVideoModelsVideoModel::Veo3 => "veo_3".to_string(), + ListVideoModelsVideoModel::Veo3Fast => "veo_3_fast".to_string(), + ListVideoModelsVideoModel::Veo3p1 => "veo_3p1".to_string(), + ListVideoModelsVideoModel::Veo3p1Fast => "veo_3p1_fast".to_string(), + ListVideoModelsVideoModel::PreviewModel => "preview_model".to_string(), + ListVideoModelsVideoModel::PreviewModelFast => "preview_model_fast".to_string(), + ListVideoModelsVideoModel::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientAspectRatio` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsAspectRatio { + Auto, + Square, + WideThreeByTwo, + WideFourByThree, + WideFiveByFour, + WideSixteenByNine, + WideTwentyOneByNine, + TallTwoByThree, + TallThreeByFour, + TallFourByFive, + TallNineBySixteen, + TallNineByTwentyOne, + Wide, + Tall, + Auto2k, + Auto3k, + Auto4k, + SquareHd, + Unknown(String), +} + +impl From for ListVideoModelsAspectRatio { + fn from(value: ApiClientAspectRatio) -> Self { + match value { + ApiClientAspectRatio::Auto => Self::Auto, + ApiClientAspectRatio::Square => Self::Square, + ApiClientAspectRatio::WideThreeByTwo => Self::WideThreeByTwo, + ApiClientAspectRatio::WideFourByThree => Self::WideFourByThree, + ApiClientAspectRatio::WideFiveByFour => Self::WideFiveByFour, + ApiClientAspectRatio::WideSixteenByNine => Self::WideSixteenByNine, + ApiClientAspectRatio::WideTwentyOneByNine => Self::WideTwentyOneByNine, + ApiClientAspectRatio::TallTwoByThree => Self::TallTwoByThree, + ApiClientAspectRatio::TallThreeByFour => Self::TallThreeByFour, + ApiClientAspectRatio::TallFourByFive => Self::TallFourByFive, + ApiClientAspectRatio::TallNineBySixteen => Self::TallNineBySixteen, + ApiClientAspectRatio::TallNineByTwentyOne => Self::TallNineByTwentyOne, + ApiClientAspectRatio::Wide => Self::Wide, + ApiClientAspectRatio::Tall => Self::Tall, + ApiClientAspectRatio::Auto2k => Self::Auto2k, + ApiClientAspectRatio::Auto3k => Self::Auto3k, + ApiClientAspectRatio::Auto4k => Self::Auto4k, + ApiClientAspectRatio::SquareHd => Self::SquareHd, + ApiClientAspectRatio::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsAspectRatio) -> Self { + match value { + ListVideoModelsAspectRatio::Auto => "auto".to_string(), + ListVideoModelsAspectRatio::Square => "square".to_string(), + ListVideoModelsAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(), + ListVideoModelsAspectRatio::WideFourByThree => "wide_four_by_three".to_string(), + ListVideoModelsAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(), + ListVideoModelsAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(), + ListVideoModelsAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(), + ListVideoModelsAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(), + ListVideoModelsAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(), + ListVideoModelsAspectRatio::TallFourByFive => "tall_four_by_five".to_string(), + ListVideoModelsAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(), + ListVideoModelsAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(), + ListVideoModelsAspectRatio::Wide => "wide".to_string(), + ListVideoModelsAspectRatio::Tall => "tall".to_string(), + ListVideoModelsAspectRatio::Auto2k => "auto_2k".to_string(), + ListVideoModelsAspectRatio::Auto3k => "auto_3k".to_string(), + ListVideoModelsAspectRatio::Auto4k => "auto_4k".to_string(), + ListVideoModelsAspectRatio::SquareHd => "square_hd".to_string(), + ListVideoModelsAspectRatio::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientResolution` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsResolution { + OneK, + TwoK, + ThreeK, + FourK, + HalfK, + FourEightyP, + SevenTwentyP, + TenEightyP, + Unknown(String), +} + +impl From for ListVideoModelsResolution { + fn from(value: ApiClientResolution) -> Self { + match value { + ApiClientResolution::OneK => Self::OneK, + ApiClientResolution::TwoK => Self::TwoK, + ApiClientResolution::ThreeK => Self::ThreeK, + ApiClientResolution::FourK => Self::FourK, + ApiClientResolution::HalfK => Self::HalfK, + ApiClientResolution::FourEightyP => Self::FourEightyP, + ApiClientResolution::SevenTwentyP => Self::SevenTwentyP, + ApiClientResolution::TenEightyP => Self::TenEightyP, + ApiClientResolution::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsResolution) -> Self { + match value { + ListVideoModelsResolution::OneK => "one_k".to_string(), + ListVideoModelsResolution::TwoK => "two_k".to_string(), + ListVideoModelsResolution::ThreeK => "three_k".to_string(), + ListVideoModelsResolution::FourK => "four_k".to_string(), + ListVideoModelsResolution::HalfK => "half_k".to_string(), + ListVideoModelsResolution::FourEightyP => "four_eighty_p".to_string(), + ListVideoModelsResolution::SevenTwentyP => "seven_twenty_p".to_string(), + ListVideoModelsResolution::TenEightyP => "ten_eighty_p".to_string(), + ListVideoModelsResolution::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientBitrate` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsBitrate { + Normal, + High, + Unknown(String), +} + +impl From for ListVideoModelsBitrate { + fn from(value: ApiClientBitrate) -> Self { + match value { + ApiClientBitrate::Normal => Self::Normal, + ApiClientBitrate::High => Self::High, + ApiClientBitrate::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsBitrate) -> Self { + match value { + ListVideoModelsBitrate::Normal => "normal".to_string(), + ListVideoModelsBitrate::High => "high".to_string(), + ListVideoModelsBitrate::Unknown(other) => other, + } + } +} + +/// Command-specific copy of `ApiClientQuality` with an `Unknown` catch-all so newer +/// server variants are preserved rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub enum ListVideoModelsQuality { + High, + Medium, + Low, + Unknown(String), +} + +impl From for ListVideoModelsQuality { + fn from(value: ApiClientQuality) -> Self { + match value { + ApiClientQuality::High => Self::High, + ApiClientQuality::Medium => Self::Medium, + ApiClientQuality::Low => Self::Low, + ApiClientQuality::Unknown(other) => Self::Unknown(other), + } + } +} + +impl From for String { + fn from(value: ListVideoModelsQuality) -> Self { + match value { + ListVideoModelsQuality::High => "high".to_string(), + ListVideoModelsQuality::Medium => "medium".to_string(), + ListVideoModelsQuality::Low => "low".to_string(), + ListVideoModelsQuality::Unknown(other) => other, + } + } +} + +// ============================ Structs ============================ + +#[derive(Clone, Debug, Serialize)] +pub struct ListVideoModelsResponse { + pub success: bool, + pub models: Vec, + pub providers: Vec, +} + +impl From for ListVideoModelsResponse { + fn from(v: OmniGenVideoModelsResponse) -> Self { + Self { + success: v.success, + models: v.models.into_iter().map(Into::into).collect(), + providers: v.providers.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ListVideoModelsProviderDetails { + pub provider: ListVideoModelsProvider, + pub models: Vec, +} + +impl From for ListVideoModelsProviderDetails { + fn from(v: OmniGenVideoModelProviderDetails) -> Self { + Self { + provider: v.provider.into(), + models: v.models.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ListVideoModelsProviderModelDetails { + pub model: ListVideoModelsVideoModel, + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +impl From for ListVideoModelsProviderModelDetails { + fn from(v: OmniGenVideoProviderModelDetails) -> Self { + Self { + model: v.model.into(), + overrides: v.overrides.map(Into::into), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ListVideoModelsModelDetails { + pub model: ListVideoModelsVideoModel, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_to_video_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_text_prompt_max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_keyframe_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_keyframe_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ending_keyframe_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_references_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_references_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_references_max_total_duration_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_references_max_total_duration_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub character_references_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub character_references_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub show_generate_with_sound_toggle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_max_with_image_references: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch_size_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl From for ListVideoModelsModelDetails { + fn from(v: OmniGenVideoModelDetails) -> Self { + Self { + model: v.model.into(), + model_creator: v.model_creator.map(Into::into), + full_name: v.full_name, + extra_info: v.extra_info, + extra_info_short: v.extra_info_short, + text_to_video_supported: v.text_to_video_supported, + text_prompt_supported: v.text_prompt_supported, + text_prompt_max_length: v.text_prompt_max_length, + negative_text_prompt_supported: v.negative_text_prompt_supported, + negative_text_prompt_max_length: v.negative_text_prompt_max_length, + starting_keyframe_supported: v.starting_keyframe_supported, + starting_keyframe_required: v.starting_keyframe_required, + ending_keyframe_supported: v.ending_keyframe_supported, + image_references_supported: v.image_references_supported, + image_references_max: v.image_references_max, + video_references_supported: v.video_references_supported, + video_references_max: v.video_references_max, + video_references_max_total_duration_seconds: v.video_references_max_total_duration_seconds, + audio_references_supported: v.audio_references_supported, + audio_references_max: v.audio_references_max, + audio_references_max_total_duration_seconds: v.audio_references_max_total_duration_seconds, + character_references_supported: v.character_references_supported, + character_references_max: v.character_references_max, + show_generate_with_sound_toggle: v.show_generate_with_sound_toggle, + aspect_ratio_options: v.aspect_ratio_options.map(|items| items.into_iter().map(Into::into).collect()), + aspect_ratio_default: v.aspect_ratio_default.map(Into::into), + resolution_options: v.resolution_options.map(|items| items.into_iter().map(Into::into).collect()), + resolution_default: v.resolution_default.map(Into::into), + bitrate_options: v.bitrate_options.map(|items| items.into_iter().map(Into::into).collect()), + bitrate_default: v.bitrate_default.map(Into::into), + quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()), + default_quality: v.default_quality.map(Into::into), + duration_seconds_min: v.duration_seconds_min, + duration_seconds_max: v.duration_seconds_max, + duration_seconds_max_with_image_references: v.duration_seconds_max_with_image_references, + duration_seconds_options: v.duration_seconds_options, + duration_seconds_default: v.duration_seconds_default, + batch_size_min: v.batch_size_min, + batch_size_max: v.batch_size_max, + batch_size_options: v.batch_size_options, + batch_size_default: v.batch_size_default, + is_disabled: v.is_disabled, + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs b/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs new file mode 100644 index 0000000000..01ea57b6fa --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs @@ -0,0 +1,211 @@ +use super::request::{Modality, OmniRequest}; +use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors; +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; +use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::{resolve_image_field, resolve_mask_field}; +use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus}; +use crate::core::commands::response::shorthand::Response; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::utils::api_host::ApiHost; +use artcraft_client::utils::basic_json_post_request::basic_json_post_request; +use enums::common::generation_provider::GenerationProvider; +use log::error; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tauri::{AppHandle, Manager}; +use tokens::tokens::media_files::MediaFileToken; +use uuid_utils::uuid::generate_random_uuid; + +#[derive(Deserialize, Serialize)] +#[serde(transparent)] +pub struct OmniResponse(pub Value); + +impl SerializeMarker for OmniResponse {} + +pub type OmniResult = Response; + +pub async fn generate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult { + let configs = app.state::(); + let manager = app.state::(); + let maybe_creds = manager.get_credentials().map_err(local_error)?; + let creds = match maybe_creds { + Some(creds) => creds, + None => { + notify_frontend_of_errors(app, &GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials)).await; + return Err(command_error(CommandErrorStatus::Unauthorized, "needs_storyteller_credentials", "You need to be logged into ArtCraft.".into())); + }, + }; + let mut fields = request.api_fields(modality); + if modality == Modality::Image { + resolve_image_inputs(&mut fields, Some(&creds), &configs.storyteller_host).await?; + } + fields.remove("estimate_only"); + if fields.get("idempotency_token").is_none_or(Value::is_null) { + fields.insert("idempotency_token".into(), Value::String(generate_random_uuid())); + } + let model = fields.get("model").and_then(Value::as_str).map(str::to_owned); + let route = format!("/v1/omni_gen/generate/{}", modality.as_str()); + let response: OmniResponse = match basic_json_post_request(&configs.storyteller_host, &route, Some(&creds), fields).await { + Ok(response) => response, + Err(err) => { + let error = api_error(&err); + notify_frontend_of_errors(app, &GenerateError::from(err)).await; + return Err(error); + }, + }; + + // A batch may create several jobs. Track every job so history and completion + // events survive restart, including when a model was unknown at build time. + let job_tokens = job_tokens(&response.0); + if job_tokens.is_empty() { + return Err(local_error("The API accepted generation but returned no job tokens.")); + } + record_usage(app, &request, modality, job_tokens.len()); + let database = app.state::(); + for job_token in job_tokens { + let success = TaskEnqueueSuccess { task_type: modality.task_type(), model: model.clone().map(GenerationModel::Unknown), provider: GenerationProvider::Artcraft, provider_job_id: Some(job_token), maybe_queue_status_url: None, maybe_queue_response_url: None, maybe_prompt_token: None }; + if let Err(err) = success.insert_into_task_database_with_frontend_payload(&database, request.frontend_caller, request.frontend_subscriber_id.as_deref(), request.frontend_subscriber_payload.as_deref()).await { + // Generation has already been accepted. Do not encourage duplicate retries. + error!("Could not persist accepted Omni generation: {:?}", err); + } + GenerationEnqueueSuccessEvent { action: success.to_frontend_event_action(), service: success.to_frontend_event_service(), model: success.model }.send_infallible(app); + } + CreditsBalanceChangedEvent {}.send_infallible(app); + Ok(response.into()) +} + +pub async fn estimate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult { + let configs = app.state::(); + let manager = app.state::(); + let creds = manager.get_credentials().map_err(local_error)?; + let mut fields = request.api_fields(modality); + if modality == Modality::Image { + // Pricing only needs reference presence, never upload a canvas on each keystroke. + resolve_image_inputs(&mut fields, None, &configs.storyteller_host).await?; + } + let route = format!("/v1/omni_gen/cost/{}", modality.as_str()); + let response: OmniResponse = basic_json_post_request(&configs.storyteller_host, &route, creds.as_ref(), fields).await.map_err(|err| api_error(&err))?; + Ok(response.into()) +} + +pub fn adapt_legacy_response(response: Response) -> OmniResult { + match response { + Ok(response) => Ok(OmniResponse(serde_json::to_value(response.payload).map_err(local_error)?).into()), + Err(err) => Err(CommandErrorResponseWrapper { status: err.status, error_message: err.error_message.or_else(|| serde_json::to_value(err.error_details).ok().and_then(|value| value.get("error_message").and_then(Value::as_str).map(str::to_owned))), error_type: err.error_type.and_then(|value| serde_json::to_value(value).ok()).and_then(|value| value.as_str().map(str::to_owned)), error_details: None }), + } +} + +pub fn decode_native(request: OmniRequest) -> Result> { + serde_json::from_value(serde_json::to_value(request).map_err(local_error)?).map_err(|err| command_error(CommandErrorStatus::BadRequest, "bad_input", err.to_string())) +} + +pub fn local_error(err: impl std::fmt::Display) -> CommandErrorResponseWrapper { + command_error(CommandErrorStatus::ServerError, "server_error", err.to_string()) +} + +fn api_error(err: &StorytellerError) -> CommandErrorResponseWrapper { + match err { + StorytellerError::Api(ApiError::InvalidRequest(body)) => command_error(CommandErrorStatus::BadRequest, "bad_input", api_message(body)), + StorytellerError::Api(ApiError::Unauthorized(body) | ApiError::Forbidden(body)) => command_error(CommandErrorStatus::Unauthorized, "unauthorized", api_message(body)), + StorytellerError::Api(ApiError::TooManyRequests(body)) => command_error(CommandErrorStatus::TooManyRequests, "too_many_requests", api_message(body)), + StorytellerError::Api(ApiError::PaymentRequired(body)) => command_error(CommandErrorStatus::BadRequest, "billing_issue", api_message(body)), + _ => local_error(err), + } +} + +fn api_message(body: &str) -> String { + serde_json::from_str::(body).ok().and_then(|value| value.get("error_message").or_else(|| value.get("message")).and_then(Value::as_str).map(str::to_owned)).unwrap_or_else(|| body.to_owned()) +} + +fn command_error(status: CommandErrorStatus, kind: &str, message: String) -> CommandErrorResponseWrapper { + CommandErrorResponseWrapper { status, error_type: Some(kind.into()), error_message: Some(message), error_details: None } +} + +async fn resolve_image_inputs(fields: &mut Map, maybe_creds: Option<&StorytellerCredentialSet>, host: &ApiHost) -> Result<(), CommandErrorResponseWrapper> { + let mut refs = Vec::new(); + for name in ["canvas_image", "scene_image", "inpainting_mask_image"] { + let token_key = format!("{}_media_token", name); + let bytes_key = format!("{}_raw_bytes", name); + let token: Option = serde_json::from_value(fields.remove(&token_key).unwrap_or(Value::Null)).map_err(local_error)?; + let bytes: Option> = serde_json::from_value(fields.remove(&bytes_key).unwrap_or(Value::Null)).map_err(local_error)?; + let resolved = if let Some(creds) = maybe_creds { if name == "inpainting_mask_image" { resolve_mask_field(token.as_ref(), bytes.as_deref(), creds, host).await } else { resolve_image_field(name, token.as_ref(), bytes.as_deref(), creds, host).await }.map_err(|err| local_error(format!("{:?}", err)))?.map(|token| token.to_string()) } else { token.map(|token| token.to_string()).or_else(|| bytes.map(|_| "mf_estimate_reference".into())) }; + if let Some(token) = resolved { + if name == "inpainting_mask_image" { + fields.insert(token_key, Value::String(token)); + } else { + refs.push(Value::String(token)); + } + } + } + if !refs.is_empty() { + if let Some(Value::Array(existing)) = fields.remove("image_media_tokens") { + refs.extend(existing); + } + fields.insert("image_media_tokens".into(), Value::Array(refs)); + } + Ok(()) +} + +fn job_tokens(response: &Value) -> Vec { + let mut result = Vec::new(); + if let Some(primary) = response.get("inference_job_token").and_then(Value::as_str) { + result.push(primary.to_owned()); + } + if let Some(tokens) = response.get("all_job_tokens").and_then(Value::as_array) { + for token in tokens.iter().filter_map(Value::as_str) { + if !result.iter().any(|existing| existing == token) { + result.push(token.to_owned()); + } + } + } + result +} + +fn record_usage(app: &AppHandle, request: &OmniRequest, modality: Modality, count: usize) { + let tracker = app.state::(); + let count = u16::try_from(count).unwrap_or(u16::MAX); + let has_media = request.fields.iter().any(|(key, value)| { + key.ends_with("media_token") && value.is_string() + || key.ends_with("media_tokens") && value.as_array().is_some_and(|values| !values.is_empty()) + }); + let usage = if has_media { ArtcraftUsageType::ImageToResult } else { ArtcraftUsageType::TextToResult }; + let result = match modality { + Modality::Video => tracker.record_video_generation(count, usage, ArtcraftUsagePage::VideoPage), + Modality::Mesh => tracker.record_object_generation(count, usage, ArtcraftUsagePage::ObjectPage), + Modality::Splat => tracker.record_object_generation(count, usage, ArtcraftUsagePage::OtherPage), + _ => Ok(()), + }; + if let Err(err) = result { + error!("Could not record generation usage: {:?}", err); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn invalid_options_return_the_api_message() { + let err = api_error(&StorytellerError::Api(ApiError::InvalidRequest(r#"{"success":false,"error_message":"Duration must be between 4 and 30 seconds."}"#.into()))); + assert!(matches!(err.status, CommandErrorStatus::BadRequest)); + assert_eq!(err.error_message.as_deref(), Some("Duration must be between 4 and 30 seconds.")); + } + + #[test] + fn batch_tracks_each_job_once() { + assert_eq!(job_tokens(&json!({"inference_job_token":"job_1", "all_job_tokens":["job_1", "job_2"]})), vec!["job_1", "job_2"]); + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/omni/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/omni/mod.rs new file mode 100644 index 0000000000..9fb0a48100 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/omni/mod.rs @@ -0,0 +1,5 @@ +pub mod request; +pub mod dispatch; + +pub use dispatch::{generate, estimate, OmniResponse, OmniResult}; +pub use request::{OmniRequest, Modality}; diff --git a/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs b/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs new file mode 100644 index 0000000000..6e7f98f2b2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs @@ -0,0 +1,187 @@ +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// Desktop metadata is local. Everything else is an API-owned field, including +/// model IDs and option strings introduced after this version of the app. +#[derive(Clone, Deserialize, Serialize)] +pub struct OmniRequest { + pub provider: Option, + pub frontend_caller: Option, + pub frontend_subscriber_id: Option, + pub frontend_subscriber_payload: Option, + #[serde(flatten)] + pub fields: Map, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Modality { + Image, + Video, + Mesh, + Splat, + Audio, +} + +impl OmniRequest { + pub fn uses_artcraft(&self) -> bool { + // Native Grok is deliberately disabled, including calls from old UI state. + self.model().is_some_and(|model| model.starts_with("grok_")) || matches!(self.provider, None | Some(GenerationProvider::Artcraft | GenerationProvider::Grok)) + } + + pub fn model(&self) -> Option<&str> { + self.fields.get("model").and_then(Value::as_str) + } + + pub fn uses_legacy_image_endpoint(&self) -> bool { + // These desktop editor integrations still have dedicated request shapes. + // Catalog models, including any future ID, use Omni by default. + matches!(self.model(), Some("recraft_3" | "flux_pro_kontext_max" | "flux_dev_juggernaut" | "flux_pro_1" | "midjourney")) + } + + pub fn api_fields(&self, modality: Modality) -> Map { + let mut fields = self.fields.clone(); + if let Some(model) = self.model() { + fields.insert("model".into(), Value::String(canonical_model_id(model).into())); + } + match modality { + Modality::Image => rename_field(&mut fields, "batch_size", "image_batch_count"), + Modality::Video => { + rename_field(&mut fields, "image_media_token", "start_frame_image_media_token"); + fields.remove("sora_orientation"); + fields.remove("grok_aspect_ratio"); + }, + Modality::Splat => rename_field(&mut fields, "image_media_tokens", "reference_image_media_tokens"), + Modality::Mesh => { + if let Some(token) = fields.remove("image_media_token").filter(|v| !v.is_null()) { + fields.entry("reference_image_media_tokens").or_insert(Value::Array(vec![token])); + } + }, + Modality::Audio => {}, + } + // These belong to the old pricing endpoint, not Omni generation or pricing. + fields.remove("generation_mode"); + fields + } +} + +impl Modality { + pub fn as_str(self) -> &'static str { + match self { + Self::Image => "image", + Self::Video => "video", + Self::Mesh => "mesh", + Self::Splat => "splat", + Self::Audio => "audio", + } + } + + pub fn task_type(self) -> TaskType { + match self { + Self::Image => TaskType::ImageGeneration, + Self::Video => TaskType::VideoGeneration, + Self::Mesh => TaskType::ObjectGeneration, + Self::Splat => TaskType::GaussianGeneration, + Self::Audio => TaskType::AudioGeneration, + } + } +} + +fn rename_field(fields: &mut Map, old: &str, new: &str) { + if let Some(value) = fields.remove(old).filter(|v| !v.is_null()) { + if fields.get(new).is_none_or(Value::is_null) { + fields.insert(new.into(), value); + } + } +} + +// Compatibility for persisted desktop settings and existing editor callers. +// Unknown IDs pass through unchanged; this is not a model allowlist. +fn canonical_model_id(model: &str) -> &str { + match model { + "flux_pro_11" => "flux_pro_1p1", + "flux_pro_11_ultra" => "flux_pro_1p1_ultra", + "gemini_25_flash" => "nano_banana", + "grok_image" => "grok_imagine_image", + "grok_video" => "grok_imagine_video", + "kling_1.6_pro" => "kling_1p6_pro", + "kling_2.1_pro" => "kling_2p1_pro", + "kling_2.1_master" => "kling_2p1_master", + "seedance_1.0_lite" => "seedance_1p0_lite", + "hunyuan_3d_2" | "hunyuan_3d_2_0" => "hunyuan_3d_2p0", + "hunyuan_3d_2_1" => "hunyuan_3d_2p1", + "world_labs_marble" | "worldlabs_marble" => "marble_0p1_plus", + _ => model, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn future_model_and_options_survive_without_desktop_metadata() { + let request: OmniRequest = serde_json::from_value(json!({ + "model": "future_video_v9", "resolution": "eight_k", "bitrate": "ultra", + "duration_seconds": 30, "generate_audio": false, "new_option": {"value": 1}, + "estimate_only": {"total_input_video_duration_millis": 7250}, + "frontend_subscriber_id": "local-only" + })) + .unwrap(); + let fields = request.api_fields(Modality::Video); + assert_eq!(fields["model"], "future_video_v9"); + assert_eq!(fields["resolution"], "eight_k"); + assert_eq!(fields["bitrate"], "ultra"); + assert_eq!(fields["duration_seconds"], 30); + assert_eq!(fields["generate_audio"], false); + assert_eq!(fields["new_option"]["value"], 1); + assert_eq!(fields["estimate_only"]["total_input_video_duration_millis"], 7250); + assert!(!fields.contains_key("frontend_subscriber_id")); + assert!(!fields.contains_key("provider")); + } + + #[test] + fn grok_uses_artcraft_even_with_a_saved_native_provider() { + let request: OmniRequest = serde_json::from_value(json!({ + "model": "grok_video", "provider": "grok" + })) + .unwrap(); + assert!(request.uses_artcraft()); + assert_eq!(request.api_fields(Modality::Video)["model"], "grok_imagine_video"); + } + + #[test] + fn legacy_mesh_and_splat_inputs_use_omni_field_names() { + let mesh: OmniRequest = serde_json::from_value(json!({ + "model": "hunyuan_3d_2_0", "image_media_token": "mf_mesh_input" + })) + .unwrap(); + let fields = mesh.api_fields(Modality::Mesh); + assert_eq!(fields["model"], "hunyuan_3d_2p0"); + assert_eq!(fields["reference_image_media_tokens"], json!(["mf_mesh_input"])); + assert!(!fields.contains_key("image_media_token")); + + let splat: OmniRequest = serde_json::from_value(json!({ + "model": "world_labs_marble", "image_media_tokens": ["mf_splat_input"] + })) + .unwrap(); + let fields = splat.api_fields(Modality::Splat); + assert_eq!(fields["model"], "marble_0p1_plus"); + assert_eq!(fields["reference_image_media_tokens"], json!(["mf_splat_input"])); + } + + #[test] + fn future_model_ids_survive_storage_and_event_serialization() { + use crate::core::events::generation_events::common::GenerationModel; + use enums::tauri::tasks::task_model_type::TaskModelType; + + let stored = TaskModelType::from_str("future_model_v99").unwrap(); + let event = GenerationModel::Unknown(stored.to_str().to_owned()); + assert_eq!(serde_json::to_value(event).unwrap(), json!("future_model_v99")); + // Keep existing event strings compatible with old history. + assert_eq!(serde_json::to_value(GenerationModel::FluxPro11).unwrap(), json!("flux_pro_1.1")); + } +} diff --git a/crates/desktop/artcraft/src/core/commands/get_app_info_command.rs b/crates/desktop/artcraft/src/core/commands/get_app_info_command.rs new file mode 100644 index 0000000000..49e386a677 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/get_app_info_command.rs @@ -0,0 +1,90 @@ +use crate::core::commands::response::shorthand::InfallibleResponse; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::artcraft_platform_info::{ArtcraftOs, ArtcraftPlatformInfo}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::os_platform::OsPlatform; +use chrono::{DateTime, Utc}; +use log::{info, warn}; +use serde_derive::Serialize; +use std::path::PathBuf; +use tauri::State; + +#[derive(Debug, Serialize)] +pub struct AppInfoResponse { + pub artcraft_version: String, + + pub build_timestamp: DateTime, + + pub git_commit_id: Option, + pub git_commit_short_id: Option, + pub git_commit_timestamp: Option>, + + pub os_platform: DetectedOs, + pub os_version: String, + + pub storyteller_host: String, + + pub artcraft_root_directory: PathBuf, + pub download_directory: PathBuf, +} + +impl SerializeMarker for AppInfoResponse {} + +#[derive(Copy, Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DetectedOs { + Windows, + #[serde(rename = "macos")] + MacOS, + Linux, + Unknown, +} + +#[tauri::command] +pub fn get_app_info_command( + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + artcraft_platform_info: State<'_, ArtcraftPlatformInfo>, + app_prefs: State<'_, AppPreferencesManager>, +) -> InfallibleResponse { + info!("get_app_info_command called..."); + + let storyteller_host = app_env_configs.storyteller_host.to_api_hostname_and_scheme(); + + let os_platform = match artcraft_platform_info.os_platform { + ArtcraftOs::Linux => DetectedOs::Linux, + ArtcraftOs::MacOS => DetectedOs::MacOS, + ArtcraftOs::Windows => DetectedOs::Windows, + ArtcraftOs::Unknown => DetectedOs::Unknown, + }; + + let root_directory = app_data_root.path().to_path_buf(); + + let download_directory = match app_prefs.get_clone() { + Ok(app_prefs) => { + app_prefs.preferred_download_directory + .download_directory(&app_data_root) + } + Err(err) => { + warn!("Can't get user download directory: {:?}", err); + app_data_root.downloads_dir().path().to_path_buf() + } + }; + + AppInfoResponse { + artcraft_version: artcraft_platform_info.artcraft_version.clone(), + build_timestamp: artcraft_platform_info.build_timestamp, + git_commit_id: artcraft_platform_info.git_commit_id.clone(), + git_commit_short_id: artcraft_platform_info.git_commit_short_id.clone(), + git_commit_timestamp: artcraft_platform_info.git_commit_timestamp, + os_platform, + os_version: artcraft_platform_info.os_version.clone(), + storyteller_host, + artcraft_root_directory: root_directory, + download_directory, + }.into() +} + diff --git a/crates/desktop/artcraft/src/core/commands/load_without_cors_command.rs b/crates/desktop/artcraft/src/core/commands/load_without_cors_command.rs new file mode 100644 index 0000000000..482dce15c3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/load_without_cors_command.rs @@ -0,0 +1,43 @@ +use futures::TryFutureExt; +use log::{error, info}; +use reqwest::Client; +use tauri::ipc::Response; + +/// Request a URL and load it without worrying about CORS. +#[tauri::command] +pub async fn load_without_cors_command(url: String) -> Result { + + info!("load_without_cors_command for URL: {}", url); + + // TODO(bt,2025-07-22): Make sure the URL is valid and safe to fetch. Check an allowlist. + + // TODO(bt,2025-07-22): Should we cache clients so we don't need to SSL handshake every time? + let client = Client::builder() + .gzip(true) + .build() + .map_err(|e| { + error!("Failed to create HTTP client: {:?}", e); + format!("Failed to create HTTP client: {:?}", e) + })?; + + let response = client.get(url.clone()) + .send() + .await + .map_err(|e| { + error!("Failed to send HTTP request: {:?}", e); + format!("Failed to send HTTP request: {:?}", e) + })?; + + if response.status() != 200 { + return Err(format!("Failed to fetch URL: {}. Status: {}", url, response.status())); + } + + let bytes = response.bytes() + .await + .map_err(|e| { + error!("Failed to read response bytes: {:?}", e); + format!("Failed to read response bytes: {:?}", e) + })?; + + Ok(Response::new(bytes.to_vec())) +} diff --git a/crates/desktop/artcraft/src/core/commands/media_files/media_file_delete_command.rs b/crates/desktop/artcraft/src/core/commands/media_files/media_file_delete_command.rs new file mode 100644 index 0000000000..ff977acab7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/media_files/media_file_delete_command.rs @@ -0,0 +1,80 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::media_file_deleted_event::MediaFileDeletedEvent; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use errors::AnyhowResult; +use log::{error, info, warn}; +use serde_derive::{Deserialize, Serialize}; +use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Deserialize)] +pub struct MediaFileDeleteRequest { + /// REQUIRED. + /// Media file to delete. The user must own the file. + pub media_file_token: MediaFileToken, +} + +#[derive(Serialize)] +pub struct MediaFileDeleteSuccessResponse { + pub success: bool, +} + +impl SerializeMarker for MediaFileDeleteSuccessResponse {} + +#[tauri::command] +pub async fn media_file_delete_command( + app: AppHandle, + request: MediaFileDeleteRequest, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> ResponseOrErrorMessage { + + info!("media_file_delete_command called"); + + let result = handle_request( + request, + &app, + &app_env_configs, + &storyteller_creds_manager, + ).await; + + if let Err(err) = result { + error!("media_file_delete_command failed: {:?}", err); + return Err("delete failed".into()) + } + + Ok(MediaFileDeleteSuccessResponse { + success: true, + }.into()) +} + +pub async fn handle_request( + request: MediaFileDeleteRequest, + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + + let creds = storyteller_creds_manager.get_credentials()?; + + let _result = delete_media_file( + &app_env_configs.storyteller_host, + creds.as_ref(), + &request.media_file_token + ).await?; + + MediaFileDeletedEvent { + media_file_token: request.media_file_token, + }.send_infallible(app); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/commands/media_files/mod.rs b/crates/desktop/artcraft/src/core/commands/media_files/mod.rs new file mode 100644 index 0000000000..c3bf63aa99 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/media_files/mod.rs @@ -0,0 +1 @@ +pub mod media_file_delete_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/mod.rs b/crates/desktop/artcraft/src/core/commands/mod.rs new file mode 100644 index 0000000000..4baa4c0584 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/mod.rs @@ -0,0 +1,15 @@ +pub mod app_preferences; +pub mod app_state; +pub mod cost_estimate; +pub mod download; +pub mod enqueue; +pub mod flip_image; +pub mod generate; +pub mod get_app_info_command; +pub mod load_without_cors_command; +pub mod media_files; +pub mod platform_info_command; +pub mod providers; +pub mod response; +pub mod task_queue; +pub mod deprecated; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/platform_info_command.rs b/crates/desktop/artcraft/src/core/commands/platform_info_command.rs new file mode 100644 index 0000000000..aa367be47a --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/platform_info_command.rs @@ -0,0 +1,61 @@ +use crate::core::commands::response::shorthand::InfallibleResponse; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::os_platform::OsPlatform; +use log::info; +use serde_derive::Serialize; + +#[derive(Debug, Serialize)] +pub struct PlatformInfoResponse { + pub os_platform: DetectedOs, + pub webview_runtime: WebviewRuntime, +} + +impl SerializeMarker for PlatformInfoResponse {} + +#[derive(Copy, Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DetectedOs { + Windows, + #[serde(rename = "macos")] + MacOS, + Linux, + Unknown, +} + +#[derive(Copy, Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WebviewRuntime { + #[serde(rename = "webkit_safari")] + WebkitSafari, + #[serde(rename = "webkit_gtk")] + WebkitGtk, + #[serde(rename = "edge_webview_2")] + EdgeWebview2, + Unknown, +} + +#[tauri::command] +pub fn platform_info_command() -> InfallibleResponse { + info!("platform_info_command called..."); + + let os_platform = match OsPlatform::maybe_get() { + Some(OsPlatform::Linux) => DetectedOs::Linux, + Some(OsPlatform::MacOs) => DetectedOs::MacOS, + Some(OsPlatform::Windows) => DetectedOs::Windows, + None => DetectedOs::Unknown, + }; + + // These are the webviews that Tauri uses on each OS: + // https://tauri.app/reference/webview-versions/ + let webview_runtime = match os_platform { + DetectedOs::Windows => WebviewRuntime::EdgeWebview2, + DetectedOs::MacOS => WebviewRuntime::WebkitSafari, + DetectedOs::Linux => WebviewRuntime::WebkitGtk, + DetectedOs::Unknown => WebviewRuntime::Unknown, + }; + + PlatformInfoResponse { + os_platform, + webview_runtime, + }.into() +} diff --git a/crates/desktop/artcraft/src/core/commands/providers/deprecated/get_provider_order_command.rs b/crates/desktop/artcraft/src/core/commands/providers/deprecated/get_provider_order_command.rs new file mode 100644 index 0000000000..0b7d69c170 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/deprecated/get_provider_order_command.rs @@ -0,0 +1,30 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use log::{error, info}; +use serde_derive::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct GetProviderOrderResponse { + pub providers: Vec, +} + +impl SerializeMarker for GetProviderOrderResponse{} + +#[tauri::command] +pub async fn get_provider_order_command( + provider_priority_store: State<'_, ProviderPriorityStore>, +) -> ResponseOrErrorMessage { + info!("get_provider_order_command called"); + + let providers = provider_priority_store.get_priority() + .map_err(|err| { + error!("Failed to get provider order: {:?}", err); + "Failed to retrieve provider order" + })?; + + Ok(GetProviderOrderResponse { + providers, + }.into()) +} diff --git a/crates/desktop/artcraft/src/core/commands/providers/deprecated/mod.rs b/crates/desktop/artcraft/src/core/commands/providers/deprecated/mod.rs new file mode 100644 index 0000000000..83c74a6411 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/deprecated/mod.rs @@ -0,0 +1,2 @@ +pub mod get_provider_order_command; +pub mod set_provider_order_command; diff --git a/crates/desktop/artcraft/src/core/commands/providers/deprecated/set_provider_order_command.rs b/crates/desktop/artcraft/src/core/commands/providers/deprecated/set_provider_order_command.rs new file mode 100644 index 0000000000..de5892d926 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/deprecated/set_provider_order_command.rs @@ -0,0 +1,34 @@ +use crate::core::commands::response::shorthand::SuccessOrErrorMessage; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::State; + +#[derive(Deserialize)] +pub struct SetProviderOrderRequest { + pub providers: Vec, +} + +#[tauri::command] +pub async fn set_provider_order_command( + request: SetProviderOrderRequest, + provider_priority_store: State<'_, ProviderPriorityStore>, + app_data_root: State<'_, AppDataRoot>, +) -> SuccessOrErrorMessage { + info!("set_provider_order_command called"); + + provider_priority_store.set_priority(&request.providers) + .map_err(|err| { + error!("Failed to set provider order: {:?}", err); + "Failed to set provider order" + })?; + + provider_priority_store.persist_to_filesystem(&app_data_root) + .map_err(|err| { + error!("Failed to persist provider order to filesystem: {:?}", err); + "Failed to persist provider order" + })?; + + Ok(().into()) +} diff --git a/crates/desktop/artcraft/src/core/commands/providers/mod.rs b/crates/desktop/artcraft/src/core/commands/providers/mod.rs new file mode 100644 index 0000000000..74b32e1655 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/mod.rs @@ -0,0 +1,4 @@ +pub mod deprecated; +pub mod provider_list_command; +pub mod provider_set_api_key_command; +pub mod provider_clear_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/providers/provider_clear_command.rs b/crates/desktop/artcraft/src/core/commands/providers/provider_clear_command.rs new file mode 100644 index 0000000000..150a7a2dda --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/provider_clear_command.rs @@ -0,0 +1,33 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Deserialize)] +pub struct ProviderClearRequest { + pub provider_credential: ProviderCredentialKey, +} + +#[derive(Serialize)] +pub struct ProviderClearResponse {} + +impl SerializeMarker for ProviderClearResponse {} + +#[tauri::command] +pub async fn provider_clear_command( + request: ProviderClearRequest, + credential_cache: State<'_, ProviderCredentialLoadingCache>, +) -> ResponseOrErrorMessage { + info!("provider_clear_command called for key: {:?}", request.provider_credential); + + credential_cache.delete_credentials(request.provider_credential) + .map_err(|err| { + error!("Failed to clear provider credential: {:?}", err); + "Failed to clear provider credential" + })?; + + Ok(ProviderClearResponse {}.into()) +} diff --git a/crates/desktop/artcraft/src/core/commands/providers/provider_list_command.rs b/crates/desktop/artcraft/src/core/commands/providers/provider_list_command.rs new file mode 100644 index 0000000000..55eee65b61 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/provider_list_command.rs @@ -0,0 +1,106 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::providers::credentials::provider_credential_type::ProviderCredentialType; +use log::{info, warn}; +use serde_derive::Serialize; +use tauri::State; + +/// All known credential keys. Add new ones here as providers are added. +const ALL_KEYS: &[ProviderCredentialKey] = &[ + ProviderCredentialKey::FalApiKey, + ProviderCredentialKey::ReplicateApiKey, + ProviderCredentialKey::GrokWebLogin, + ProviderCredentialKey::HiggsfieldWebLogin, + ProviderCredentialKey::MidjourneyLogin, + ProviderCredentialKey::RunwayWebLogin, +]; + +const REDACTED_KEY_VISIBLE_CHARS: usize = 6; + +#[derive(Serialize)] +pub struct ProviderListResponse { + pub providers: Vec, +} + +impl SerializeMarker for ProviderListResponse {} + +#[derive(Serialize)] +pub struct ProviderListEntry { + pub provider_credential: ProviderCredentialKey, + pub credential_type: ProviderCredentialType, + pub has_credentials: bool, + pub maybe_details: Option, +} + +#[derive(Serialize)] +pub struct ProviderCredentialDetails { + /// For API keys: the first few characters followed by asterisks. + pub maybe_key_start: Option, + /// For API keys: the full key value. + pub maybe_full_key: Option, + /// For web logins: the email address if available. + pub maybe_email_address: Option, + /// For web logins: the username if available. + pub maybe_username: Option, +} + +#[tauri::command] +pub async fn provider_list_command( + credential_cache: State<'_, ProviderCredentialLoadingCache>, +) -> ResponseOrErrorMessage { + info!("provider_list_command called"); + + let mut providers = Vec::with_capacity(ALL_KEYS.len()); + + for &key in ALL_KEYS { + let maybe_payload = match credential_cache.get_credentials(key) { + Ok(payload) => payload, + Err(err) => { + warn!("Error checking credential for {:?}: {:?}", key, err); + None + } + }; + + let has_credentials = maybe_payload.is_some(); + + let maybe_details = maybe_payload.map(|payload| match payload { + ProviderCredentialPayload::ApiKey(data) => { + ProviderCredentialDetails { + maybe_key_start: Some(redact_key(data.as_str())), + maybe_full_key: Some(data.as_str().to_string()), + maybe_email_address: None, + maybe_username: None, + } + } + ProviderCredentialPayload::WebLogin(data) => { + ProviderCredentialDetails { + maybe_key_start: None, + maybe_full_key: None, + maybe_email_address: data.email_address, + maybe_username: data.username, + } + } + }); + + providers.push(ProviderListEntry { + provider_credential: key, + credential_type: key.get_type(), + has_credentials, + maybe_details, + }); + } + + Ok(ProviderListResponse { providers }.into()) +} + +fn redact_key(key: &str) -> String { + if key.len() <= REDACTED_KEY_VISIBLE_CHARS { + "********".to_string() + } else { + let visible = &key[..REDACTED_KEY_VISIBLE_CHARS]; + format!("{}********", visible) + } +} diff --git a/crates/desktop/artcraft/src/core/commands/providers/provider_set_api_key_command.rs b/crates/desktop/artcraft/src/core/commands/providers/provider_set_api_key_command.rs new file mode 100644 index 0000000000..fc08ce0f2d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/providers/provider_set_api_key_command.rs @@ -0,0 +1,39 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::providers::credentials::payload::api_key::ApiKeyData; +use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Deserialize)] +pub struct ProviderSetApiKeyRequest { + pub provider_credential: ProviderCredentialKey, + pub api_key: String, +} + +#[derive(Serialize)] +pub struct ProviderSetApiKeyResponse {} + +impl SerializeMarker for ProviderSetApiKeyResponse {} + +#[tauri::command] +pub async fn provider_set_api_key_command( + request: ProviderSetApiKeyRequest, + credential_cache: State<'_, ProviderCredentialLoadingCache>, +) -> ResponseOrErrorMessage { + info!("provider_set_api_key_command called for provider: {:?}", request.provider_credential); + + let api_key_data = ApiKeyData::from_str(&request.api_key); + let payload = ProviderCredentialPayload::ApiKey(api_key_data); + + credential_cache.save_credentials(request.provider_credential, payload) + .map_err(|err| { + error!("Failed to save API key: {:?}", err); + "Failed to save API key" + })?; + + Ok(ProviderSetApiKeyResponse {}.into()) +} diff --git a/crates/desktop/artcraft/src/core/commands/response/failure_response_wrapper.rs b/crates/desktop/artcraft/src/core/commands/response/failure_response_wrapper.rs new file mode 100644 index 0000000000..b193791d49 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/response/failure_response_wrapper.rs @@ -0,0 +1,53 @@ +use serde::Serialize; + +/// Statuses for errorful commands. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum CommandErrorStatus { + BadRequest, // 400 + Unauthorized, // 401 + TooManyRequests, // 429 + ServerError, // 500 +} + +#[derive(Serialize, Debug)] +pub struct CommandErrorResponseWrapper { + /// Both "success" and "error" types have a `status` field for the frontend. + /// We constrain the value types for successes and failures. + pub status: CommandErrorStatus, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + + /// Represents a "type" of error, preferably an enum without payloads. + /// Preferably this serializes to a string. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_type: Option, + + /// Represents a full error details object or payload. + /// This could be a structure, message, or whatever. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_details: Option, +} + +impl From<&str> for CommandErrorResponseWrapper<(), ()> { + fn from(value: &str) -> Self { + CommandErrorResponseWrapper { + status: CommandErrorStatus::BadRequest, // NB: Default to 500 error. + error_message: Some(value.to_string()), + error_type: None, + error_details: None, + } + } +} + +impl From for CommandErrorResponseWrapper<(), ()> { + fn from(value: String) -> Self { + CommandErrorResponseWrapper { + status: CommandErrorStatus::BadRequest, // NB: Default to 500 error. + error_message: Some(value), + error_type: None, + error_details: None, + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/response/mod.rs b/crates/desktop/artcraft/src/core/commands/response/mod.rs new file mode 100644 index 0000000000..d9d4f3720d --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/response/mod.rs @@ -0,0 +1,3 @@ +pub mod failure_response_wrapper; +pub mod shorthand; +pub mod success_response_wrapper; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/response/shorthand.rs b/crates/desktop/artcraft/src/core/commands/response/shorthand.rs new file mode 100644 index 0000000000..be12a6cfea --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/response/shorthand.rs @@ -0,0 +1,57 @@ +use crate::core::commands::response::failure_response_wrapper::CommandErrorResponseWrapper; +use crate::core::commands::response::success_response_wrapper::CommandSuccessResponseWrapper; + +// TODO(needs command response refactor): +// - get_app_preferences_command +// - update_app_preferences_command +// - flip_image +// - check_sora_session_command +// - open_sora_login_command +// - sora_image_generation_command + +// TODO(already updated, need to update frontend): +// - platform_info_command + +// TODO(simplify error types): +// - enqueue_text_to_image_command +// - fal_background_removal_command +// - fal_hunyuan_image_to_3d_command +// - fal_kling_image_to_video_command +// - get_fal_api_key_command +// - set_fal_api_key_command +// - sora_image_remix_command + +/// Easy to use infallible response. +/// This always contains a success payload and never results in an error. +pub type InfallibleResponse = CommandSuccessResponseWrapper; + +/// Easy to use Result type. +/// +/// There are easier ways to use Response : +/// - `SimpleResponse` just returns strings on success or error (but provides clues to the frontend) +/// - `SuccessOrErrorMessage` returns an empty success payload or an error message +/// - `ResponseOrErrorMessage` returns a success payload or an error message +/// - `ResponseOrErrorType` returns a success payload or an error type +/// - `ResponseOrError` returns a success payload or an error payload +/// +pub type Response = + Result, CommandErrorResponseWrapper>; + +/// No inner payloads for this type. Just strings as messages. +pub type SimpleResponse = Response<(), (), ()>; + +/// Either an empty success payload or error message. +pub type SuccessOrErrorMessage = + Result, CommandErrorResponseWrapper<(), ()>>; + +/// Either a success or error message. +pub type ResponseOrErrorMessage = + Result, CommandErrorResponseWrapper<(), ()>>; + +/// Either a success or error type. +pub type ResponseOrErrorType = + Result, CommandErrorResponseWrapper>; + +/// Either a success or error payload. +pub type ResponseOrError = + Result, CommandErrorResponseWrapper<(), ErrPayload>>; diff --git a/crates/desktop/artcraft/src/core/commands/response/success_response_wrapper.rs b/crates/desktop/artcraft/src/core/commands/response/success_response_wrapper.rs new file mode 100644 index 0000000000..2666ce5f36 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/response/success_response_wrapper.rs @@ -0,0 +1,86 @@ +use serde::Serialize; + +/// Statuses for successful commands. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum CommandSuccessStatus { + Success, // 200 +} + +#[derive(Serialize, Debug)] +pub struct CommandSuccessResponseWrapper { + /// Both "success" and "error" types have a `status` field for the frontend. + /// We constrain the value types for successes and failures. + pub status: CommandSuccessStatus, + + #[serde(skip_serializing_if = "Option::is_none")] + pub success_message: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +/// NB: This is a hack because of `()` (a.k.a. the "unit" type) and `Serialize` trait coherence and overlap. +/// We cannot currently implement a trait for `()` and generic `Serialize` because `()` itself implements +/// `Serialize`. Rust's `"specialization"`, which would let us account for this, is an unstable, nightly-only +/// feature. So instead we manually mark all types other than `()` with `SerializeMarker` as a hack to +/// distinguish the two. +pub trait SerializeMarker : Serialize {} + +impl CommandSuccessResponseWrapper<()> { + pub fn empty_success() -> Self { + Self::empty() + } + pub fn empty() -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: None, + payload: None, + } + } +} + +// NB: () is Serialize! So we can't broadly implement just `T: Serialize`. +impl From<()> for CommandSuccessResponseWrapper<()> { + fn from(_: ()) -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: None, + payload: None, + } + } +} + +/// NB: Note the `"+ SerializeMarker"` bound here. This is a hack to distinguish between `()` and other types +/// for which we will manually mark as `SerializeMarker`. +impl From for CommandSuccessResponseWrapper where T: Serialize + SerializeMarker { + fn from(val: T) -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: None, + payload: Some(val), + } + } +} + +impl From<&str> for CommandSuccessResponseWrapper { + fn from(msg: &str) -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: Some(msg.to_string()), + payload: None, + } + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn test_empty_tuple_serialize() { + let response = super::CommandSuccessResponseWrapper::<()>::from(()); + let serialized = serde_json::to_string(&response).unwrap(); + // NB: This tests that the blanket "Serialize" impl doesn't produce a "null payload" field. + assert_eq!(serialized, r#"{"status":"success"}"#); + } +} diff --git a/crates/desktop/artcraft/src/core/commands/task_queue/get_task_queue_command.rs b/crates/desktop/artcraft/src/core/commands/task_queue/get_task_queue_command.rs new file mode 100644 index 0000000000..89cc80c7da --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/task_queue/get_task_queue_command.rs @@ -0,0 +1,173 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use chrono::{DateTime, Utc}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use errors::AnyhowResult; +use log::{debug, error, info, warn}; +use serde_derive::{Deserialize, Serialize}; +use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend; +use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file; +use tauri::{AppHandle, State}; +use enums::tauri::tasks::task_failure_type::TaskFailureType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::sqlite::tasks::TaskId; + +#[derive(Serialize)] +pub struct GetTaskQueueCommandResponse { + tasks: Vec, +} + +#[derive(Serialize)] +pub struct TaskQueueItem { + pub id: TaskId, + + pub task_status: TaskStatus, + pub task_type: TaskType, + pub model_type: Option, + + pub provider: Option, + pub provider_job_id: Option, + + /// If the item is done, these will be filled out. + pub completed_item: Option, + + /// If the item failed, this will be filled out. + pub failure_reason: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +#[derive(Serialize)] +pub struct CompletedItemData { + pub primary_media_file: MediaFileData, + + /// The type of file(s) generated. + pub media_file_class: Option, + + /// If generated in a batch, we probably have a batch token we can query. + pub maybe_batch_token: Option, +} + +#[derive(Serialize)] +pub struct MediaFileData { + pub token: MediaFileToken, + pub cdn_url: String, + pub maybe_thumbnail_url_template: Option, + + // NB: The frontend wants this. + pub created_at: DateTime, +} + +#[derive(Serialize)] +pub struct FailedItemData { + pub failure_type: TaskFailureType, + pub failure_message: Option, +} + +impl SerializeMarker for GetTaskQueueCommandResponse {} + +#[tauri::command] +pub async fn get_task_queue_command( + app: AppHandle, + app_env_configs: State<'_, AppEnvConfigs>, + task_database: State<'_, TaskDatabase>, +) -> ResponseOrErrorMessage { + + // NB: This is debug because it spams the logs. + debug!("get_task_queue_command called"); + + let result = handle_request( + &task_database, + ).await; + + let tasks = match result { + Ok(items) => items, + Err(err) => { + error!("get_task_queue_command failed: {:?}", err); + return Err("get_task_queue_command failed".into()) + } + }; + + Ok(GetTaskQueueCommandResponse{ + tasks, + }.into()) +} + +pub async fn handle_request( + task_database: &TaskDatabase, +) -> AnyhowResult> { + + let tasks = list_tasks_for_frontend(task_database.get_connection()) + .await?; + + let mut transformed_tasks = Vec::with_capacity(tasks.tasks.len()); + + for task in tasks.tasks.into_iter() { + let mut completed_item = None; + let mut failure_reason = None; + + if task.status == TaskStatus::CompleteSuccess { + let token_and_url = task.on_complete_primary_media_file_token + .zip(task.on_complete_primary_media_file_cdn_url); + + if let Some((primary_media_file_token, media_file_url)) = token_and_url { + completed_item = Some(CompletedItemData { + primary_media_file: MediaFileData { + token: primary_media_file_token, + cdn_url: media_file_url.clone(), + maybe_thumbnail_url_template: task.on_complete_primary_media_file_thumbnail_url_template.clone(), + // NB: This isn't the exact completion date. Also, fallback to now if missing. + created_at: task.completed_at.unwrap_or_else(Utc::now), + }, + media_file_class: task.on_complete_primary_media_file_class, + maybe_batch_token: task.on_complete_batch_token, + }); + } else { + warn!("Task {} is marked complete but has no primary media file token or URL.", task.id); + } + } else { + // If either failure field is present, fill out the failure report. + if let Some(failure_message) = task.on_failure_message.as_deref() { + failure_reason = Some(FailedItemData { + failure_type: task.on_failure_type.unwrap_or(TaskFailureType::Unknown), + failure_message: Some(failure_message.to_string()), + }); + } else if let Some(failure_type) = task.on_failure_type { + failure_reason = Some(FailedItemData { + failure_type, + failure_message: None, + }); + } + } + + transformed_tasks.push(TaskQueueItem { + id: task.id, + task_status: task.status, + task_type: task.task_type, + model_type: task.model_type, + provider: task.provider, + provider_job_id: task.provider_job_id, + created_at: task.created_at, + updated_at: task.updated_at, + completed_at: task.completed_at, + completed_item, + failure_reason, + }) + } + + Ok(transformed_tasks) +} diff --git a/crates/desktop/artcraft/src/core/commands/task_queue/mark_task_as_dismissed_command.rs b/crates/desktop/artcraft/src/core/commands/task_queue/mark_task_as_dismissed_command.rs new file mode 100644 index 0000000000..cbdeff9633 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/task_queue/mark_task_as_dismissed_command.rs @@ -0,0 +1,64 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use chrono::{DateTime, Utc}; +use errors::AnyhowResult; +use log::{error, info, warn}; +use serde_derive::{Deserialize, Serialize}; +use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend; +use sqlite_tasks::queries::mark_task_as_dismissed::mark_task_as_dismissed; +use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::sqlite::tasks::TaskId; + +#[derive(Deserialize)] +pub struct MarkTaskAsDismissedRequest { + task: TaskId, +} + +#[derive(Serialize)] +pub struct MarkTaskAsDismissedResponse { + success: bool, +} + +impl SerializeMarker for MarkTaskAsDismissedResponse {} + +#[tauri::command] +pub async fn mark_task_as_dismissed_command( + request: MarkTaskAsDismissedRequest, + app: AppHandle, + app_env_configs: State<'_, AppEnvConfigs>, + task_database: State<'_, TaskDatabase>, +) -> ResponseOrErrorMessage { + + info!("mark_task_as_dismissed_command called"); + + let result = handle_request( + &request.task, + &task_database, + ).await; + + if let Err(err) = &result { + error!("mark_task_as_dismissed_command failed: {:?}", err); + return Err("mark_task_as_dismissed_command failed".into()) + } + + Ok(MarkTaskAsDismissedResponse{ + success: true, + }.into()) +} + +pub async fn handle_request( + task_id: &TaskId, + task_database: &TaskDatabase, +) -> AnyhowResult<()> { + let _result = mark_task_as_dismissed(task_database.get_connection(), task_id).await?; + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/commands/task_queue/mod.rs b/crates/desktop/artcraft/src/core/commands/task_queue/mod.rs new file mode 100644 index 0000000000..2bcdeb6515 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/task_queue/mod.rs @@ -0,0 +1,3 @@ +pub mod get_task_queue_command; +pub mod mark_task_as_dismissed_command; +pub mod tasks_nuke_all_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/commands/task_queue/tasks_nuke_all_command.rs b/crates/desktop/artcraft/src/core/commands/task_queue/tasks_nuke_all_command.rs new file mode 100644 index 0000000000..dac2300aab --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/task_queue/tasks_nuke_all_command.rs @@ -0,0 +1,57 @@ +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::{Provider, ProviderPriorityStore}; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use chrono::{DateTime, Utc}; +use errors::AnyhowResult; +use log::{error, info, warn}; +use serde_derive::{Deserialize, Serialize}; +use sqlite_tasks::queries::dismiss_all_tasks::dismiss_all_tasks; +use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend; +use sqlite_tasks::queries::mark_task_as_dismissed::mark_task_as_dismissed; +use sqlite_tasks::queries::nuke_all_tasks::nuke_all_tasks; +use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::sqlite::tasks::TaskId; + +#[derive(Serialize)] +pub struct TasksNukeAllResponse { + success: bool, +} + +impl SerializeMarker for TasksNukeAllResponse {} + +#[tauri::command] +pub async fn tasks_nuke_all_command( + task_database: State<'_, TaskDatabase>, +) -> ResponseOrErrorMessage { + + info!("tasks_nuke_all_command called"); + + let result = handle_request( + &task_database, + ).await; + + if let Err(err) = &result { + error!("tasks_nuke_all_command failed: {:?}", err); + return Err("tasks_nuke_all_command failed".into()) + } + + Ok(TasksNukeAllResponse{ + success: true, + }.into()) +} + +pub async fn handle_request( + task_database: &TaskDatabase, +) -> AnyhowResult<()> { + let _result = nuke_all_tasks(task_database.get_connection()).await?; + let _result = dismiss_all_tasks(task_database.get_connection()).await?; + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/events/basic_sendable_event_trait.rs b/crates/desktop/artcraft/src/core/events/basic_sendable_event_trait.rs new file mode 100644 index 0000000000..8eae34f14b --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/basic_sendable_event_trait.rs @@ -0,0 +1,59 @@ +use crate::core::events::sendable_event_error::SendableEventError; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use log::{error, info}; +use serde::Serialize; +use std::fmt::Debug; +use tauri::{AppHandle, Emitter}; + +// Tagged enums here are waaay too "Rustic" +// #[derive(Clone, Serialize)] +// #[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "event", content = "data")] +// pub enum BasicEvent { +// Success(T), +// Failure(T), +// } + +#[derive(Copy, Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BasicEventStatus { + Success, + Failure, +} + +/// Wrap the event with "status" and "data" fields. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +struct BasicEventWrapper { + status: BasicEventStatus, + data: T, +} + +pub trait BasicSendableEvent : Clone + Debug + Serialize { + /// This is the name of the event that the frontend subscribes to. + const FRONTEND_EVENT_NAME: TauriEventName; + + /// This is the status of the event: Success, Failure, etc. + const EVENT_STATUS: BasicEventStatus; + + /// Default implementation of send(). + /// This serializes and sends the event to the frontend. + fn send(&self, app: &AppHandle) -> Result<(), SendableEventError> { + let wrapped = BasicEventWrapper { + status: Self::EVENT_STATUS, + data: self.clone(), + }; + info!("Sending event to frontend: {} ; payload = {:?}", Self::FRONTEND_EVENT_NAME.to_str(), wrapped); + let result = app.emit(Self::FRONTEND_EVENT_NAME.to_str(), wrapped); + if let Err(err) = result { + return Err(SendableEventError::from(err)); + } + Ok(()) + } + + fn send_infallible(&self, app: &AppHandle) { + if let Err(err) = self.send(app) { + // Fail open + error!("Failed to send event `{}`: {:?}", Self::FRONTEND_EVENT_NAME.to_str(), err); + } + } +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/canvas_background_removal_complete_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/canvas_background_removal_complete_event.rs new file mode 100644 index 0000000000..54fd74decb --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/canvas_background_removal_complete_event.rs @@ -0,0 +1,19 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct CanvasBackgroundRemovalCompleteEvent { + pub media_token: MediaFileToken, + pub image_cdn_url: Url, + pub maybe_frontend_subscriber_id: Option, + pub maybe_frontend_subscriber_payload: Option, +} + +impl BasicSendableEvent for CanvasBackgroundRemovalCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::CanvasBgRemovedEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/credits_balance_changed_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/credits_balance_changed_event.rs new file mode 100644 index 0000000000..c295351c19 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/credits_balance_changed_event.rs @@ -0,0 +1,17 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct CreditsBalanceChangedEvent { + // NB: We don't know how much was added or removed, just that it changed. + // The frontend should re-query the balance +} + +impl BasicSendableEvent for CreditsBalanceChangedEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::CreditsBalanceChangedEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/gaussian_generation_complete_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/gaussian_generation_complete_event.rs new file mode 100644 index 0000000000..51ffda4794 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/gaussian_generation_complete_event.rs @@ -0,0 +1,26 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GaussianGenerationCompleteEvent { + pub generated_gaussian: Option, + pub maybe_frontend_subscriber_id: Option, + pub maybe_frontend_subscriber_payload: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GeneratedGaussian { + pub media_token: MediaFileToken, + pub cdn_url: Url, + pub maybe_thumbnail_template: Option, +} + +impl BasicSendableEvent for GaussianGenerationCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GaussianGenerationCompleteEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/image_edit_complete_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/image_edit_complete_event.rs new file mode 100644 index 0000000000..ca89dcbb22 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/image_edit_complete_event.rs @@ -0,0 +1,27 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ImageEditCompleteEvent { + // pub source_media_token: MediaFileToken, + pub edited_images: Vec, + pub maybe_frontend_subscriber_id: Option, + pub maybe_frontend_subscriber_payload: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct EditedImage { + pub media_token: MediaFileToken, + pub cdn_url: Url, + pub maybe_thumbnail_template: Option, +} + +impl BasicSendableEvent for ImageEditCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::ImageEditCompleteEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/media_file_deleted_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/media_file_deleted_event.rs new file mode 100644 index 0000000000..a77d15a0d5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/media_file_deleted_event.rs @@ -0,0 +1,16 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct MediaFileDeletedEvent { + pub media_file_token: MediaFileToken, +} + +impl BasicSendableEvent for MediaFileDeletedEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::MediaFileDeletedEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/mod.rs b/crates/desktop/artcraft/src/core/events/functional_events/mod.rs new file mode 100644 index 0000000000..2ef485383a --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/mod.rs @@ -0,0 +1,12 @@ +pub mod canvas_background_removal_complete_event; +pub mod credits_balance_changed_event; +pub mod gaussian_generation_complete_event; +pub mod image_edit_complete_event; +pub mod media_file_deleted_event; +pub mod object_generation_complete_event; +pub mod refresh_account_state_event; +pub mod show_provider_billing_modal_event; +pub mod show_provider_login_modal_event; +pub mod subscription_plan_changed_event; +pub mod text_to_image_generation_complete_event; +pub mod video_generation_complete_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/events/functional_events/object_generation_complete_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/object_generation_complete_event.rs new file mode 100644 index 0000000000..43b2311886 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/object_generation_complete_event.rs @@ -0,0 +1,26 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ObjectGenerationCompleteEvent { + pub generated_object: Option, + pub maybe_frontend_subscriber_id: Option, + pub maybe_frontend_subscriber_payload: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GeneratedObject { + pub media_token: MediaFileToken, + pub cdn_url: Url, + pub maybe_thumbnail_template: Option, +} + +impl BasicSendableEvent for ObjectGenerationCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::ObjectGenerationCompleteEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/refresh_account_state_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/refresh_account_state_event.rs new file mode 100644 index 0000000000..56d55ad94f --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/refresh_account_state_event.rs @@ -0,0 +1,19 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +/// Send a signal to the frontend that an account state has changed: login, +/// logout, invalidated, etc. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct RefreshAccountStateEvent { + /// The frontend can optionally decide whether it wants to refresh based + /// on the impacted provider account. + pub provider: Option, +} + +impl BasicSendableEvent for RefreshAccountStateEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::RefreshAccountStateEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/show_provider_billing_modal_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/show_provider_billing_modal_event.rs new file mode 100644 index 0000000000..1aaa4b5102 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/show_provider_billing_modal_event.rs @@ -0,0 +1,25 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tauri::AppHandle; + +/// Send a signal to the frontend to show a modal that suggests fixing or setting up +/// service billing requirements. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ShowProviderBillingModalEvent { + pub provider: GenerationProvider, +} + +impl ShowProviderBillingModalEvent { + pub fn send_for_provider(provider: GenerationProvider, app: &AppHandle) { + let event = Self { provider }; + event.send_infallible(&app); + } +} + +impl BasicSendableEvent for ShowProviderBillingModalEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::ShowProviderBillingModalEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/show_provider_login_modal_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/show_provider_login_modal_event.rs new file mode 100644 index 0000000000..9e4726c367 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/show_provider_login_modal_event.rs @@ -0,0 +1,24 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tauri::AppHandle; + +/// Send a signal to the frontend to show a modal that suggests service setup. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ShowProviderLoginModalEvent { + pub provider: GenerationProvider, +} + +impl ShowProviderLoginModalEvent { + pub fn send_for_provider(provider: GenerationProvider, app: &AppHandle) { + let event = Self { provider }; + event.send_infallible(&app); + } +} + +impl BasicSendableEvent for ShowProviderLoginModalEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::ShowProviderLoginModalEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/subscription_plan_changed_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/subscription_plan_changed_event.rs new file mode 100644 index 0000000000..78174ce4b8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/subscription_plan_changed_event.rs @@ -0,0 +1,18 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct SubscriptionPlanChangedEvent { + // NB: We don't know what plan was changed (subscribed, upgraded, canceled, etc.), + // just that it changed. The actual fulfillment is async anyway. + // The frontend should re-query the subscription plan after a short delay. +} + +impl BasicSendableEvent for SubscriptionPlanChangedEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::SubscriptionPlanChangedEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/text_to_image_generation_complete_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/text_to_image_generation_complete_event.rs new file mode 100644 index 0000000000..31736a94bd --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/text_to_image_generation_complete_event.rs @@ -0,0 +1,26 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct TextToImageGenerationCompleteEvent { + pub generated_images: Vec, + pub maybe_frontend_subscriber_id: Option, + pub maybe_frontend_subscriber_payload: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GeneratedImage { + pub media_token: MediaFileToken, + pub cdn_url: Url, + pub maybe_thumbnail_template: Option, +} + +impl BasicSendableEvent for TextToImageGenerationCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::TextToImageGenerationCompleteEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/functional_events/video_generation_complete_event.rs b/crates/desktop/artcraft/src/core/events/functional_events/video_generation_complete_event.rs new file mode 100644 index 0000000000..5e20bcf548 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/functional_events/video_generation_complete_event.rs @@ -0,0 +1,26 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use url::Url; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct VideoGenerationCompleteEvent { + pub generated_video: Option, + pub maybe_frontend_subscriber_id: Option, + pub maybe_frontend_subscriber_payload: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GeneratedVideo { + pub media_token: MediaFileToken, + pub cdn_url: Url, + pub maybe_thumbnail_template: Option, +} + +impl BasicSendableEvent for VideoGenerationCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::VideoGenerationCompleteEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/generation_events/common.rs b/crates/desktop/artcraft/src/core/events/generation_events/common.rs new file mode 100644 index 0000000000..12484e62fc --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/generation_events/common.rs @@ -0,0 +1,164 @@ +use serde::Serialize; + +#[derive(Debug, Copy, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationServiceProvider { + Artcraft, + Fal, + Grok, + Midjourney, + Sora, + WorldLabs, + Higgsfield, + Krea, + Leonardo, + Magnific, + Openart, + Picsart, + Pixverse, + Runway, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationModel { + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_dev_juggernaut")] + FluxDevJuggernaut, + #[serde(rename = "flux_pro_1")] + FluxPro1, + #[serde(rename = "flux_pro_1.1")] + FluxPro11, + #[serde(rename = "flux_pro_1.1_ultra")] + FluxPro11Ultra, + + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + #[serde(rename = "grok_image")] + GrokImage, + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + + #[serde(rename = "hunyuan_3d_2_0")] + Hunyuan3d2_0, + #[serde(rename = "hunyuan_3d_2_1")] + Hunyuan3d2_1, + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + + #[serde(rename = "worldlabs_marble")] + WorldlabsMarble, + #[serde(rename = "worldlabs_marble_0p1_mini")] + WorldlabsMarble0p1Mini, + #[serde(rename = "worldlabs_marble_0p1_plus")] + WorldlabsMarble0p1Plus, + + // Generic Midjourney model, version unknown. + #[serde(rename = "midjourney")] + Midjourney, + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, + + // Generic Grok video model, version unknown. + #[serde(rename = "grok_video")] + GrokVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + // TODO: Should be Kling16Pro + #[serde(rename = "kling_1.6")] + Kling1_6, + #[serde(rename = "kling_2.0")] + Kling2_0, + #[serde(rename = "kling_2.1_master")] + Kling21Master, + #[serde(rename = "kling_2.1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "recraft_3")] + Recraft3, + #[serde(rename = "sora")] + Sora, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "seedance_1.0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + #[serde(rename = "seedance_2p0")] + Seedance2p0, + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, + + /// An API model ID that does not need a compiled desktop variant. + #[serde(untagged)] + Unknown(String), +} + +#[derive(Debug, Copy, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationAction { + GenerateImage, + GenerateVideo, + GenerateAudio, + RemoveBackground, + #[serde(rename = "image_to_3d")] + ImageTo3d, + GenerateGaussian, + #[serde(rename = "image_inpaint_edit")] + ImageInpaintEdit, +} diff --git a/crates/desktop/artcraft/src/core/events/generation_events/generation_complete_event.rs b/crates/desktop/artcraft/src/core/events/generation_events/generation_complete_event.rs new file mode 100644 index 0000000000..e1c225dc41 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/generation_events/generation_complete_event.rs @@ -0,0 +1,17 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GenerationCompleteEvent { + pub action: Option, + pub service: GenerationServiceProvider, + pub model: Option, +} + +impl BasicSendableEvent for GenerationCompleteEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationCompleteEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/generation_events/generation_enqueue_failure_event.rs b/crates/desktop/artcraft/src/core/events/generation_events/generation_enqueue_failure_event.rs new file mode 100644 index 0000000000..c55620f983 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/generation_events/generation_enqueue_failure_event.rs @@ -0,0 +1,40 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GenerationEnqueueFailureEvent { + pub action: GenerationAction, + pub service: GenerationServiceProvider, + pub model: Option, + + /// User-facing reason for the failure, if available. + pub reason: Option, +} + +impl BasicSendableEvent for GenerationEnqueueFailureEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationEnqueueFailureEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure; +} + +impl GenerationEnqueueFailureEvent { + pub fn no_artcraft_credentials(action: GenerationAction) -> Self { + Self { + action, + service: GenerationServiceProvider::Artcraft, + model: None, + reason: Some("Please log into Artcraft.".to_string()), + } + } + + pub fn no_fal_api_key(action: GenerationAction) -> Self { + Self { + action, + service: GenerationServiceProvider::Fal, + model: None, + reason: Some("No FAL API key is set. Configure this in your settings.".to_string()), + } + } +} diff --git a/crates/desktop/artcraft/src/core/events/generation_events/generation_enqueue_success_event.rs b/crates/desktop/artcraft/src/core/events/generation_events/generation_enqueue_success_event.rs new file mode 100644 index 0000000000..7737d3477a --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/generation_events/generation_enqueue_success_event.rs @@ -0,0 +1,17 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GenerationEnqueueSuccessEvent { + pub action: GenerationAction, + pub service: GenerationServiceProvider, + pub model: Option, +} + +impl BasicSendableEvent for GenerationEnqueueSuccessEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationEnqueueSuccessEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/desktop/artcraft/src/core/events/generation_events/generation_failed_event.rs b/crates/desktop/artcraft/src/core/events/generation_events/generation_failed_event.rs new file mode 100644 index 0000000000..f4d580fc43 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/generation_events/generation_failed_event.rs @@ -0,0 +1,20 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct GenerationFailedEvent { + pub action: GenerationAction, + pub service: GenerationServiceProvider, + pub model: Option, + + /// User-facing reason for the failure, if available. + pub reason: Option, +} + +impl BasicSendableEvent for GenerationFailedEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationFailedEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure; +} diff --git a/crates/desktop/artcraft/src/core/events/generation_events/mod.rs b/crates/desktop/artcraft/src/core/events/generation_events/mod.rs new file mode 100644 index 0000000000..67ff3ba2df --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/generation_events/mod.rs @@ -0,0 +1,5 @@ +pub mod common; +pub mod generation_enqueue_failure_event; +pub mod generation_enqueue_success_event; +pub mod generation_complete_event; +pub mod generation_failed_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/events/mod.rs b/crates/desktop/artcraft/src/core/events/mod.rs new file mode 100644 index 0000000000..f3e7c25b00 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/mod.rs @@ -0,0 +1,6 @@ +pub mod basic_sendable_event_trait; +pub mod functional_events; +pub mod generation_events; +pub mod sendable_event_error; +pub mod sendable_event_trait; +pub mod warning_events; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/events/sendable_event_error.rs b/crates/desktop/artcraft/src/core/events/sendable_event_error.rs new file mode 100644 index 0000000000..25d4a57433 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/sendable_event_error.rs @@ -0,0 +1,31 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum SendableEventError { + TauriError(tauri::Error), + AnyhowError(anyhow::Error), +} + +impl Error for SendableEventError {} + +impl Display for SendableEventError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::TauriError(err) => write!(f, "SendableEventError::TauriError: {}", err), + Self::AnyhowError(err) => write!(f, "SendableEventError::AnyhowError: {}", err), + } + } +} + +impl From for SendableEventError { + fn from(err: anyhow::Error) -> Self { + SendableEventError::AnyhowError(err) + } +} + +impl From for SendableEventError { + fn from(err: tauri::Error) -> Self { + SendableEventError::TauriError(err) + } +} diff --git a/crates/desktop/artcraft/src/core/events/sendable_event_trait.rs b/crates/desktop/artcraft/src/core/events/sendable_event_trait.rs new file mode 100644 index 0000000000..11641ba869 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/sendable_event_trait.rs @@ -0,0 +1,23 @@ +use crate::core::events::sendable_event_error::SendableEventError; +use log::info; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +/// Implement this on a Serializable object to be able to send it as an event to the frontend. +#[deprecated(note = "Use BasicSendableEvent instead as it wraps the event with status and data fields.")] +pub trait SendableEvent : Serialize { + /// This is the name of the event that the frontend subscribes to. + const FRONTEND_EVENT_NAME: &'static str; + + /// Default implementation of send(). + /// This serializes and sends the event to the frontend. + #[deprecated(note = "Use BasicSendableEvent instead as it wraps the event with status and data fields.")] + fn send(&self, app: &AppHandle) -> Result<(), SendableEventError> { + info!("Sending event to frontend: {}", Self::FRONTEND_EVENT_NAME); + let result = app.emit(Self::FRONTEND_EVENT_NAME, self); + if let Err(err) = result { + return Err(SendableEventError::from(err)); + } + Ok(()) + } +} diff --git a/crates/desktop/artcraft/src/core/events/warning_events/flash_file_download_error_event.rs b/crates/desktop/artcraft/src/core/events/warning_events/flash_file_download_error_event.rs new file mode 100644 index 0000000000..a1bea798a4 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/warning_events/flash_file_download_error_event.rs @@ -0,0 +1,26 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; +use std::path::PathBuf; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct FlashFileDownloadErrorEvent { + pub error_type: FlashFileDownloadErrorType, + pub filename: Option, + pub message: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FlashFileDownloadErrorType { + FileAlreadyDownloaded, + FilesystemError, + NetworkError, + UnknownError, +} + +impl BasicSendableEvent for FlashFileDownloadErrorEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::FlashFileDownloadErrorEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure; +} diff --git a/crates/desktop/artcraft/src/core/events/warning_events/flash_user_input_error_event.rs b/crates/desktop/artcraft/src/core/events/warning_events/flash_user_input_error_event.rs new file mode 100644 index 0000000000..8586d52a34 --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/warning_events/flash_user_input_error_event.rs @@ -0,0 +1,14 @@ +use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct FlashUserInputErrorEvent { + pub message: String, +} + +impl BasicSendableEvent for FlashUserInputErrorEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::FlashUserInputErrorEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure; +} diff --git a/crates/desktop/artcraft/src/core/events/warning_events/mod.rs b/crates/desktop/artcraft/src/core/events/warning_events/mod.rs new file mode 100644 index 0000000000..c7d712d92d --- /dev/null +++ b/crates/desktop/artcraft/src/core/events/warning_events/mod.rs @@ -0,0 +1,2 @@ +pub mod flash_file_download_error_event; +pub mod flash_user_input_error_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/lifecycle/mod.rs b/crates/desktop/artcraft/src/core/lifecycle/mod.rs new file mode 100644 index 0000000000..f58fb458ae --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/mod.rs @@ -0,0 +1 @@ +pub mod startup; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs new file mode 100644 index 0000000000..033ea2236e --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs @@ -0,0 +1,136 @@ +use crate::core::lifecycle::startup::tasks::bootstrap_task_database::bootstrap_task_database; +use crate::core::lifecycle::startup::tasks::initially_size_and_position_windows::initially_size_and_position_windows; +use crate::core::lifecycle::startup::tasks::load_provider_priority_state::load_provider_priority_state; +use crate::core::lifecycle::startup::tasks::set_app_log_level::set_app_log_level; +use crate::core::lifecycle::startup::tasks::spawn_discord_presence_thread::spawn_discord_presence_thread; +use crate::core::lifecycle::startup::tasks::spawn_main_window_thread::spawn_main_window_thread; +use crate::core::lifecycle::startup::tasks::spawn_sora_task_polling_thread::spawn_sora_task_polling_thread; +use crate::core::lifecycle::startup::tasks::spawn_storyteller_threads::spawn_storyteller_threads; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::GrokImagePromptQueue; +use crate::services::grok::threads::grok_image_websocket_thread::grok_image_websocket_thread::grok_image_websocket_thread; +use crate::services::grok::threads::grok_video_task_polling::grok_video_task_polling_thread::grok_video_task_polling_thread; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::threads::midjourney_long_polling_thread::midjourney_long_polling_thread; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::threads::third_party_task_polling_thread::third_party_task_polling_thread::third_party_task_polling_thread; +use crate::services::worldlabs::threads::worldlabs_marble_task_polling::worldlabs_marble_task_polling; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub async fn handle_tauri_startup( + app: AppHandle, + root: AppDataRoot, + app_env_configs: AppEnvConfigs, + artcraft_platform_info: ArtcraftPlatformInfo, + artcraft_usage_tracker: ArtcraftUsageTracker, + storyteller_creds_manager: StorytellerCredentialManager, + sora_credential_manager: SoraCredentialManager, + sora_task_queue: SoraTaskQueue, + mj_creds_manager: MidjourneyCredentialManager, + grok_creds_manager: GrokCredentialManager, + grok_image_prompt_queue: GrokImagePromptQueue, + worldlabs_bearer_bridge: WorldlabsBearerBridge, + worldlabs_creds_manager: WorldlabsCredentialManager, + credential_cache: ProviderCredentialLoadingCache, +) -> AnyhowResult<()> { + + set_app_log_level( + &app, + &root, + )?; + + let task_database = + bootstrap_task_database(&app, &root).await?; + + load_provider_priority_state( + &app, + &root, + )?; + + spawn_main_window_thread( + &app, + &root, + &storyteller_creds_manager, + )?; + + spawn_storyteller_threads( + &app, + &app_env_configs, + &artcraft_usage_tracker, + &artcraft_platform_info, + &task_database, + &storyteller_creds_manager, + )?; + + spawn_sora_task_polling_thread( + &app, + &root, + &app_env_configs, + &task_database, + &sora_credential_manager, + &storyteller_creds_manager, + &sora_task_queue, + )?; + + tauri::async_runtime::spawn(grok_video_task_polling_thread( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + grok_creds_manager.clone(), + storyteller_creds_manager.clone(), + )); + + tauri::async_runtime::spawn(grok_image_websocket_thread( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + grok_creds_manager.clone(), + grok_image_prompt_queue.clone(), + storyteller_creds_manager.clone(), + )); + + tauri::async_runtime::spawn(midjourney_long_polling_thread( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + mj_creds_manager.clone(), + storyteller_creds_manager.clone(), + )); + + tauri::async_runtime::spawn(worldlabs_marble_task_polling( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + worldlabs_creds_manager.clone(), + storyteller_creds_manager.clone(), + )); + + tauri::async_runtime::spawn(third_party_task_polling_thread( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + storyteller_creds_manager.clone(), + credential_cache, + )); + + spawn_discord_presence_thread()?; + + initially_size_and_position_windows(&app, &root); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/mod.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/mod.rs new file mode 100644 index 0000000000..07db4020c0 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/mod.rs @@ -0,0 +1,3 @@ +mod tasks; +pub mod handle_tauri_startup; +pub mod setup_main_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs new file mode 100644 index 0000000000..391b638296 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs @@ -0,0 +1,99 @@ +use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME; +use errors::AnyhowResult; +use tauri::window::Color; +use tauri::{AppHandle, TitleBarStyle, WebviewUrl, WebviewWindowBuilder}; + +pub async fn setup_main_window( + app: &AppHandle, +) -> AnyhowResult<()> { + + let win_builder = + WebviewWindowBuilder::new(app, MAIN_WINDOW_NAME, WebviewUrl::default()) + .title("ArtCraft") + .resizable(true) + .fullscreen(false) + .background_color(Color(0, 0, 0, 0)) + .enable_clipboard_access() + .inner_size(2400.0, 1300.0); + + #[cfg(target_os = "macos")] + let win_builder = win_builder + .decorations(true) // NB: Mac requires decorations. Tons of capabilities disappear otherwise. + .title_bar_style(TitleBarStyle::Overlay) + .hidden_title(true) + .accept_first_mouse(true) // https://github.com/tauri-apps/tauri/issues/11605#issuecomment-2460112096 + .focusable(true) + .focused(true); + + // NB: Setting decorations(false) "breaks" Mac, though I never documented how/why. + // NB: On Linux, setting decorations(false) will cause the window to be unable to be + // resized, despite the arrow cursor indicating resize is possible. This might be + // fixable another way, but for now it seems fine to disable this. There also seem to + // be some performance loss with decorations(false), but I might be imagining it. + #[cfg(target_os = "windows")] + let win_builder = win_builder + .decorations(false); // NB: This breaks Mac! (And breaks resize on Linux) + + // On macOS, Cmd+A/C/V/X/Z and friends are dispatched by AppKit through the application + // menu. Without an Edit submenu containing the standard predefined items, these + // shortcuts never reach the WKWebView and text inputs behave as if Cmd is unmapped. + #[cfg(target_os = "macos")] + install_macos_app_menu(app)?; + + let _window = win_builder.build()?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn install_macos_app_menu(app: &AppHandle) -> AnyhowResult<()> { + use tauri::menu::{Menu, PredefinedMenuItem, Submenu}; + + let app_submenu = Submenu::with_items( + app, + "ArtCraft", + true, + &[ + &PredefinedMenuItem::about(app, None, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::show_all(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?; + + let edit_submenu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + + let window_submenu = Submenu::with_items( + app, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?; + + let menu = Menu::with_items(app, &[&app_submenu, &edit_submenu, &window_submenu])?; + app.set_menu(menu)?; + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/bootstrap_task_database.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/bootstrap_task_database.rs new file mode 100644 index 0000000000..343241fd17 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/bootstrap_task_database.rs @@ -0,0 +1,10 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::task_database::TaskDatabase; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub async fn bootstrap_task_database(app: &AppHandle, root: &AppDataRoot) -> AnyhowResult { + let task_database = TaskDatabase::connect(root).await?; + app.manage(task_database.clone()); + Ok(task_database) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/initially_size_and_position_windows.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/initially_size_and_position_windows.rs new file mode 100644 index 0000000000..c2c04e51d3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/initially_size_and_position_windows.rs @@ -0,0 +1,42 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::window::main_window_position::MainWindowPosition; +use crate::core::state::window::main_window_size::MainWindowSize; +use crate::core::utils::window::position_main_window::position_main_window; +use crate::core::utils::window::resize_main_window::resize_main_window; +use tauri::AppHandle; + +pub fn initially_size_and_position_windows( + app: &AppHandle, + root: &AppDataRoot, +) { + + println!("Sizing and positioning window..."); + + match MainWindowSize::from_filesystem_configs(&root) { + Ok(None) => {} + Ok(Some(size)) => { + println!("Resizing window to: {:?}", size); + let result = resize_main_window(app, &size); + if let Err(err) = result { + eprintln!("Could not set window size: {:?}", err); + } + } + Err(err) => { + eprintln!("Failed to read window size from disk: {:?}", err); + } + } + + match MainWindowPosition::from_filesystem_configs(&root) { + Ok(None) => {} + Ok(Some(pos)) => { + println!("Moving window to: {:?}", pos); + let result = position_main_window(app, &pos); + if let Err(err) = result { + eprintln!("Could not set window position: {:?}", err); + } + } + Err(err) => { + eprintln!("Failed to read window position from disk: {:?}", err); + } + } +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/load_provider_priority_state.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/load_provider_priority_state.rs new file mode 100644 index 0000000000..58d300d5a8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/load_provider_priority_state.rs @@ -0,0 +1,34 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use anyhow::anyhow; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub fn load_provider_priority_state( + app: &AppHandle, + root: &AppDataRoot, +) -> AnyhowResult<()> { + let provider_priority = match ProviderPriorityStore::from_filesystem_configs(root) { + Ok(Some(priority)) => { + println!("Loaded provider priority from disk: {:?}", priority.get_priority()); + priority + } + Ok(None) => { + println!("No provider priority found on disk, using default."); + ProviderPriorityStore::default() + } + Err(err) => { + eprintln!("Failed to read provider priority from disk: {:?}", err); + ProviderPriorityStore::default() + } + }; + + let success = app.manage(provider_priority); + + if !success { + eprintln!("Failed to manage provider priority state."); + return Err(anyhow!("Failed to manage provider priority state.")); + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs new file mode 100644 index 0000000000..b69bc77f05 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs @@ -0,0 +1,8 @@ +pub (super) mod bootstrap_task_database; +pub (super) mod set_app_log_level; +pub (super) mod initially_size_and_position_windows; +pub (super) mod spawn_discord_presence_thread; +pub (super) mod spawn_main_window_thread; +pub (super) mod spawn_sora_task_polling_thread; +pub (super) mod spawn_storyteller_threads; +pub (super) mod load_provider_priority_state; diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/set_app_log_level.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/set_app_log_level.rs new file mode 100644 index 0000000000..471b24866a --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/set_app_log_level.rs @@ -0,0 +1,73 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use log::LevelFilter; +use std::env; +use std::env::VarError; +use tauri::AppHandle; +use tauri_plugin_log::{Target, TargetKind}; + +const RUST_LOG: &str = "RUST_LOG"; + +pub fn set_app_log_level( + app: &AppHandle, + root: &AppDataRoot, +) -> AnyhowResult<()> { + + //print_env_var(RUST_LOG); + + // NB: The semantics of this do not match `RUST_LOG` exactly as it's not set per-package. + let maybe_level = env::var(RUST_LOG) + .map(|value| value.trim().to_ascii_lowercase()); + + let mut log_level = LevelFilter::Info; + + match maybe_level { + Ok(level) => { + println!("The `RUST_LOG` environment variable was set to: `{}`.", level); + + if level.contains("debug") { + log_level = LevelFilter::Debug; + } else if level.contains("info") { + log_level = LevelFilter::Info; + } else if level.contains("warn") { + log_level = LevelFilter::Warn; + } else if level.contains("error") { + log_level = LevelFilter::Error; + } else if level.contains("off") { + log_level = LevelFilter::Off; + } + } + Err(VarError::NotPresent) => { + // NB: This demonstrably tests stderr output in the default case. + eprintln!("No `RUST_LOG` environment variable found."); + } + Err(VarError::NotUnicode(_)) => { + // NB: This demonstrably tests stderr output in the default case. + eprintln!("Could not parse `RUST_LOG` environment variable; not unicode."); + } + } + + println!("Setting app log level to: {:?}", log_level); + + app.plugin( + tauri_plugin_log::Builder::default() + .level(log_level) + .targets(vec![ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::LogDir { + file_name: Some(root.log_file_name_str().to_string()) + }), + ]) + .build(), + )?; + + Ok(()) +} + +// pub fn print_env_var(var_name: &str) { +// match env::var(var_name) { +// Ok(value) => println!("Environment variable `{}` = {}", var_name, value), +// Err(VarError::NotPresent) => println!("Environment variable `{}` is not set.", var_name), +// Err(VarError::NotUnicode(_)) => println!("Environment variable `{}` is not a valid Unicode string", var_name), +// } +// } diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_discord_presence_thread.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_discord_presence_thread.rs new file mode 100644 index 0000000000..24b12c0d52 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_discord_presence_thread.rs @@ -0,0 +1,10 @@ +use crate::core::threads::discord_presence_thread::discord_presence_thread; +use errors::AnyhowResult; + +pub fn spawn_discord_presence_thread( +) -> AnyhowResult<()> { + + tauri::async_runtime::spawn(discord_presence_thread()); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_main_window_thread.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_main_window_thread.rs new file mode 100644 index 0000000000..85b54a4e56 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_main_window_thread.rs @@ -0,0 +1,20 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::threads::main_window_thread::main_window_thread::main_window_thread; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub fn spawn_main_window_thread( + app: &AppHandle, + root: &AppDataRoot, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + + tauri::async_runtime::spawn(main_window_thread( + app.clone(), + root.clone(), + storyteller_creds_manager.clone(), + )); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_sora_task_polling_thread.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_sora_task_polling_thread.rs new file mode 100644 index 0000000000..8606e95140 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_sora_task_polling_thread.rs @@ -0,0 +1,32 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub fn spawn_sora_task_polling_thread( + app: &AppHandle, + root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + task_database: &TaskDatabase, + sora_credential_manager: &SoraCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, + sora_task_queue: &SoraTaskQueue, +) -> AnyhowResult<()> { + + tauri::async_runtime::spawn(sora_task_polling_thread( + app.clone(), + app_env_configs.clone(), + root.clone(), + task_database.clone(), + sora_credential_manager.clone(), + storyteller_creds_manager.clone(), + sora_task_queue.clone(), + )); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storyteller_threads.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storyteller_threads.rs new file mode 100644 index 0000000000..680c21c414 --- /dev/null +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storyteller_threads.rs @@ -0,0 +1,35 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::core::state::task_database::TaskDatabase; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::threads::storyteller_activity_thread::storyteller_activity_thread; +use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread::storyteller_task_polling_thread; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub fn spawn_storyteller_threads( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + artcraft_usage_tracker: &ArtcraftUsageTracker, + artcraft_platform_info: &ArtcraftPlatformInfo, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + + tauri::async_runtime::spawn(storyteller_task_polling_thread( + app.clone(), + app_env_configs.clone(), + task_database.clone(), + storyteller_creds_manager.clone(), + )); + + tauri::async_runtime::spawn(storyteller_activity_thread( + app_env_configs.clone(), + artcraft_platform_info.clone(), + artcraft_usage_tracker.clone(), + storyteller_creds_manager.clone(), + )); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/mod.rs b/crates/desktop/artcraft/src/core/mod.rs new file mode 100644 index 0000000000..cbdc9bd9fb --- /dev/null +++ b/crates/desktop/artcraft/src/core/mod.rs @@ -0,0 +1,10 @@ +pub mod api_adapters; +pub mod artcraft_error; +pub mod commands; +pub mod events; +pub mod lifecycle; +pub mod providers; +pub mod state; +pub mod threads; +pub mod utils; +pub mod windows; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/providers/credentials/mod.rs b/crates/desktop/artcraft/src/core/providers/credentials/mod.rs new file mode 100644 index 0000000000..1af6c8b918 --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/mod.rs @@ -0,0 +1,4 @@ +pub mod payload; +pub mod provider_credential_key; +pub mod provider_credential_loading_cache; +pub mod provider_credential_type; diff --git a/crates/desktop/artcraft/src/core/providers/credentials/payload/api_key.rs b/crates/desktop/artcraft/src/core/providers/credentials/payload/api_key.rs new file mode 100644 index 0000000000..c00bfedcde --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/payload/api_key.rs @@ -0,0 +1,100 @@ +use std::error::Error; +use std::fmt::Display; +use std::path::Path; + +#[derive(Clone)] +pub struct ApiKeyData(String); + +impl ApiKeyData { + pub fn from_str(s: &str) -> Self { + Self(s.trim().to_string()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn load_from_file>(file_path: P) -> Result { + let contents = std::fs::read_to_string(file_path) + .map_err(ApiKeyDataError::IoError)?; + Ok(Self(contents.trim().to_string())) + } + + pub fn save_to_file>(&self, file_path: P) -> Result<(), ApiKeyDataError> { + std::fs::write(file_path, self.0.trim()) + .map_err(ApiKeyDataError::IoError)?; + Ok(()) + } +} + +#[derive(Debug)] +pub enum ApiKeyDataError { + IoError(std::io::Error), +} + +impl Error for ApiKeyDataError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + ApiKeyDataError::IoError(e) => Some(e), + } + } +} + +impl Display for ApiKeyDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiKeyDataError::IoError(e) => write!(f, "IO error: {}", e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + use std::io::Write; + + #[test] + fn load_trims_whitespace() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, " sk-abc123 \n").unwrap(); + + let data = ApiKeyData::load_from_file(file.path()).unwrap(); + assert_eq!(data.as_str(), "sk-abc123"); + } + + #[test] + fn load_trims_newlines() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "\nmy_secret_key\n\n").unwrap(); + + let data = ApiKeyData::load_from_file(file.path()).unwrap(); + assert_eq!(data.as_str(), "my_secret_key"); + } + + #[test] + fn save_trims_whitespace() { + let file = NamedTempFile::new().unwrap(); + let data = ApiKeyData::from_str(" sk-padded "); + data.save_to_file(file.path()).unwrap(); + + let contents = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(contents, "sk-padded"); + } + + #[test] + fn round_trip() { + let file = NamedTempFile::new().unwrap(); + let original = ApiKeyData::from_str("sk-round-trip-test-key"); + original.save_to_file(file.path()).unwrap(); + + let loaded = ApiKeyData::load_from_file(file.path()).unwrap(); + assert_eq!(loaded.as_str(), "sk-round-trip-test-key"); + } + + #[test] + fn load_nonexistent_file_returns_io_error() { + let result = ApiKeyData::load_from_file("/tmp/nonexistent_api_key_file_12345.txt"); + assert!(result.is_err()); + } +} diff --git a/crates/desktop/artcraft/src/core/providers/credentials/payload/mod.rs b/crates/desktop/artcraft/src/core/providers/credentials/payload/mod.rs new file mode 100644 index 0000000000..8d5083bee3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/payload/mod.rs @@ -0,0 +1,3 @@ +pub mod provider_credential_payload; +pub mod api_key; +pub mod web_login; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/providers/credentials/payload/provider_credential_payload.rs b/crates/desktop/artcraft/src/core/providers/credentials/payload/provider_credential_payload.rs new file mode 100644 index 0000000000..c2b8ab562c --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/payload/provider_credential_payload.rs @@ -0,0 +1,10 @@ +use crate::core::providers::credentials::payload::api_key::ApiKeyData; +use crate::core::providers::credentials::payload::web_login::WebLoginData; + +#[derive(Clone)] +pub enum ProviderCredentialPayload { + ApiKey(ApiKeyData), + + // TODO: There might be logins in the future that use weird header states, etc. + WebLogin(WebLoginData), +} diff --git a/crates/desktop/artcraft/src/core/providers/credentials/payload/web_login.rs b/crates/desktop/artcraft/src/core/providers/credentials/payload/web_login.rs new file mode 100644 index 0000000000..4f9a41766e --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/payload/web_login.rs @@ -0,0 +1,251 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::error::Error; +use std::fmt::Display; +use std::path::Path; + +const CURRENT_VERSION: u8 = 1; + +#[derive(Clone)] +pub struct WebLoginData { + pub cookies_header: Option, + pub additional_headers: Option>, + + pub username: Option, + pub email_address: Option, + + pub created_at: Option>, + pub updated_at: Option>, +} + +impl WebLoginData { + pub fn new() -> Self { + Self { + cookies_header: None, + additional_headers: None, + username: None, + email_address: None, + created_at: None, + updated_at: None, + } + } + + pub fn load_from_file>(file_path: P) -> Result { + let contents = std::fs::read_to_string(file_path) + .map_err(WebLoginDataError::IoError)?; + + let serializable: WebLoginDataSerializable = serde_json::from_str(&contents) + .map_err(WebLoginDataError::DeserializeError)?; + + Ok(Self { + cookies_header: serializable.cookies_header, + additional_headers: serializable.additional_headers, + username: serializable.username, + email_address: serializable.email_address, + created_at: serializable.created_at, + updated_at: serializable.updated_at, + }) + } + + pub fn save_to_file>(&self, file_path: P) -> Result<(), WebLoginDataError> { + let serializable = WebLoginDataSerializable { + version: CURRENT_VERSION, + cookies_header: self.cookies_header.clone(), + additional_headers: self.additional_headers.clone(), + username: self.username.clone(), + email_address: self.email_address.clone(), + created_at: self.created_at, + updated_at: self.updated_at, + }; + + let contents = serde_json::to_string_pretty(&serializable) + .map_err(WebLoginDataError::SerializeError)?; + + std::fs::write(file_path, contents) + .map_err(WebLoginDataError::IoError)?; + + Ok(()) + } +} + +#[derive(Debug)] +pub enum WebLoginDataError { + IoError(std::io::Error), + DeserializeError(serde_json::Error), + SerializeError(serde_json::Error), +} + +impl Error for WebLoginDataError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + WebLoginDataError::IoError(e) => Some(e), + WebLoginDataError::DeserializeError(e) => Some(e), + WebLoginDataError::SerializeError(e) => Some(e), + _ => None, + } + } +} + +impl Display for WebLoginDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WebLoginDataError::IoError(e) => write!(f, "IO error: {}", e), + WebLoginDataError::DeserializeError(e) => write!(f, "Deserialization error: {}", e), + WebLoginDataError::SerializeError(e) => write!(f, "Serialization error: {}", e), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +struct WebLoginDataSerializable { + version: u8, + + #[serde(skip_serializing_if = "Option::is_none")] + cookies_header: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + additional_headers: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + email_address: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + use std::io::Write; + + #[test] + fn save_writes_version() { + let file = NamedTempFile::new().unwrap(); + let data = WebLoginData::new(); + data.save_to_file(file.path()).unwrap(); + + let contents = std::fs::read_to_string(file.path()).unwrap(); + assert!(contents.contains("\"version\": 1")); + } + + #[test] + fn load_reads_all_fields() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, r#"{{ + "version": 1, + "cookies_header": "session=abc123; visitor=xyz", + "additional_headers": {{"Authorization": "Bearer tok"}}, + "username": "testuser", + "email_address": "test@example.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-05-10T12:00:00Z" + }}"#).unwrap(); + + let data = WebLoginData::load_from_file(file.path()).unwrap(); + assert_eq!(data.cookies_header.as_deref(), Some("session=abc123; visitor=xyz")); + assert_eq!(data.username.as_deref(), Some("testuser")); + assert_eq!(data.email_address.as_deref(), Some("test@example.com")); + assert!(data.additional_headers.is_some()); + let headers = data.additional_headers.unwrap(); + assert_eq!(headers.get("Authorization").unwrap(), "Bearer tok"); + assert!(data.created_at.is_some()); + assert!(data.updated_at.is_some()); + } + + #[test] + fn load_tolerates_absent_optional_fields() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, r#"{{"version": 1}}"#).unwrap(); + + let data = WebLoginData::load_from_file(file.path()).unwrap(); + assert!(data.cookies_header.is_none()); + assert!(data.additional_headers.is_none()); + assert!(data.username.is_none()); + assert!(data.email_address.is_none()); + assert!(data.created_at.is_none()); + assert!(data.updated_at.is_none()); + } + + #[test] + fn load_tolerates_null_fields() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, r#"{{ + "version": 1, + "cookies_header": null, + "username": null, + "email_address": null + }}"#).unwrap(); + + let data = WebLoginData::load_from_file(file.path()).unwrap(); + assert!(data.cookies_header.is_none()); + assert!(data.username.is_none()); + assert!(data.email_address.is_none()); + } + + #[test] + fn load_invalid_json_returns_error() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "not valid json at all").unwrap(); + + let result = WebLoginData::load_from_file(file.path()); + assert!(result.is_err()); + } + + #[test] + fn load_nonexistent_file_returns_io_error() { + let result = WebLoginData::load_from_file("/tmp/nonexistent_web_login_12345.json"); + assert!(result.is_err()); + } + + #[test] + fn round_trip_full() { + let file = NamedTempFile::new().unwrap(); + + let mut headers = HashMap::new(); + headers.insert("X-Custom".to_string(), "value".to_string()); + + let now = Utc::now(); + + let original = WebLoginData { + cookies_header: Some("session=roundtrip".to_string()), + additional_headers: Some(headers), + username: Some("round_trip_user".to_string()), + email_address: Some("rt@example.com".to_string()), + created_at: Some(now), + updated_at: Some(now), + }; + + original.save_to_file(file.path()).unwrap(); + let loaded = WebLoginData::load_from_file(file.path()).unwrap(); + + assert_eq!(loaded.cookies_header.as_deref(), Some("session=roundtrip")); + assert_eq!(loaded.username.as_deref(), Some("round_trip_user")); + assert_eq!(loaded.email_address.as_deref(), Some("rt@example.com")); + assert_eq!(loaded.additional_headers.as_ref().unwrap().get("X-Custom").unwrap(), "value"); + assert!(loaded.created_at.is_some()); + assert!(loaded.updated_at.is_some()); + } + + #[test] + fn round_trip_minimal() { + let file = NamedTempFile::new().unwrap(); + + let original = WebLoginData::new(); + original.save_to_file(file.path()).unwrap(); + + let loaded = WebLoginData::load_from_file(file.path()).unwrap(); + assert!(loaded.cookies_header.is_none()); + assert!(loaded.additional_headers.is_none()); + assert!(loaded.username.is_none()); + assert!(loaded.email_address.is_none()); + } +} diff --git a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs new file mode 100644 index 0000000000..4ba3f3ce3b --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs @@ -0,0 +1,50 @@ +use serde_derive::{Deserialize, Serialize}; +use crate::core::providers::credentials::provider_credential_type::ProviderCredentialType; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderCredentialKey { + + // ========== API KEYS========== + + FalApiKey, + ReplicateApiKey, + + // ========== WEB LOGINS ========== + + GrokWebLogin, + HiggsfieldWebLogin, + MidjourneyLogin, + RunwayWebLogin, +} + + +impl ProviderCredentialKey { + pub fn get_type(&self) -> ProviderCredentialType { + match self { + // Api keys + Self::FalApiKey => ProviderCredentialType::ApiKey, + Self::ReplicateApiKey => ProviderCredentialType::ApiKey, + // Web logins + Self::GrokWebLogin => ProviderCredentialType::WebLogin, + Self::HiggsfieldWebLogin => ProviderCredentialType::WebLogin, + Self::MidjourneyLogin => ProviderCredentialType::WebLogin, + Self::RunwayWebLogin => ProviderCredentialType::WebLogin, + } + } + + pub fn get_filename(&self) -> &'static str { + match self { + // Api keys + ProviderCredentialKey::FalApiKey => "fal.api_key.txt", + ProviderCredentialKey::ReplicateApiKey => "replicate.api_key.txt", + // Web logins + ProviderCredentialKey::GrokWebLogin => "grok.web_login.toml", + ProviderCredentialKey::HiggsfieldWebLogin => "higgsfield.web_login.toml", + ProviderCredentialKey::MidjourneyLogin => "midjourney.web_login.toml", + ProviderCredentialKey::RunwayWebLogin => "runway.web_login.toml", + } + } +} + + diff --git a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs new file mode 100644 index 0000000000..deed0e0636 --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_loading_cache.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; +use std::error::Error; +use std::fmt::Display; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use log::{info, warn}; + +use crate::core::providers::credentials::payload::api_key::{ApiKeyData, ApiKeyDataError}; +use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload; +use crate::core::providers::credentials::payload::web_login::{WebLoginData, WebLoginDataError}; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::providers::credentials::provider_credential_type::ProviderCredentialType; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; + +struct CacheEntry { + payload: ProviderCredentialPayload, + loaded_at: Instant, +} + +#[derive(Clone)] +pub struct ProviderCredentialLoadingCache { + inner: Arc>>, + app_data_root: AppDataRoot, + ttl: Duration, +} + +impl ProviderCredentialLoadingCache { + pub fn new(app_data_root: AppDataRoot) -> Self { + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + app_data_root, + ttl: Duration::from_secs(300), // 5 minutes + } + } + + pub fn new_with_ttl(app_data_root: AppDataRoot, ttl: Duration) -> Self { + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + app_data_root, + ttl, + } + } + + pub fn get_credentials( + &self, + key: ProviderCredentialKey, + ) -> Result, ProviderCredentialLoadingCacheError> { + // Check cache first. + { + let cache = self.inner.read() + .map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?; + + if let Some(entry) = cache.get(&key) { + if entry.loaded_at.elapsed() < self.ttl { + return Ok(Some(entry.payload.clone())); + } + } + } + + // Cache miss or expired — load from disk. + let file_path = self.app_data_root.credentials_dir().path().join(key.get_filename()); + + if !file_path.exists() { + return Ok(None); + } + + info!("Loading credential from disk: {:?}", file_path); + + let payload = match key.get_type() { + ProviderCredentialType::ApiKey => { + let data = ApiKeyData::load_from_file(&file_path) + .map_err(ProviderCredentialLoadingCacheError::ApiKeyError)?; + ProviderCredentialPayload::ApiKey(data) + } + ProviderCredentialType::WebLogin => { + let data = WebLoginData::load_from_file(&file_path) + .map_err(ProviderCredentialLoadingCacheError::WebLoginError)?; + ProviderCredentialPayload::WebLogin(data) + } + }; + + // Store in cache. + { + let mut cache = self.inner.write() + .map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?; + + cache.insert(key, CacheEntry { + payload: payload.clone(), + loaded_at: Instant::now(), + }); + } + + Ok(Some(payload)) + } + + pub fn save_credentials( + &self, + key: ProviderCredentialKey, + payload: ProviderCredentialPayload, + ) -> Result<(), ProviderCredentialLoadingCacheError> { + let file_path = self.app_data_root.credentials_dir().path().join(key.get_filename()); + + info!("Saving credential to disk: {:?}", file_path); + + match (&payload, key.get_type()) { + (ProviderCredentialPayload::ApiKey(data), ProviderCredentialType::ApiKey) => { + data.save_to_file(&file_path) + .map_err(ProviderCredentialLoadingCacheError::ApiKeyError)?; + } + (ProviderCredentialPayload::WebLogin(data), ProviderCredentialType::WebLogin) => { + data.save_to_file(&file_path) + .map_err(ProviderCredentialLoadingCacheError::WebLoginError)?; + } + _ => { + warn!("Credential type mismatch for key {:?}", key); + return Err(ProviderCredentialLoadingCacheError::TypeMismatch); + } + } + + // Update cache. + { + let mut cache = self.inner.write() + .map_err(|_| ProviderCredentialLoadingCacheError::LockPoisoned)?; + + cache.insert(key, CacheEntry { + payload, + loaded_at: Instant::now(), + }); + } + + Ok(()) + } + + /// Invalidate a cached entry, forcing a re-read from disk on next access. + pub fn invalidate(&self, key: ProviderCredentialKey) { + if let Ok(mut cache) = self.inner.write() { + cache.remove(&key); + } + } + + pub fn delete_credentials(&self, key: ProviderCredentialKey) -> Result<(), ProviderCredentialLoadingCacheError> { + let file_path = self.app_data_root.credentials_dir().path().join(key.get_filename()); + + if file_path.exists() { + std::fs::remove_file(file_path) + .map_err(|e| ProviderCredentialLoadingCacheError::IoError(e))?; + } + + self.invalidate(key); + + Ok(()) + } +} + +#[derive(Debug)] +pub enum ProviderCredentialLoadingCacheError { + IoError(std::io::Error), + ApiKeyError(ApiKeyDataError), + WebLoginError(WebLoginDataError), + TypeMismatch, + LockPoisoned, +} + +impl Error for ProviderCredentialLoadingCacheError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::IoError(err) => Some(err), + Self::ApiKeyError(err) => Some(err), + Self::WebLoginError(err) => Some(err), + _ => None, + } + } +} + +impl Display for ProviderCredentialLoadingCacheError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoError(e) => write!(f, "IO error: {}", e), + Self::ApiKeyError(e) => write!(f, "API key error: {}", e), + Self::WebLoginError(e) => write!(f, "Web login error: {}", e), + Self::TypeMismatch => write!(f, "Credential type does not match key type"), + Self::LockPoisoned => write!(f, "Internal lock poisoned"), + } + } +} diff --git a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_type.rs b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_type.rs new file mode 100644 index 0000000000..fd113fcec8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_type.rs @@ -0,0 +1,9 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderCredentialType { + ApiKey, + WebLogin, +} + diff --git a/crates/desktop/artcraft/src/core/providers/mod.rs b/crates/desktop/artcraft/src/core/providers/mod.rs new file mode 100644 index 0000000000..48c828e752 --- /dev/null +++ b/crates/desktop/artcraft/src/core/providers/mod.rs @@ -0,0 +1 @@ +pub mod credentials; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs new file mode 100644 index 0000000000..930ca5858e --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs @@ -0,0 +1,37 @@ +use crate::core::state::app_env_configs::app_env_configs_serializeable::{AppEnvConfigsSerializable, StorytellerApiHost}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use artcraft_client::utils::api_host::ApiHost; + +#[derive(Clone)] +pub struct AppEnvConfigs { + pub storyteller_host: ApiHost, +} + +impl AppEnvConfigs { + + pub fn load_from_filesystem(root: &AppDataRoot) -> AnyhowResult { + println!("Loading app environmental configs from filesystem..."); + let input = AppEnvConfigsSerializable::load_from_filesystem(root)?; + + let storyteller = input.as_ref() + .map(|i| i.storyteller_host) + .flatten(); + + let storyteller_api = match storyteller { + Some(StorytellerApiHost::Localhost) => ApiHost::Localhost { + port: input.as_ref() + .and_then(|i| i.storyteller_port) + .unwrap_or(12345) + }, + Some(StorytellerApiHost::Production) => ApiHost::Storyteller, + _ => ApiHost::Storyteller, + }; + + println!("Using storyteller API host: {:?}", storyteller_api); + + Ok(Self { + storyteller_host: storyteller_api, + }) + } +} diff --git a/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs_serializeable.rs b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs_serializeable.rs new file mode 100644 index 0000000000..16eb34e4db --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs_serializeable.rs @@ -0,0 +1,34 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use serde_derive::{Deserialize, Serialize}; + +const CURRENT_VERSION: &str = "1"; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StorytellerApiHost { + Production, + Localhost, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AppEnvConfigsSerializable { + /// Versioning string. + pub version: Option, + pub storyteller_port: Option, + pub storyteller_host: Option, +} + +impl AppEnvConfigsSerializable { + pub fn load_from_filesystem(app_data_root: &AppDataRoot) -> AnyhowResult> { + let filename = app_data_root.settings_dir().get_app_env_configs_path(); + if !filename.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(filename)?; + let data: Self = serde_json::from_str(&contents)?; + Ok(Some(data)) + } +} diff --git a/crates/desktop/artcraft/src/core/state/app_env_configs/mod.rs b/crates/desktop/artcraft/src/core/state/app_env_configs/mod.rs new file mode 100644 index 0000000000..be171e276d --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_env_configs/mod.rs @@ -0,0 +1,2 @@ +pub mod app_env_configs; +pub mod app_env_configs_serializeable; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences.rs b/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences.rs new file mode 100644 index 0000000000..8178bfca2e --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences.rs @@ -0,0 +1,75 @@ +use crate::core::state::app_preferences::app_preferences_serializable::AppPreferencesSerializable; +use crate::core::state::app_preferences::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; + +#[derive(Clone)] +pub struct AppPreferences { + /// The downloads directory to use when a user downloads a file. + pub preferred_download_directory: PreferredDownloadDirectory, + + /// Play sounds on events. + pub play_sounds: bool, + + /// Key pointing to file; defined in the frontend code. + pub delete_file_sound: Option, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[deprecated] + pub generation_enqueue_sound: Option, +} + +impl Default for AppPreferences { + fn default() -> Self { + Self { + preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads), + play_sounds: true, + // NB: These are defined in the frontend. + enqueue_success_sound: Some("done".to_string()), + enqueue_failure_sound: Some("spike_throw".to_string()), + generation_success_sound: Some("special_flower".to_string()), + generation_failure_sound: Some("crumble".to_string()), + generation_enqueue_sound: Some("done".to_string()), + delete_file_sound: Some("trash".to_string()), + } + } +} + +impl AppPreferences { + pub fn load_from_file_or_default(data_root: &AppDataRoot) -> Self { + let filename = data_root.settings_dir().get_app_preferences_path(); + if !filename.exists() { + return Self::default(); + } + + match AppPreferencesSerializable::load_from_file(data_root) { + Ok(Some(serializable)) => { + serializable.to_preferences() + } + Ok(None) => { + Self::default() + } + Err(err) => { + println!("Error loading app preferences: {}", err); + Self::default() + } + } + } + + pub fn to_serializable(&self) -> AppPreferencesSerializable { + AppPreferencesSerializable::from_preferences(self) + } +} diff --git a/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences_manager.rs b/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences_manager.rs new file mode 100644 index 0000000000..23282364a9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences_manager.rs @@ -0,0 +1,10 @@ +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use memory_store::clone_cell::CloneCell; + +pub type AppPreferencesManager = CloneCell; + +pub fn load_app_preferences_or_default(data_root: &AppDataRoot) -> AppPreferencesManager { + let prefs = AppPreferences::load_from_file_or_default(data_root); + AppPreferencesManager::with_owned(prefs) +} diff --git a/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences_serializable.rs b/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences_serializable.rs new file mode 100644 index 0000000000..e795d44cc2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_preferences/app_preferences_serializable.rs @@ -0,0 +1,83 @@ +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use serde_derive::{Deserialize, Serialize}; + +/// Vector clock versioning string rather than semver. +/// - Version 1 - initial version. +/// - Version 2 - added "delete_file_sound", marked optionals "skip_serializing_if" +const CURRENT_VERSION: &str = "2"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppPreferencesSerializable { + /// Versioning string. + pub version: String, + + /// The downloads directory to use when a user downloads a file. + pub preferred_download_directory: Option, + + /// Play sounds on events. + #[serde(skip_serializing_if = "Option::is_none")] + pub play_sounds: Option, + + /// Key pointing to file; defined in the frontend code. + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_file_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[serde(skip_serializing_if = "Option::is_none")] + pub generation_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[serde(skip_serializing_if = "Option::is_none")] + pub generation_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[serde(skip_serializing_if = "Option::is_none")] + pub generation_enqueue_sound: Option, +} + +impl AppPreferencesSerializable { + pub fn load_from_file(app_data_root: &AppDataRoot) -> AnyhowResult> { + let filename = app_data_root.settings_dir().get_app_preferences_path(); + if !filename.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(filename)?; + let data: Self = serde_json::from_str(&contents)?; + Ok(Some(data)) + } + + pub fn from_preferences(preferences: &AppPreferences) -> Self { + Self { + version: CURRENT_VERSION.to_string(), + preferred_download_directory: Some(preferences.preferred_download_directory.clone()), + play_sounds: Some(preferences.play_sounds), + delete_file_sound: preferences.delete_file_sound.clone(), + generation_success_sound: preferences.generation_success_sound.clone(), + generation_failure_sound: preferences.generation_failure_sound.clone(), + generation_enqueue_sound: preferences.generation_enqueue_sound.clone(), + } + } + + pub fn to_preferences(&self) -> AppPreferences { + let mut preferences = AppPreferences::default(); + + if let Some(preferred_download_directory) = &self.preferred_download_directory { + preferences.preferred_download_directory = preferred_download_directory.clone(); + } + + if let Some(play_sounds) = self.play_sounds { + preferences.play_sounds = play_sounds; + } + + preferences.delete_file_sound = self.delete_file_sound.clone(); + preferences.generation_success_sound = self.generation_success_sound.clone(); + preferences.generation_failure_sound = self.generation_failure_sound.clone(); + preferences.generation_enqueue_sound = self.generation_enqueue_sound.clone(); + + preferences + } +} diff --git a/crates/desktop/artcraft/src/core/state/app_preferences/mod.rs b/crates/desktop/artcraft/src/core/state/app_preferences/mod.rs new file mode 100644 index 0000000000..d2385da909 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_preferences/mod.rs @@ -0,0 +1,4 @@ +pub mod app_preferences; +pub mod app_preferences_manager; +pub mod app_preferences_serializable; +pub mod preferred_download_directory; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/app_preferences/preferred_download_directory.rs b/crates/desktop/artcraft/src/core/state/app_preferences/preferred_download_directory.rs new file mode 100644 index 0000000000..dd065998f8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_preferences/preferred_download_directory.rs @@ -0,0 +1,105 @@ +use errors::AnyhowError; +use serde_derive::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; + +const SYSTEM_DEFAULT_SENTINEL_VALUE: &str = "system_default"; + +/// NB: The reason these are not flat is so that they serialize/deserialize nicely to/from JSON. +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PreferredDownloadDirectory { + /// The system-default downloads directory, which varies by OS. + /// NB: Serializes as `"system_default"` in JSON. + System(SystemDownloadDirectory), + + /// A user-defined path + /// NB: Serializes as `{"custom": "/path/to/dir"}` in JSON. + Custom(PathBuf), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SystemDownloadDirectory { + Downloads, + Documents, +} + +impl PreferredDownloadDirectory { + /// This doesn't have a fallback. + pub fn maybe_to_download_directory(&self) -> Option { + match self { + PreferredDownloadDirectory::Custom(path) => Some(path.clone()), + PreferredDownloadDirectory::System(SystemDownloadDirectory::Documents) => { + dirs::document_dir() + } + PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads) => { + dirs::download_dir() + } + } + } + + /// Fallback to the Artcraft directory. + pub fn download_directory(&self, root: &AppDataRoot) -> PathBuf { + let maybe_path = self.maybe_to_download_directory(); + maybe_path.unwrap_or_else(|| root.downloads_dir().path().to_path_buf()) + } +} + +impl FromStr for SystemDownloadDirectory { + type Err = AnyhowError; + + fn from_str(s: &str) -> Result { + match (s) { + "downloads" => Ok(SystemDownloadDirectory::Downloads), + "documents" => Ok(SystemDownloadDirectory::Documents), + _ => Err(AnyhowError::msg(format!("Invalid system download directory: {}", s))), + } + } +} + +impl Display for PreferredDownloadDirectory { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + PreferredDownloadDirectory::System(system) => system.fmt(f), + PreferredDownloadDirectory::Custom(path) => write!(f, "{}", path.to_string_lossy()), + } + } +} + +impl Display for SystemDownloadDirectory { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + SystemDownloadDirectory::Downloads => write!(f, "downloads"), + SystemDownloadDirectory::Documents => write!(f, "documents"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; + use crate::core::state::app_preferences::preferred_download_directory::SystemDownloadDirectory; + + + mod json { + use super::*; + + #[test] + fn to_json_system_default() { + let val = PreferredDownloadDirectory::System(SystemDownloadDirectory::Documents); + let val = serde_json::to_string(&val).unwrap(); + assert_eq!(&val, "{\"system\":\"documents\"}"); + } + + #[test] + fn to_json_custom() { + let val = PreferredDownloadDirectory::Custom("/tmp".into()); + let val = serde_json::to_string(&val).unwrap(); + assert_eq!(&val, "{\"custom\":\"/tmp\"}"); + } + } +} diff --git a/crates/desktop/artcraft/src/core/state/app_startup_time.rs b/crates/desktop/artcraft/src/core/state/app_startup_time.rs new file mode 100644 index 0000000000..900797ebd7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/app_startup_time.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, TimeDelta, Utc}; + +pub struct AppStartupTime { + startup_time: DateTime, +} + +impl AppStartupTime { + pub fn new() -> Self { + Self { + startup_time: Utc::now(), + } + } + + pub fn get_startup_time(&self) -> DateTime { + self.startup_time + } + + pub fn time_delta_since(&self) -> TimeDelta { + let now = Utc::now(); + now.signed_duration_since(self.startup_time) + } +} diff --git a/crates/desktop/artcraft/src/core/state/artcraft_platform_info.rs b/crates/desktop/artcraft/src/core/state/artcraft_platform_info.rs new file mode 100644 index 0000000000..c042c4d2fe --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/artcraft_platform_info.rs @@ -0,0 +1,68 @@ +use crate::core::state::os_platform::OsPlatform; +use crate::version::ARTCRAFT_VERSION; +use chrono::{DateTime, Utc}; + +#[derive(Clone, Debug)] +pub struct ArtcraftPlatformInfo { + pub artcraft_version: String, + + pub os_platform: ArtcraftOs, + pub os_version: String, + + pub build_timestamp: DateTime, + + pub git_commit_id: Option, + pub git_commit_short_id: Option, + pub git_commit_timestamp: Option>, +} + +#[derive(Copy, Clone, Debug)] +pub enum ArtcraftOs { + Windows, + MacOS, + Linux, + Unknown, +} + +impl ArtcraftPlatformInfo { + + pub fn get() -> Self { + let build_timestamp = build_metadata::build_timestamp(); + let git_commit_id = build_metadata::git_commit_id().map(|s| s.to_string()); + let git_commit_short_id = build_metadata::git_commit_short_id().map(|s| s.to_string()); + let git_commit_timestamp = build_metadata::git_commit_timestamp(); + + let os_info = os_info::get(); + + let maybe_os_version = Some(os_info.version().to_string()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + + Self { + artcraft_version: ARTCRAFT_VERSION.to_string(), + os_platform: match OsPlatform::maybe_get() { + Some(OsPlatform::Linux) => ArtcraftOs::Linux, + Some(OsPlatform::MacOs) => ArtcraftOs::MacOS, + Some(OsPlatform::Windows) => ArtcraftOs::Windows, + None => ArtcraftOs::Unknown, + }, + os_version: maybe_os_version, + build_timestamp, + git_commit_id, + git_commit_short_id, + git_commit_timestamp, + } + } +} + +impl ArtcraftOs { + pub fn as_str(&self) -> &'static str { + match self { + ArtcraftOs::Windows => "windows", + ArtcraftOs::MacOS => "macos", + ArtcraftOs::Linux => "linux", + ArtcraftOs::Unknown => "unknown", + } + } +} diff --git a/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_tracker.rs b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_tracker.rs new file mode 100644 index 0000000000..fca429d97a --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_tracker.rs @@ -0,0 +1,135 @@ +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker_data::ArtcraftUsageTrackerData; +use std::sync::{Arc, LockResult, RwLock}; +use log::{error, warn}; +use crate::core::artcraft_error::ArtcraftError; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; + +/// Track generation volume so we know which areas to focus efforts on. +/// But also note: https://en.wikipedia.org/wiki/Survivorship_bias +#[derive(Clone)] +pub struct ArtcraftUsageTracker { + data: Arc>, +} + + +impl ArtcraftUsageTracker { + pub fn new() -> Self { + Self { + data: Arc::new(RwLock::new(ArtcraftUsageTrackerData::new())), + } + } + + pub fn record_image_generation(&self, num_images: u16, usage_type: ArtcraftUsageType, page: ArtcraftUsagePage) -> Result<(), ArtcraftError> { + let mut data = self.get()?; + data.total_generation_count += num_images; + data.image_generation_count += num_images; + match usage_type { + ArtcraftUsageType::TextToResult => { + data.text_to_image_count += num_images; + } + ArtcraftUsageType::ImageToResult => { + data.image_to_image_count += num_images; + } + ArtcraftUsageType::Other => {} + } + match page { + ArtcraftUsagePage::ImagePage => { + data.image_page_prompt_count += num_images; + } + ArtcraftUsagePage::EditPage => { + data.edit_page_prompt_count += num_images; + } + ArtcraftUsagePage::StagePage => { + data.stage_page_prompt_count += num_images; + } + ArtcraftUsagePage::OtherPage => { + data.other_page_prompt_count += num_images; + } + ArtcraftUsagePage::VideoPage => {} // NB: Does not generate images. + ArtcraftUsagePage::ObjectPage => {} // NB: Does not generate images. + } + self.set(data)?; + Ok(()) + } + + pub fn record_video_generation(&self, num_videos: u16, usage_type: ArtcraftUsageType, page: ArtcraftUsagePage) -> Result<(), ArtcraftError> { + let mut data = self.get()?; + data.total_generation_count += num_videos; + data.video_generation_count += num_videos; + match usage_type { + ArtcraftUsageType::TextToResult => { + data.text_to_video_count += num_videos; + } + ArtcraftUsageType::ImageToResult => { + data.image_to_video_count += num_videos; + } + ArtcraftUsageType::Other => {} + } + match page { + ArtcraftUsagePage::VideoPage => { + data.video_page_prompt_count += num_videos; + } + ArtcraftUsagePage::OtherPage => { + data.other_page_prompt_count += num_videos; + } + ArtcraftUsagePage::ImagePage => {} // NB: Does not generate videos. + ArtcraftUsagePage::EditPage => {} // NB: Does not generate videos. + ArtcraftUsagePage::StagePage => {} // NB: Does not generate videos. + ArtcraftUsagePage::ObjectPage => {} // NB: Does not generate videos. + } + self.set(data)?; + Ok(()) + } + + pub fn record_object_generation(&self, num_objects: u16, usage_type: ArtcraftUsageType, page: ArtcraftUsagePage) -> Result<(), ArtcraftError> { + let mut data = self.get()?; + data.total_generation_count += num_objects; + data.object_generation_count += num_objects; + match usage_type { + ArtcraftUsageType::TextToResult => { + data.text_to_object_count += num_objects; + } + ArtcraftUsageType::ImageToResult => { + data.image_to_object_count += num_objects; + } + ArtcraftUsageType::Other => {} + } + match page { + ArtcraftUsagePage::ObjectPage => { + data.object_page_prompt_count += num_objects; + } + ArtcraftUsagePage::OtherPage => { + data.other_page_prompt_count += num_objects; + } + ArtcraftUsagePage::ImagePage => {} // NB: Does not generate objects. + ArtcraftUsagePage::VideoPage => {} // NB: Does not generate objects. + ArtcraftUsagePage::EditPage => {} // NB: Does not generate objects. + ArtcraftUsagePage::StagePage => {} // NB: Does not generate objects. + } + self.set(data)?; + Ok(()) + } + + pub fn get(&self) -> Result { + match self.data.read() { + Ok(data) => Ok(data.clone()), + Err(err) => { + error!("Lock read error: {:?}", err); + Err(ArtcraftError::RwLockReadError) + } + } + } + + fn set(&self, data: ArtcraftUsageTrackerData) -> Result<(), ArtcraftError> { + match self.data.write() { + Ok(mut lock) => { + *lock = data; + Ok(()) + }, + Err(err) => { + error!("Lock write error: {:?}", err); + Err(ArtcraftError::RwLockWriteError) + } + } + } +} diff --git a/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_tracker_data.rs b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_tracker_data.rs new file mode 100644 index 0000000000..725c7609d9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_tracker_data.rs @@ -0,0 +1,46 @@ + +/// Track generation volume so we know which areas to focus efforts on. +/// But also note: https://en.wikipedia.org/wiki/Survivorship_bias +#[derive(Clone)] +pub struct ArtcraftUsageTrackerData { + pub total_generation_count: u16, + pub image_generation_count: u16, + pub video_generation_count: u16, + pub object_generation_count: u16, + pub text_to_image_count: u16, + pub image_to_image_count: u16, + pub text_to_video_count: u16, + pub image_to_video_count: u16, + pub text_to_object_count: u16, + pub image_to_object_count: u16, + pub image_page_prompt_count: u16, + pub video_page_prompt_count: u16, + pub edit_page_prompt_count: u16, + pub stage_page_prompt_count: u16, + pub object_page_prompt_count: u16, + pub other_page_prompt_count: u16, +} + +impl ArtcraftUsageTrackerData { + pub fn new() -> Self { + Self { + total_generation_count: 0, + image_generation_count: 0, + video_generation_count: 0, + object_generation_count: 0, + text_to_image_count: 0, + image_to_image_count: 0, + text_to_video_count: 0, + image_to_video_count: 0, + text_to_object_count: 0, + image_to_object_count: 0, + image_page_prompt_count: 0, + video_page_prompt_count: 0, + edit_page_prompt_count: 0, + stage_page_prompt_count: 0, + object_page_prompt_count: 0, + other_page_prompt_count: 0, + } + } +} + diff --git a/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_type.rs b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_type.rs new file mode 100644 index 0000000000..6fe8684bcd --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/artcraft_usage_type.rs @@ -0,0 +1,18 @@ + + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ArtcraftUsageType { + TextToResult, + ImageToResult, + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ArtcraftUsagePage { + ImagePage, + VideoPage, + EditPage, + StagePage, + ObjectPage, + OtherPage, +} diff --git a/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/mod.rs b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/mod.rs new file mode 100644 index 0000000000..8a61045b3d --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/artcraft_usage_tracker/mod.rs @@ -0,0 +1,3 @@ +pub mod artcraft_usage_tracker; +pub mod artcraft_usage_type; +mod artcraft_usage_tracker_data; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_assets_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_assets_dir.rs new file mode 100644 index 0000000000..154eb799ae --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_assets_dir.rs @@ -0,0 +1,36 @@ +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use chrono::{DateTime, Local}; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct AppAssetsDir { + path: PathBuf, +} + +impl DataSubdir for AppAssetsDir { + const DIRECTORY_NAME: &'static str = "assets"; + + fn new_from>(dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl AppAssetsDir { + /// Return the current date asset directory + /// If it doesn't exist, create it. + pub fn make_or_get_current_date_dir(&self) -> anyhow::Result { + let format: DateTime = Local::now(); + let date_directory_name = format.format("%Y-%d-%m").to_string(); + let full_path = self.path.join(date_directory_name); + if !full_path.exists() { + std::fs::create_dir(&full_path)?; + } + Ok(full_path) + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_credentials_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_credentials_dir.rs new file mode 100644 index 0000000000..31f4ba8f77 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_credentials_dir.rs @@ -0,0 +1,80 @@ +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct AppCredentialsDir { + path: PathBuf, +} + +impl DataSubdir for AppCredentialsDir { + const DIRECTORY_NAME: &'static str = "credentials"; + + fn new_from> (dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl AppCredentialsDir { + pub fn get_grok_state_path(&self) -> PathBuf { + self.path.join("grok_state.json") + } + + pub fn get_grok_cookies_path(&self) -> PathBuf { + self.path.join("grok_cookies.txt") + } + + pub fn get_sora_cookie_file_path(&self) -> PathBuf { + self.path.join("sora_cookies.txt") + } + + pub fn get_sora_bearer_token_file_path(&self) -> PathBuf { + self.path.join("sora_bearer_token.txt") + } + + #[deprecated(note="use the new sentinel token methods")] + pub fn get_sora_legacy_sentinel_file_path(&self) -> PathBuf { + self.path.join("sora_sentinel.txt") + } + + pub fn get_sora_sentinel_token_file_path(&self) -> PathBuf { + self.path.join("sora_sentinel_token_store.json") + } + + pub fn get_storyteller_avt_cookie_file_path(&self) -> PathBuf { + self.path.join("artcraft_avt.txt") + } + + pub fn get_storyteller_session_cookie_file_path(&self) -> PathBuf { + self.path.join("artcraft_session.txt") + } + + pub fn get_fal_api_key_file_path(&self) -> PathBuf { + self.path.join("fal_api_key.txt") + } + + pub fn get_midjourney_state_path(&self) -> PathBuf { + self.path.join("midjourney_state.json") + } + + pub fn get_worldlabs_state_path(&self) -> PathBuf { + self.path.join("worldlabs_state.json") + } + + pub fn get_worldlabs_cookies_path(&self) -> PathBuf { + self.path.join("worldlabs_cookies.txt") + } + + pub fn get_worldlabs_bearer_path(&self) -> PathBuf { + self.path.join("worldlabs_bearer.txt") + } + + pub fn get_worldlabs_refresh_path(&self) -> PathBuf { + self.path.join("worldlabs_refresh.txt") + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_data_root.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_data_root.rs new file mode 100644 index 0000000000..5ca486fbc9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_data_root.rs @@ -0,0 +1,158 @@ +use crate::core::state::data_dir::app_assets_dir::AppAssetsDir; +use crate::core::state::data_dir::app_credentials_dir::AppCredentialsDir; +use crate::core::state::data_dir::app_downloads_dir::AppDownloadsDir; +use crate::core::state::data_dir::app_settings_dir::AppSettingsDir; +use crate::core::state::data_dir::app_state_dir::AppStateDir; +use crate::core::state::data_dir::temporary_dir::TemporaryDir; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::expanduser::expanduser; +use crate::core::state::os_platform::OsPlatform; +use anyhow::anyhow; +use directories::UserDirs; +use std::path::{Path, PathBuf}; + +const DEFAULT_DATA_DIR : &str = "Artcraft"; + +/// Note: Tauri appends ".log" to the end of the filename. +const LOG_FILE_NAME : &str = "artcraft_debug"; + +/// The path to the application data directory, which includes "asset" and "weights" data. +#[derive(Clone)] +pub struct AppDataRoot { + path: PathBuf, + + log_file_name: PathBuf, + log_file_name_string: String, + + assets_dir: AppAssetsDir, + credentials_dir: AppCredentialsDir, + downloads_dir: AppDownloadsDir, + settings_dir: AppSettingsDir, + state_dir: AppStateDir, + temp_dir: TemporaryDir, +} + +impl AppDataRoot { + pub fn create_default() -> anyhow::Result { + let directory = get_default_data_dir()?; + println!("App data directory: {:?}", directory); + Self::create_existing(directory) + } + + pub fn create_existing>(dir: P) -> anyhow::Result { + let mut dir = dir.as_ref().to_path_buf(); + + match OsPlatform::get() { + OsPlatform::Linux | OsPlatform::MacOs => { + if let Some(d) = dir.as_os_str().to_str() { + dir = expanduser(d)?; + } + }, + OsPlatform::Windows => {} + } + + if !dir.is_dir() { + println!("Creating directory {:?}", dir); + std::fs::create_dir_all(&dir)?; + } + + match dir.canonicalize() { + Ok(d) => dir = d, + Err(err) => { + println!("Error canonicalizing {:?}: {}", dir, err); + } + } + + let assets_dir = AppAssetsDir::get_or_create_in_root_dir(&dir)?; + let credentials_dir = AppCredentialsDir::get_or_create_in_root_dir(&dir)?; + let downloads_dir = AppDownloadsDir::get_or_create_in_root_dir(&dir)?; + let settings_dir = AppSettingsDir::get_or_create_in_root_dir(&dir)?; + let state_dir = AppStateDir::get_or_create_in_root_dir(&dir)?; + let temp_dir = TemporaryDir::get_or_create_in_root_dir(&dir)?; + let log_file_name = dir.join(LOG_FILE_NAME); + let log_file_name_string = log_file_name + .to_str() + .ok_or(anyhow!("couldn't convert log path to str"))? + .to_string(); + + Ok(Self { + path: dir, + log_file_name, + log_file_name_string, + assets_dir, + credentials_dir, + downloads_dir, + settings_dir, + state_dir, + temp_dir, + }) + } + + pub fn assets_dir(&self) -> &AppAssetsDir { + &self.assets_dir + } + + pub fn credentials_dir(&self) -> &AppCredentialsDir { + &self.credentials_dir + } + + pub fn downloads_dir(&self) -> &AppDownloadsDir { + &self.downloads_dir + } + + pub fn settings_dir(&self) -> &AppSettingsDir { + &self.settings_dir + } + + pub fn state_dir(&self) -> &AppStateDir { + &self.state_dir + } + + pub fn temp_dir(&self) -> &TemporaryDir { + &self.temp_dir + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn log_file_name(&self) -> &Path { + &self.log_file_name + } + + pub fn log_file_name_str(&self) -> &str { + &self.log_file_name_string + } + + pub fn get_sora_cookie_file_path(&self) -> PathBuf { + self.credentials_dir().get_sora_cookie_file_path() + } + + pub fn get_sora_bearer_token_file_path(&self) -> PathBuf { + self.credentials_dir().get_sora_bearer_token_file_path() + } + + pub fn get_sora_legacy_sentinel_file_path(&self) -> PathBuf { + self.credentials_dir().get_sora_legacy_sentinel_file_path() + } + + pub fn get_sora_sentinel_token_file_path(&self) -> PathBuf { + self.credentials_dir().get_sora_sentinel_token_file_path() + } + + pub fn get_window_size_config_file(&self) -> PathBuf { + self.state_dir.get_window_size_config_file() + } + + pub fn get_window_position_config_file(&self) -> PathBuf { + self.state_dir.get_window_position_config_file() + } +} + +// eg. /home/bob/artcraft, /Users/alice/artcraft, or C:\Users\Taylor\artcraft +fn get_default_data_dir() -> anyhow::Result { + Ok(UserDirs::new() + .ok_or_else(|| anyhow!("could not determine user home directory"))? + .home_dir() + .join(DEFAULT_DATA_DIR)) +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_downloads_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_downloads_dir.rs new file mode 100644 index 0000000000..f08250686f --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_downloads_dir.rs @@ -0,0 +1,21 @@ +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct AppDownloadsDir { + path: PathBuf, +} + +impl DataSubdir for AppDownloadsDir { + const DIRECTORY_NAME: &'static str = "downloads"; + + fn new_from>(dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_settings_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_settings_dir.rs new file mode 100644 index 0000000000..55cb1fd9f5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_settings_dir.rs @@ -0,0 +1,64 @@ +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use errors::AnyhowResult; +use serde::Serialize; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// For now, let's just keep a collection of loose json files in a settings dir. +/// Once we know the structure and schema, we can standardize on a single json file. +#[derive(Clone)] +pub struct AppSettingsDir { + path: PathBuf, +} + +impl DataSubdir for AppSettingsDir { + const DIRECTORY_NAME: &'static str = "settings"; + + fn new_from> (dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl AppSettingsDir { + pub fn get_app_preferences_path(&self) -> PathBuf { + self.path.join("app_preferences.json") + } + + pub fn get_app_env_configs_path(&self) -> PathBuf { + self.path.join("env_configs.json") + } + + pub fn get_provider_preferences_path(&self) -> PathBuf { + self.path.join("provider_preferences.json") + } + + pub fn write_app_preferences(&self, prefs: &AppPreferences) -> AnyhowResult<()> { + let serializable = prefs.to_serializable(); + + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + serializable.serialize(&mut ser)?; + + let filename = self.get_app_preferences_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + + file.write_all(&buf)?; + file.flush()?; + + Ok(()) + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_state_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_state_dir.rs new file mode 100644 index 0000000000..66223b5a40 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_state_dir.rs @@ -0,0 +1,38 @@ +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database_version::TASK_DATABASE_VERSION; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct AppStateDir { + path: PathBuf, +} + +impl DataSubdir for AppStateDir{ + const DIRECTORY_NAME: &'static str = "state"; + + fn new_from>(dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl AppStateDir { + pub fn get_tasks_sqlite_database_path(&self) -> PathBuf { + // NB: This must be incremented on any change to the database schema (including comments). + // The database houses ephemeral content and will be migrated automatically. + self.path.join(format!("tasks_v{TASK_DATABASE_VERSION}.sqlite")) + } + + pub fn get_window_size_config_file(&self) -> PathBuf { + self.path.join("window_size.json") + } + + pub fn get_window_position_config_file(&self) -> PathBuf { + self.path.join("window_position.json") + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/app_weights_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/app_weights_dir.rs new file mode 100644 index 0000000000..e670f15d5f --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/app_weights_dir.rs @@ -0,0 +1,21 @@ +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct AppWeightsDir { + path: PathBuf, +} + +impl DataSubdir for AppWeightsDir { + const DIRECTORY_NAME: &'static str = "weights"; + + fn new_from>(dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/mod.rs b/crates/desktop/artcraft/src/core/state/data_dir/mod.rs new file mode 100644 index 0000000000..439208b8a0 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/mod.rs @@ -0,0 +1,9 @@ +pub mod app_assets_dir; +pub mod app_credentials_dir; +pub mod app_data_root; +pub mod app_downloads_dir; +pub mod app_settings_dir; +pub mod app_state_dir; +pub mod app_weights_dir; +pub mod temporary_dir; +pub mod trait_data_subdir; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/data_dir/temporary_dir.rs b/crates/desktop/artcraft/src/core/state/data_dir/temporary_dir.rs new file mode 100644 index 0000000000..4456562e35 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/temporary_dir.rs @@ -0,0 +1,51 @@ +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use std::path::{Path, PathBuf}; +use tempdir::TempDir; +use tempfile::{Builder, NamedTempFile}; + +#[derive(Clone)] +pub struct TemporaryDir { + path: PathBuf, +} + +impl DataSubdir for TemporaryDir { + const DIRECTORY_NAME: &'static str = "temp"; + + fn new_from>(dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl TemporaryDir { + pub fn with_prefix(&self, prefix: &str) -> anyhow::Result { + let tempdir = TempDir::new_in(&self.path, prefix)?; + Ok(tempdir) + } + + pub fn new_named_temp_file(&self) -> anyhow::Result { + let tempfile = Builder::new() + .prefix("temp_") + .suffix(".bin") + .tempfile_in(&self.path)?; + Ok(tempfile) + } + + pub fn new_named_temp_file_with_extension(&self, extension: &str) -> Result { + let extension = if extension.starts_with(".") { + extension.to_string() + } else { + format!(".{}", extension) + }; + let tempfile = Builder::new() + .prefix("temp_") + .suffix(&extension) + .tempfile_in(&self.path)?; + Ok(tempfile) + } +} diff --git a/crates/desktop/artcraft/src/core/state/data_dir/trait_data_subdir.rs b/crates/desktop/artcraft/src/core/state/data_dir/trait_data_subdir.rs new file mode 100644 index 0000000000..eb44a402a1 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/data_dir/trait_data_subdir.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +pub trait DataSubdir : Sized { + const DIRECTORY_NAME: &'static str; + + fn new_from>(dir: P) -> Self; + + fn path(&self) -> &Path; + + fn get_or_create_in_root_dir>(root_dir: P) -> anyhow::Result { + Self::get_or_create_dir(root_dir.as_ref().join(Self::DIRECTORY_NAME)) + } + + fn get_or_create_dir>(dir: P) -> anyhow::Result { + let mut dir = dir.as_ref().to_path_buf(); + + match dir.canonicalize() { + Ok(d) => dir = d, + Err(err) => { + println!("Error canonicalizing {:?}: {}", dir, err); + } + } + + if !dir.exists() { + println!("Creating directory {:?}", dir); + std::fs::create_dir(&dir)?; + } + + Ok(Self::new_from(dir)) + } +} diff --git a/crates/desktop/artcraft/src/core/state/expanduser.rs b/crates/desktop/artcraft/src/core/state/expanduser.rs new file mode 100644 index 0000000000..0db8d5cec2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/expanduser.rs @@ -0,0 +1,73 @@ +use once_cell::sync::Lazy; +use std::io; +use std::path::{PathBuf, MAIN_SEPARATOR}; + +static PREFIX: Lazy = Lazy::new(|| format!("~{}", MAIN_SEPARATOR)); + +/// The "expanduser" crate doesn't compile on Windows, so we replace its functionality slightly +pub fn expanduser>(path: P) -> io::Result { + + Ok(match path.as_ref() { + // matches an exact "~" + s if s == "~" => { + home_dir()? + }, + // matches paths that start with `~/` + s if s.starts_with(&*PREFIX) => { + let home = home_dir()?; + home.join(&s[2..]) + }, + // // matches paths that start with `~` but not `~/`, might be a `~username/` path + // s if s.starts_with("~") => { + // let mut parts = s[1..].splitn(2, MAIN_SEPARATOR); + // let user = parts.next() + // .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "malformed path"))?; + // let user = Passwd::from_name(&user) + // .map_err(|_| io::Error::new(io::ErrorKind::Other, "error searching for user"))? + // .ok_or_else(|| io::Error::new(io::ErrorKind::Other, format!("user '{}', does not exist", &user)))?; + // if let Some(ref path) = parts.next() { + // PathBuf::from(user.dir).join(&path) + // } else { + // PathBuf::from(user.dir) + // } + // }, + // nothing to expand, just make a PathBuf + s => PathBuf::from(s) + }) +} + +pub fn home_dir() -> io::Result { + dirs::home_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no home directory is set")) +} + +#[cfg(test)] +mod tests { + use crate::core::state::expanduser::expanduser; + use std::env; + use std::path::PathBuf; + + #[test] + #[serial_test::serial] + fn test_success() { + let old_home = env::var("HOME").expect("no home dir set"); + let new_home = "/home/foo"; + env::set_var("HOME", new_home); + let path = expanduser("~/path/to/directory"); + env::set_var("HOME", old_home); + assert_eq!(path.expect("io error"), PathBuf::from("/home/foo/path/to/directory")); + } + + #[test] + #[serial_test::serial] + fn test_only_tilde() { + let old_home = env::var("HOME").expect("no home dir set"); + let new_home = "/home/foo"; + env::set_var("HOME", new_home); + let pathstr = "~"; + let path = expanduser(pathstr); + env::set_var("HOME", old_home); + assert_eq!(path.expect("io error"), PathBuf::from("/home/foo")); + } + +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/mod.rs b/crates/desktop/artcraft/src/core/state/mod.rs new file mode 100644 index 0000000000..5a4a453b21 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/mod.rs @@ -0,0 +1,12 @@ +pub mod app_env_configs; +pub mod app_preferences; +pub mod app_startup_time; +pub mod artcraft_platform_info; +pub mod artcraft_usage_tracker; +pub mod data_dir; +pub mod expanduser; +pub mod os_platform; +pub mod provider_priority; +pub mod task_database; +pub mod task_database_version; +pub mod window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/os_platform.rs b/crates/desktop/artcraft/src/core/state/os_platform.rs new file mode 100644 index 0000000000..1d4e933ae4 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/os_platform.rs @@ -0,0 +1,33 @@ + +#[derive(Copy,Clone,Eq,PartialEq,Debug)] +pub enum OsPlatform { + Linux, + MacOs, + Windows, +} + +impl OsPlatform { + pub fn maybe_get() -> Option { + // Totally scientific. + // https://doc.rust-lang.org/std/env/consts/constant.OS.html + match std::env::consts::OS { + "linux" => Some(Self::Linux), + "windows" => Some(Self::Windows), + "macos" | "apple" => Some(Self::MacOs), + _ => None, + } + } + + pub fn maybe_get_str() -> Option<&'static str> { + match Self::maybe_get() { + Some(Self::Linux) => Some("linux"), + Some(Self::Windows) => Some("windows"), + Some(Self::MacOs) => Some("macos"), // NB: "apple" can become "macos". + None => None, + } + } + + pub fn get() -> Self { + Self::maybe_get().unwrap_or(Self::Linux) + } +} diff --git a/crates/desktop/artcraft/src/core/state/provider_priority.rs b/crates/desktop/artcraft/src/core/state/provider_priority.rs new file mode 100644 index 0000000000..395ae3dd68 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/provider_priority.rs @@ -0,0 +1,162 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use serde_derive::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::OpenOptions; +use std::io::Write; +use std::sync::{Arc, RwLock}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Provider { + Artcraft, + Fal, + Sora, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ProviderPriority { + /// Providers may occur zero or one time. + /// They are ordered by priority, highest to lowest. + provider_priority: Vec, +} + +#[derive(Clone)] +pub struct ProviderPriorityStore { + provider_priority: Arc>, +} + +impl ProviderPriority { + pub fn default() -> Self { + Self { + provider_priority: vec![ + Provider::Artcraft, + Provider::Sora, + Provider::Fal, + ], + } + } + + pub fn set_priority(&mut self, ordered_list: &[Provider]) -> AnyhowResult<()> { + let set : HashSet<&Provider> = HashSet::from_iter(ordered_list.iter()); + + if set.len() != ordered_list.len() { + return Err(anyhow::anyhow!("Duplicate providers in the ordered list")); + } + + self.provider_priority = ordered_list.to_vec(); + Ok(()) + } + + pub fn get_priority(&self) -> &[Provider] { + &self.provider_priority + } + + pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult> { + let filename = app_data_root.settings_dir().get_provider_preferences_path(); + if !filename.exists() { + return Ok(None); + } + let contents = std::fs::read_to_string(filename)?; + let providers : Self = serde_json::from_str(&contents)?; + Ok(Some(providers)) + } + + pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.settings_dir().get_provider_preferences_path(); + let json = serde_json::to_string(self)?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + file.write_all(json.as_bytes())?; + file.flush()?; + Ok(()) + } +} + +impl ProviderPriorityStore { + pub fn default() -> Self { + Self { + provider_priority: Arc::new(RwLock::new(ProviderPriority::default())), + } + } + + pub fn set_priority(&self, ordered_list: &[Provider]) -> AnyhowResult<()> { + match self.provider_priority.write() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire write lock on provider priority: {}", err)) + } + Ok(mut providers) => { + providers.set_priority(ordered_list)?; + Ok(()) + } + } + } + + pub fn get_priority(&self) -> AnyhowResult> { + match self.provider_priority.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock on provider priority: {}", err)) + } + Ok(providers) => { + Ok(providers.get_priority().to_vec()) + } + } + } + + pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult> { + match ProviderPriority::from_filesystem_configs(app_data_root) { + Err(err) => { + Err(anyhow::anyhow!("Failed to read provider preferences from filesystem: {}", err)) + } + Ok(None) => Ok(None), + Ok(Some(providers)) => { + Ok(Some(Self { + provider_priority: Arc::new(RwLock::new(providers)), + })) + } + } + } + + pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + match self.provider_priority.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock on provider priority: {}", err)) + } + Ok(providers) => { + providers.persist_to_filesystem(app_data_root)?; + Ok(()) + } + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_priority() { + let priority = ProviderPriority::default(); + assert_eq!(priority.get_priority(), &[Provider::Artcraft, Provider::Sora, Provider::Fal]); + } + + #[test] + fn test_set_priority() -> AnyhowResult<()> { + let mut priority = ProviderPriority::default(); + priority.set_priority(&[Provider::Fal, Provider::Artcraft])?; + assert_eq!(priority.get_priority(), &[Provider::Fal, Provider::Artcraft]); + Ok(()) + } + + #[test] + fn test_set_priority_with_duplicates() { + let mut priority = ProviderPriority::default(); + let result = priority.set_priority(&[Provider::Artcraft, Provider::Artcraft]); + assert!(result.is_err()); + } +} diff --git a/crates/desktop/artcraft/src/core/state/task_database.rs b/crates/desktop/artcraft/src/core/state/task_database.rs new file mode 100644 index 0000000000..4467785f0d --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/task_database.rs @@ -0,0 +1,20 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use sqlite_tasks::connection::TaskDbConnection; + +#[derive(Clone)] +pub struct TaskDatabase { + connection: TaskDbConnection, +} + +impl TaskDatabase { + pub async fn connect(root: &AppDataRoot) -> AnyhowResult { + let path = root.state_dir().get_tasks_sqlite_database_path(); + let connection = TaskDbConnection::connect_and_migrate(path).await?; + Ok(Self { connection}) + } + + pub fn get_connection(&self) -> &TaskDbConnection { + &self.connection + } +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/state/task_database_version.rs b/crates/desktop/artcraft/src/core/state/task_database_version.rs new file mode 100644 index 0000000000..d4a7d8c32b --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/task_database_version.rs @@ -0,0 +1,10 @@ + + +/// Used to bump the task database schema version. +/// Migrations on customers' machines tend to fail on existing databases, +/// even if we instruct that the database file be deleted first. +/// It's easiest to just write to a new file and migrate from scratch. +/// We increment this whenever we make a change to the database schema. +/// This prevents deadlock on startup if the schema has changed from the +/// previous version. +pub const TASK_DATABASE_VERSION: u32 = 7; diff --git a/crates/desktop/artcraft/src/core/state/window/main_window_position.rs b/crates/desktop/artcraft/src/core/state/window/main_window_position.rs new file mode 100644 index 0000000000..e11b6d8ffb --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/window/main_window_position.rs @@ -0,0 +1,65 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME; +use errors::AnyhowResult; +use serde_derive::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; +use tauri::{AppHandle, Manager, PhysicalPosition, Window}; + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct MainWindowPosition { + pub x: i32, + pub y: i32, +} + +impl MainWindowPosition { + pub fn new(x: i32, y: i32) -> Self { + Self { x, y } + } + + pub fn from_main_window(app: &AppHandle) -> AnyhowResult { + let windows = app.windows(); + windows.get(MAIN_WINDOW_NAME) + .map(|window| Self::from_window(window)) + .unwrap_or_else(|| Err(anyhow::anyhow!("Main window not found"))) + } + + pub fn from_window(window: &Window) -> AnyhowResult { + let position = window.outer_position()?; + Ok(Self { + x: position.x, + y: position.y, + }) + } + + pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult> { + let filename = app_data_root.get_window_position_config_file(); + if !filename.exists() { + return Ok(None); + } + let contents = std::fs::read_to_string(filename)?; + let pos : MainWindowPosition = serde_json::from_str(&contents)?; + Ok(Some(pos)) + } + + pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.get_window_position_config_file(); + let json = serde_json::to_string(self)?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + file.write_all(json.as_bytes())?; + file.flush()?; + Ok(()) + } + + pub fn to_physical_position(&self) -> PhysicalPosition { + PhysicalPosition::new(self.x, self.y) + } + + pub fn matches_physical_position(&self, pos: &PhysicalPosition) -> bool { + self.x == pos.x && self.y == pos.y + } +} diff --git a/crates/desktop/artcraft/src/core/state/window/main_window_size.rs b/crates/desktop/artcraft/src/core/state/window/main_window_size.rs new file mode 100644 index 0000000000..0735b19545 --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/window/main_window_size.rs @@ -0,0 +1,82 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::window::get_window_size_heuristic::get_window_size_heuristic; +use crate::core::windows::main_window::constants::{MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_NAME}; +use errors::AnyhowResult; +use serde_derive::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; +use tauri::{AppHandle, Manager, PhysicalSize, Window}; + + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct MainWindowSize { + pub width: u32, + pub height: u32, +} + +impl MainWindowSize { + pub fn new(width: u32, height: u32) -> Self { + Self { width, height } + } + + pub fn from_main_window(app: &AppHandle) -> AnyhowResult { + let windows = app.windows(); + windows.get(MAIN_WINDOW_NAME) + .map(|window| Self::from_window(window)) + .unwrap_or_else(|| Err(anyhow::anyhow!("Main window not found"))) + } + + pub fn from_window(window: &Window) -> AnyhowResult { + let size = get_window_size_heuristic(window)?; + Ok(Self { + width: size.width, + height: size.height, + }) + } + + pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult> { + let filename = app_data_root.get_window_size_config_file(); + if !filename.exists() { + return Ok(None); + } + let contents = std::fs::read_to_string(filename)?; + let size: MainWindowSize = serde_json::from_str(&contents)?; + Ok(Some(size)) + } + + pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.get_window_size_config_file(); + let json = serde_json::to_string(self)?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + file.write_all(json.as_bytes())?; + file.flush()?; + Ok(()) + } + + /// Window size is not wide enough to persist or apply. + pub fn is_not_wide_enough(&self) -> bool { + self.width < MAIN_WINDOW_MIN_WIDTH + } + + /// Window size is not tall enough to persist or apply. + pub fn is_not_tall_enough(&self) -> bool { + self.height < MAIN_WINDOW_MIN_HEIGHT + } + + /// Window size is not big enough (tall or wide) to persist or apply. + pub fn is_not_big_enough(&self) -> bool { + self.is_not_wide_enough() || self.is_not_tall_enough() + } + + pub fn to_physical_size(&self) -> PhysicalSize { + PhysicalSize::new(self.width, self.height) + } + + pub fn matches_physical_size(&self, size: &PhysicalSize) -> bool { + self.width == size.width && self.height == size.height + } +} diff --git a/crates/desktop/artcraft/src/core/state/window/mod.rs b/crates/desktop/artcraft/src/core/state/window/mod.rs new file mode 100644 index 0000000000..697e172a0a --- /dev/null +++ b/crates/desktop/artcraft/src/core/state/window/mod.rs @@ -0,0 +1,2 @@ +pub mod main_window_position; +pub mod main_window_size; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/threads/discord_presence_thread.rs b/crates/desktop/artcraft/src/core/threads/discord_presence_thread.rs new file mode 100644 index 0000000000..c11ff89e25 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/discord_presence_thread.rs @@ -0,0 +1,63 @@ +use anyhow::anyhow; +use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; +use errors::AnyhowResult; +use log::{debug, info}; +use rand::random_range; + +/// Our Discord App ID. Not sure if this is a secret. +const DISCORD_APP_ID : &str = "1366596912593113138"; + +const DISCORD_ARTCRAFT_LOGO_SPIN : &str = "https://getartcraft.com/discord-status-icons/artcraft-a-spin-512x512.webp"; +const DISCORD_ARTCRAFT_LOGO_STATIC : &str = "https://getartcraft.com/discord-status-icons/artcraft-a-512x512.png"; +const DISCORD_KITSUNE_MASK_LOGO : &str = "https://getartcraft.com/discord-status-icons/kitsune-mask-512x512.png"; +const DISCORD_LINK_SPIN: &str = "https://getartcraft.com/discord-status-icons/link-spin-200x216.webp"; + +pub async fn discord_presence_thread() -> ! { + loop { + let client = DiscordIpcClient::new(DISCORD_APP_ID); + let _err = discord_main_loop(client).await; + tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + } +} + +async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> { + info!("(Re)-connecting Discord IPC client..."); + + client.connect() + .map_err(|err| anyhow!("Error connecting to Discord IPC: {:?}", err))?; + + loop { + debug!("Notifying discord presence API..."); + + let discord_logo_image = get_random_discord_icon(); + + let assets = discord_rich_presence::activity::Assets::new() + .large_image(discord_logo_image) + .large_text("https://GetArtCraft.com"); + //.small_image("https://storyteller.ai/android-chrome-192x192.png") + //.small_text("small text"); + + client.set_activity(discord_rich_presence::activity::Activity::new() + //.state("Anyone can make arts!") + .state("GetArtCraft.com") + .details("Crafting Arts with ArtCraft") + .activity_type(discord_rich_presence::activity::ActivityType::Playing) + .assets(assets)) + .map_err(|err| anyhow!("Error setting Discord activity: {:?}", err))?; + + tokio::time::sleep(std::time::Duration::from_millis(60_000)).await; + } +} + +fn get_random_discord_icon() -> &'static str { + let value = random_range(0..10u8); + if value < 6 { + DISCORD_ARTCRAFT_LOGO_SPIN + } else if value < 7 { + DISCORD_ARTCRAFT_LOGO_STATIC + } else if value < 8 { + DISCORD_KITSUNE_MASK_LOGO + } else { + DISCORD_LINK_SPIN + } +} diff --git a/crates/desktop/artcraft/src/core/threads/main_window_thread/main_window_thread.rs b/crates/desktop/artcraft/src/core/threads/main_window_thread/main_window_thread.rs new file mode 100644 index 0000000000..720a8ed896 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/main_window_thread/main_window_thread.rs @@ -0,0 +1,90 @@ +use crate::core::state::app_startup_time::AppStartupTime; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::window::main_window_position::MainWindowPosition; +use crate::core::state::window::main_window_size::MainWindowSize; +use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task; +use crate::core::threads::main_window_thread::persist_window_position_task::persist_window_position_task; +use crate::core::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task; +use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use errors::AnyhowResult; +use log::{error, info, warn}; +use memory_store::clone_slot::CloneSlot; +use tauri::{AppHandle, Manager, Window}; + +pub async fn main_window_thread( + app: AppHandle, + app_data_root: AppDataRoot, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + // TODO: Move these into some kind of dependency injection framework + let window_size_slot: CloneSlot = CloneSlot::empty(); + let window_pos_slot: CloneSlot = CloneSlot::empty(); + let app_startup_time = AppStartupTime::new(); + + // Emit debugging information to the logs (do not remove this!) + info!("git commit id: {:?}", build_metadata::git_commit_id() + .unwrap_or_else(|| "unknown")); + info!("git commit timestamp: {:?}", build_metadata::git_commit_timestamp() + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string())); + info!("build timestamp: {:?}", build_metadata::build_timestamp() + .to_string()); + + // debug_try_clear_all_webview_data(&app); + + loop { + for (window_name, window) in app.windows() { + if window_name == MAIN_WINDOW_NAME { + let result = handle_main_window( + &app, + &window, + &app_data_root, + &storyteller_creds_manager, + &window_size_slot, + &window_pos_slot, + &app_startup_time, + ).await; + if let Err(err) = result { + error!("Error handling main window: {:?}", err); + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + } +} + +pub async fn handle_main_window( + app: &AppHandle, + window: &Window, + app_data_root: &AppDataRoot, + storyteller_creds_manager: &StorytellerCredentialManager, + window_size_slot: &CloneSlot, + window_pos_slot: &CloneSlot, + app_startup_time: &AppStartupTime, +) -> AnyhowResult<()> { + loop { + log_errors(persist_window_resize_task(window, app_data_root, window_size_slot).await); + log_errors(persist_window_position_task(window, app_data_root, window_pos_slot).await); + log_errors(persist_storyteller_cookies_task(app, storyteller_creds_manager, app_startup_time).await); + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + } +} + +pub fn log_errors(result: AnyhowResult) { + if let Err(err) = result { + error!("Error persisting window size: {:?}", err); + } +} + +fn debug_try_clear_all_webview_data(app: &AppHandle) { + warn!("[!!!] THIS IS ONLY FOR DEBUGGING PURPOSES: Attempting to clear webview data..."); + + for (name, webview) in app.webviews() { + if let Err(err) = webview.clear_all_browsing_data() { + error!("Failed to clear cookies for '{}' window webview: {:?}", name, err); + } else { + warn!("Successfully cleared cookies for '{}' window webview", name); + } + } +} diff --git a/crates/desktop/artcraft/src/core/threads/main_window_thread/mod.rs b/crates/desktop/artcraft/src/core/threads/main_window_thread/mod.rs new file mode 100644 index 0000000000..db7084f698 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/main_window_thread/mod.rs @@ -0,0 +1,4 @@ +pub mod main_window_thread; +pub mod persist_storyteller_cookies_task; +pub mod persist_window_position_task; +pub mod persist_window_resize_task; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs new file mode 100644 index 0000000000..1c26f73623 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs @@ -0,0 +1,108 @@ +use crate::core::state::app_startup_time::AppStartupTime; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use chrono::TimeDelta; +use errors::AnyhowResult; +use log::{error, info}; +use once_cell::sync::Lazy; +use reqwest::Url; +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; +use tauri::{AppHandle, Manager}; +use tauri_plugin_http::reqwest_cookie_store::CookieStore; +use tauri_plugin_http::Http; + +const STORYTELLER_ROOT_COOKIE_URL_STR: &str = "https://api.storyteller.ai/"; + +static STORYTELLER_ROOT_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse(STORYTELLER_ROOT_COOKIE_URL_STR).expect("URL should parse") +}); + +const AVT_COOKIE_NAME : &str = "visitor"; +const SESSION_COOKIE_NAME : &str = "session"; + +/// There's some kind of race condition that we need to wait for before inspecting cookies. +const RACE_CONDITION_WAIT_TIME: TimeDelta = TimeDelta::milliseconds(5000); + +pub async fn persist_storyteller_cookies_task( + app: &AppHandle, + storyteller_credential_manager: &StorytellerCredentialManager, + app_startup_time: &AppStartupTime, +) -> AnyhowResult<()> { + + //if app_startup_time.time_delta_since() < RACE_CONDITION_WAIT_TIME { + // // NB: There's an issue when the "main window thread" inquires about the + // // webview cookies shortly after app startup. When it attempts to dump the + // // cookies within a few milliseconds of app launch, it deadlocks and the + // // app never launches correctly. The entire webview goes blank and the app + // // freezes. This hack seems to fix the problem. + // return Ok(()) + //} + + let maybe_http = app.try_state::(); + + let http = match maybe_http { + None => { + error!("No HTTP plugin found"); + return Err(anyhow!("No HTTP plugin found")); + } + Some(http) => http, + }; + + match http.cookies_jar.store.lock() { + Err(err) => { + error!("Failed to lock cookie jar: {:?}", err); + return Err(anyhow!("Failed to lock cookie jar")); + } + Ok(cookie_jar) => { + sync_tauri_credentials(&cookie_jar, storyteller_credential_manager)?; + } + } + + Ok(()) +} + +fn sync_tauri_credentials( + cookie_store: &CookieStore, + storyteller_credential_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store)?; + + let mut replace_credentials = true; + + let maybe_old_credentials = storyteller_credential_manager.get_credentials()?; + + if let Some(old_credentials) = maybe_old_credentials { + if old_credentials.equals(¤t_http_plugin_credentials) { + replace_credentials = false; + } + } + + if replace_credentials { + info!("Syncing ArtCraft credentials ..."); + storyteller_credential_manager.set_credentials(¤t_http_plugin_credentials)?; + // NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now. + //storyteller_credential_manager.persist_all_to_disk()?; + } + + Ok(()) +} + +pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore) -> AnyhowResult { + let mut avt_cookie = None; + let mut session_cookie = None; + + for cookie in cookie_jar.iter_unexpired() { + if let Some(avt) = StorytellerAvtCookie::maybe_from_cookie(&cookie) { + avt_cookie = Some(avt); + } else if let Some(session) = StorytellerSessionCookie::maybe_from_cookie(&cookie) { + session_cookie = Some(session); + } + } + + Ok(StorytellerCredentialSet::initialize( + avt_cookie, + session_cookie, + )) +} diff --git a/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_window_position_task.rs b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_window_position_task.rs new file mode 100644 index 0000000000..b1ca129ac7 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_window_position_task.rs @@ -0,0 +1,32 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::window::main_window_position::MainWindowPosition; +use errors::AnyhowResult; +use log::info; +use memory_store::clone_slot::CloneSlot; +use tauri::Window; + +pub async fn persist_window_position_task( + window: &Window, + app_data_root: &AppDataRoot, + window_pos_slot: &CloneSlot, +) -> AnyhowResult<()> { + + let current_window_position = MainWindowPosition::from_window(window)?; + let current_physical_pos = window.outer_position()?; + + let mut save_size_to_disk = true; + + if let Ok(Some(old_size)) = window_pos_slot.get_clone() { + if old_size.matches_physical_position(¤t_physical_pos) { + save_size_to_disk = false; + } + } + + if save_size_to_disk { + info!("Saving window position configs to disk..."); + current_window_position.persist_to_filesystem(app_data_root)?; + window_pos_slot.set_clone(¤t_window_position)?; + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_window_resize_task.rs b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_window_resize_task.rs new file mode 100644 index 0000000000..4ab91c5035 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_window_resize_task.rs @@ -0,0 +1,37 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::window::main_window_size::MainWindowSize; +use crate::core::utils::window::get_window_size_heuristic::get_window_size_heuristic; +use errors::AnyhowResult; +use log::info; +use memory_store::clone_slot::CloneSlot; +use tauri::Window; + +pub async fn persist_window_resize_task( + window: &Window, + app_data_root: &AppDataRoot, + window_size_slot: &CloneSlot, +) -> AnyhowResult<()> { + + let current_window_size = MainWindowSize::from_window(window)?; + let current_physical_size = get_window_size_heuristic(window)?; + + let mut save_size_to_disk = true; + + if let Ok(Some(old_size)) = window_size_slot.get_clone() { + if old_size.matches_physical_size(¤t_physical_size) { + save_size_to_disk = false; + } + } + + if current_window_size.is_not_big_enough() { + save_size_to_disk = false; + } + + if save_size_to_disk { + info!("Saving window size configs to disk..."); + current_window_size.persist_to_filesystem(app_data_root)?; + window_size_slot.set_clone(¤t_window_size)?; + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/threads/mod.rs b/crates/desktop/artcraft/src/core/threads/mod.rs new file mode 100644 index 0000000000..85518c0d82 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/mod.rs @@ -0,0 +1,3 @@ +pub mod discord_presence_thread; +pub mod main_window_thread; +pub mod third_party_task_polling_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/events/mod.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/events/mod.rs new file mode 100644 index 0000000000..911c8e7130 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/events/mod.rs @@ -0,0 +1 @@ +pub mod notify_frontend_of_completion; diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/events/notify_frontend_of_completion.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/events/notify_frontend_of_completion.rs new file mode 100644 index 0000000000..508a3b1549 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/events/notify_frontend_of_completion.rs @@ -0,0 +1,349 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent; +use crate::core::events::functional_events::gaussian_generation_complete_event::{GaussianGenerationCompleteEvent, GeneratedGaussian}; +use crate::core::events::functional_events::image_edit_complete_event::{EditedImage, ImageEditCompleteEvent}; +use crate::core::events::functional_events::object_generation_complete_event::{GeneratedObject, ObjectGenerationCompleteEvent}; +use crate::core::events::functional_events::text_to_image_generation_complete_event::{GeneratedImage, TextToImageGenerationCompleteEvent}; +use crate::core::events::functional_events::video_generation_complete_event::{GeneratedVideo, VideoGenerationCompleteEvent}; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::utils::api_host::ApiHost; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info, warn}; +use sqlite_tasks::queries::task::Task; +use tauri::AppHandle; +use reqwest::Url; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Data about a successfully completed third-party generation. +pub struct CompletionData { + /// The primary media file token (first uploaded file). + pub primary_media_file_token: MediaFileToken, + + /// The CDN URL for the primary media file, if looked up. + pub maybe_cdn_url: Option, + + /// A thumbnail URL template for the primary media file, if available. + pub maybe_thumbnail_url_template: Option, + + /// The batch token, if multiple files were generated. + pub maybe_batch_token: Option, + + /// The media class of the generated output. + pub media_class: TaskMediaFileClass, +} + +/// Notify the frontend that a third-party job has completed, sending the +/// appropriate typed event based on the task type. +/// +/// This is the cross-cutting equivalent of the Storyteller-specific +/// `maybe_handle_frontend_caller_notification` — it works for any provider. +pub async fn notify_frontend_of_completion( + app: &AppHandle, + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + completion: &CompletionData, +) { + // Fire the generic generation-complete event (for the task queue UI). + let generation_action = task_type_to_generation_action(task.task_type); + let generation_model = task.model_type.clone().and_then(task_model_type_to_generation_model); + let generation_service = provider_to_generation_service(task.provider); + + let complete_event = GenerationCompleteEvent { + action: Some(generation_action), + service: generation_service, + model: generation_model, + }; + complete_event.send_infallible(app); + + // Fire the typed frontend notification (for the specific page/component that initiated the job). + let result = match task.task_type { + // Audio pages consume the common completion event and refresh their feed. + TaskType::AudioGeneration => Ok(()), + TaskType::ImageGeneration => { + notify_image_generation(app, api_host, maybe_creds, task, completion).await + } + TaskType::ImageInpaintEdit => { + notify_image_edit(app, api_host, maybe_creds, task, completion).await + } + TaskType::VideoGeneration => { + notify_video_generation(app, task, completion) + } + TaskType::ObjectGeneration => { + notify_object_generation(app, task, completion) + } + TaskType::GaussianGeneration => { + notify_gaussian_generation(app, task, completion) + } + TaskType::BackgroundRemoval => { + notify_background_removal(app, task, completion) + } + }; + + if let Err(err) = result { + error!("[ThirdPartyEvents] Failed to send frontend notification for task {}: {:?}", task.id.as_str(), err); + } +} + +// ── Per-type handlers ── + +async fn notify_image_generation( + app: &AppHandle, + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + completion: &CompletionData, +) -> Result<(), Box> { + let generated_images = collect_generated_images( + api_host, + maybe_creds, + completion, + ).await; + + let event = TextToImageGenerationCompleteEvent { + generated_images, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + Ok(()) +} + +async fn notify_image_edit( + app: &AppHandle, + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + completion: &CompletionData, +) -> Result<(), Box> { + let images = collect_generated_images(api_host, maybe_creds, completion).await; + + let edited_images: Vec = images.into_iter() + .map(|img| EditedImage { + media_token: img.media_token, + cdn_url: img.cdn_url, + maybe_thumbnail_template: img.maybe_thumbnail_template, + }) + .collect(); + + let event = ImageEditCompleteEvent { + edited_images, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + Ok(()) +} + +fn notify_video_generation( + app: &AppHandle, + task: &Task, + completion: &CompletionData, +) -> Result<(), Box> { + let event = VideoGenerationCompleteEvent { + generated_video: Some(GeneratedVideo { + media_token: completion.primary_media_file_token.clone(), + cdn_url: completion.maybe_cdn_url.clone().unwrap_or_else(|| Url::parse("https://cdn.artcraft.ai/placeholder").unwrap()), + maybe_thumbnail_template: completion.maybe_thumbnail_url_template.clone(), + }), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + Ok(()) +} + +fn notify_object_generation( + app: &AppHandle, + task: &Task, + completion: &CompletionData, +) -> Result<(), Box> { + let event = ObjectGenerationCompleteEvent { + generated_object: Some(GeneratedObject { + media_token: completion.primary_media_file_token.clone(), + cdn_url: completion.maybe_cdn_url.clone().unwrap_or_else(|| Url::parse("https://cdn.artcraft.ai/placeholder").unwrap()), + maybe_thumbnail_template: completion.maybe_thumbnail_url_template.clone(), + }), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + Ok(()) +} + +fn notify_gaussian_generation( + app: &AppHandle, + task: &Task, + completion: &CompletionData, +) -> Result<(), Box> { + let event = GaussianGenerationCompleteEvent { + generated_gaussian: Some(GeneratedGaussian { + media_token: completion.primary_media_file_token.clone(), + cdn_url: completion.maybe_cdn_url.clone().unwrap_or_else(|| Url::parse("https://cdn.artcraft.ai/placeholder").unwrap()), + maybe_thumbnail_template: completion.maybe_thumbnail_url_template.clone(), + }), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + Ok(()) +} + +fn notify_background_removal( + app: &AppHandle, + task: &Task, + completion: &CompletionData, +) -> Result<(), Box> { + let event = CanvasBackgroundRemovalCompleteEvent { + media_token: completion.primary_media_file_token.clone(), + image_cdn_url: completion.maybe_cdn_url.clone().unwrap_or_else(|| Url::parse("https://cdn.artcraft.ai/placeholder").unwrap()), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + Ok(()) +} + +// ── Helpers ── + +/// Collect generated images. If there's a batch token, fetch all files in the batch. +/// Otherwise, return a single-element list with the primary file. +async fn collect_generated_images( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + completion: &CompletionData, +) -> Vec { + // If we have a batch token, try to fetch all files in the batch. + if let Some(batch_token) = &completion.maybe_batch_token { + match list_batch_generated_redux_media_files(api_host, maybe_creds, batch_token).await { + Ok(result) if !result.media_files.is_empty() => { + return result.media_files.into_iter() + .map(|file| GeneratedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect(); + } + Ok(_) => { + warn!("[ThirdPartyEvents] Batch token {} returned no media files, falling back to primary", batch_token); + } + Err(err) => { + error!("[ThirdPartyEvents] Failed to list batch media files: {:?}, falling back to primary", err); + } + } + } + + // Fallback: single image from primary token. + vec![GeneratedImage { + media_token: completion.primary_media_file_token.clone(), + cdn_url: completion.maybe_cdn_url.clone().unwrap_or_else(|| Url::parse("https://cdn.artcraft.ai/placeholder").unwrap()), + maybe_thumbnail_template: completion.maybe_thumbnail_url_template.clone(), + }] +} + +fn task_type_to_generation_action(task_type: TaskType) -> GenerationAction { + match task_type { + TaskType::ImageGeneration => GenerationAction::GenerateImage, + TaskType::VideoGeneration => GenerationAction::GenerateVideo, + TaskType::AudioGeneration => GenerationAction::GenerateAudio, + TaskType::BackgroundRemoval => GenerationAction::RemoveBackground, + TaskType::ObjectGeneration => GenerationAction::ImageTo3d, + TaskType::GaussianGeneration => GenerationAction::GenerateGaussian, + TaskType::ImageInpaintEdit => GenerationAction::ImageInpaintEdit, + } +} + +fn provider_to_generation_service(provider: GenerationProvider) -> GenerationServiceProvider { + match provider { + GenerationProvider::Artcraft => GenerationServiceProvider::Artcraft, + GenerationProvider::Fal => GenerationServiceProvider::Fal, + GenerationProvider::Grok => GenerationServiceProvider::Grok, + GenerationProvider::Midjourney => GenerationServiceProvider::Midjourney, + GenerationProvider::Sora => GenerationServiceProvider::Sora, + GenerationProvider::WorldLabs => GenerationServiceProvider::WorldLabs, + GenerationProvider::Higgsfield => GenerationServiceProvider::Higgsfield, + GenerationProvider::Krea => GenerationServiceProvider::Krea, + GenerationProvider::Leonardo => GenerationServiceProvider::Leonardo, + GenerationProvider::Magnific => GenerationServiceProvider::Magnific, + GenerationProvider::Openart => GenerationServiceProvider::Openart, + GenerationProvider::Picsart => GenerationServiceProvider::Picsart, + GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse, + GenerationProvider::Runway => GenerationServiceProvider::Runway, + } +} + +fn task_model_type_to_generation_model(model: TaskModelType) -> Option { + match model { + TaskModelType::Unknown(model) => Some(GenerationModel::Unknown(model)), + TaskModelType::Flux1Dev => Some(GenerationModel::Flux1Dev), + TaskModelType::Flux1Schnell => Some(GenerationModel::Flux1Schnell), + TaskModelType::FluxPro1 => Some(GenerationModel::FluxPro1), + TaskModelType::FluxPro11 => Some(GenerationModel::FluxPro11), + TaskModelType::FluxPro11Ultra => Some(GenerationModel::FluxPro11Ultra), + TaskModelType::FluxProKontextMax => Some(GenerationModel::FluxProKontextMax), + TaskModelType::FluxDevJuggernaut => Some(GenerationModel::FluxDevJuggernaut), + TaskModelType::Flux2LoraAngles => Some(GenerationModel::Flux2LoraAngles), + TaskModelType::GptImage1 => Some(GenerationModel::GptImage1), + TaskModelType::GptImage1p5 => Some(GenerationModel::GptImage1p5), + TaskModelType::GptImage2 => Some(GenerationModel::GptImage2), + TaskModelType::GptImage2p5Flare => Some(GenerationModel::GptImage2p5Flare), + TaskModelType::GptImage2p5Sunburst => Some(GenerationModel::GptImage2p5Sunburst), + TaskModelType::NanoBanana => Some(GenerationModel::NanoBanana), + TaskModelType::NanoBanana2 => Some(GenerationModel::NanoBanana2), + TaskModelType::NanoBananaPro => Some(GenerationModel::NanoBananaPro), + TaskModelType::Seedream4 => Some(GenerationModel::Seedream4), + TaskModelType::Seedream4p5 => Some(GenerationModel::Seedream4p5), + TaskModelType::Seedream5Lite => Some(GenerationModel::Seedream5Lite), + TaskModelType::QwenEdit2511Angles => Some(GenerationModel::QwenEdit2511Angles), + TaskModelType::GrokImage => Some(GenerationModel::GrokImage), + TaskModelType::Recraft3 => Some(GenerationModel::Recraft3), + TaskModelType::Midjourney7 => Some(GenerationModel::Midjourney7), + TaskModelType::Midjourney7Niji => Some(GenerationModel::Midjourney7Niji), + TaskModelType::Midjourney8 => Some(GenerationModel::Midjourney8), + TaskModelType::GrokVideo => Some(GenerationModel::GrokVideo), + TaskModelType::GrokImagineVideo1p5 => Some(GenerationModel::GrokImagineVideo1p5), + TaskModelType::Kling16Pro => Some(GenerationModel::Kling1_6), + TaskModelType::Kling21Pro => Some(GenerationModel::Kling21Pro), + TaskModelType::Kling21Master => Some(GenerationModel::Kling21Master), + TaskModelType::Kling2p5TurboPro => Some(GenerationModel::Kling2p5TurboPro), + TaskModelType::Kling2p6Pro => Some(GenerationModel::Kling2p6Pro), + TaskModelType::Kling3p0Standard => Some(GenerationModel::Kling3p0Standard), + TaskModelType::Kling3p0Pro => Some(GenerationModel::Kling3p0Pro), + TaskModelType::HappyHorse1p0 => Some(GenerationModel::HappyHorse1p0), + TaskModelType::Seedance10Lite => Some(GenerationModel::Seedance10Lite), + TaskModelType::Seedance1p5Pro => Some(GenerationModel::Seedance1p5Pro), + TaskModelType::Seedance2p0 => Some(GenerationModel::Seedance2p0), + TaskModelType::Seedance2p0Fast => Some(GenerationModel::Seedance2p0Fast), + TaskModelType::Sora2 => Some(GenerationModel::Sora2), + TaskModelType::Sora2Pro => Some(GenerationModel::Sora2Pro), + TaskModelType::Veo2 => Some(GenerationModel::Veo2), + TaskModelType::Veo3 => Some(GenerationModel::Veo3), + TaskModelType::Veo3Fast => Some(GenerationModel::Veo3Fast), + TaskModelType::Veo3p1 => Some(GenerationModel::Veo3p1), + TaskModelType::Veo3p1Fast => Some(GenerationModel::Veo3p1Fast), + TaskModelType::Hunyuan3d2_0 => Some(GenerationModel::Hunyuan3d2_0), + TaskModelType::Hunyuan3d2_1 => Some(GenerationModel::Hunyuan3d2_1), + TaskModelType::Hunyuan3d3 => Some(GenerationModel::Hunyuan3d3), + TaskModelType::Midjourney => Some(GenerationModel::Midjourney), + TaskModelType::Gemini25Flash => Some(GenerationModel::Gemini25Flash), + TaskModelType::WorldlabsMarble => Some(GenerationModel::WorldlabsMarble), + TaskModelType::WorldlabsMarble0p1Mini => Some(GenerationModel::WorldlabsMarble0p1Mini), + TaskModelType::WorldlabsMarble0p1Plus => Some(GenerationModel::WorldlabsMarble0p1Plus), + } +} diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/handle_fal_complete.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/handle_fal_complete.rs new file mode 100644 index 0000000000..de3ddd51fe --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/handle_fal_complete.rs @@ -0,0 +1,344 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::threads::third_party_task_polling_thread::events::notify_frontend_of_completion::{ + notify_frontend_of_completion, CompletionData, +}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::legacy_upload_media_file_from_file::{ + legacy_upload_media_file_from_file, LegacyUploadMediaFileFromFileArgs, +}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{ + upload_image_media_file_from_file, UploadImageFromFileArgs, +}; +use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{ + upload_video_media_file_from_file, UploadVideoFromFileArgs, +}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use enums::tauri::tasks::task_type::TaskType; +use fal_client::polling::poll_job_response::poll_job_response::PollJobResponse; +use fal_client::polling::poll_job_response::success_case_extractors::PollResponseExtractedContents; +use log::{error, info, warn}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{ + update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs, +}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +use uuid_utils::uuid::generate_random_uuid; + +pub async fn handle_fal_complete( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + task: &Task, + job_response: PollJobResponse, +) { + info!("[FalComplete] Handling completed task {}", task.id.as_str()); + + let result = handle_fal_complete_inner( + app_handle, + app_env_configs, + app_data_root, + task_database, + storyteller_creds_manager, + task, + job_response, + ).await; + + if let Err(err) = result { + error!("[FalComplete] Failed to handle task {}: {:?}", task.id.as_str(), err); + } +} + +async fn handle_fal_complete_inner( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + task: &Task, + job_response: PollJobResponse, +) -> Result<(), Box> { + let creds = storyteller_creds_manager.get_credentials()? + .ok_or("No Storyteller credentials available")?; + + let extracted = job_response.extracted_contents; + + // Determine what kind of media we got and collect download URLs. + let (urls, media_class) = collect_media_urls(task, &extracted)?; + + if urls.is_empty() { + warn!("[FalComplete] Task {} completed but no downloadable media found in response", task.id.as_str()); + //warn!("[FalComplete] Raw body: {}", &job_response.raw_body[..job_response.raw_body.len().min(500)]); + return Ok(()); + } + + // Use the prompt token from the task record (created at generation time). + let maybe_prompt_token = task.prompt_token.as_ref() + .map(|s| PromptToken::new_from_str(s)); + + if maybe_prompt_token.is_some() { + info!("[FalComplete] Using prompt token from task: {:?}", maybe_prompt_token); + } else { + warn!("[FalComplete] Task {} has no prompt token, uploading without prompt association", task.id.as_str()); + } + + let maybe_batch_token = if urls.len() > 1 { + let token = BatchGenerationToken::generate(); + info!("[FalComplete] Using batch token for {} files: {:?}", urls.len(), token); + Some(token) + } else { + None + }; + + let mut maybe_primary_media_file_token: Option = None; + + for (i, url) in urls.iter().enumerate() { + info!("[FalComplete] Downloading result {} from: {}", i, url); + + let download_path = download_file(url, app_data_root, i).await?; + + info!("[FalComplete] Uploading result {} to backend...", i); + + let media_token = upload_to_backend( + &creds, + app_env_configs, + &download_path, + maybe_prompt_token.as_ref(), + maybe_batch_token.as_ref(), + media_class, + ).await?; + + info!("[FalComplete] Uploaded as media file: {:?}", media_token); + + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(media_token); + } + } + + // Look up CDN/thumbnail URLs for the primary media file + let mut maybe_cdn_url: Option = None; + let mut maybe_cdn_url_str: Option = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + match get_media_file(&app_env_configs.storyteller_host, media_file_token).await { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.clone()); + maybe_cdn_url_str = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("[FalComplete] Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + // Mark the task as completed in the local database + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &task.id, + maybe_batch_token: maybe_batch_token.as_ref(), + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(media_class), + maybe_primary_media_file_cdn_url: maybe_cdn_url_str.as_deref(), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + }).await?; + + if updated { + if let Some(primary_token) = maybe_primary_media_file_token { + let completion = CompletionData { + primary_media_file_token: primary_token, + maybe_cdn_url, + maybe_thumbnail_url_template, + maybe_batch_token, + media_class, + }; + + notify_frontend_of_completion( + app_handle, + &app_env_configs.storyteller_host, + Some(&creds), + task, + &completion, + ).await; + } + } + + info!("[FalComplete] Task {} fully handled", task.id.as_str()); + + Ok(()) +} + +// ── Helpers ── + +/// Collect downloadable media URLs from the extracted response contents. +fn collect_media_urls( + task: &Task, + extracted: &Option, +) -> Result<(Vec, TaskMediaFileClass), Box> { + let extracted = match extracted { + Some(e) => e, + None => return Ok((vec![], TaskMediaFileClass::Image)), + }; + + // Images (batch) + if let Some(images) = &extracted.images { + let urls: Vec = images.iter() + .filter_map(|img| img.url.clone()) + .collect(); + if !urls.is_empty() { + return Ok((urls, TaskMediaFileClass::Image)); + } + } + + // Single image (e.g. background removal) + if let Some(image) = &extracted.image { + if let Some(url) = &image.url { + return Ok((vec![url.clone()], TaskMediaFileClass::Image)); + } + } + + // Video + if let Some(video) = &extracted.video { + if let Some(url) = &video.url { + return Ok((vec![url.clone()], TaskMediaFileClass::Video)); + } + } + + // 3D model (GLB) + if let Some(glb) = &extracted.model_glb { + if let Some(url) = &glb.url { + return Ok((vec![url.clone()], TaskMediaFileClass::Dimensional)); + } + } + + Ok((vec![], TaskMediaFileClass::Image)) +} + +async fn download_file( + url: &str, + app_data_root: &AppDataRoot, + index: usize, +) -> Result> { + let response = reqwest::get(url).await?; + let bytes = response.bytes().await?; + + let extension = url_utils::download_extension::extract_download_extension_from_url::extract_download_extension_from_url_str(url) + .map(|ext| ext.as_extension_without_period()) + .unwrap_or("bin"); + + let tempdir = app_data_root.temp_dir().path(); + let filename = format!("fal_{}_{}.{}", generate_random_uuid(), index, extension); + let download_path = tempdir.join(filename); + + let mut file = File::create(&download_path)?; + file.write_all(&bytes)?; + + Ok(download_path) +} + +const MAX_UPLOAD_RETRIES: u32 = 5; +const INITIAL_RETRY_DELAY_SECS: u64 = 10; + +async fn upload_to_backend( + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, + download_path: &PathBuf, + maybe_prompt_token: Option<&PromptToken>, + maybe_batch_token: Option<&BatchGenerationToken>, + media_class: TaskMediaFileClass, +) -> Result> { + let mut retry_delay_secs = INITIAL_RETRY_DELAY_SECS; + + for attempt in 0..MAX_UPLOAD_RETRIES { + let result = try_upload(creds, app_env_configs, download_path, maybe_prompt_token, maybe_batch_token, media_class).await; + + match result { + Ok(token) => return Ok(token), + Err(StorytellerError::Api(ApiError::TooManyRequests(_))) => { + if attempt + 1 < MAX_UPLOAD_RETRIES { + warn!( + "[FalComplete] Upload rate-limited (429), retrying in {}s (attempt {}/{})", + retry_delay_secs, + attempt + 1, + MAX_UPLOAD_RETRIES, + ); + tokio::time::sleep(Duration::from_secs(retry_delay_secs)).await; + retry_delay_secs = (retry_delay_secs * 2).min(60); + } else { + error!("[FalComplete] Upload rate-limited after {} attempts, giving up", MAX_UPLOAD_RETRIES); + return Err(Box::new(StorytellerError::Api(ApiError::TooManyRequests( + "Max retries exceeded".to_string(), + )))); + } + } + Err(err) => return Err(Box::new(err)), + } + } + + unreachable!() +} + +async fn try_upload( + creds: &StorytellerCredentialSet, + app_env_configs: &AppEnvConfigs, + download_path: &PathBuf, + maybe_prompt_token: Option<&PromptToken>, + maybe_batch_token: Option<&BatchGenerationToken>, + media_class: TaskMediaFileClass, +) -> Result { + let media_token = match media_class { + TaskMediaFileClass::Video => { + let result = upload_video_media_file_from_file(UploadVideoFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(creds), + path: download_path, + maybe_prompt_token, + maybe_generation_provider: Some(GenerationProvider::Fal), + }).await?; + result.media_file_token + } + TaskMediaFileClass::Dimensional => { + let result = legacy_upload_media_file_from_file(LegacyUploadMediaFileFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(creds), + path: download_path, + maybe_generation_provider: Some(GenerationProvider::Fal), + }).await?; + result.media_file_token + } + _ => { + let result = upload_image_media_file_from_file(UploadImageFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(creds), + path: download_path, + is_intermediate_system_file: false, + maybe_prompt_token, + maybe_batch_token, + maybe_generation_provider: Some(GenerationProvider::Fal), + }).await?; + result.media_file_token + } + }; + + Ok(media_token) +} + diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/handle_fal_failure.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/handle_fal_failure.rs new file mode 100644 index 0000000000..3398dac7bb --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/handle_fal_failure.rs @@ -0,0 +1,52 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::state::task_database::TaskDatabase; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use log::{error, info}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use tauri::AppHandle; + +pub async fn handle_fal_failure( + app_handle: &AppHandle, + task_database: &TaskDatabase, + task: &Task, + reason: &str, +) { + info!("[FalPolling] Marking task {} as failed: {}", task.id.as_str(), reason); + + let update_result = update_task_status(UpdateTaskArgs { + db: task_database.get_connection(), + task_id: &task.id, + status: TaskStatus::CompleteFailure, + }).await; + + if let Err(err) = update_result { + error!("[FalPolling] Failed to update task status for {}: {:?}", task.id.as_str(), err); + } + + let action = task_type_to_generation_action(task.task_type); + + let event = GenerationFailedEvent { + action, + service: GenerationServiceProvider::Fal, + model: None, + reason: Some(reason.to_string()), + }; + + event.send_infallible(app_handle); +} + +fn task_type_to_generation_action(task_type: TaskType) -> GenerationAction { + match task_type { + TaskType::ImageGeneration => GenerationAction::GenerateImage, + TaskType::VideoGeneration => GenerationAction::GenerateVideo, + TaskType::AudioGeneration => GenerationAction::GenerateAudio, + TaskType::BackgroundRemoval => GenerationAction::RemoveBackground, + TaskType::ObjectGeneration => GenerationAction::ImageTo3d, + TaskType::GaussianGeneration => GenerationAction::GenerateGaussian, + TaskType::ImageInpaintEdit => GenerationAction::ImageInpaintEdit, + } +} diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/mod.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/mod.rs new file mode 100644 index 0000000000..95e4589169 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/mod.rs @@ -0,0 +1,3 @@ +pub mod handle_fal_complete; +pub mod handle_fal_failure; +pub mod poll_fal_tasks; diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs new file mode 100644 index 0000000000..81f9f585ac --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs @@ -0,0 +1,183 @@ +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload; +use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::task_database::TaskDatabase; +use crate::core::threads::third_party_task_polling_thread::handlers::fal::handle_fal_complete::handle_fal_complete; +use crate::core::threads::third_party_task_polling_thread::handlers::fal::handle_fal_failure::handle_fal_failure; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::tauri::tasks::task_status::TaskStatus; +use fal_client::creds::fal_api_key::FalApiKey; +use fal_client::polling::poll_job_response::poll_job_response::{poll_job_response, PollJobResponseArgs}; +use fal_client::polling::poll_job_status::poll_job_status::{poll_job_status, FalJobStatus, PollJobStatusArgs}; +use log::{error, info, warn}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use tauri::AppHandle; + +pub async fn poll_fal_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + credential_cache: &ProviderCredentialLoadingCache, + fal_tasks: &[&Task], +) { + let api_key = match load_fal_api_key(credential_cache) { + Some(key) => key, + None => { + warn!("[FalPolling] No FAL API key configured, skipping poll"); + return; + } + }; + + for task in fal_tasks { + let result = poll_single_fal_task( + app_handle, + app_env_configs, + app_data_root, + task_database, + storyteller_creds_manager, + &api_key, + task, + ).await; + + if let Err(err) = result { + error!( + "[FalPolling] Error processing task {}: {:?}", + task.id.as_str(), + err, + ); + } + } +} + +async fn poll_single_fal_task( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + api_key: &FalApiKey, + task: &Task, +) -> Result<(), Box> { + let status_url = match &task.queue_status_url { + Some(url) => url.as_str(), + None => { + warn!("[FalPolling] Task {} has no queue_status_url, marking as failed", task.id.as_str()); + handle_fal_failure( + app_handle, + task_database, + task, + "No queue status URL to check", + ).await; + return Ok(()); + } + }; + + // Step 1: Check job status + info!("[FalPolling] Checking status for task {}: {}", task.id.as_str(), status_url); + + let status_response = poll_job_status(PollJobStatusArgs { + status_url, + api_key, + }).await?; + + match status_response.status { + FalJobStatus::InQueue | FalJobStatus::InProgress => { + info!( + "[FalPolling] Task {} still {:?} (queue_position={:?})", + task.id.as_str(), + status_response.status, + status_response.maybe_queue_position, + ); + return Ok(()); + } + FalJobStatus::Unknown(ref status) => { + warn!("[FalPolling] Task {} has unknown status: {}", task.id.as_str(), status); + return Ok(()); + } + FalJobStatus::Completed => { + info!("[FalPolling] Task {} completed, fetching results...", task.id.as_str()); + } + } + + // Step 2: Fetch the response + let response_url = pick_response_url(task, &status_response.response_url); + + let response_url = match response_url { + Some(url) => url, + None => { + warn!("[FalPolling] Task {} completed but has no response URL, marking as failed", task.id.as_str()); + handle_fal_failure( + app_handle, + task_database, + task, + "Job completed but no response URL available", + ).await; + return Ok(()); + } + }; + + let job_response = match poll_job_response(PollJobResponseArgs { + response_url: &response_url, + api_key, + }).await { + Ok(response) => response, + Err(err) => { + error!( + "[FalPolling] Failed to fetch response for task {}: {}. Skipping for now.", + task.id.as_str(), + err, + ); + return Ok(()); + } + }; + + // Step 3: Handle completed job + handle_fal_complete( + app_handle, + app_env_configs, + app_data_root, + task_database, + storyteller_creds_manager, + task, + job_response, + ).await; + + Ok(()) +} + +// ── Helpers ── + +fn load_fal_api_key(credential_cache: &ProviderCredentialLoadingCache) -> Option { + match credential_cache.get_credentials(ProviderCredentialKey::FalApiKey) { + Ok(Some(ProviderCredentialPayload::ApiKey(data))) => { + Some(FalApiKey::from_str(data.as_str())) + } + _ => None, + } +} + +/// Use the task's stored response URL first; fall back to the one from the status poll. +/// Log if they differ. +fn pick_response_url(task: &Task, polled_response_url: &Option) -> Option { + match (&task.queue_response_url, polled_response_url) { + (Some(db_url), Some(polled_url)) => { + if db_url != polled_url { + warn!( + "[FalPolling] Task {} response URLs differ: db={}, polled={}. Using db URL.", + task.id.as_str(), + db_url, + polled_url, + ); + } + Some(db_url.clone()) + } + (Some(db_url), None) => Some(db_url.clone()), + (None, Some(polled_url)) => Some(polled_url.clone()), + (None, None) => None, + } +} diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/mod.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/mod.rs new file mode 100644 index 0000000000..e02f03de99 --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/mod.rs @@ -0,0 +1 @@ +pub mod fal; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/mod.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/mod.rs new file mode 100644 index 0000000000..3c01673afd --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/mod.rs @@ -0,0 +1,3 @@ +pub mod events; +pub mod handlers; +pub mod third_party_task_polling_thread; diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/third_party_task_polling_thread.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/third_party_task_polling_thread.rs new file mode 100644 index 0000000000..552633c4cd --- /dev/null +++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/third_party_task_polling_thread.rs @@ -0,0 +1,138 @@ +use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::task_database::TaskDatabase; +use crate::core::threads::third_party_task_polling_thread::handlers::fal::poll_fal_tasks::poll_fal_tasks; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use enums::common::generation_provider::GenerationProvider; +use log::{error, info, warn}; +use sqlite_tasks::queries::list_non_artcraft_pending_tasks::{ + list_non_artcraft_pending_tasks, ListNonArtcraftPendingTasksArgs, +}; +use sqlite_tasks::queries::task::Task; +use std::time::Duration; +use tauri::AppHandle; + +const SLEEP_NO_THIRD_PARTY_JOBS_SEEN: Duration = Duration::from_secs(10); +const SLEEP_THIRD_PARTY_JOBS_SEEN: Duration = Duration::from_secs(2); +const SLEEP_BETWEEN_FAL_POLLS: Duration = Duration::from_secs(1); +const SLEEP_ON_ERROR: Duration = Duration::from_secs(30); + +pub async fn third_party_task_polling_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + storyteller_creds_manager: StorytellerCredentialManager, + credential_cache: ProviderCredentialLoadingCache, +) -> ! { + let mut has_ever_seen_third_party_jobs = false; + + loop { + let result = poll_iteration( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &storyteller_creds_manager, + &credential_cache, + &mut has_ever_seen_third_party_jobs, + ).await; + + if let Err(err) = result { + error!("[ThirdPartyPolling] Error in polling loop: {:?}", err); + tokio::time::sleep(SLEEP_ON_ERROR).await; + } + } +} + +async fn poll_iteration( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + credential_cache: &ProviderCredentialLoadingCache, + has_ever_seen_third_party_jobs: &mut bool, +) -> Result<(), PollError> { + let task_list = list_non_artcraft_pending_tasks(ListNonArtcraftPendingTasksArgs { + db: task_database.get_connection(), + task_statuses: &TASK_DATABASE_PENDING_STATUSES, + }).await?; + + let tasks = task_list.tasks; + + if tasks.is_empty() { + let sleep_duration = if *has_ever_seen_third_party_jobs { + SLEEP_THIRD_PARTY_JOBS_SEEN + } else { + SLEEP_NO_THIRD_PARTY_JOBS_SEEN + }; + tokio::time::sleep(sleep_duration).await; + return Ok(()); + } + + *has_ever_seen_third_party_jobs = true; + + let fal_tasks: Vec<&Task> = tasks.iter() + .filter(|t| t.provider == GenerationProvider::Fal) + .collect(); + + let non_fal_tasks: Vec<&Task> = tasks.iter() + .filter(|t| t.provider != GenerationProvider::Fal) + .collect(); + + if !non_fal_tasks.is_empty() { + for task in &non_fal_tasks { + warn!( + "[ThirdPartyPolling] Skipping non-FAL task: id={}, provider={:?}, type={:?}", + task.id.as_str(), + task.provider, + task.task_type, + ); + } + } + + if fal_tasks.is_empty() { + tokio::time::sleep(SLEEP_THIRD_PARTY_JOBS_SEEN).await; + return Ok(()); + } + + info!("[ThirdPartyPolling] {} FAL job(s) ready to check", fal_tasks.len()); + + poll_fal_tasks( + app_handle, + app_env_configs, + app_data_root, + task_database, + storyteller_creds_manager, + credential_cache, + &fal_tasks, + ).await; + + tokio::time::sleep(SLEEP_BETWEEN_FAL_POLLS).await; + + Ok(()) +} + +// ── Error ── + +#[derive(Debug)] +enum PollError { + SqliteTasksError(sqlite_tasks::error::SqliteTasksError), +} + +impl From for PollError { + fn from(err: sqlite_tasks::error::SqliteTasksError) -> Self { + Self::SqliteTasksError(err) + } +} + +impl std::fmt::Display for PollError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SqliteTasksError(err) => write!(f, "SQLite error: {:?}", err), + } + } +} diff --git a/crates/desktop/artcraft/src/core/utils/clear_all_webview_cookies.rs b/crates/desktop/artcraft/src/core/utils/clear_all_webview_cookies.rs new file mode 100644 index 0000000000..fb39071dbf --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/clear_all_webview_cookies.rs @@ -0,0 +1,7 @@ +use errors::AnyhowResult; +use tauri::Webview; + +pub fn clear_all_webview_cookies(webview: &Webview) -> AnyhowResult<()> { + webview.clear_all_browsing_data()?; + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/utils/download_media_file_to_temp_dir.rs b/crates/desktop/artcraft/src/core/utils/download_media_file_to_temp_dir.rs new file mode 100644 index 0000000000..c064f74c35 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/download_media_file_to_temp_dir.rs @@ -0,0 +1,31 @@ +use crate::core::artcraft_error::ArtcraftError; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download_to_tempfile::simple_http_download_to_tempfile; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use tempfile::NamedTempFile; +use tokens::tokens::media_files::MediaFileToken; +// TODO: Better more concrete error handling + +pub async fn download_media_file_to_temp_dir(app_env_configs: &AppEnvConfigs, app_data_root: &AppDataRoot, token: &MediaFileToken) -> Result { + let response = get_media_file( + &app_env_configs.storyteller_host, + token + ).await?; + + let media_file_url = &response.media_file.media_links.cdn_url; + + let extension_with_dot = get_url_file_extension(media_file_url) + .map(|ext| format!(".{}", ext)) + .unwrap_or_else(|| ".png".to_string()); // TODO: Better default extension passed by caller as heuristic. + + //let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot); + //let filename = app_data_root.downloads_dir().path().join(&filename); + + let mut file = app_data_root.temp_dir().new_named_temp_file_with_extension(&extension_with_dot)?; + + simple_http_download_to_tempfile(&media_file_url, &mut file).await?; + + Ok(file) // NB: Must return TempFile to not drop / delete it +} diff --git a/crates/desktop/artcraft/src/core/utils/download_url_to_temp_dir.rs b/crates/desktop/artcraft/src/core/utils/download_url_to_temp_dir.rs new file mode 100644 index 0000000000..60122a08d5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/download_url_to_temp_dir.rs @@ -0,0 +1,20 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use std::io::Write; +use tempfile::NamedTempFile; +use url::Url; + +pub async fn download_url_to_temp_dir(url: &str, app_data_root: &AppDataRoot) -> AnyhowResult { + let url = Url::parse(&url)?; + + let response = reqwest::get(url.clone()).await?; + let response_bytes = response.bytes().await?; + + let ext = url.path().split(".").last().unwrap_or("png"); + + let mut temp_file = app_data_root.temp_dir().new_named_temp_file_with_extension(ext)?; + + temp_file.write_all(&response_bytes)?; + + Ok(temp_file) +} diff --git a/crates/desktop/artcraft/src/core/utils/download_url_to_user_download_dir.rs b/crates/desktop/artcraft/src/core/utils/download_url_to_user_download_dir.rs new file mode 100644 index 0000000000..7196ef45ae --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/download_url_to_user_download_dir.rs @@ -0,0 +1,127 @@ +use crate::core::artcraft_error::ArtcraftError; +use crate::core::state::app_preferences::app_preferences::AppPreferences; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use url::Url; + +pub async fn download_url_to_user_download_dir( + url: &Url, + app_data_root: &AppDataRoot, + app_prefs: &AppPreferences, +) -> Result { + + let url_file_name = { + let path_segments = url.path_segments() + .ok_or_else(|| ArtcraftError::AnyhowError(anyhow!("URL does not have path segments (1): {:?}", url)))?; + + let mut url_file_name = path_segments.last() + .ok_or_else(|| ArtcraftError::AnyhowError(anyhow!("URL does not have path segments (2): {:?}", url)))? + .trim() + .to_string(); + + if !url_file_name.to_lowercase().starts_with("artcraft") { + url_file_name = format!("artcraft_{}", url_file_name); + } + + url_file_name + }; + + check_url_file_name_for_downloadability(&url_file_name)?; + + let download_directory = app_prefs + .preferred_download_directory + .download_directory(app_data_root); + + info!("Pushing path `{}` onto downloaded directory `{:?}`", url_file_name, download_directory); + + let download_filename = { + let mut download_filename = download_directory.clone(); + download_filename.push(&url_file_name); + download_filename + }; + + info!("Download filename: `{:?}`", download_filename); + + if download_filename == download_directory { + return Err(ArtcraftError::AnyhowError(anyhow!("Download filename resolved to directory: {:?}", download_filename)))?; + } + + if download_filename.exists() { + if download_filename.is_dir() { + return Err(ArtcraftError::AnyhowError(anyhow!("Download path exists and resolved to directory: {:?}", download_filename))); + } else { + return Err(ArtcraftError::CannotDownloadFilePathAlreadyExists { path: download_filename }.into()); + } + } + + info!("Downloading bytes from url: {:?}", url); + + let response = reqwest::get(url.clone()).await?; + let response_bytes = response.bytes().await?; + + info!("Writing file: {:?}", download_filename); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&download_filename)?; + + file.write_all(&response_bytes)?; + + Ok(download_filename) +} + +// TODO: This likely needs more protections. +fn check_url_file_name_for_downloadability(filename: &str) -> Result<(), ArtcraftError> { + if filename.trim().is_empty() { + error!("Download filename is empty!"); + return Err(ArtcraftError::BadDownloadFilename { path: "".into() }.into()); + } + + if !filename.is_ascii() { + error!("Download filename is not completely ASCII: {:?}", filename); + return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into()); + } + + if filename.contains("/") + || filename.contains("..") + || filename.contains("\\") + { + error!("Cannot download filename that has relative path components: {:?}", filename); + return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into()); + } + + if filename.contains("'") + || filename.contains("\"") + || filename.contains("%") + || filename.contains("<") + || filename.contains(">") + || filename.contains("|") + { + error!("Download filename has dangerous path components: {:?}", filename); + return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into()); + } + + if filename.ends_with(".apk") + || filename.ends_with(".app") + || filename.ends_with(".bat") + || filename.ends_with(".cmd") + || filename.ends_with(".com") + || filename.ends_with(".ps1") + || filename.ends_with(".sh") + || filename.ends_with(".so") + || filename.ends_with("dll") // Don't even risk the '.' + || filename.ends_with("exe") // Don't even risk the '.' + { + error!("Cannot download filename that resembles executable: {:?}", filename); + return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into()); + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/utils/enum_conversion/generation_provider.rs b/crates/desktop/artcraft/src/core/utils/enum_conversion/generation_provider.rs new file mode 100644 index 0000000000..7da9a460e9 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/enum_conversion/generation_provider.rs @@ -0,0 +1,22 @@ +use crate::core::events::generation_events::common::GenerationServiceProvider; +use enums::common::generation_provider::GenerationProvider; + +// TODO(bt,2025-07-15): Get rid of GenerationServiceProvider +pub fn to_generation_service_provider(provider: GenerationProvider) -> GenerationServiceProvider { + match provider { + GenerationProvider::Artcraft => GenerationServiceProvider::Artcraft, + GenerationProvider::Fal => GenerationServiceProvider::Fal, + GenerationProvider::Grok => GenerationServiceProvider::Grok, + GenerationProvider::Midjourney => GenerationServiceProvider::Midjourney, + GenerationProvider::Sora => GenerationServiceProvider::Sora, + GenerationProvider::WorldLabs => GenerationServiceProvider::WorldLabs, + GenerationProvider::Higgsfield => GenerationServiceProvider::Higgsfield, + GenerationProvider::Krea => GenerationServiceProvider::Krea, + GenerationProvider::Leonardo => GenerationServiceProvider::Leonardo, + GenerationProvider::Magnific => GenerationServiceProvider::Magnific, + GenerationProvider::Openart => GenerationServiceProvider::Openart, + GenerationProvider::Picsart => GenerationServiceProvider::Picsart, + GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse, + GenerationProvider::Runway => GenerationServiceProvider::Runway, + } +} diff --git a/crates/desktop/artcraft/src/core/utils/enum_conversion/mod.rs b/crates/desktop/artcraft/src/core/utils/enum_conversion/mod.rs new file mode 100644 index 0000000000..67b4f3707f --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/enum_conversion/mod.rs @@ -0,0 +1,2 @@ +pub mod generation_provider; +pub mod task_type; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/utils/enum_conversion/task_type.rs b/crates/desktop/artcraft/src/core/utils/enum_conversion/task_type.rs new file mode 100644 index 0000000000..54f73cbd8e --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/enum_conversion/task_type.rs @@ -0,0 +1,14 @@ +use crate::core::events::generation_events::common::GenerationAction; +use enums::tauri::tasks::task_type::TaskType; + +pub fn to_generation_action(task_type: TaskType) -> GenerationAction { + match task_type { + TaskType::ImageGeneration => GenerationAction::GenerateImage, + TaskType::VideoGeneration => GenerationAction::GenerateVideo, + TaskType::AudioGeneration => GenerationAction::GenerateAudio, + TaskType::BackgroundRemoval => GenerationAction::RemoveBackground, + TaskType::ObjectGeneration => GenerationAction::ImageTo3d, + TaskType::GaussianGeneration => GenerationAction::GenerateGaussian, + TaskType::ImageInpaintEdit => GenerationAction::ImageInpaintEdit, + } +} diff --git a/crates/desktop/artcraft/src/core/utils/get_url_file_extension.rs b/crates/desktop/artcraft/src/core/utils/get_url_file_extension.rs new file mode 100644 index 0000000000..9acf0cfae2 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/get_url_file_extension.rs @@ -0,0 +1,35 @@ +use reqwest::Url; +use std::path::Path; + +pub fn get_url_file_extension(url: &Url) -> Option<&str> { + Path::new(url.path()) + .extension() + .filter(|ext| !ext.is_empty()) + .and_then(|ext| ext.to_str()) +} + +#[cfg(test)] +mod tests { + use reqwest::Url; + + #[test] + fn extension_exists() { + let url = Url::parse("https://example.com/image.png").unwrap(); + let extension = super::get_url_file_extension(&url); + assert_eq!(extension, Some("png")); + } + + #[test] + fn extension_does_not_exist() { + let url = Url::parse("https://example.com/foo").unwrap(); + let extension = super::get_url_file_extension(&url); + assert_eq!(extension, None); + } + + #[test] + fn period_not_in_path() { + let url = Url::parse("https://example.com/invalid.period/foo").unwrap(); + let extension = super::get_url_file_extension(&url); + assert_eq!(extension, None); + } +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/utils/image/decode_base64_image.rs b/crates/desktop/artcraft/src/core/utils/image/decode_base64_image.rs new file mode 100644 index 0000000000..d8ebb2b148 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/image/decode_base64_image.rs @@ -0,0 +1,14 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use image::{DynamicImage, ImageReader}; +use std::io::Cursor; + +pub fn decode_base64_image(base64_image: &str) -> anyhow::Result { + let bytes = BASE64_STANDARD.decode(base64_image)?; + + let image = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format()? + .decode()?; + + Ok(image) +} diff --git a/crates/desktop/artcraft/src/core/utils/image/encode_dynamic_image_base64_png.rs b/crates/desktop/artcraft/src/core/utils/image/encode_dynamic_image_base64_png.rs new file mode 100644 index 0000000000..cb68e228aa --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/image/encode_dynamic_image_base64_png.rs @@ -0,0 +1,14 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use image::{DynamicImage, ImageFormat}; +use std::io::Cursor; + +pub fn encode_dynamic_image_base64_png(image: DynamicImage) -> anyhow::Result { + let mut bytes = Vec::with_capacity(1024*1024); + + image.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)?; + + let bytes = BASE64_STANDARD.encode(bytes); + + Ok(bytes) +} diff --git a/crates/desktop/artcraft/src/core/utils/image/encode_rgb_image_base64_png.rs b/crates/desktop/artcraft/src/core/utils/image/encode_rgb_image_base64_png.rs new file mode 100644 index 0000000000..76eb333de0 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/image/encode_rgb_image_base64_png.rs @@ -0,0 +1,16 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use image::{ImageFormat, RgbImage}; +use std::io::Cursor; + +pub fn encode_rgb_image_base64_png( + image: RgbImage +) -> anyhow::Result { + let mut bytes = Vec::with_capacity(1024*1024); + + image.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)?; + + let bytes = (&BASE64_STANDARD).encode(bytes); + + Ok(bytes) +} diff --git a/crates/desktop/artcraft/src/core/utils/image/mod.rs b/crates/desktop/artcraft/src/core/utils/image/mod.rs new file mode 100644 index 0000000000..3b7d7a6b76 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/image/mod.rs @@ -0,0 +1,3 @@ +pub mod decode_base64_image; +pub mod encode_dynamic_image_base64_png; +pub mod encode_rgb_image_base64_png; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/utils/large_file_transfer/download.rs b/crates/desktop/artcraft/src/core/utils/large_file_transfer/download.rs new file mode 100644 index 0000000000..14222bdc6b --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/large_file_transfer/download.rs @@ -0,0 +1,239 @@ +//! Implemented from https://github.com/huggingface/hf_transfer/blob/main/src/lib.rs + +use crate::core::utils::large_file_transfer::error::Error; +use crate::core::utils::large_file_transfer::util::{exponential_backoff, BASE_WAIT_TIME, MAX_WAIT_TIME}; +use futures::stream::FuturesUnordered; +use futures::StreamExt; +use log::info; +use reqwest::header::{ + HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_RANGE, + RANGE, +}; +use reqwest::Url; +use std::collections::HashMap; +use std::io::SeekFrom; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::time::Duration; +use tempfile::NamedTempFile; +use tokio::fs::OpenOptions; +use tokio::io::AsyncSeekExt; +use tokio::io::AsyncWriteExt; +use tokio::sync::Semaphore; +use tokio::time::sleep; + +pub struct ProgressUpdate { + pub complete: usize, + pub total_length: usize, +} + +/// max_files: Number of open file handles, which determines the maximum number of parallel downloads +/// parallel_failures: Number of maximum failures of different chunks in parallel (cannot exceed max_files) +/// max_retries: Number of maximum attempts per chunk. (Retries are exponentially backed off + jitter) +/// +/// The number of threads can be tuned by the environment variable `TOKIO_WORKER_THREADS` as documented in +/// https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.worker_threads +#[allow(clippy::too_many_arguments)] +pub async fn download_async>( + url: String, + filename: NamedTempFile, + final_filename: P, + max_files: usize, + chunk_size: usize, + parallel_failures: usize, + max_retries: usize, + input_headers: Option>, + maybe_progress_sender: Option>, +) -> Result<(), Error> { + info!(">>> Downloading: {}", filename.as_ref().display()); + + let client = reqwest::Client::builder() + // https://github.com/hyperium/hyper/issues/2136#issuecomment-589488526 + .http2_keep_alive_timeout(Duration::from_secs(15)) + .build()?; + + let mut headers = HeaderMap::new(); + let mut auth_token = None; + if let Some(input_headers) = input_headers { + headers.reserve(input_headers.len()); + for (k, v) in input_headers { + let name: HeaderName = k + .try_into() + .map_err(|err| Error::new_err(format!("Invalid header: {err}")))?; + let value: HeaderValue = AsRef::::as_ref(&v) + .try_into() + .map_err(|err| Error::new_err(format!("Invalid header value: {err}")))?; + if name == AUTHORIZATION { + auth_token = Some(value); + } else { + headers.insert(name, value); + } + } + }; + + let response = if let Some(token) = auth_token.as_ref() { + client.get(&url).header(AUTHORIZATION, token) + } else { + client.get(&url) + } + .headers(headers.clone()) + .header(RANGE, "bytes=0-0") + .send() + .await + .map_err(|err| Error::new_err(format!("Error while downloading: {err}")))? + .error_for_status() + .map_err(|err| Error::new_err(err.to_string()))?; + + // Only call the final redirect URL to avoid overloading the Hub with requests and also + // altering the download count + let redirected_url = response.url(); + if Url::parse(&url) + .map_err(|err| Error::new_err(format!("failed to parse url: {err}")))? + .host() + == redirected_url.host() + { + if let Some(token) = auth_token { + headers.insert(AUTHORIZATION, token); + } + } + + let content_range = response + .headers() + .get(CONTENT_RANGE) + .ok_or(Error::new_err("No content length".to_string()))? + .to_str() + .map_err(|err| Error::new_err(format!("Error while downloading: {err}")))?; + + let size: Vec<&str> = content_range.split('/').collect(); + // Content-Range: bytes 0-0/702517648 + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range + let length: usize = size + .last() + .ok_or(Error::new_err( + "Error while downloading: No size was detected".to_string(), + ))? + .parse() + .map_err(|err| Error::new_err(format!("Error while downloading: {err}")))?; + + + info!("File {:?} is {:?} length", filename.as_ref(), length); + + let mut handles = FuturesUnordered::new(); + let semaphore = Arc::new(Semaphore::new(max_files)); + let parallel_failures_semaphore = Arc::new(Semaphore::new(parallel_failures)); + let max_chunk_complete = Arc::new(AtomicUsize::new(0)); + + for start in (0..length).step_by(chunk_size) { + let url = redirected_url.to_string(); + let filename = filename.as_ref().to_owned(); + let client = client.clone(); + let headers = headers.clone(); + + let stop = std::cmp::min(start + chunk_size - 1, length); + let semaphore = semaphore.clone(); + let parallel_failures_semaphore = parallel_failures_semaphore.clone(); + let maybe_progress = maybe_progress_sender.clone(); + let max_chunk_complete = max_chunk_complete.clone(); + handles.push(tokio::spawn(async move { + let permit = semaphore + .acquire_owned() + .await + .map_err(|err| Error::new_err(format!("Error while downloading: {err}")))?; + let mut chunk = download_chunk(&client, &url, &filename, start, stop, headers.clone()).await; + let mut i = 0; + if parallel_failures > 0 { + while let Err(dlerr) = chunk { + if i >= max_retries { + return Err(Error::new_err(format!( + "Failed after too many retries ({max_retries}): {dlerr}" + ))); + } + let parallel_failure_permit = parallel_failures_semaphore.clone().try_acquire_owned().map_err(|err| { + Error::new_err(format!( + "Failed too many failures in parallel ({parallel_failures}): {dlerr} ({err})" + )) + })?; + + let wait_time = exponential_backoff(BASE_WAIT_TIME, i, MAX_WAIT_TIME); + sleep(Duration::from_millis(wait_time as u64)).await; + + chunk = download_chunk(&client, &url, &filename, start, stop, headers.clone()).await; + i += 1; + drop(parallel_failure_permit); + + if let Some(sender) = maybe_progress.as_ref() { + // NB: Calling fetch_max twice. First call will return the lower value. + let _old = max_chunk_complete.fetch_max(stop, Ordering::SeqCst); + let max_chunk = max_chunk_complete.fetch_max(stop, Ordering::SeqCst); + let progress_update = ProgressUpdate { + complete: max_chunk, + total_length: length, + }; + if let Err(_err) = sender.send(progress_update) { + // Fail silently, not essential to issue progress updates + } + } + } + } + drop(permit); + chunk.map_err(|e| Error::new_err(format!("Downloading error {e}"))).and(Ok(stop - start)) + })); + } + + // Output the chained result + while let Some(result) = handles.next().await { + match result { + Ok(Ok(size)) => { + //if let Some(ref callback) = callback { + // callback.call((size,), None)?; + //} + } + Ok(Err(py_err)) => { + return Err(py_err); + } + Err(err) => { + return Err(Error::new_err(format!( + "Error while downloading: {err}" + ))); + } + } + } + + let file = filename.persist(final_filename) + .map_err(|e| Error::new_err(format!("Could not rename to final filename: {:?}", e)))?; + + file.sync_all()?; + + Ok(()) +} + +async fn download_chunk>( + client: &reqwest::Client, + url: &str, + filename: P, + start: usize, + stop: usize, + headers: HeaderMap, +) -> Result<(), Error> { + // Process each socket concurrently. + let range = format!("bytes={start}-{stop}"); + let mut file = OpenOptions::new() + .write(true) + .truncate(false) + .create(true) + .open(filename) + .await?; + file.seek(SeekFrom::Start(start as u64)).await?; + let response = client + .get(url) + .headers(headers) + .header(RANGE, range) + .send() + .await? + .error_for_status()?; + let content = response.bytes().await?; + file.write_all(&content).await?; + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/utils/large_file_transfer/error.rs b/crates/desktop/artcraft/src/core/utils/large_file_transfer/error.rs new file mode 100644 index 0000000000..4411edb4e8 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/large_file_transfer/error.rs @@ -0,0 +1,49 @@ +//! Implemented from https://github.com/huggingface/hf_transfer/blob/main/src/lib.rs + +use reqwest::header::ToStrError; +use std::fmt::Display; + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + Request(reqwest::Error), + ToStrError(ToStrError), + Misc(String), +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + Self::Request(value) + } +} + +impl From for Error { + fn from(value: ToStrError) -> Self { + Self::ToStrError(value) + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(io) => write!(f, "Io: {io}"), + Self::Request(req) => write!(f, "Request: {req}"), + Self::ToStrError(req) => write!(f, "Response non ascii: {req}"), + Self::Misc(err) => write!(f, "Misc error: {err}"), + } + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new_err(reason: String) -> Self { + Self::Misc(reason) + } +} diff --git a/crates/desktop/artcraft/src/core/utils/large_file_transfer/mod.rs b/crates/desktop/artcraft/src/core/utils/large_file_transfer/mod.rs new file mode 100644 index 0000000000..cd6facd1b6 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/large_file_transfer/mod.rs @@ -0,0 +1,4 @@ +pub mod download; +pub mod upload; +pub mod util; +pub mod error; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/utils/large_file_transfer/upload.rs b/crates/desktop/artcraft/src/core/utils/large_file_transfer/upload.rs new file mode 100644 index 0000000000..e0e2a7e021 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/large_file_transfer/upload.rs @@ -0,0 +1,188 @@ +//! Implemented from https://github.com/huggingface/hf_transfer/blob/main/src/lib.rs + +use crate::core::utils::large_file_transfer::error::Error; +use crate::core::utils::large_file_transfer::util::{exponential_backoff, BASE_WAIT_TIME, MAX_WAIT_TIME}; +use futures::stream::FuturesUnordered; +use futures::StreamExt; +use reqwest::header::CONTENT_LENGTH; +use std::collections::HashMap; +use std::io::SeekFrom; +use std::sync::Arc; +use std::time::Duration; +use tokio::fs::OpenOptions; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio::sync::Semaphore; +use tokio::time::sleep; +use tokio_util::codec::{BytesCodec, FramedRead}; + +/// parts_urls: Dictionary consisting of part numbers as keys and the associated url as values +/// completion_url: The url that should be called when the upload is finished +/// max_files: Number of open file handles, which determines the maximum number of parallel uploads +/// parallel_failures: Number of maximum failures of different chunks in parallel (cannot exceed max_files) +/// max_retries: Number of maximum attempts per chunk. (Retries are exponentially backed off + jitter) +/// +/// The number of threads can be tuned by the environment variable `TOKIO_WORKER_THREADS` as documented in +/// https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.worker_threads +/// +/// See https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html for more information +/// on the multipart upload +#[allow(clippy::too_many_arguments)] +fn multipart_upload( + file_path: String, + parts_urls: Vec, + chunk_size: u64, + max_files: usize, + parallel_failures: usize, + max_retries: usize, +) -> Result>, Error> { + if parallel_failures > max_files { + return Err(Error::new_err( + "Error parallel_failures cannot be > max_files".to_string(), + )); + } + if (parallel_failures == 0) != (max_retries == 0) { + return Err(Error::new_err( + "For retry mechanism you need to set both `parallel_failures` and `max_retries`" + .to_string(), + )); + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + upload_async( + file_path, + parts_urls, + chunk_size, + max_files, + parallel_failures, + max_retries, + ) + .await + }) +} + + + +#[allow(clippy::too_many_arguments)] +async fn upload_async( + file_path: String, + parts_urls: Vec, + chunk_size: u64, + max_files: usize, + parallel_failures: usize, + max_retries: usize, +) -> Result>, Error> { + let client = reqwest::Client::new(); + + let mut handles = FuturesUnordered::new(); + let semaphore = Arc::new(Semaphore::new(max_files)); + let parallel_failures_semaphore = Arc::new(Semaphore::new(parallel_failures)); + + for (part_number, part_url) in parts_urls.iter().enumerate() { + let url = part_url.to_string(); + let path = file_path.to_owned(); + let client = client.clone(); + + let start = (part_number as u64) * chunk_size; + let semaphore = semaphore.clone(); + let parallel_failures_semaphore = parallel_failures_semaphore.clone(); + handles.push(tokio::spawn(async move { + let permit = semaphore + .clone() + .acquire_owned() + .await + .map_err(|err| Error::new_err(format!("Error acquiring semaphore: {err}")))?; + let mut chunk = upload_chunk(&client, &url, &path, start, chunk_size).await; + let mut i = 0; + if parallel_failures > 0 { + while let Err(ul_err) = chunk { + if i >= max_retries { + return Err(Error::new_err(format!( + "Failed after too many retries ({max_retries}): {ul_err}" + ))); + } + + let parallel_failure_permit = parallel_failures_semaphore.clone().try_acquire_owned().map_err(|err| { + Error::new_err(format!( + "Failed too many failures in parallel ({parallel_failures}): {ul_err} ({err})" + )) + })?; + + let wait_time = exponential_backoff(BASE_WAIT_TIME, i, MAX_WAIT_TIME); + sleep(Duration::from_millis(wait_time as u64)).await; + + chunk = upload_chunk(&client, &url, &path, start, chunk_size).await; + i += 1; + drop(parallel_failure_permit); + } + } + drop(permit); + chunk.map_err(|e|{ + //match e { + // Error::Io(io) => Error::new_err(format!("Io error {io}")), + // Error::Request(req) => Error::new_err(format!("Error while sending chunk {req}")), + // Error::ToStrError(req) => Error::new_err(format!("Response header contains non ASCII chars: {req}")), + //} + e + }).map(|chunk| (part_number, chunk, chunk_size)) + })); + } + + let mut results: Vec> = vec![HashMap::default(); parts_urls.len()]; + + while let Some(result) = handles.next().await { + match result { + Ok(Ok((part_number, headers, size))) => { + //if let Some(ref callback) = callback { + // callback.call((size,), None)?; + //} + results[part_number] = headers; + } + Ok(Err(py_err)) => { + return Err(py_err); + } + Err(err) => { + return Err(Error::new_err(format!( + "Error occurred while uploading: {err}" + ))); + } + } + } + + Ok(results) +} + +async fn upload_chunk( + client: &reqwest::Client, + url: &str, + path: &str, + start: u64, + chunk_size: u64, +) -> Result, Error> { + let mut options = OpenOptions::new(); + let mut file = options.read(true).open(path).await?; + let file_size = file.metadata().await?.len(); + let bytes_transferred = std::cmp::min(file_size - start, chunk_size); + + file.seek(SeekFrom::Start(start)).await?; + let chunk = file.take(chunk_size); + + let response = client + .put(url) + .header(CONTENT_LENGTH, bytes_transferred) + .body(reqwest::Body::wrap_stream(FramedRead::new( + chunk, + BytesCodec::new(), + ))) + .send() + .await?; + let response = response.error_for_status()?; + let mut headers = HashMap::new(); + for (name, value) in response.headers().into_iter() { + headers.insert(name.to_string(), value.to_str()?.to_owned()); + } + Ok(headers) +} + diff --git a/crates/desktop/artcraft/src/core/utils/large_file_transfer/util.rs b/crates/desktop/artcraft/src/core/utils/large_file_transfer/util.rs new file mode 100644 index 0000000000..e58da7d5ac --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/large_file_transfer/util.rs @@ -0,0 +1,14 @@ +//! Implemented from https://github.com/huggingface/hf_transfer/blob/main/src/lib.rs + +use rand::{thread_rng, Rng}; + +pub const BASE_WAIT_TIME: usize = 300; +pub const MAX_WAIT_TIME: usize = 10_000; + +fn jitter() -> usize { + thread_rng().gen_range(0..=500) +} + +pub fn exponential_backoff(base_wait_time: usize, n: usize, max: usize) -> usize { + (base_wait_time + n.pow(2) + jitter()).min(max) +} diff --git a/crates/desktop/artcraft/src/core/utils/mod.rs b/crates/desktop/artcraft/src/core/utils/mod.rs new file mode 100644 index 0000000000..5ea5eb93de --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/mod.rs @@ -0,0 +1,15 @@ +pub mod clear_all_webview_cookies; +pub mod download_media_file_to_temp_dir; +pub mod download_url_to_temp_dir; +pub mod download_url_to_user_download_dir; +pub mod enum_conversion; +pub mod get_url_file_extension; +pub mod image; +pub mod large_file_transfer; +pub mod object_store; +pub mod save_base64_image_to_temp_dir; +pub mod simple_http_download; +pub mod simple_http_download_to_tempfile; +pub mod task_database_pending_statuses; +pub mod upload_bytes_to_media_file; +pub mod window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/utils/object_store.rs b/crates/desktop/artcraft/src/core/utils/object_store.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/desktop/artcraft/src/core/utils/save_base64_image_to_temp_dir.rs b/crates/desktop/artcraft/src/core/utils/save_base64_image_to_temp_dir.rs new file mode 100644 index 0000000000..4ae504376d --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/save_base64_image_to_temp_dir.rs @@ -0,0 +1,22 @@ +use crate::core::artcraft_error::ArtcraftError; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use std::io::Write; +use tempfile::NamedTempFile; +use web_base64::web_base64_decode::web_base64_decode; + +pub async fn save_base64_image_to_temp_dir(app_data_root: &AppDataRoot, base64_image: &str) -> Result { + let bytes = web_base64_decode(base64_image)?; + + let extension = MimetypeInfo::get_for_bytes(&bytes) + .map(|info| info.file_extension()) + .flatten() + .map(|ext| ext.extension_with_period().to_string()) + .unwrap_or_else(|| ".png".to_string()); + + let mut file = app_data_root.temp_dir().new_named_temp_file_with_extension(&extension)?; + + file.write_all(bytes.as_ref())?; + + Ok(file) // NB: Must return TempFile to not drop / delete it +} diff --git a/crates/desktop/artcraft/src/core/utils/simple_http_download.rs b/crates/desktop/artcraft/src/core/utils/simple_http_download.rs new file mode 100644 index 0000000000..966b71950c --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/simple_http_download.rs @@ -0,0 +1,34 @@ +use errors::AnyhowResult; +use reqwest::Client; +use reqwest::Url; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +const USER_AGENT: &str = "storyteller-client/1.0"; + +/// Downloads a (binary) file to a filesystem path. Good for images, etc. Not great for large files. +pub async fn simple_http_download>(url: &Url, download_path: P) -> AnyhowResult<()> { + let client = Client::builder() + .gzip(true) + .build()?; + + let response = client.get(url.clone()) // NB: No IntoUrl for &Url. + .header("User-Agent", USER_AGENT) + .header("Accept", "*/*") + .send() + .await?; + + //let response : Response = reqwest::get(url).await?; + let bytes = response.bytes().await?; + + let mut file = OpenOptions::new() + .create(true) // To create a new file + .write(true) + .truncate(true) + .open(download_path)?; + + file.write_all(&bytes)?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/utils/simple_http_download_to_tempfile.rs b/crates/desktop/artcraft/src/core/utils/simple_http_download_to_tempfile.rs new file mode 100644 index 0000000000..b1ae596fd3 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/simple_http_download_to_tempfile.rs @@ -0,0 +1,26 @@ +use errors::AnyhowResult; +use reqwest::Client; +use reqwest::Url; +use std::io::Write; +use tempfile::NamedTempFile; + +const USER_AGENT: &str = "storyteller-client/1.0"; + +/// Downloads a (binary) file to a filesystem path. Good for images, etc. Not great for large files. +pub async fn simple_http_download_to_tempfile(url: &Url, temp_file: &mut NamedTempFile) -> AnyhowResult<()> { + let client = Client::builder() + .gzip(true) + .build()?; + + let response = client.get(url.clone()) // NB: No IntoUrl for &Url. + .header("User-Agent", USER_AGENT) + .header("Accept", "*/*") + .send() + .await?; + + let bytes = response.bytes().await?; + + temp_file.write_all(&bytes)?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/utils/task_database_pending_statuses.rs b/crates/desktop/artcraft/src/core/utils/task_database_pending_statuses.rs new file mode 100644 index 0000000000..f74e5e9703 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/task_database_pending_statuses.rs @@ -0,0 +1,12 @@ +use enums::tauri::tasks::task_status::TaskStatus; +use once_cell::sync::Lazy; +use std::collections::HashSet; + +/// Reused predicate for querying pending SQLite tasks +pub static TASK_DATABASE_PENDING_STATUSES: Lazy> = Lazy::new(|| { + let mut statuses = HashSet::new(); + statuses.insert(TaskStatus::Pending); + statuses.insert(TaskStatus::Started); + statuses.insert(TaskStatus::AttemptFailed); + statuses +}); diff --git a/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/mod.rs b/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/mod.rs new file mode 100644 index 0000000000..9f4a49e121 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/mod.rs @@ -0,0 +1,2 @@ +pub mod upload_image_bytes_as_media_file; +pub mod upload_mask_image_bytes_as_media_file; diff --git a/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/upload_image_bytes_as_media_file.rs b/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/upload_image_bytes_as_media_file.rs new file mode 100644 index 0000000000..497bcc3544 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/upload_image_bytes_as_media_file.rs @@ -0,0 +1,38 @@ +use log::info; + +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{ + upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs, +}; +use artcraft_client::utils::api_host::ApiHost; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::enqueue::generate_error::GenerateError; + +/// Upload raw image bytes to ArtCraft, returning the resulting MediaFileToken. +/// +/// This is NOT marked as an intermediate system file — the image will appear +/// in the user's gallery. +pub async fn upload_image_bytes_as_media_file( + creds: &StorytellerCredentialSet, + api_host: &ApiHost, + image_bytes: Vec, +) -> Result { + info!("Uploading image bytes ({} bytes) as media file...", image_bytes.len()); + + let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { + api_host, + maybe_creds: Some(creds), + image_bytes, + image_type: ImageType::Png, + is_intermediate_system_file: false, + maybe_generation_provider: None, + }).await.map_err(|err| { + log::error!("Failed to upload image bytes as media file: {:?}", err); + GenerateError::from(err) + })?; + + info!("Uploaded image bytes as media file: {}", result.media_file_token.as_str()); + + Ok(result.media_file_token) +} diff --git a/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/upload_mask_image_bytes_as_media_file.rs b/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/upload_mask_image_bytes_as_media_file.rs new file mode 100644 index 0000000000..7807ae8bcb --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/upload_bytes_to_media_file/upload_mask_image_bytes_as_media_file.rs @@ -0,0 +1,48 @@ +use log::{error, info}; + +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{ + upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs, +}; +use artcraft_client::utils::api_host::ApiHost; +use images::mask_images::normalize_image_bytes_to_flux_mask::normalize_image_bytes_to_flux_mask; +use tokens::tokens::media_files::MediaFileToken; + +use crate::core::commands::enqueue::generate_error::GenerateError; + +/// Upload raw mask image bytes to ArtCraft, returning the resulting MediaFileToken. +/// +/// The mask bytes are first normalized via `normalize_image_bytes_to_flux_mask` +/// (transparent pixels → black/ignore, opaque pixels → white/interest region), +/// then uploaded as an intermediate system file (hidden from the user's gallery). +pub async fn upload_mask_image_bytes_as_media_file( + creds: &StorytellerCredentialSet, + api_host: &ApiHost, + mask_image_bytes: &[u8], +) -> Result { + info!("Normalizing mask image bytes ({} bytes)...", mask_image_bytes.len()); + + let normalized = normalize_image_bytes_to_flux_mask(mask_image_bytes) + .map_err(|err| { + error!("Failed to normalize mask image bytes: {:?}", err); + GenerateError::AnyhowError(anyhow::anyhow!("Failed to normalize mask image bytes")) + })?; + + info!("Uploading normalized mask ({} bytes) as intermediate system file...", normalized.0.len()); + + let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { + api_host, + maybe_creds: Some(creds), + image_bytes: normalized.0, + image_type: ImageType::Png, + is_intermediate_system_file: true, + maybe_generation_provider: None, + }).await.map_err(|err| { + error!("Failed to upload mask image bytes: {:?}", err); + GenerateError::from(err) + })?; + + info!("Uploaded mask as media file: {}", result.media_file_token.as_str()); + + Ok(result.media_file_token) +} diff --git a/crates/desktop/artcraft/src/core/utils/window/get_webview_window_hostname.rs b/crates/desktop/artcraft/src/core/utils/window/get_webview_window_hostname.rs new file mode 100644 index 0000000000..4fd10cd58f --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/window/get_webview_window_hostname.rs @@ -0,0 +1,11 @@ +use anyhow::anyhow; +use tauri::WebviewWindow; +use errors::AnyhowResult; + +pub fn get_webview_window_hostname(webview: &WebviewWindow) -> AnyhowResult { + let url = webview.url()?; + let url_hostname= url.host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + Ok(url_hostname) +} diff --git a/crates/desktop/artcraft/src/core/utils/window/get_webview_window_url_path.rs b/crates/desktop/artcraft/src/core/utils/window/get_webview_window_url_path.rs new file mode 100644 index 0000000000..0cdec49494 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/window/get_webview_window_url_path.rs @@ -0,0 +1,7 @@ +use errors::AnyhowResult; +use tauri::WebviewWindow; + +pub fn get_webview_window_url_path(webview: &WebviewWindow) -> AnyhowResult { + let url = webview.url()?; + Ok(url.path().to_string()) +} diff --git a/crates/desktop/artcraft/src/core/utils/window/get_window_size_heuristic.rs b/crates/desktop/artcraft/src/core/utils/window/get_window_size_heuristic.rs new file mode 100644 index 0000000000..6631f783b0 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/window/get_window_size_heuristic.rs @@ -0,0 +1,25 @@ +use errors::AnyhowResult; +use tauri::{PhysicalSize, Window}; + +// A typical height difference when dev tools are open: +// Window inner size = PhysicalSize { width: 3704, height: 732 } +// Window outer size = PhysicalSize { width: 3704, height: 1476 } +const DEV_TOOLS_HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300; + +/// Get the best window size +pub fn get_window_size_heuristic(window: &Window) -> AnyhowResult> { + // NB: Ordinarily we'd use inner size, as the outer size includes the window decorations. + // However, dev tools contract the inner size, and in the event they're open, we should + // prefer the outer size. + let inner = window.inner_size()?; + let outer = window.outer_size()?; + + let height_delta = outer.height.saturating_sub(inner.height); + if height_delta > DEV_TOOLS_HEIGHT_DIFFERENCE_THRESHOLD { + // Dev tools are open, use the outer size + Ok(outer) + } else { + // Dev tools are not open, use the inner size + Ok(inner) + } +} diff --git a/crates/desktop/artcraft/src/core/utils/window/mod.rs b/crates/desktop/artcraft/src/core/utils/window/mod.rs new file mode 100644 index 0000000000..bc3d5e1a84 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/window/mod.rs @@ -0,0 +1,5 @@ +pub mod get_webview_window_hostname; +pub mod get_window_size_heuristic; +pub mod position_main_window; +pub mod resize_main_window; +pub mod get_webview_window_url_path; diff --git a/crates/desktop/artcraft/src/core/utils/window/position_main_window.rs b/crates/desktop/artcraft/src/core/utils/window/position_main_window.rs new file mode 100644 index 0000000000..5d01076995 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/window/position_main_window.rs @@ -0,0 +1,65 @@ +use crate::core::state::window::main_window_position::MainWindowPosition; +use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager, PhysicalPosition}; + +/// Try to position the main window at startup. +/// If it doesn't meet certain placement heuristics, abandon manual placement and let the OS +/// decide where to place the app. +pub fn position_main_window( + app: &AppHandle, + position: &MainWindowPosition, +) -> AnyhowResult<()> { + + // NB: This non-negative position heuristic is good for MacBook (single screen). + // Not sure how this fares on Windows, Linux, or multiscreen setups. + // On Mac, this prevents the windows from being positioned off to the top-left, + // as (0,0) is the top left directly under the top menu bar. + if position.x < 0 { + // TODO(bt,2025-07-16): just in the case of x/width, allow slight placement off + // screen as long as the app is wide enough. + return Err(anyhow::anyhow!("X position must be non-negative")); + } else if position.y < 0 { + return Err(anyhow::anyhow!("Y position must be non-negative")); + } + + let windows = app.windows(); + + let window = windows.get(MAIN_WINDOW_NAME) + .ok_or_else(|| anyhow::anyhow!("Main window not found"))?; + + if let Ok(Some(monitor)) = window.current_monitor() { + // Reports the size of the monitor, e.g. PhysicalSize { width: 4112, height: 2658 } + // This is not impacted by `scale_factor`, even if that's a multiple like "2". + // i.e., positioning the app at `monitor.size().width/2` and `monitor.size().height/2` + // will place the **top left corner of the app** in roughly the center of the monitor. + let monitor_size = monitor.size(); + + println!("Current monitor - size: {:?}", monitor_size); + println!("Current monitor - scale factor: {:?}", monitor.scale_factor()); + println!("Current monitor - position: {:?}", monitor.position()); + + let width_padding = monitor_size.width.div_ceil(6); + let height_padding = monitor_size.height.div_ceil(6); + + let max_width = monitor_size.width.saturating_sub(width_padding); + let max_height = monitor_size.height.saturating_sub(height_padding); + + let max_width = i32::try_from(max_width)?; + let max_height = i32::try_from(max_height)?; + + // Prevent the window from being positioned off the screen to the bottom, right, and bottom right. + // This works on MacBook (single screen), but should be tested on Windows, Linux, and multiscreen setups. + if position.x > max_width { + return Err(anyhow::anyhow!("X position must be less than or equal to {} (monitor width {})", + max_width, monitor_size.width)); + } else if position.y > max_height { + return Err(anyhow::anyhow!("Y position must be less than or equal to {} (monitor height {})", + max_height, monitor_size.height)); + } + } + + window.set_position(PhysicalPosition::new(position.x, position.y))?; + + Ok(()) +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/utils/window/resize_main_window.rs b/crates/desktop/artcraft/src/core/utils/window/resize_main_window.rs new file mode 100644 index 0000000000..76accb3814 --- /dev/null +++ b/crates/desktop/artcraft/src/core/utils/window/resize_main_window.rs @@ -0,0 +1,63 @@ +use crate::core::state::window::main_window_size::MainWindowSize; +use crate::core::windows::main_window::constants::{MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_NAME}; +use tauri::{AppHandle, Manager, PhysicalSize}; + +/// Try to resize the main window at startup. +/// If it doesn't meet certain sizing heuristics, abandon manual resize and let the OS +/// decide how to size the app. +pub fn resize_main_window( + app: &AppHandle, + size: &MainWindowSize, +) -> errors::AnyhowResult<()> { + + let mut resize_width = size.width; + let mut resize_height = size.height; + let mut resize_was_corrected = false; + + if resize_width < MAIN_WINDOW_MIN_WIDTH { + resize_width = MAIN_WINDOW_MIN_WIDTH; + resize_was_corrected = true; + } + if resize_height < MAIN_WINDOW_MIN_HEIGHT { + resize_height = MAIN_WINDOW_MIN_HEIGHT; + resize_was_corrected = true; + } + + let windows = app.windows(); + let window = windows.get(MAIN_WINDOW_NAME) + .ok_or_else(|| anyhow::anyhow!("Main window not found"))?; + + if let Ok(Some(monitor)) = window.current_monitor() { + let scale_factor = monitor.scale_factor(); + + if scale_factor > 1.0 { + + let min_width = MAIN_WINDOW_MIN_WIDTH as f64 * scale_factor; + let min_height = MAIN_WINDOW_MIN_HEIGHT as f64 * scale_factor; + + // Is this safe? https://users.rust-lang.org/t/convert-an-f64-to-an-i32/50991/4 + let min_width = min_width as u32; + let min_height = min_height as u32; + + if resize_width < min_width { + resize_width = min_width; + resize_was_corrected = true; + } + if resize_height < min_height { + resize_height = min_height; + resize_was_corrected = true; + } + } + } + + if resize_was_corrected { + println!("Resizing window to: {}x{} (corrected from {}x{})", + resize_width, resize_height, size.width, size.height); + } else { + println!("Resizing window to: {}x{} (no correction needed)", resize_width, resize_height); + } + + window.set_size(PhysicalSize::new(resize_width, resize_height))?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/core/windows/main_window/constants.rs b/crates/desktop/artcraft/src/core/windows/main_window/constants.rs new file mode 100644 index 0000000000..62c6e0e84f --- /dev/null +++ b/crates/desktop/artcraft/src/core/windows/main_window/constants.rs @@ -0,0 +1,15 @@ + +/// The name of the main application window. +/// This is the Artcraft application UX itself. +pub const MAIN_WINDOW_NAME : &str = "main"; + +/// This is the name of the main window's main webview. +pub const MAIN_WEBVIEW_NAME: &str = "main"; + +// TODO: handle pixel density, eg. retina displays +/// This is the minimum width to resize the main window in an automated fashion +pub const MAIN_WINDOW_MIN_WIDTH : u32 = 800; + +// TODO: handle pixel density, eg. retina displays +/// This is the minimum height to resize the main window in an automated fashion +pub const MAIN_WINDOW_MIN_HEIGHT : u32 = 600; diff --git a/crates/desktop/artcraft/src/core/windows/main_window/mod.rs b/crates/desktop/artcraft/src/core/windows/main_window/mod.rs new file mode 100644 index 0000000000..987b09fc8a --- /dev/null +++ b/crates/desktop/artcraft/src/core/windows/main_window/mod.rs @@ -0,0 +1 @@ +pub mod constants; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/core/windows/mod.rs b/crates/desktop/artcraft/src/core/windows/mod.rs new file mode 100644 index 0000000000..84cc560f4b --- /dev/null +++ b/crates/desktop/artcraft/src/core/windows/mod.rs @@ -0,0 +1 @@ +pub mod main_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs new file mode 100644 index 0000000000..f1e56503c9 --- /dev/null +++ b/crates/desktop/artcraft/src/lib.rs @@ -0,0 +1,279 @@ +use crate::core::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command; +use crate::core::commands::cost_estimate::estimate_mesh_cost_command::estimate_mesh_cost_command; +use crate::core::commands::generate::generate_splat_command::generate_splat_command; +use crate::core::commands::generate::generate_audio_command::generate_audio_command; +use crate::core::commands::generate::generate_mesh_command::generate_mesh_command; +pub mod core; +pub mod services; +pub mod version; + +use tauri::Manager; + +use crate::core::commands::app_preferences::get_app_preferences_command::get_app_preferences_command; +use crate::core::commands::app_preferences::update_app_preference_command::update_app_preferences_command; +use crate::core::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command; +use crate::core::commands::cost_estimate::estimate_splat_cost_command::estimate_splat_cost_command; +use crate::core::commands::cost_estimate::estimate_video_cost_command::estimate_video_cost_command; +use crate::core::commands::generate::models::image::list_image_models_command::list_image_models_command; +use crate::core::commands::generate::models::video::list_video_models_command::list_video_models_command; +use crate::core::commands::download::download_directory_reveal_command::download_directory_reveal_command; +use crate::core::commands::download::download_media_file_command::download_media_file_command; +use crate::core::commands::download::download_url_command::download_url_command; +use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::enqueue_image_bg_removal_command; +use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::enqueue_image_to_gaussian_command; +use crate::core::commands::enqueue::image_to_object::enqueue_image_to_3d_object_command::enqueue_image_to_3d_object_command; +use crate::core::commands::generate::generate_image::generate_image_command::generate_image_command; +use crate::core::commands::generate::generate_video::generate_video_command::generate_video_command; +use crate::core::commands::flip_image::flip_image; +use crate::core::commands::get_app_info_command::get_app_info_command; +use crate::core::commands::load_without_cors_command::load_without_cors_command; +use crate::core::commands::media_files::media_file_delete_command::media_file_delete_command; +use crate::core::commands::platform_info_command::platform_info_command; +use crate::core::commands::providers::deprecated::get_provider_order_command::get_provider_order_command; +use crate::core::commands::providers::deprecated::set_provider_order_command::set_provider_order_command; +use crate::core::commands::providers::provider_clear_command::provider_clear_command; +use crate::core::commands::providers::provider_list_command::provider_list_command; +use crate::core::commands::providers::provider_set_api_key_command::provider_set_api_key_command; +use crate::core::commands::task_queue::get_task_queue_command::get_task_queue_command; +use crate::core::commands::task_queue::mark_task_as_dismissed_command::mark_task_as_dismissed_command; +use crate::core::commands::task_queue::tasks_nuke_all_command::tasks_nuke_all_command; +use crate::core::lifecycle::startup::handle_tauri_startup::handle_tauri_startup; +use crate::core::lifecycle::startup::setup_main_window::setup_main_window; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::app_preferences::app_preferences_manager::load_app_preferences_or_default; +use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::ProviderPriorityStore; +use crate::core::threads::discord_presence_thread::discord_presence_thread; +use crate::core::threads::main_window_thread::main_window_thread::main_window_thread; +use crate::services::grok::commands::grok_clear_credentials_command::grok_clear_credentials_command; +use crate::services::grok::commands::grok_get_credential_info_command::grok_get_credential_info_command; +use crate::services::grok::commands::grok_open_login_command::grok_open_login_command; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::GrokImagePromptQueue; +use crate::services::midjourney::commands::midjourney_clear_credentials_command::midjourney_clear_credentials_command; +use crate::services::midjourney::commands::midjourney_get_credential_info_command::midjourney_get_credential_info_command; +use crate::services::midjourney::commands::midjourney_open_login_command::midjourney_open_login_command; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::sora::commands::check_sora_session_command::check_sora_session_command; +use crate::services::sora::commands::open_sora_login_command::open_sora_login_command; +use crate::services::sora::commands::sora_get_credential_info_command::sora_get_credential_info_command; +use crate::services::sora::commands::sora_logout_command::sora_logout_command; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread; +use crate::services::storyteller::commands::storyteller_get_credits_command::storyteller_get_credits_command; +use crate::services::storyteller::commands::storyteller_get_subscription_command::storyteller_get_subscription_command; +use crate::services::storyteller::commands::storyteller_purge_credentials_command::storyteller_purge_credentials_command; +use crate::services::storyteller::commands::stripe_checkout::storyteller_open_credits_purchase_command::storyteller_open_credits_purchase_command; +use crate::services::storyteller::commands::stripe_checkout::storyteller_open_subscription_purchase_command::storyteller_open_subscription_purchase_command; +use crate::services::storyteller::commands::stripe_customer_portal::storyteller_open_customer_portal_cancel_plan_command::storyteller_open_customer_portal_cancel_plan_command; +use crate::services::storyteller::commands::stripe_customer_portal::storyteller_open_customer_portal_manage_plan_command::storyteller_open_customer_portal_manage_plan_command; +use crate::services::storyteller::commands::stripe_customer_portal::storyteller_open_customer_portal_switch_plan_command::storyteller_open_customer_portal_switch_plan_command; +use crate::services::storyteller::commands::stripe_customer_portal::storyteller_open_customer_portal_update_payment_method_command::storyteller_open_customer_portal_update_payment_method_command; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::worldlabs::commands::worldlabs_clear_credentials_command::worldlabs_clear_credentials_command; +use crate::services::worldlabs::commands::worldlabs_get_credential_info_command::worldlabs_get_credential_info_command; +use crate::services::worldlabs::commands::worldlabs_open_login_command::worldlabs_open_login_command; +use crate::services::worldlabs::commands::worldlabs_receive_bearer_command::worldlabs_receive_bearer_command; +use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use log::error; + +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use tauri_plugin_dialog; +use tauri_plugin_http; +use tauri_plugin_log::Target; +use tauri_plugin_log::TargetKind; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + // NB: Tauri wants to install the logger itself, so we can't rely on the logger crate + // until the tauri runtime begins. + println!("Loading config..."); + let app_data_root = AppDataRoot::create_default().expect("data directory should be created"); + let app_data_root_2 = app_data_root.clone(); + + println!("Getting platform info..."); + let artcraft_platform_info = ArtcraftPlatformInfo::get(); + let artcraft_platform_info_2 = artcraft_platform_info.clone(); + + println!("Platform info: {:?}", artcraft_platform_info); + + println!("Loading app preferences..."); + let app_preferences = load_app_preferences_or_default(&app_data_root); + + let provider_credential_cache = crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache::new(app_data_root.clone()); + let provider_credential_cache_2 = provider_credential_cache.clone(); + + // NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now. + // println!("Attempting to read existing artcraft credentials..."); + // let storyteller_creds_manager = StorytellerCredentialManager::initialize_from_disk_infallible(&app_data_root); + let storyteller_creds_manager = StorytellerCredentialManager::initialize_empty(&app_data_root); + let storyteller_creds_manager_2 = storyteller_creds_manager.clone(); + let storyteller_creds_manager_3 = storyteller_creds_manager.clone(); + + println!("Attempting to read existing sora credentials..."); + let sora_creds_manager = SoraCredentialManager::initialize_from_disk_infallible(&app_data_root); + let sora_creds_manager_2 = sora_creds_manager.clone(); + + // Other state + let sora_task_queue = SoraTaskQueue::new(); + let sora_task_queue_2 = sora_task_queue.clone(); + + let app_env_configs = AppEnvConfigs::load_from_filesystem(&app_data_root) + .expect("AppEnvConfigs should be loaded from disk"); + + let app_env_configs_2 = app_env_configs.clone(); + + let midjourney_creds_manager = MidjourneyCredentialManager::initialize_from_disk_infallible(&app_data_root); + let midjourney_creds_manager_2 = midjourney_creds_manager.clone(); + + let grok_creds_manager = GrokCredentialManager::initialize_from_disk_infallible(&app_data_root); + let grok_creds_manager_2 = grok_creds_manager.clone(); + + let grok_prompt_queue = GrokImagePromptQueue::new(); + let grok_prompt_queue_2 = grok_prompt_queue.clone(); + + let worldlabs_creds_manager = WorldlabsCredentialManager::initialize_from_disk_infallible(&app_data_root); + let worldlabs_creds_manager_2 = worldlabs_creds_manager.clone(); + + let worldlabs_bearer_bridge = WorldlabsBearerBridge::empty(); + let worldlabs_bearer_bridge_2 = worldlabs_bearer_bridge.clone(); + + let artcraft_usage_tracker = ArtcraftUsageTracker::new(); + let artcraft_usage_tracker_2 = artcraft_usage_tracker.clone(); + + println!("Initializing backend runtime..."); + + let builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_upload::init()) + .setup(move |app| { + // TODO(bt): This is broken on windows + // log_environment_details(); + + //if cfg!(debug_assertions) { + // app.handle().plugin( + // tauri_plugin_log::Builder::default() + // .level(log::LevelFilter::Info) + // .build(), + // )?; + //} + let app = app.handle().clone(); + let handle = app.clone(); + let root = app_data_root_2.clone(); + let env_config = app_env_configs_2.clone(); + let storyteller_creds = storyteller_creds_manager_2.clone(); + let sora_creds = sora_creds_manager_2.clone(); + let sora_tasks = sora_task_queue_2.clone(); + + tauri::async_runtime::block_on(async move { + let result = setup_main_window(&app).await; + + let result = handle_tauri_startup( + handle, + root, + env_config, + artcraft_platform_info_2, + artcraft_usage_tracker_2, + storyteller_creds, + sora_creds, + sora_tasks, + midjourney_creds_manager_2, + grok_creds_manager_2, + grok_prompt_queue_2, + worldlabs_bearer_bridge_2, + worldlabs_creds_manager_2, + provider_credential_cache_2, + ).await; + + if let Err(err) = result { + error!("Failed to handle Tauri startup: {:?}", err); + panic!("Failed to handle Tauri startup: {:?}", err); + } + }); + + Ok(()) + }) + .manage(app_data_root) + .manage(app_env_configs) + .manage(app_preferences) + .manage(artcraft_platform_info) + .manage(artcraft_usage_tracker) + .manage(grok_creds_manager) + .manage(grok_prompt_queue) + .manage(midjourney_creds_manager) + .manage(sora_creds_manager) + .manage(sora_task_queue) + .manage(storyteller_creds_manager_3) + .manage(worldlabs_bearer_bridge) + .manage(provider_credential_cache) + .manage(worldlabs_creds_manager); + + // TODO: Break this out into another module, because RustRover/IntelliJ lags with these macros. + // My first attempt at naively doing this didn't work because the macros can't find their codegen'd targets. + let builder = builder.invoke_handler(tauri::generate_handler![ + check_sora_session_command, + download_directory_reveal_command, + download_media_file_command, + download_url_command, + enqueue_image_bg_removal_command, + enqueue_image_to_3d_object_command, + enqueue_image_to_gaussian_command, + estimate_image_cost_command, + estimate_splat_cost_command, + estimate_video_cost_command, + estimate_audio_cost_command, + estimate_mesh_cost_command, + list_image_models_command, + list_video_models_command, + flip_image, + generate_image_command, + generate_video_command, + generate_splat_command, + generate_audio_command, + generate_mesh_command, + get_app_info_command, + get_app_preferences_command, + get_provider_order_command, + get_task_queue_command, + provider_clear_command, + provider_list_command, + provider_set_api_key_command, + grok_clear_credentials_command, + grok_get_credential_info_command, + grok_open_login_command, + load_without_cors_command, + mark_task_as_dismissed_command, + media_file_delete_command, + midjourney_clear_credentials_command, + midjourney_get_credential_info_command, + midjourney_open_login_command, + open_sora_login_command, + platform_info_command, + set_provider_order_command, + sora_get_credential_info_command, + sora_logout_command, + storyteller_get_credits_command, + storyteller_get_subscription_command, + storyteller_open_credits_purchase_command, + storyteller_open_customer_portal_cancel_plan_command, + storyteller_open_customer_portal_manage_plan_command, + storyteller_open_customer_portal_switch_plan_command, + storyteller_open_customer_portal_update_payment_method_command, + storyteller_open_subscription_purchase_command, + storyteller_purge_credentials_command, + tasks_nuke_all_command, + update_app_preferences_command, + worldlabs_clear_credentials_command, + worldlabs_get_credential_info_command, + worldlabs_open_login_command, + worldlabs_receive_bearer_command, + ]); + + builder.run(tauri::generate_context!("tauri.conf.json")) + .expect("error while running tauri application"); +} diff --git a/crates/desktop/artcraft/src/main.rs b/crates/desktop/artcraft/src/main.rs new file mode 100644 index 0000000000..50008bc51b --- /dev/null +++ b/crates/desktop/artcraft/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + artcraft_app_lib::run(); +} diff --git a/crates/desktop/artcraft/src/services/grok/commands/grok_clear_credentials_command.rs b/crates/desktop/artcraft/src/services/grok/commands/grok_clear_credentials_command.rs new file mode 100644 index 0000000000..164a68c2da --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/commands/grok_clear_credentials_command.rs @@ -0,0 +1,45 @@ +use crate::core::commands::response::shorthand::{Response, SimpleResponse}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::State; + +#[tauri::command] +pub async fn grok_clear_credentials_command( + root: State<'_, AppDataRoot>, + creds_manager: State<'_, GrokCredentialManager>, +) -> SimpleResponse { + info!("grok_clear_credentials_command called"); + + clear_creds(&root, &creds_manager) + .map_err(|err| { + error!("Error clearing creds: {:?}", err); + "error clearing creds" + })?; + + Ok(().into()) +} + +fn clear_creds( + root: &AppDataRoot, + creds: &GrokCredentialManager, +) -> AnyhowResult<()> { + + info!("Clearing credentials..."); + creds.clear_credentials()?; + + info!("Persisting to disk..."); + creds.persist_to_disk()?; + + let creds_path = root.credentials_dir().get_grok_state_path(); + + if creds_path.exists() { + info!("Removing from disk..."); + std::fs::remove_file(creds_path)?; + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/grok/commands/grok_get_credential_info_command.rs b/crates/desktop/artcraft/src/services/grok/commands/grok_get_credential_info_command.rs new file mode 100644 index 0000000000..673a1061ab --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/commands/grok_get_credential_info_command.rs @@ -0,0 +1,54 @@ +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct GrokGetCredentialInfoResponse { + pub maybe_email: Option, + pub can_clear_state: bool, +} + +impl SerializeMarker for GrokGetCredentialInfoResponse {} + +#[tauri::command] +pub async fn grok_get_credential_info_command( + creds_manager: State<'_, GrokCredentialManager>, +) -> ResponseOrErrorMessage { + info!("grok_get_credential_info_command called"); + + let info = get_info(&creds_manager) + .map_err(|err| { + error!("Error getting info: {:?}", err); + "error getting info" + })?; + + Ok(info.into()) +} + +fn get_info( + creds: &GrokCredentialManager, +) -> AnyhowResult { + let mut can_clear_state = true; + + let maybe_cookies = creds.maybe_copy_cookie_store()?; + let maybe_full_credentials = creds.maybe_copy_full_credentials()?; + + if maybe_cookies.is_none() && maybe_full_credentials.is_none() { + can_clear_state = false; + } + + let maybe_email = maybe_full_credentials + .map(|full_creds| full_creds.client_secrets.user_email) + .map(|maybe_email| maybe_email.map(|email| email.to_string())) + .flatten(); + + Ok(GrokGetCredentialInfoResponse { + maybe_email, + can_clear_state, + }) +} diff --git a/crates/desktop/artcraft/src/services/grok/commands/grok_open_login_command.rs b/crates/desktop/artcraft/src/services/grok/commands/grok_open_login_command.rs new file mode 100644 index 0000000000..6d60b4155f --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/commands/grok_open_login_command.rs @@ -0,0 +1,38 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::grok::windows::grok_login_window::grok_login_window_open::grok_login_window_open; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use tauri::{AppHandle, State}; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; + +#[tauri::command] +pub async fn grok_open_login_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + grok_creds_manager: State<'_, GrokCredentialManager>, +) -> Result { + info!("grok_open_login_command called"); + + do_open_login(&app, &app_data_root, &grok_creds_manager) + .await + .map_err(|err| { + error!("Error opening login: {:?}", err); + format!("Error opening login: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_login( + app: &AppHandle, + app_data_root: &AppDataRoot, + grok_creds_manager: &GrokCredentialManager, +) -> AnyhowResult<()> { + info!("Building login window..."); + + grok_login_window_open(app, app_data_root, grok_creds_manager).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/grok/commands/mod.rs b/crates/desktop/artcraft/src/services/grok/commands/mod.rs new file mode 100644 index 0000000000..4162aedac7 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod grok_open_login_command; +pub mod grok_get_credential_info_command; +pub mod grok_clear_credentials_command; diff --git a/crates/desktop/artcraft/src/services/grok/mod.rs b/crates/desktop/artcraft/src/services/grok/mod.rs new file mode 100644 index 0000000000..285ecaa7e1 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/mod.rs @@ -0,0 +1,5 @@ +pub mod commands; +pub mod state; +pub mod threads; +pub mod util; +pub mod windows; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_credential_holder.rs b/crates/desktop/artcraft/src/services/grok/state/grok_credential_holder.rs new file mode 100644 index 0000000000..2969a15894 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/grok_credential_holder.rs @@ -0,0 +1,25 @@ +use cookie_store::cookie_store::CookieStore; +use grok_consumer_client::credentials::grok_client_secrets::GrokClientSecrets; +use grok_consumer_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_consumer_client::credentials::grok_user_data::GrokUserData; + +#[derive(Clone)] +pub struct GrokCredentialHolder { + /// Directly off the Tauri browser session. + /// Read once, write to disk + /// The Grok client consumes a string-only form (rather than this cookie jar) + pub browser_cookies: Option, + + /// Full credentials. + /// NOT PERSISTED TO DISK. + pub grok_full_credentials: Option, +} + +impl GrokCredentialHolder { + pub fn empty() -> Self { + Self { + browser_cookies: None, + grok_full_credentials: None, + } + } +} diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs b/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs new file mode 100644 index 0000000000..1e7ea6548e --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/grok_credential_manager.rs @@ -0,0 +1,216 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::grok::state::grok_credential_holder::GrokCredentialHolder; +use crate::services::grok::state::grok_serializable_state::{GrokSerializableState, SERIALIZABLE_GROK_STATE_VERSION}; +use cookie_store::cookie_store::CookieStore; +use errors::AnyhowResult; +use grok_consumer_client::credentials::grok_client_secrets::GrokClientSecrets; +use grok_consumer_client::credentials::grok_cookies::GrokCookies; +use grok_consumer_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_consumer_client::credentials::grok_user_data::GrokUserData; +use log::{info, warn}; +use std::fs::read_to_string; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct GrokCredentialManager { + // TODO: Put last write/last read timestamps on these. + credential_data: Arc>, + + //user_info: Arc>>, + app_data_root: AppDataRoot, +} + +impl GrokCredentialManager { + pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self { + Self { + credential_data: Arc::new(RwLock::new(GrokCredentialHolder::empty())), + app_data_root: app_data_root.clone(), + } + } + + pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self { + let mut credential_data; + + let result = GrokSerializableState::read_from_disk(app_data_root); + match result { + Err(err) => { + warn!("Failed to read grok state from disk: {:?}", err); + credential_data = Arc::new(RwLock::new(GrokCredentialHolder::empty())); + }, + Ok(None) => { + credential_data = Arc::new(RwLock::new(GrokCredentialHolder::empty())); + } + Ok(Some(state)) => { + let maybe_cookies = state.user_cookies + .map(|cookies| cookies.to_cookie_store()); + + let mut user_data = None; + if let Some(user_id) = state.user_id { + user_data = Some(GrokUserData::from_id_and_email(user_id, state.user_email)); + } + + credential_data = Arc::new(RwLock::new(GrokCredentialHolder { + browser_cookies: maybe_cookies, + grok_full_credentials: None, // NB: We don't want to keep this on disk. It goes stale. + })); + } + }; + + Self { + credential_data, + app_data_root: app_data_root.clone(), + } + } + + pub fn maybe_copy_cookie_store(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(holder) => Ok(holder.browser_cookies.clone()), + } + } + + pub fn maybe_copy_cookie_header_string(&self) -> anyhow::Result> { + let maybe_cookies = self.maybe_copy_cookie_store()?; + let maybe_cookies = maybe_cookies.map(|cookies| { + cookies.to_cookie_string() + }); + Ok(maybe_cookies) + } + + pub fn maybe_copy_typed_cookies(&self) -> anyhow::Result> { + let maybe_cookies = self.maybe_copy_cookie_header_string()?; + let maybe_cookies = maybe_cookies.map(|cookies| { + GrokCookies::new(cookies) + }); + Ok(maybe_cookies) + } + + pub fn maybe_copy_full_credentials(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(holder) => Ok(holder.grok_full_credentials.clone()), + } + } + + pub fn replace_cookie_store(&self, store: CookieStore) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.browser_cookies = Some(store); + Ok(()) + } + } + } + + pub fn replace_full_credentials(&self, creds: GrokFullCredentials) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.grok_full_credentials = Some(creds); + Ok(()) + } + } + } + + pub fn replace_client_secrets_only(&self, secrets: GrokClientSecrets) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + let mut maybe_full_creds = holder.grok_full_credentials.clone(); + if let Some(creds) = maybe_full_creds.as_mut() { + creds.client_secrets = secrets; + } else { + warn!("No existing credentials to replace secrets into."); + } + holder.grok_full_credentials = maybe_full_creds; + Ok(()) + } + } + } + + // NB: This is just a heuristic. We'll add better checks later. + pub fn do_task_polling(&self) -> anyhow::Result { + self.session_appears_active() + } + + // NB: This is just a heuristic. We'll add better checks later. + pub fn session_appears_active(&self) -> anyhow::Result { + let holder = match self.credential_data.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => store.clone(), + }; + + if holder.grok_full_credentials.is_some() { + return Ok(true); + } + + if holder.browser_cookies.is_none() { + return Ok(false); + } + + // TODO: Heuristic + Ok(true) + + //let cookies = match holder.browser_cookies.as_ref() { + // None => return Ok(false), + // Some(cookies) => cookies, + //}; + //// NB: We consider the session active if we have auth cookies and a user id. + ////let has_user_id = user_info.user_id.is_some(); + ////let maybe_has_auth_cookies = cookie_store_has_auth_cookies(&cookies); + //let maybe_has_auth_cookies = true; // TODO TODO TODO - fix this + //// Misc cookies without login cookies are ~1055 length + //// AUTH_I is ~1500 length + //// AUTH_R is ~500 length + //let maybe_has_big_enough_cookie = cookies.calculate_approx_cookie_character_length() > 2100; + //// TODO: Heuristic should count. + //// TODO: Consolidate with "login window thread" logic. + //// TODO: Check timestamp of last valid request. + //Ok((maybe_has_auth_cookies && maybe_has_big_enough_cookie && maybe_has_big_enough_cookie)) + } + + pub fn clear_credentials(&self) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => *store = GrokCredentialHolder::empty(), + } + Ok(()) + } + + pub fn persist_to_disk(&self) -> anyhow::Result<()> { + let creds = match self.credential_data.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => store.clone(), + }; + + let state = GrokSerializableState { + version: SERIALIZABLE_GROK_STATE_VERSION, + user_cookies: creds.browser_cookies + .as_ref() + .map(|cookies| cookies.to_serializable()), + user_id: creds.grok_full_credentials + .as_ref() + .map(|creds| creds.client_secrets.user_id.to_string()), + user_email: creds.grok_full_credentials + .as_ref() + .map(|data| data.client_secrets.user_email.as_ref()) + .flatten() + .map(|email| email.to_string()), + }; + + let path = self.app_data_root.credentials_dir().get_grok_state_path(); + let serialized = serde_json::to_string(&state)?; + + std::fs::write(path, serialized)?; + + // TODO: This is just for building the client and testing. + if let Some(cookies) = creds.browser_cookies.as_ref() { + let cookies_header = cookies.to_cookie_string(); + let path = self.app_data_root.credentials_dir().get_grok_cookies_path(); + std::fs::write(path, cookies_header)?; + } + + Ok(()) + } +} + diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_image_prompt_queue.rs b/crates/desktop/artcraft/src/services/grok/state/grok_image_prompt_queue.rs new file mode 100644 index 0000000000..e3e71340df --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/grok_image_prompt_queue.rs @@ -0,0 +1,80 @@ +use crate::core::artcraft_error::ArtcraftError; +use log::error; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; + +#[derive(Clone)] +pub struct GrokImagePromptQueue { + pub prompt_queue: Arc>>, +} + + +#[derive(Clone, Debug)] +pub struct PromptItem { + /// Local database task ID + pub task_id: String, + + /// Text prompt + pub prompt: String, + + /// The aspect ratio of the image + pub aspect_ratio: ClientMessageAspectRatio, +} + +impl GrokImagePromptQueue { + pub fn new() -> Self { + Self { + prompt_queue: Arc::new(Mutex::new(VecDeque::new())), + } + } + + pub fn enqueue(&self, prompt_item: PromptItem) -> Result<(), ArtcraftError> { + match self.prompt_queue.lock() { + Ok(mut queue) => { + queue.push_back(prompt_item); + Ok(()) + }, + Err(err) => { + error!("Error locking prompt queue: {:?}", err); + Err(ArtcraftError::MutexLockError) + }, + } + } + + pub fn dequeue(&self) -> Result, ArtcraftError> { + match self.prompt_queue.lock() { + Ok(mut queue) => { + Ok(queue.pop_front()) + }, + Err(err) => { + error!("Error locking prompt queue: {:?}", err); + Err(ArtcraftError::MutexLockError) + }, + } + } + + pub fn is_empty(&self) -> Result { + match self.prompt_queue.lock() { + Ok(mut queue) => { + Ok(queue.is_empty()) + }, + Err(err) => { + error!("Error locking prompt queue: {:?}", err); + Err(ArtcraftError::MutexLockError) + }, + } + } + + pub fn len(&self) -> Result { + match self.prompt_queue.lock() { + Ok(mut queue) => { + Ok(queue.len()) + }, + Err(err) => { + error!("Error locking prompt queue: {:?}", err); + Err(ArtcraftError::MutexLockError) + }, + } + } +} diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs b/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs new file mode 100644 index 0000000000..4379a4c1f9 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/grok_serializable_state.rs @@ -0,0 +1,43 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use cookie_store::serialized_cookie_store::SerializableCookieStore; +use serde_derive::{Deserialize, Serialize}; +use std::fs::read_to_string; + +/// History +/// v1 - initial +pub (super) const SERIALIZABLE_GROK_STATE_VERSION: u32 = 1; + +/// We only need to serialize Grok cookies. +/// Most of the other state (verification_token, etc.) can be pulled at runtime. +/// We will cache the `user_id` and `user_email`. +#[derive(Serialize, Deserialize)] +pub (super) struct GrokSerializableState { + pub (super) version: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_cookies: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_email: Option, +} + +impl GrokSerializableState { + pub fn read_from_disk(root: &AppDataRoot) -> anyhow::Result> { + let state_path = root.credentials_dir().get_grok_state_path(); + + if !state_path.exists() { + return Ok(None); + } + + let contents = read_to_string(&state_path)? + .trim() + .to_string(); + + let state: GrokSerializableState = serde_json::from_str(&contents)?; + + Ok(Some(state)) + } +} diff --git a/crates/desktop/artcraft/src/services/grok/state/grok_websocket_manager.rs b/crates/desktop/artcraft/src/services/grok/state/grok_websocket_manager.rs new file mode 100644 index 0000000000..ff41ef8aad --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/grok_websocket_manager.rs @@ -0,0 +1,62 @@ +use crate::core::artcraft_error::ArtcraftError; +use errors::AnyhowResult; +use grok_consumer_client::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket; +use log::error; +use std::sync::{Arc, RwLock}; + + +/// NB: This is inefficient because the websockets are locked at two layers. +/// Should be fine for our performance needs, though. +#[derive(Clone)] +pub struct GrokWebsocketManager { + //websocket: Arc>>, + websocket: Arc>>, +} + +impl GrokWebsocketManager { + pub fn new() -> Self { + Self { + websocket: Arc::new(RwLock::new(None)), + } + } + + pub fn set_websocket(&self, websocket: GrokWrappedWebsocket) -> Result<(), ArtcraftError> { + match self.websocket.write() { + Ok(mut guard) => { + //*guard = Some(websocket); + *guard = Some(()); + Ok(()) + } + Err(err) => { + error!("Error writing locked websocket: {}", err); + Err(ArtcraftError::RwLockWriteError) + } + } + } + + pub fn clear_websocket(&self) -> Result<(), ArtcraftError> { + match self.websocket.write() { + Ok(mut guard) => { + *guard = None; + Ok(()) + } + Err(err) => { + error!("Error writing locked websocket: {}", err); + Err(ArtcraftError::RwLockWriteError) + } + } + } + + pub fn grab_websocket(&self) -> Result, ArtcraftError> { + match self.websocket.read() { + Ok(guard) => { + //Ok(guard.clone()) + Ok(None) + } + Err(err) => { + error!("Error reading locked websocket: {}", err); + Err(ArtcraftError::RwLockReadError) + } + } + } +} diff --git a/crates/desktop/artcraft/src/services/grok/state/mod.rs b/crates/desktop/artcraft/src/services/grok/state/mod.rs new file mode 100644 index 0000000000..5b557e4567 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/state/mod.rs @@ -0,0 +1,7 @@ +pub mod grok_credential_manager; +pub mod grok_image_prompt_queue; +pub mod grok_serializable_state; +pub mod grok_websocket_manager; + +// Private +mod grok_credential_holder; diff --git a/crates/desktop/artcraft/src/services/grok/threads/grok_image_websocket_thread/grok_image_websocket_thread.rs b/crates/desktop/artcraft/src/services/grok/threads/grok_image_websocket_thread/grok_image_websocket_thread.rs new file mode 100644 index 0000000000..178002053d --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/grok_image_websocket_thread/grok_image_websocket_thread.rs @@ -0,0 +1,351 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::text_to_image_generation_complete_event::{GeneratedImage, TextToImageGenerationCompleteEvent}; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::state::grok_image_prompt_queue::{GrokImagePromptQueue, PromptItem}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use enums::common::generation_provider::GenerationProvider; +use enums::common::generation::common_model_type::CommonModelType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use errors::AnyhowResult; +use grok_consumer_client::recipes::prompt_websocket_image_with_retry::{prompt_websocket_image_with_retry, PromptWebsocketImageWithRetryArgs}; +use grok_consumer_client::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs}; +use grok_consumer_client::requests::image_websocket::grok_websocket::GrokWebsocket; +use grok_consumer_client::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket; +use grok_consumer_client::requests::image_websocket::listen_for_websocket_images::{listen_for_websocket_images, ImageResults, ListenForWebsocketImagesArgs}; +use grok_consumer_client::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio; +use grok_consumer_client::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs}; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info, warn}; +use sqlite_tasks::queries::get_task_by_provider_and_provider_job_id::{get_task_by_provider_and_provider_job_id, GetTaskByProviderAndProviderJobIdArgs}; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +use sqlite_tasks::queries::update_successful_task_status_with_metadata_by_provider::{update_successful_task_status_with_metadata_by_provider, UpdateSuccessfulTaskByProviderArgs}; +use std::time::Duration; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs, UploadImageMediaFileSuccessResponse}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::sqlite::tasks::TaskId; + +pub async fn grok_image_websocket_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + creds: GrokCredentialManager, + prompt_queue: GrokImagePromptQueue, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + loop { + let res = inner_loop( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &creds, + &prompt_queue, + &storyteller_creds_manager, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Only sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + } +} + +async fn inner_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + grok_creds: &GrokCredentialManager, + prompt_queue: &GrokImagePromptQueue, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + + loop { + if !grok_creds.do_task_polling()? { + tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + continue; + } + + let maybe_cookies = grok_creds.maybe_copy_typed_cookies()?; + + //let maybe_cookie_header = grok_creds.maybe_copy_cookie_header_string()?; + + let cookies = match maybe_cookies { + Some(cookies) => cookies, + None => { + tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + continue; + }, + }; + + //let cookie_header = match maybe_cookie_header { + // Some(cookie_header) => cookie_header, + // None => { + // tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + // continue; + // } + //}; + + let websocket = create_listen_websocket(CreateListenWebsocketArgs { + cookies: cookies.as_str(), + }).await?; + + let mut websocket = GrokWebsocket::new(websocket); + + loop { + let maybe_prompt = prompt_queue.dequeue()?; + + let prompt_item = match maybe_prompt { + Some(prompt_item) => prompt_item, + None => { + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + continue; + } + }; + + info!("Prompt received over in-memory queue. Prompting Grok websocket: {}", prompt_item.prompt); + + let result = prompt_websocket_image_with_retry(PromptWebsocketImageWithRetryArgs { + websocket: &mut websocket, + prompt: &prompt_item.prompt, + aspect_ratio: prompt_item.aspect_ratio, + cookies: &cookies, + }).await?; + + if let Some(new_websocket) = result.maybe_new_websocket { + info!("Replacing websocket with new one..."); + websocket = new_websocket; + } + + let images = listen_for_websocket_images(ListenForWebsocketImagesArgs { + websocket: &mut websocket, + timeout: Duration::from_millis(30_000), + }).await?; + + upload_images_to_storyteller( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &storyteller_creds_manager, + &prompt_item, + &images, + ).await? + } + + } +} + +async fn upload_images_to_storyteller( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, + prompt_item: &PromptItem, + images: &ImageResults, +) -> AnyhowResult<()> { + + loop { + let storyteller_creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + error!("No Storyteller credentials found. Cannot proceed with Grok polling."); + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + }; + + let prompt = prompt_item.prompt.trim().to_string(); + + let request = CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: Some(prompt), + negative_prompt: None, + model_type: Some(CommonModelType::GrokImage), + generation_provider: Some(GenerationProvider::Grok), + maybe_generation_mode: None, + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_batch_count: None, + maybe_generate_audio: None, + maybe_duration_seconds: None, }; + + let prompt_response = create_prompt( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request + ).await?; + + info!("Created prompt: {:?}", &prompt_response.prompt_token); + + // TODO: Move this from clientside to the backend. + // The first upload should produce a batch token that we can reuse. + let batch_token = BatchGenerationToken::generate(); + + let mut maybe_primary_media_file_token = None; + + for (i, image) in images.images.iter().enumerate() { + loop { + let url = image.url.to_string(); + let file = download_url_to_temp_dir(&url, app_data_root).await?; + + info!("Uploading image {} of {} ...", i, images.images.len()); + + let result = upload_image_media_file_from_file(UploadImageFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&storyteller_creds), + path: file.path(), + is_intermediate_system_file: false, + maybe_prompt_token: Some(&prompt_response.prompt_token), + maybe_batch_token: Some(&batch_token), + maybe_generation_provider: Some(GenerationProvider::Grok), + }).await; + + match result { + Ok(uploaded) => { + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(uploaded.media_file_token); + } + break; // Break retry loop. + } + Err(StorytellerError::Api(ApiError::TooManyRequests(_))) => { + tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + continue; + } + Err(err) => { + error!("Error uploading image: {}", err); + break; + } + } + } // Retry on 429s, etc. + } + + let event = GenerationCompleteEvent { + //media_file_token: result.media_file_token, + action: Some(GenerationAction::GenerateImage), + service: GenerationServiceProvider::Grok, + model: None, + }; + + event.send_infallible(&app_handle); + + let task = get_task_by_provider_and_provider_job_id(GetTaskByProviderAndProviderJobIdArgs { + db: task_database.get_connection(), + provider: GenerationProvider::Grok, + provider_job_id: &prompt_item.task_id, + }).await?; + + let task = match task { + Some(task) => task, + None => { + warn!("Couldn't find local sqlite task by id: {}", &prompt_item.task_id); + return Ok(()); + } + }; + + let mut maybe_cdn_url = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + info!("Looking up file to grab CDN and thumbnail URLs: {:?} ...", media_file_token); + + let lookup_result = get_media_file( + &app_env_configs.storyteller_host, + media_file_token, + ).await; + match lookup_result { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &task.id, + maybe_batch_token: Some(&batch_token), + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(TaskMediaFileClass::Image), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + maybe_primary_media_file_cdn_url: maybe_cdn_url.as_deref(), + }).await?; + + if !updated { + return Ok(()); // If anything breaks with queries, don't spam events. + } + + send_frontend_ui_update( + app_handle, + app_env_configs, + Some(&storyteller_creds), + &task, + &batch_token, + ).await?; + + return Ok(()) + } +} + +async fn send_frontend_ui_update( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + batch_token: &BatchGenerationToken, +) -> AnyhowResult<()> { + + let result = list_batch_generated_redux_media_files( + &app_env_configs.storyteller_host, + maybe_creds, + batch_token, + ).await?; + + if result.media_files.is_empty() { + return Err(anyhow!("No media files found for batch token: {}", batch_token)); + } + + let media_files = result.media_files + .into_iter() + .map(|file| GeneratedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect(); + + let event = TextToImageGenerationCompleteEvent { + generated_images: media_files, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(app); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/grok/threads/grok_image_websocket_thread/mod.rs b/crates/desktop/artcraft/src/services/grok/threads/grok_image_websocket_thread/mod.rs new file mode 100644 index 0000000000..5021bcae7c --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/grok_image_websocket_thread/mod.rs @@ -0,0 +1 @@ +pub mod grok_image_websocket_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/grok_video_task_polling_thread.rs b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/grok_video_task_polling_thread.rs new file mode 100644 index 0000000000..c622cfde68 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/grok_video_task_polling_thread.rs @@ -0,0 +1,368 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::util::get_or_upgrade_grok_full_credentials::get_or_update_grok_full_credentials; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::utils::download_midjourney_image::download_midjourney_image; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use enums::common::generation::common_model_type::CommonModelType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use errors::AnyhowResult; +use grok_consumer_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_consumer_client::error::grok_error::GrokError; +use grok_consumer_client::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs}; +use grok_consumer_client::requests::download_video_file::grok_download_video::GrokDownloadVideo; +use grok_consumer_client::requests::media_posts::list_media_posts::grok_list_media_posts::{GrokMediaPostList, GrokMediaPostListRequest, VideoData}; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId; +use midjourney_client::endpoints::imagine::{imagine, ImagineItem, ImagineRequest, MidjourneyJobType}; +use midjourney_client::utils::get_image_url::get_image_url; +use midjourney_client::utils::image_downloader_client::ImageDownloaderClient; +use once_cell::sync::Lazy; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use url::Url; + +pub async fn grok_video_task_polling_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + creds: GrokCredentialManager, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + loop { + let res = polling_loop( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &creds, + &storyteller_creds_manager, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Only sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + } +} + +async fn polling_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + grok_creds: &GrokCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + loop { + if !grok_creds.do_task_polling()? { + tokio::time::sleep(std::time::Duration::from_millis(10_000)).await; + continue; + } + + // TODO: Graceful wait, fix this long function body + let storyteller_creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + error!("No Storyteller credentials found. Cannot proceed with Grok polling."); + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + }; + + let grok_full_creds = match get_or_update_grok_full_credentials(&grok_creds).await { + Ok(creds) => creds, + Err(err) => { + info!("No full grok credentials: {:?}", err); + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + continue; + } + }; + + let local_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs { + db: task_database.get_connection(), + provider: GenerationProvider::Grok, + task_statuses: &TASK_DATABASE_PENDING_STATUSES, + }).await?; + + poll_grok_tasks( + app_handle, + app_env_configs, + app_data_root, + task_database, + &grok_full_creds, + &storyteller_creds, + local_tasks, + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +async fn poll_grok_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + grok_full_creds: &GrokFullCredentials, + storyteller_creds: &StorytellerCredentialSet, + local_tasks: TaskList, +) -> AnyhowResult<()> { + let local_tasks = local_tasks.tasks; + + if local_tasks.is_empty() { + return Ok(()) + } + + info!("Grok tasks waiting: {:?}", local_tasks.len()); + + // Map of Grok Post ID to Local Task. + let local_tasks_by_grok_post_id = local_tasks.iter() + .filter_map(|task| { + if let Some(provider_job_id) = &task.provider_job_id { + Some((provider_job_id.clone(), task.clone())) + } else { + None + } + }) + .collect::>(); + + let list_media_request = GrokMediaPostListRequest { + cookie: grok_full_creds.cookies.as_str(), + cursor: None, + request_timeout: Some(Duration::from_millis(20_000)), + }; + + let list_result = list_media_request.send().await?; + + let grok_posts = list_result.posts; + + let grok_video_posts_by_id = { + let mut hash = HashMap::new(); + for post in grok_posts.iter() { + if let Some(video_data) = &post.video_data { + hash.insert(post.post_id.to_string(), video_data.clone()); + } + } + hash + }; + + for (grok_post_id, local_task) in local_tasks_by_grok_post_id.iter() { + // TODO: Copy prompt from this. + let grok_video_data = match grok_video_posts_by_id.get(grok_post_id) { + Some(video_data) => video_data, + None => continue, + }; + + upload_grok_video( + &app_handle, + &app_env_configs, + app_data_root, + task_database, + &storyteller_creds, + grok_full_creds, + grok_post_id, + &local_task, + grok_video_data + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } + + tokio::time::sleep(std::time::Duration::from_millis(60_000)).await; + + Ok(()) +} + +async fn upload_grok_video( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds: &StorytellerCredentialSet, + grok_full_creds: &GrokFullCredentials, + grok_post_id: &String, + local_task: &Task, + grok_video_post: &VideoData +) -> AnyhowResult<()> { + + let request = CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: grok_video_post.prompt.clone(), + negative_prompt: None, + model_type: Some(CommonModelType::GrokVideo), + generation_provider: Some(GenerationProvider::Grok), + maybe_generation_mode: None, + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_batch_count: None, + maybe_generate_audio: None, + maybe_duration_seconds: None, }; + + let prompt_response = create_prompt( + &app_env_configs.storyteller_host, + Some(storyteller_creds), + request + ).await?; + + info!("Created prompt: {:?}", &prompt_response.prompt_token); + + let mut maybe_primary_media_file_token = None; + + info!("Downloading generated Grok video ..."); + + let tempdir = app_data_root.temp_dir().path(); + let download_filename = format!("{}.mp4", grok_post_id); + let download_path = tempdir.join(download_filename); + + let download = GrokDownloadVideo { + cookies: grok_full_creds.cookies.as_str(), + user_id: grok_full_creds.get_user_id_ref(), + file_id: &grok_video_post.file_id, + request_timeout: None, + }; + + download.download_to_path(&download_path).await?; + + let mut wait_delay = 0; + + loop { + info!("Uploading to backend..."); + + // TODO: media_files.origin_category + // TODO: media_files.maybe_prompt_token + // TODO: media_files.maybe_generation_provider + // TODO: media_files.maybe_origin_model_type + // TODO: media_files.maybe_origin_model_token (sref?) + // TODO: media_files.maybe_batch_token + // TODO: media_files.is_user_upload + + // TODO: batch_generations.token + // TODO: batch_generations.entity_type + // TODO: batch_generations.entity_token + + let result = upload_video_media_file_from_file(UploadVideoFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&storyteller_creds), + path: &download_path, + maybe_prompt_token: Some(&prompt_response.prompt_token), + maybe_generation_provider: Some(GenerationProvider::Grok), + }).await; + + match result { + Ok(result) => { + info!("Successfully uploaded to backend: {:?}", result.media_file_token); + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(result.media_file_token); + } + break; + }, + Err(ApiError::TooManyRequests(_)) => { + error!("Too many requests, retrying upload after delay..."); + // If we hit a rate limit, we can retry after a short delay. + wait_delay += 10; + if wait_delay > 60 { + wait_delay = 60; + } + tokio::time::sleep(std::time::Duration::from_secs(wait_delay)).await; + continue; // Retry the upload. + } + Err(err) => { + error!("Failed to upload to backend: {:?}", err); + return Err(err.into()) + }, + } + } // End loop + + let mut maybe_cdn_url = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + info!("Looking up file to grab CDN and thumbnail URLs: {:?} ...", media_file_token); + + let lookup_result = get_media_file( + &app_env_configs.storyteller_host, + media_file_token, + ).await; + match lookup_result { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &local_task.id, + maybe_batch_token: None, + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(TaskMediaFileClass::Video), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + maybe_primary_media_file_cdn_url: maybe_cdn_url.as_deref(), + }).await?; + + if !updated { + return Ok(()); // If anything breaks with queries, don't spam events. + } + + let event = GenerationCompleteEvent { + //media_file_token: result.media_file_token, + action: Some(GenerationAction::GenerateVideo), + service: GenerationServiceProvider::Grok, + model: None, + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open + } + + //let result = maybe_handle_text_to_image_complete_event( + // app_handle, + // app_env_configs, + // Some(storyteller_creds), + // local_task, + // &batch_token, + //).await; + + //if let Err(err) = result { + // error!("Failed to send text-to-image complete event: {:?}", err); + //} + + Ok(()) +} + diff --git a/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/mod.rs b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/mod.rs new file mode 100644 index 0000000000..8a7f640414 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/grok_video_task_polling/mod.rs @@ -0,0 +1 @@ +pub mod grok_video_task_polling_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/threads/mod.rs b/crates/desktop/artcraft/src/services/grok/threads/mod.rs new file mode 100644 index 0000000000..07dab82921 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/threads/mod.rs @@ -0,0 +1,2 @@ +pub mod grok_video_task_polling; +pub mod grok_image_websocket_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/util/get_or_upgrade_grok_full_credentials.rs b/crates/desktop/artcraft/src/services/grok/util/get_or_upgrade_grok_full_credentials.rs new file mode 100644 index 0000000000..ab4b7ce415 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/util/get_or_upgrade_grok_full_credentials.rs @@ -0,0 +1,49 @@ +use crate::core::commands::enqueue::generate_error::GenerateError; +use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use grok_consumer_client::credentials::grok_cookies::GrokCookies; +use grok_consumer_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_consumer_client::error::grok_client_error::GrokClientError; +use grok_consumer_client::error::grok_error::GrokError; +use grok_consumer_client::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs}; +use log::{error, info, warn}; +use tauri::AppHandle; +use crate::core::artcraft_error::ArtcraftError; + +pub async fn get_or_update_grok_full_credentials(grok_credential_manager: &GrokCredentialManager) -> Result { + if let Some(creds) = grok_credential_manager.maybe_copy_full_credentials()? { + return Ok(creds); + } + + let cookies = match grok_credential_manager.maybe_copy_cookie_store()? { + Some(cookies) => cookies.to_cookie_string(), + None => { + return Err(GrokError::Client(GrokClientError::NoCookiesPresent).into()) + } + }; + + let cookies = GrokCookies::new(cookies); + + info!("Requesting Grok client secrets..."); + + let upgraded = request_client_secrets(RequestClientSecretsArgs { + cookies: &cookies, + }).await; + + match upgraded { + Err(err) => { + error!("Failed to fetch Grok client secrets: {}", err); // NB: Fall-through + return Err(ArtcraftError::from(err)); + } + Ok(secrets) => { + info!("Grok client secrets successfully upgraded..."); + let full_creds = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets); + grok_credential_manager.replace_full_credentials(full_creds.clone())?; + info!("Persisting grok credentials to disk..."); + grok_credential_manager.persist_to_disk()?; + return Ok(full_creds) + } + } + + warn!("Grok upgrade failed. Try logging in again..."); +} diff --git a/crates/desktop/artcraft/src/services/grok/util/mod.rs b/crates/desktop/artcraft/src/services/grok/util/mod.rs new file mode 100644 index 0000000000..c3ec90aebb --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/util/mod.rs @@ -0,0 +1,2 @@ +pub mod get_or_upgrade_grok_full_credentials; +pub mod upload_image_media_file_to_grok; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/grok/util/upload_image_media_file_to_grok.rs b/crates/desktop/artcraft/src/services/grok/util/upload_image_media_file_to_grok.rs new file mode 100644 index 0000000000..661166285b --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/util/upload_image_media_file_to_grok.rs @@ -0,0 +1,80 @@ +use crate::core::state::app_env_configs::app_env_configs_serializeable::StorytellerApiHost; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use errors::AnyhowResult; +use grok_consumer_client::datatypes::api::file_id::FileId; +use grok_consumer_client::datatypes::file_upload_spec::FileUploadSpec; +use grok_consumer_client::requests::upload_file::grok_upload_file::GrokUploadFile; +use log::{error, info}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use std::time::Duration; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::utils::api_host::ApiHost; +use tokens::tokens::media_files::MediaFileToken; + +const GROK_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +pub struct UploadImageMediaFileToGrok<'a> { + pub storyteller_host: &'a ApiHost, + pub image_media_token: &'a MediaFileToken, + + pub app_data_root: &'a AppDataRoot, + pub grok_creds_manager: &'a GrokCredentialManager, +} + +pub struct UploadImageMediaFileToGrokResult { + pub grok_file_id: FileId, +} + +pub async fn upload_image_media_file_to_grok( + args: UploadImageMediaFileToGrok<'_>, +) -> AnyhowResult { + + info!("Calling get media file API: {:?}", args.storyteller_host); + + info!("Using media token: {:?}", args.image_media_token); + + let response = get_media_file( + args.storyteller_host, + args.image_media_token + ).await?; + + let media_file_url = &response.media_file.media_links.cdn_url; + let extension_with_dot = get_url_file_extension(media_file_url) + .map(|ext| format!(".{}", ext)) + .unwrap_or_else(|| ".png".to_string()); + + let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot); + let filename = args.app_data_root.downloads_dir().path().join(&filename); + + simple_http_download(&media_file_url, &filename).await?; + + let cookies = args.grok_creds_manager.maybe_copy_cookie_header_string()? + .ok_or_else(|| anyhow!("Missing Grok cookies"))?; + + info!("Uploading image to Grok..."); + + let upload = GrokUploadFile { + file: FileUploadSpec::Path(filename), + cookie: cookies, + request_timeout: Some(GROK_IMAGE_UPLOAD_TIMEOUT), + }; + + let response = upload.upload().await?; + + let file_id = response.file_id + .ok_or_else(|| anyhow!("Media upload did not produce a file_id!"))?; + + Ok(UploadImageMediaFileToGrokResult { + grok_file_id: file_id, + }) +} diff --git a/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_webview_extract_cookies.rs b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_webview_extract_cookies.rs new file mode 100644 index 0000000000..45f7dbe795 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_webview_extract_cookies.rs @@ -0,0 +1,44 @@ +use cookie_store::cookie_store::CookieStore; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::collections::HashSet; +use tauri::webview::Cookie; +use tauri::WebviewWindow; + +//static WWW_COOKIE_URL: Lazy = Lazy::new(|| { +// Url::parse("https://www.grok.com").expect("URL should parse") +//}); + +static ROOT_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse("https://grok.com").expect("URL should parse") +}); + +pub fn grok_login_webview_extract_cookies(webview: &WebviewWindow) -> AnyhowResult { + let mut cookie_store = CookieStore::empty(); + let cookies = get_all_grok_cookies(webview)?; + for cookie in cookies.iter() { + cookie_store.add_cookie_name_and_value( + cookie.name().to_string(), + cookie.value().to_string(), + ); + } + Ok(cookie_store) +} + +fn get_all_grok_cookies(webview: &WebviewWindow) -> AnyhowResult> { + //let www_cookies = webview.cookies_for_url(WWW_COOKIE_URL.clone())?; + let root_cookies = webview.cookies_for_url(ROOT_COOKIE_URL.clone())?; + + let mut all_cookies = root_cookies; + //let mut cookie_names = HashSet::new(); + + //for cookie in root_cookies.iter() { + // if !cookie_names.contains(cookie.name()) { + // cookie_names.insert(cookie.name().to_string()); + // all_cookies.push(cookie.clone()); + // } + //} + + Ok(all_cookies) +} diff --git a/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_window_open.rs b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_window_open.rs new file mode 100644 index 0000000000..2eeb173593 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_window_open.rs @@ -0,0 +1,74 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::clear_all_webview_cookies::clear_all_webview_cookies; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::midjourney_login_window_thread::midjourney_login_window_thread; +use anyhow::anyhow; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::time::Duration; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::windows::grok_login_window::grok_login_window_thread::grok_login_window_thread; + +/// Name of the window +pub (super) const GROK_LOGIN_WINDOW_NAME: &str = "grok_login_window"; + +pub (super) static START_URL: Lazy = Lazy::new(|| { + Url::parse("https://google.com").expect("URL should parse") +}); + +pub (super) static GROK_HOMEPAGE_URL: Lazy = Lazy::new(|| { + Url::parse("https://grok.com/").expect("URL should parse") +}); + +pub (super) static GROK_LOGIN_URL: Lazy = Lazy::new(|| { + Url::parse("https://accounts.x.ai/sign-in?redirect=grok-com").expect("URL should parse") +}); + +pub async fn grok_login_window_open( + app: &AppHandle, + app_data_root: &AppDataRoot, + grok_creds_manager: &GrokCredentialManager, +) -> AnyhowResult<()> { + if app.get_window(GROK_LOGIN_WINDOW_NAME).is_some() { + return Err(anyhow!("Login window already open")); + } + + let url = WebviewUrl::External(START_URL.clone()); + + let window = WebviewWindowBuilder::new(app, GROK_LOGIN_WINDOW_NAME, url) + //.user_agent(openai_sora_client::credentials::USER_AGENT) + .always_on_top(false) + .title("Login to Grok") + .center() + .resizable(true) + .visible(true) + .closable(true) + .min_inner_size(200.0, 800.0) + .focused(true) + .devtools(true) + .build()?; + + let webview = window.get_webview(GROK_LOGIN_WINDOW_NAME) + .ok_or_else(|| anyhow!("no webview found"))?; + + clear_all_webview_cookies(&webview)?; + + webview.navigate(GROK_HOMEPAGE_URL.clone())?; + + // NB: We're starting to get Cloudflare protection screens. Let's try to avoid. + tokio::time::sleep(Duration::from_millis(100)).await; + + webview.navigate(GROK_LOGIN_URL.clone())?; + + let app_handle = app.clone(); + let app_data_root = app_data_root.clone(); + let grok_creds_manager= grok_creds_manager.clone(); + + let _ = tauri::async_runtime::spawn(async move { + grok_login_window_thread(app_handle, app_data_root, grok_creds_manager).await; + }); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_window_thread.rs b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_window_thread.rs new file mode 100644 index 0000000000..672e1d011c --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/grok_login_window_thread.rs @@ -0,0 +1,182 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::refresh_account_state_event::RefreshAccountStateEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::window::get_webview_window_hostname::get_webview_window_hostname; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::windows::grok_login_window::grok_login_webview_extract_cookies::grok_login_webview_extract_cookies; +use crate::services::grok::windows::grok_login_window::grok_login_window_open::GROK_LOGIN_WINDOW_NAME; +use crate::services::sora::events::sora_login_success_event::SoraLoginSuccessEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies; +use anyhow::anyhow; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use log::{error, info}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +pub async fn grok_login_window_thread( + app: AppHandle, + app_data_root: AppDataRoot, + grok_creds_manager: GrokCredentialManager, +) { + let mut visited_login = false; + + loop { + let login_webview_window = match app.get_webview_window(GROK_LOGIN_WINDOW_NAME) { + Some(webview) => webview, + None => { + info!("Exit Grok login thread."); + return; // NB: Only exit if we don't have the webview. + } + }; + + let result = check_login_window( + &app, + &login_webview_window, + &app_data_root, + &grok_creds_manager, + &mut visited_login, + ).await; + + match result { + Err(err) => { + error!("Error checking Grok login window: {:?}", err); + } + Ok(false) => {} // Continue iteration and try again... + Ok(true) => { + info!("Successfully saved cookies from login window. Closing."); + if let Err(err) = login_webview_window.close() { + error!("Error closing login window: {:?}", err); + } + return; + } + } + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +/// Returns true if we can exit. +async fn check_login_window( + app_handle: &AppHandle, + webview_window: &WebviewWindow, + app_data_root: &AppDataRoot, + grok_creds_manager: &GrokCredentialManager, + visited_login: &mut bool, +) -> AnyhowResult { + + /* Login flow looks like this: + + 1. Start: https://accounts.x.ai/sign-in?redirect=grok-com + 2. SSO / Google Login (etc): https://accounts.google.com/v3/signin/challenge/pwd?[query] + 3. Done / Landing: https://grok.com/... + */ + + let hostname = get_webview_window_hostname(webview_window)?; + + let mut maybe_at_destination = false; + + match hostname.as_str() { + "www.grok.com" | + "grok.com" + => { + maybe_at_destination = true; + } + // chatgpt.com/auth is also an auth domain + "accounts.x.ai" | + "auth.openai.com" | + "accounts.google.com" | + "accounts.youtube.com" | + "login.live.com" | + "appleid.apple.com" + => { + // NB: We're in auth flow. + info!("Grok webview is in auth flow; hostname `{}`.", hostname); + *visited_login = true; + return Ok(false) + } + _ => {}, // We just don't know... + } + + let cookie_store = grok_login_webview_extract_cookies(webview_window)?; + + let maybe_has_auth_cookies = true; //cookie_store_has_auth_cookies(&cookie_store); // TODO TODO FIXME + let maybe_has_enough_cookies = cookie_store.len() > 6; + let maybe_completed_login_cycle = *visited_login && maybe_at_destination; + + // Misc cookies without login cookies are ~1055 length + // AUTH_I is ~1500 length + // AUTH_R is ~500 length + let maybe_has_big_enough_cookie = cookie_store.calculate_approx_cookie_character_length() > 2100; + + let mut heuristic_count = 0; + + if maybe_has_auth_cookies { + heuristic_count += 1; + } + if maybe_has_enough_cookies { + heuristic_count += 1; + } + if maybe_completed_login_cycle { + heuristic_count += 1; + } + if maybe_has_big_enough_cookie { + heuristic_count += 1; + } + + if heuristic_count < 3 { + return Ok(false); + } + + info!("Heuristic count is ({}); we're likely done.", heuristic_count); + + info!("Current cookies (len {}): {:?}", cookie_store.len(), cookie_store.to_cookie_string()); + + //let response = get_user_info(GetUserInfoRequest { + // hostname: MidjourneyHostname::Standard, + // cookie_header: cookie_store.to_cookie_string(), + //}).await; + + //match response { + // Err(err) => { + // error!("Error getting midjourney user info: {:?}", err); + // // NB: Fall through. Allow us to update the cookies. + // } + // Ok(user_info) => { + // info!("Got midjourney user info: {:?}", user_info); + // let user_info = MidjourneyUserInfo::from_api_response(user_info); + // + // //if let Err(err) = grok_creds_manager.replace_user_info(user_info) { + // // error!("Error saving midjourney user info: {:?}", err); + // // // NB: Fall through. Allow us to update the cookies. + // //} + // } + //} + + grok_creds_manager.replace_cookie_store(cookie_store)?; + + let result = grok_creds_manager.persist_to_disk(); + + if let Err(err) = result { + error!("Error persisting grok cookies to disk: {:?}", err); + return Ok(false); + } + + let event = RefreshAccountStateEvent { + provider: Some(GenerationProvider::Grok), + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send RefreshAccountStateEvent: {:?}", err); // Fail open + } + + Ok(true) +} + diff --git a/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/mod.rs b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/mod.rs new file mode 100644 index 0000000000..5f818a63fe --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/windows/grok_login_window/mod.rs @@ -0,0 +1,3 @@ +pub mod grok_login_window_open; +pub (super) mod grok_login_window_thread; +pub (super) mod grok_login_webview_extract_cookies; diff --git a/crates/desktop/artcraft/src/services/grok/windows/mod.rs b/crates/desktop/artcraft/src/services/grok/windows/mod.rs new file mode 100644 index 0000000000..66ef2bb3e8 --- /dev/null +++ b/crates/desktop/artcraft/src/services/grok/windows/mod.rs @@ -0,0 +1 @@ +pub mod grok_login_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_clear_credentials_command.rs b/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_clear_credentials_command.rs new file mode 100644 index 0000000000..da44996e06 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_clear_credentials_command.rs @@ -0,0 +1,39 @@ +use crate::core::commands::response::shorthand::{Response, SimpleResponse}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::State; + +#[tauri::command] +pub async fn midjourney_clear_credentials_command( + root: State<'_, AppDataRoot>, + creds_manager: State<'_, MidjourneyCredentialManager>, +) -> SimpleResponse { + info!("midjourney_clear_credentials_command called"); + + clear_creds(&root, &creds_manager) + .map_err(|err| { + error!("Error clearing creds: {:?}", err); + "error clearing creds" + })?; + + Ok(().into()) +} + +fn clear_creds( + root: &AppDataRoot, + creds: &MidjourneyCredentialManager, +) -> AnyhowResult<()> { + + creds.clear_credentials()?; + creds.persist_to_disk()?; + + let creds_path = root.credentials_dir().get_midjourney_state_path(); + if creds_path.exists() { + std::fs::remove_file(creds_path)?; + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_get_credential_info_command.rs b/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_get_credential_info_command.rs new file mode 100644 index 0000000000..bfe4286bb8 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_get_credential_info_command.rs @@ -0,0 +1,52 @@ +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct MidjourneyGetCredentialInfoResponse { + pub maybe_email: Option, + pub can_clear_state: bool, +} + +impl SerializeMarker for MidjourneyGetCredentialInfoResponse {} + +#[tauri::command] +pub async fn midjourney_get_credential_info_command( + creds_manager: State<'_, MidjourneyCredentialManager>, +) -> ResponseOrErrorMessage { + info!("midjourney_get_credential_info_command called"); + + let info = get_info(&creds_manager) + .map_err(|err| { + error!("Error getting info: {:?}", err); + "error getting info" + })?; + + Ok(info.into()) +} + +fn get_info( + creds: &MidjourneyCredentialManager, +) -> AnyhowResult { + let mut can_clear_state = true; + + let maybe_cookies = creds.maybe_copy_cookie_store()?; + let maybe_user_info = creds.maybe_copy_user_info()?; + + if maybe_cookies.is_none() && maybe_user_info.is_none() { + can_clear_state = false; + } + + let maybe_email = maybe_user_info + .map(|info| info.email.clone()) + .flatten(); + + Ok(MidjourneyGetCredentialInfoResponse { + maybe_email, + can_clear_state, + }) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_open_login_command.rs b/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_open_login_command.rs new file mode 100644 index 0000000000..b774410195 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/commands/midjourney_open_login_command.rs @@ -0,0 +1,38 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use errors::AnyhowResult; +use log::{error, info}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn midjourney_open_login_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + mj_creds_manager: State<'_, MidjourneyCredentialManager>, +) -> Result { + info!("midjourney_open_login_command called"); + + do_open_login(&app, &app_data_root, &mj_creds_manager) + .await + .map_err(|err| { + error!("Error opening login: {:?}", err); + format!("Error opening login: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_login( + app: &AppHandle, + app_data_root: &AppDataRoot, + mj_creds_manager: &MidjourneyCredentialManager, +) -> AnyhowResult<()> { + info!("Building login window..."); + + open_midjourney_login_window(app, app_data_root, mj_creds_manager).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/commands/mod.rs b/crates/desktop/artcraft/src/services/midjourney/commands/mod.rs new file mode 100644 index 0000000000..b31d0a1ed6 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod midjourney_clear_credentials_command; +pub mod midjourney_get_credential_info_command; +pub mod midjourney_open_login_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/mod.rs b/crates/desktop/artcraft/src/services/midjourney/mod.rs new file mode 100644 index 0000000000..9e15c7b3a9 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/mod.rs @@ -0,0 +1,5 @@ +pub mod commands; +pub mod state; +pub mod threads; +pub mod utils; +pub mod windows; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/state/midjourney_credential_manager.rs b/crates/desktop/artcraft/src/services/midjourney/state/midjourney_credential_manager.rs new file mode 100644 index 0000000000..efe80dea02 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/state/midjourney_credential_manager.rs @@ -0,0 +1,196 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_user_info::MidjourneyUserInfo; +use crate::services::midjourney::state::serializable_midjourney_state::{SerializableMidjourneyState, SERIALIZABLE_MIDJOURNEY_STATE_VERSION}; +use crate::services::storyteller::state::read_storyteller_credentials_from_disk::read_storyteller_credentials_from_disk; +use crate::services::storyteller::state::storyteller_credential_holder::StorytellerCredentialHolder; +use cookie_store::cookie_store::CookieStore; +use errors::AnyhowResult; +use log::{info, warn}; +use std::fs::read_to_string; +use std::sync::{Arc, RwLock}; +use midjourney_client::credentials::cookie_store_has_auth_cookies::cookie_store_has_auth_cookies; +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; + +#[derive(Clone)] +pub struct MidjourneyCredentialManager { + // TODO: Put last write/last read timestamps on these. + cookies: Arc>>, + user_info: Arc>>, + app_data_root: AppDataRoot, +} + +impl MidjourneyCredentialManager { + pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self { + Self { + cookies: Arc::new(RwLock::new(None)), + user_info: Arc::new(RwLock::new(None)), + app_data_root: app_data_root.clone(), + } + } + + pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self { + let mut cookies; + let mut user_info; + + match read_midjourney_state_from_disk(app_data_root) { + Err(err) => { + warn!("Failed to read midjourney state from disk: {:?}", err); + cookies = Arc::new(RwLock::new(None)); + user_info = Arc::new(RwLock::new(None)); + }, + Ok(None) => { + cookies = Arc::new(RwLock::new(None)); + user_info = Arc::new(RwLock::new(None)); + } + Ok(Some(state)) => { + let maybe_cookies = state.user_cookies + .map(|cookies| cookies.to_cookie_store()); + let maybe_user_info = state.user_info + .map(|info| info.to_user_info()); + cookies = Arc::new(RwLock::new(maybe_cookies)); + user_info = Arc::new(RwLock::new(maybe_user_info)); + } + }; + + Self { + cookies, + user_info, + app_data_root: app_data_root.clone(), + } + } + + pub fn maybe_copy_cookie_store(&self) -> anyhow::Result> { + match self.cookies.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(store) => Ok(store.clone()), + } + } + + pub fn maybe_copy_user_info(&self) -> anyhow::Result> { + match self.user_info.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(info) => Ok(info.clone()), + } + } + + pub fn replace_cookie_store(&self, store: CookieStore) -> anyhow::Result<()> { + match self.cookies.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut current_store) => { + *current_store = Some(store); + Ok(()) + } + } + } + + pub fn replace_user_info(&self, user_info: MidjourneyUserInfo) -> anyhow::Result<()> { + match self.user_info.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut current_info) => { + *current_info = Some(user_info); + Ok(()) + } + } + } + + // NB: This is just a heuristic. We'll add better checks later. + pub fn session_appears_active(&self) -> anyhow::Result { + let maybe_cookies = match self.cookies.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => store.clone(), + }; + + let maybe_user_info = match self.user_info.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(info) => info.clone(), + }; + + if maybe_cookies.is_none() || maybe_user_info.is_none() { + return Ok(false); + } + + let cookies = maybe_cookies.unwrap(); + let user_info = maybe_user_info.unwrap(); + + // NB: We consider the session active if we have auth cookies and a user id. + let has_user_id = user_info.user_id.is_some(); + let maybe_has_auth_cookies = cookie_store_has_auth_cookies(&cookies); + + // Misc cookies without login cookies are ~1055 length + // AUTH_I is ~1500 length + // AUTH_R is ~500 length + let maybe_has_big_enough_cookie = cookies.calculate_approx_cookie_character_length() > 2100; + + // TODO: Heuristic should count. + // TODO: Consolidate with "login window thread" logic. + // TODO: Check timestamp of last valid request. + Ok(has_user_id && maybe_has_auth_cookies && maybe_has_big_enough_cookie && maybe_has_big_enough_cookie) + } + + pub fn clear_credentials(&self) -> anyhow::Result<()> { + match self.cookies.write() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => *store = None, + } + match self.user_info.write() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut info) => *info = None, + } + Ok(()) + } + + pub fn persist_to_disk(&self) -> anyhow::Result<()> { + let maybe_cookies = match self.cookies.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => store.clone(), + }; + + let maybe_user_info = match self.user_info.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(info) => info.clone(), + }; + + let maybe_cookies= maybe_cookies + .map(|cookies| cookies.to_serializable()); + + let maybe_user_info= maybe_user_info + .map(|info| info.to_serializable()); + + if maybe_cookies.is_none() && maybe_user_info.is_none() { + info!("Nothing to write to disk, skipping."); + return Ok(()); + + } + + let state = SerializableMidjourneyState { + version: SERIALIZABLE_MIDJOURNEY_STATE_VERSION, + user_cookies: maybe_cookies, + user_info: maybe_user_info, + }; + + let path = self.app_data_root.credentials_dir().get_midjourney_state_path(); + let serialized = serde_json::to_string(&state)?; + + std::fs::write(path, serialized)?; + + Ok(()) + } +} + +fn read_midjourney_state_from_disk(root: &AppDataRoot) -> AnyhowResult> { + let midjourney_state_path= root.credentials_dir().get_midjourney_state_path(); + + if !midjourney_state_path.exists() { + return Ok(None); + } + + let contents = read_to_string(&midjourney_state_path)? + .trim() + .to_string(); + + let state: SerializableMidjourneyState = serde_json::from_str(&contents)?; + + Ok(Some(state)) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/state/midjourney_user_info.rs b/crates/desktop/artcraft/src/services/midjourney/state/midjourney_user_info.rs new file mode 100644 index 0000000000..258804ff97 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/state/midjourney_user_info.rs @@ -0,0 +1,30 @@ +use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId; +use midjourney_client::recipes::get_user_info::GetUserInfoResponse; +use crate::services::midjourney::state::serializable_midjourney_state::SerializableMidjourneyUserInfo; + +#[derive(Clone)] +pub struct MidjourneyUserInfo { + pub user_id: Option, + pub email: Option, + pub websocket_token: Option, +} + +impl MidjourneyUserInfo { + pub fn to_serializable(&self) -> SerializableMidjourneyUserInfo { + SerializableMidjourneyUserInfo { + user_id: self.user_id + .as_ref() + .map(|id| id.to_string()), + email: self.email.clone(), + websocket_token: self.websocket_token.clone(), + } + } + + pub fn from_api_response(response: GetUserInfoResponse) -> Self { + Self { + user_id: response.user_id, + email: response.email, + websocket_token: response.websocket_token, + } + } +} diff --git a/crates/desktop/artcraft/src/services/midjourney/state/mod.rs b/crates/desktop/artcraft/src/services/midjourney/state/mod.rs new file mode 100644 index 0000000000..4dd0dd83ff --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/state/mod.rs @@ -0,0 +1,3 @@ +pub (super) mod serializable_midjourney_state; +pub mod midjourney_credential_manager; +pub mod midjourney_user_info; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/state/serializable_midjourney_state.rs b/crates/desktop/artcraft/src/services/midjourney/state/serializable_midjourney_state.rs new file mode 100644 index 0000000000..e379f61db0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/state/serializable_midjourney_state.rs @@ -0,0 +1,41 @@ +use crate::services::midjourney::state::midjourney_user_info::MidjourneyUserInfo; +use cookie_store::serialized_cookie_store::SerializableCookieStore; +use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId; +use serde_derive::{Deserialize, Serialize}; + +pub (super) const SERIALIZABLE_MIDJOURNEY_STATE_VERSION: u32 = 1; + +#[derive(Serialize, Deserialize)] +pub (super) struct SerializableMidjourneyState { + pub (super) version: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_cookies: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_info: Option, +} + +#[derive(Serialize, Deserialize)] +pub (super) struct SerializableMidjourneyUserInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) email: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) websocket_token: Option, +} + +impl SerializableMidjourneyUserInfo { + pub fn to_user_info(&self) -> MidjourneyUserInfo { + MidjourneyUserInfo { + user_id: self.user_id + .as_deref() + .map(|id| MidjourneyUserId::from_str(id)), + email: self.email.clone(), + websocket_token: self.websocket_token.clone(), + } + } +} diff --git a/crates/desktop/artcraft/src/services/midjourney/threads/events/maybe_handle_text_to_image_complete_event.rs b/crates/desktop/artcraft/src/services/midjourney/threads/events/maybe_handle_text_to_image_complete_event.rs new file mode 100644 index 0000000000..1f3a1449c0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/threads/events/maybe_handle_text_to_image_complete_event.rs @@ -0,0 +1,83 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::text_to_image_generation_complete_event::{GeneratedImage, TextToImageGenerationCompleteEvent}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use anyhow::anyhow; +use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsItem, ListSessionResultDetailsResponse}; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowResult; +use log::{error, warn}; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn maybe_handle_text_to_image_complete_event( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + batch_token: &BatchGenerationToken, +) -> AnyhowResult<()> { + + match task.task_type { + TaskType::ImageGeneration => {} // NB: Fall-through + _ => return Ok(()), + } + + match task.frontend_caller { + Some(TauriCommandCaller::TextToImage) => {} // NB: Fall-through + _ => return Ok(()), + } + + let event = handle_batch( + app, + app_env_configs, + maybe_creds, + task, + batch_token, + ).await?; + + if let Err(err) = event.send(&app) { + error!("Failed to send TextToImageGenerationCompleteEvent: {:?}", err); // Fail open + } + + Ok(()) +} + +async fn handle_batch( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + batch_token: &BatchGenerationToken, +) -> AnyhowResult { + + let result = list_batch_generated_redux_media_files( + &app_env_configs.storyteller_host, + maybe_creds, + batch_token, + ).await?; + + if result.media_files.is_empty() { + return Err(anyhow!("No media files found for batch token: {}", batch_token)); + } + + let media_files = result.media_files + .into_iter() + .map(|file| GeneratedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect(); + + Ok(TextToImageGenerationCompleteEvent { + generated_images: media_files, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/threads/events/mod.rs b/crates/desktop/artcraft/src/services/midjourney/threads/events/mod.rs new file mode 100644 index 0000000000..3475003cdb --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/threads/events/mod.rs @@ -0,0 +1 @@ +pub (super) mod maybe_handle_text_to_image_complete_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/threads/midjourney_long_polling_thread.rs b/crates/desktop/artcraft/src/services/midjourney/threads/midjourney_long_polling_thread.rs new file mode 100644 index 0000000000..001f2b628f --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/threads/midjourney_long_polling_thread.rs @@ -0,0 +1,403 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::threads::events::maybe_handle_text_to_image_complete_event::maybe_handle_text_to_image_complete_event; +use crate::services::midjourney::utils::download_midjourney_image::download_midjourney_image; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use enums::common::generation::common_model_type::CommonModelType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId; +use midjourney_client::endpoints::imagine::{imagine, ImagineItem, ImagineRequest, MidjourneyJobType}; +use midjourney_client::utils::get_image_url::get_image_url; +use midjourney_client::utils::image_downloader_client::ImageDownloaderClient; +use once_cell::sync::Lazy; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use url::Url; + +/// This thread is responsible for picking up tasks that fell through the cracks of +/// the faster websocket thread. +pub async fn midjourney_long_polling_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + creds: MidjourneyCredentialManager, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + loop { + let res = polling_loop( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &creds, + &storyteller_creds_manager, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Only sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + } +} + +async fn polling_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + creds: &MidjourneyCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + loop { + if !creds.session_appears_active()? { + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + continue; + } + + // TODO: Graceful wait, fix this long function body + let storyteller_creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + error!("No Storyteller credentials found. Cannot proceed with Midjourney polling."); + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + }; + + let cookies = creds.maybe_copy_cookie_store()?; + + let cookies = match cookies { + Some(cookies) => cookies, + None => { + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + continue; + } + }; + + let user_info = creds.maybe_copy_user_info()?; + + let maybe_user_id = user_info + .map(|info| info.user_id) + .flatten(); + + let user_id = match maybe_user_id { + Some(user_id) => user_id, + None => { + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + continue; + } + }; + + let local_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs { + db: task_database.get_connection(), + provider: GenerationProvider::Midjourney, + task_statuses: &TASK_DATABASE_PENDING_STATUSES, + }).await?; + + poll_midjourney_tasks( + app_handle, + app_env_configs, + app_data_root, + task_database, + &cookies, + &user_id, + &storyteller_creds, + local_tasks, + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +async fn poll_midjourney_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + mj_cookies: &CookieStore, + mj_user_id: &MidjourneyUserId, + storyteller_creds: &StorytellerCredentialSet, + local_tasks: TaskList, +) -> AnyhowResult<()> { + let local_tasks = local_tasks.tasks; + + if local_tasks.is_empty() { + return Ok(()) + } + + // Map of Midjourney Job ID to Local Task. + let local_tasks_by_midjourney_job_id = local_tasks.iter() + .filter_map(|task| { + if let Some(provider_job_id) = &task.provider_job_id { + Some((provider_job_id.clone(), task.clone())) + } else { + None + } + }) + .collect::>(); + + let cookie_header = mj_cookies.to_cookie_string(); + + let midjourney_result = imagine(ImagineRequest { + hostname: MidjourneyHostname::Standard, + cookie_header, + user_id: mj_user_id, + page_size: None, + }).await?; + + let midjourney_items = midjourney_result.items; + + let midjourney_items_by_id = { + let mut hash = HashMap::new(); + for item in midjourney_items.iter() { + if let Some(id) = &item.id { + hash.insert(id.to_string(), item.clone()); + } + } + hash + }; + + // TODO: If we introduce another job polling mechanism, we may need to handle concurrency. + // One idea might be to add a new job state that acts as an optimistic lock + + let image_downloader = ImageDownloaderClient::create()?; + + for (midjourney_job_id, local_task) in local_tasks_by_midjourney_job_id.iter() { + // TODO: Copy prompt from this. + let midjourney_item = match midjourney_items_by_id.get(midjourney_job_id) { + Some(item) => item, + None => continue, + }; + + upload_midjourney_batch( + &app_handle, + &app_env_configs, + app_data_root, + task_database, + &storyteller_creds, + &image_downloader, + midjourney_job_id, + &local_task, + midjourney_item + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } + + tokio::time::sleep(std::time::Duration::from_millis(60_000)).await; + + Ok(()) +} + +async fn upload_midjourney_batch( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds: &StorytellerCredentialSet, + image_downloader: &ImageDownloaderClient, + midjourney_job_id: &String, + local_task: &Task, + midjourney_item: &ImagineItem +) -> AnyhowResult<()> { + let model_type = match midjourney_item.job_type { + Some(MidjourneyJobType::V6Diffusion) => CommonModelType::MidjourneyV6, + Some(MidjourneyJobType::V6p1Diffusion) => CommonModelType::MidjourneyV6p1, + Some(MidjourneyJobType::V6p1RawDiffusion) => CommonModelType::MidjourneyV6p1Raw, + Some(MidjourneyJobType::V7Diffusion) => CommonModelType::MidjourneyV7, + Some(MidjourneyJobType::V7RawDiffusion) => CommonModelType::MidjourneyV7Raw, + Some(MidjourneyJobType::V7DraftDiffusion) => CommonModelType::MidjourneyV7Draft, + Some(MidjourneyJobType::V7DraftRawDiffusion) => CommonModelType::MidjourneyV7DraftRaw, + Some(MidjourneyJobType::Other(ref other)) => { + info!("Unknown Midjourney job type (for job id {}): {}", midjourney_job_id, other); + CommonModelType::Midjourney + }, + _ => CommonModelType::Midjourney, + }; + + let request = CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: midjourney_item.full_command.clone(), + negative_prompt: None, + model_type: Some(model_type), + generation_provider: Some(GenerationProvider::Midjourney), + maybe_generation_mode: None, + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_batch_count: None, + maybe_generate_audio: None, + maybe_duration_seconds: None, }; + + let prompt_response = create_prompt( + &app_env_configs.storyteller_host, + Some(storyteller_creds), + request + ).await?; + + info!("Created prompt: {:?}", &prompt_response.prompt_token); + + // TODO: Move this from clientside to the backend. + // The first upload should produce a batch token that we can reuse. + let batch_token = BatchGenerationToken::generate(); + + info!("Using synthetic batch token: {:?}", &batch_token); + + let mut maybe_primary_media_file_token = None; + + for index in 0..4 { + info!("Downloading generated Midjourney file..."); + + let download_path = download_midjourney_image( + &image_downloader, + midjourney_job_id, + index, + app_data_root + ).await?; + + let mut wait_delay = 0; + + loop { + info!("Uploading to backend..."); + + // TODO: media_files.origin_category + // TODO: media_files.maybe_prompt_token + // TODO: media_files.maybe_generation_provider + // TODO: media_files.maybe_origin_model_type + // TODO: media_files.maybe_origin_model_token (sref?) + // TODO: media_files.maybe_batch_token + // TODO: media_files.is_user_upload + + // TODO: batch_generations.token + // TODO: batch_generations.entity_type + // TODO: batch_generations.entity_token + + let result = upload_image_media_file_from_file(UploadImageFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&storyteller_creds), + path: &download_path, + is_intermediate_system_file: false, + maybe_prompt_token: Some(&prompt_response.prompt_token), + maybe_batch_token: Some(&batch_token), + maybe_generation_provider: Some(GenerationProvider::Midjourney), + }).await; + + match result { + Ok(result) => { + info!("Successfully uploaded to backend: {:?}", result.media_file_token); + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(result.media_file_token); + } + break; + }, + Err(StorytellerError::Api(ApiError::TooManyRequests(_))) => { + error!("Too many requests, retrying upload after delay..."); + // If we hit a rate limit, we can retry after a short delay. + wait_delay += 10; + if wait_delay > 60 { + wait_delay = 60; + } + tokio::time::sleep(std::time::Duration::from_secs(wait_delay)).await; + continue; // Retry the upload. + } + Err(err) => { + error!("Failed to upload to backend: {:?}", err); + return Err(err.into()) + }, + } + } // End loop + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + let mut maybe_cdn_url = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + info!("Looking up file to grab CDN and thumbnail URLs: {:?} ...", media_file_token); + + let lookup_result = get_media_file( + &app_env_configs.storyteller_host, + media_file_token, + ).await; + match lookup_result { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &local_task.id, + maybe_batch_token: Some(&batch_token), + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(TaskMediaFileClass::Image), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + maybe_primary_media_file_cdn_url: maybe_cdn_url.as_deref(), + }).await?; + + if !updated { + return Ok(()); // If anything breaks with queries, don't spam events. + } + + let event = GenerationCompleteEvent { + //media_file_token: result.media_file_token, + action: Some(GenerationAction::GenerateImage), + service: GenerationServiceProvider::Midjourney, + model: None, + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open + } + + let result = maybe_handle_text_to_image_complete_event( + app_handle, + app_env_configs, + Some(storyteller_creds), + local_task, + &batch_token, + ).await; + + if let Err(err) = result { + error!("Failed to send text-to-image complete event: {:?}", err); + } + + Ok(()) +} + diff --git a/crates/desktop/artcraft/src/services/midjourney/threads/mod.rs b/crates/desktop/artcraft/src/services/midjourney/threads/mod.rs new file mode 100644 index 0000000000..b4be5d8ad1 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/threads/mod.rs @@ -0,0 +1,2 @@ +pub mod midjourney_long_polling_thread; +mod events; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/utils/download_midjourney_image.rs b/crates/desktop/artcraft/src/services/midjourney/utils/download_midjourney_image.rs new file mode 100644 index 0000000000..fb63021c15 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/utils/download_midjourney_image.rs @@ -0,0 +1,35 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use errors::AnyhowResult; +use midjourney_client::utils::get_image_url::get_image_url; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use url::Url; +use midjourney_client::utils::image_downloader_client::ImageDownloaderClient; + +pub async fn download_midjourney_image( + image_downloader: &ImageDownloaderClient, + job_id: &str, + index: u8, + app_data_root: &AppDataRoot +) -> AnyhowResult { + let url = get_image_url(job_id, index)?; + let parsed_url = Url::parse(&url)?; + + //let response = reqwest::get(&url).await?; + //let image_bytes = response.bytes().await?; + + let image_bytes = image_downloader.download_image(job_id, index).await?; + + let ext = parsed_url.path().split(".").last().unwrap_or("png"); + + let tempdir = app_data_root.temp_dir().path(); + let download_filename = format!("{}_{}.{}", job_id, index, ext); + let download_path = tempdir.join(download_filename); + + let mut file = File::create(&download_path)?; + file.write_all(&image_bytes)?; + + Ok(download_path) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/utils/mod.rs b/crates/desktop/artcraft/src/services/midjourney/utils/mod.rs new file mode 100644 index 0000000000..acf0cc90dc --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/utils/mod.rs @@ -0,0 +1 @@ +pub mod download_midjourney_image; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/windows/extract_midjourney_webview_cookies.rs b/crates/desktop/artcraft/src/services/midjourney/windows/extract_midjourney_webview_cookies.rs new file mode 100644 index 0000000000..9c348c2de8 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/windows/extract_midjourney_webview_cookies.rs @@ -0,0 +1,46 @@ +use cookie_store::cookie_store::CookieStore; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::collections::HashSet; +use tauri::webview::Cookie; +use tauri::WebviewWindow; + +static WWW_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse("https://www.midjourney.com").expect("URL should parse") +}); + +static ROOT_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse("https://midjourney.com").expect("URL should parse") +}); + +pub fn extract_midjourney_webview_cookies(webview: &WebviewWindow) -> AnyhowResult { + let mut cookie_store = CookieStore::empty(); + let cookies = get_all_midjourney_cookies(webview)?; + for cookie in cookies.iter() { + cookie_store.add_cookie_name_and_value( + cookie.name().to_string(), + cookie.value().to_string(), + ); + } + Ok(cookie_store) +} + +fn get_all_midjourney_cookies(webview: &WebviewWindow) -> AnyhowResult> { + // NB: WWW domain contains the auth/session cookies. + // Root domain contains some other cookies, such as Cloudflare. + let www_cookies = webview.cookies_for_url(WWW_COOKIE_URL.clone())?; + let root_cookies = webview.cookies_for_url(ROOT_COOKIE_URL.clone())?; + + let mut all_cookies = www_cookies; + let mut cookie_names = HashSet::new(); + + for cookie in root_cookies.iter() { + if !cookie_names.contains(cookie.name()) { + cookie_names.insert(cookie.name().to_string()); + all_cookies.push(cookie.clone()); + } + } + + Ok(all_cookies) +} diff --git a/crates/desktop/artcraft/src/services/midjourney/windows/midjourney_login_window_thread.rs b/crates/desktop/artcraft/src/services/midjourney/windows/midjourney_login_window_thread.rs new file mode 100644 index 0000000000..55a267eb51 --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/windows/midjourney_login_window_thread.rs @@ -0,0 +1,185 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::refresh_account_state_event::RefreshAccountStateEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::window::get_webview_window_hostname::get_webview_window_hostname; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::state::midjourney_user_info::MidjourneyUserInfo; +use crate::services::midjourney::windows::extract_midjourney_webview_cookies::extract_midjourney_webview_cookies; +use crate::services::midjourney::windows::open_midjourney_login_window::MIDJOURNEY_LOGIN_WINDOW_NAME; +use crate::services::sora::events::sora_login_success_event::SoraLoginSuccessEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies; +use anyhow::anyhow; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::credentials::cookie_store_has_auth_cookies::cookie_store_has_auth_cookies; +use midjourney_client::recipes::get_user_info::{get_user_info, GetUserInfoRequest}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +pub async fn midjourney_login_window_thread( + app: AppHandle, + app_data_root: AppDataRoot, + mj_creds_manager: MidjourneyCredentialManager, +) { + let mut visited_login = false; + + loop { + let login_webview_window = match app.get_webview_window(MIDJOURNEY_LOGIN_WINDOW_NAME) { + Some(webview) => webview, + None => { + info!("Exit midjourney login thread."); + return; // NB: Only exit if we don't have the webview. + } + }; + + let result = check_login_window( + &app, + &login_webview_window, + &app_data_root, + &mj_creds_manager, + &mut visited_login, + ).await; + + match result { + Err(err) => { + error!("Error checking login window: {:?}", err); + } + Ok(false) => {} // Continue iteration and try again... + Ok(true) => { + info!("Successfully saved cookies from login window. Closing."); + if let Err(err) = login_webview_window.close() { + error!("Error closing login window: {:?}", err); + } + return; + } + } + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +/// Returns true if we can exit. +async fn check_login_window( + app_handle: &AppHandle, + webview_window: &WebviewWindow, + app_data_root: &AppDataRoot, + mj_creds_manager: &MidjourneyCredentialManager, + visited_login: &mut bool, +) -> AnyhowResult { + + /* Login flow looks like this: + + 1. Start: https://www.midjourney.com/auth/signin + 2. SSO / Google Login (etc): https://accounts.google.com/v3/signin/challenge/pwd?[query] + 3. Done / Landing: https://www.midjourney.com/... + */ + + let hostname = get_webview_window_hostname(webview_window)?; + + let mut maybe_at_destination = false; + + match hostname.as_str() { + "www.midjourney.com" | + "midjourney.com" + => { + maybe_at_destination = true; + } + // chatgpt.com/auth is also an auth domain + "auth.openai.com" | + "accounts.google.com" | + "accounts.youtube.com" | + "login.live.com" | + "appleid.apple.com" + => { + // NB: We're in auth flow. + info!("Midjourney webview is in auth flow; hostname `{}`.", hostname); + *visited_login = true; + return Ok(false) + } + _ => {}, // We just don't know... + } + + let cookie_store = extract_midjourney_webview_cookies(webview_window)?; + + let maybe_has_auth_cookies = cookie_store_has_auth_cookies(&cookie_store); + let maybe_has_enough_cookies = cookie_store.len() > 6; + let maybe_completed_login_cycle = *visited_login && maybe_at_destination; + + // Misc cookies without login cookies are ~1055 length + // AUTH_I is ~1500 length + // AUTH_R is ~500 length + let maybe_has_big_enough_cookie = cookie_store.calculate_approx_cookie_character_length() > 2100; + + let mut heuristic_count = 0; + + if maybe_has_auth_cookies { + heuristic_count += 1; + } + if maybe_has_enough_cookies { + heuristic_count += 1; + } + if maybe_completed_login_cycle { + heuristic_count += 1; + } + if maybe_has_big_enough_cookie { + heuristic_count += 1; + } + + if heuristic_count < 3 { + return Ok(false); + } + + info!("Heuristic count is ({}); we're likely done.", heuristic_count); + + info!("Current cookies (len {}): {:?}", cookie_store.len(), cookie_store.to_cookie_string()); + + let response = get_user_info(GetUserInfoRequest { + hostname: MidjourneyHostname::Standard, + cookie_header: cookie_store.to_cookie_string(), + }).await; + + match response { + Err(err) => { + error!("Error getting midjourney user info: {:?}", err); + // NB: Fall through. Allow us to update the cookies. + } + Ok(user_info) => { + info!("Got midjourney user info: {:?}", user_info); + let user_info = MidjourneyUserInfo::from_api_response(user_info); + + if let Err(err) = mj_creds_manager.replace_user_info(user_info) { + error!("Error saving midjourney user info: {:?}", err); + // NB: Fall through. Allow us to update the cookies. + } + } + } + + mj_creds_manager.replace_cookie_store(cookie_store)?; + + let result = mj_creds_manager.persist_to_disk(); + + if let Err(err) = result { + error!("Error persisting midjourney cookies to disk: {:?}", err); + return Ok(false); + } + + let event = RefreshAccountStateEvent { + provider: Some(GenerationProvider::Midjourney), + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send RefreshAccountStateEvent: {:?}", err); // Fail open + } + + Ok(true) +} + diff --git a/crates/desktop/artcraft/src/services/midjourney/windows/mod.rs b/crates/desktop/artcraft/src/services/midjourney/windows/mod.rs new file mode 100644 index 0000000000..b5417787da --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/windows/mod.rs @@ -0,0 +1,3 @@ +pub mod extract_midjourney_webview_cookies; +pub mod midjourney_login_window_thread; +pub mod open_midjourney_login_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/midjourney/windows/open_midjourney_login_window.rs b/crates/desktop/artcraft/src/services/midjourney/windows/open_midjourney_login_window.rs new file mode 100644 index 0000000000..8d536c08ea --- /dev/null +++ b/crates/desktop/artcraft/src/services/midjourney/windows/open_midjourney_login_window.rs @@ -0,0 +1,78 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::clear_all_webview_cookies::clear_all_webview_cookies; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::midjourney_login_window_thread::midjourney_login_window_thread; +use anyhow::anyhow; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::time::Duration; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Name of the window +pub (super) const MIDJOURNEY_LOGIN_WINDOW_NAME: &str = "midjourney_login_window"; + +pub (super) const MIDJOURNEY_START_URL_STR: &str = "https://google.com"; + +pub (super) static START_URL: Lazy = Lazy::new(|| { + Url::parse(MIDJOURNEY_START_URL_STR).expect("URL should parse") +}); + +pub (super) const MIDJOURNEY_HOMEPAGE_URL_STR: &str = "https://www.midjourney.com/"; + +pub (super) static MIDJOURNEY_HOMEPAGE_URL: Lazy = Lazy::new(|| { + Url::parse(MIDJOURNEY_HOMEPAGE_URL_STR).expect("URL should parse") +}); + +pub (super) const MIDJOURNEY_LOGIN_URL_STR: &str = "https://www.midjourney.com/auth/signin"; + +pub (super) static MIDJOURNEY_LOGIN_URL: Lazy = Lazy::new(|| { + Url::parse(MIDJOURNEY_LOGIN_URL_STR).expect("URL should parse") +}); + +pub async fn open_midjourney_login_window( + app: &AppHandle, + app_data_root: &AppDataRoot, + mj_creds_manager: &MidjourneyCredentialManager, +) -> AnyhowResult<()> { + if app.get_window(MIDJOURNEY_LOGIN_WINDOW_NAME).is_some() { + return Err(anyhow!("Login window already open")); + } + + let url = WebviewUrl::External(START_URL.clone()); + + let window = WebviewWindowBuilder::new(app, MIDJOURNEY_LOGIN_WINDOW_NAME, url) + //.user_agent(openai_sora_client::credentials::USER_AGENT) + .always_on_top(false) + .title("Login to Midjourney") + .center() + .resizable(true) + .visible(true) + .closable(true) + .min_inner_size(200.0, 800.0) + .focused(true) + .devtools(true) + .build()?; + + let webview = window.get_webview(MIDJOURNEY_LOGIN_WINDOW_NAME) + .ok_or_else(|| anyhow!("no webview found"))?; + + clear_all_webview_cookies(&webview)?; + + webview.navigate(MIDJOURNEY_HOMEPAGE_URL.clone())?; + + // NB: We're starting to get Cloudflare protection screens. Let's try to avoid. + tokio::time::sleep(Duration::from_millis(100)).await; + + webview.navigate(MIDJOURNEY_LOGIN_URL.clone())?; + + let app_handle = app.clone(); + let app_data_root = app_data_root.clone(); + let mj_creds_manager= mj_creds_manager.clone(); + + let _ = tauri::async_runtime::spawn(async move { + midjourney_login_window_thread(app_handle, app_data_root, mj_creds_manager).await; + }); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/mod.rs b/crates/desktop/artcraft/src/services/mod.rs new file mode 100644 index 0000000000..cea58b5941 --- /dev/null +++ b/crates/desktop/artcraft/src/services/mod.rs @@ -0,0 +1,5 @@ +pub mod grok; +pub mod midjourney; +pub mod sora; +pub mod storyteller; +pub mod worldlabs; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/commands/check_sora_session_command.rs b/crates/desktop/artcraft/src/services/sora/commands/check_sora_session_command.rs new file mode 100644 index 0000000000..8bb241e972 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/commands/check_sora_session_command.rs @@ -0,0 +1,109 @@ +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use chrono::{TimeDelta, Utc}; +use errors::AnyhowResult; +use log::{error, info}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::list_classic_tasks; +use serde_derive::Serialize; +use std::ops::Add; +use tauri::{AppHandle, State}; + +const CACHE_PERIOD : TimeDelta = TimeDelta::milliseconds(1000 * 60 * 5); // 5 minutes + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub struct CheckSoraSessionCommand { + pub state: SoraSessionState, + pub maybe_account_email: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SoraSessionState{ + #[serde(rename = "not_set_up")] + NotSetUp, + #[serde(rename = "expired_or_error")] + ExpiredOrError, + #[serde(rename = "valid")] + Valid, +} + +// TODO: Deprecate this +#[deprecated(note="Not 100% deprecated yet - it may still have uses. But use sora_get_credential_info_command instead.")] +#[tauri::command] +pub async fn check_sora_session_command( + app: AppHandle, + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> Result { + info!("check_sora_session_command called"); + + match do_check(&sora_creds_manager).await { + Ok(state) => Ok(state), + Err(err) => { + error!("Error checking session state: {:?}", err); + Err(CheckSoraSessionCommand { + state: SoraSessionState::ExpiredOrError, + maybe_account_email: None, + }) + } + } +} + +async fn do_check( + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult { + + let mut creds = match sora_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Ok(CheckSoraSessionCommand { + state: SoraSessionState::NotSetUp, + maybe_account_email: None, + }); + } + }; + + let mut needs_upgrade_or_check = false; + + let stats = sora_creds_manager.get_credential_stats()?; + + match stats.last_consecutive_credential_success { + None => { + info!("Session never checked, needs upgrade or status checking."); + needs_upgrade_or_check= true; + } + Some(last_success) => { + let now = chrono::Utc::now(); + let expires = last_success.add(CACHE_PERIOD); + if now.gt(&expires) { + info!("Session success expired, needs upgrade or status checking."); + needs_upgrade_or_check = true; + } + } + } + + let mut upgraded = false; + + if needs_upgrade_or_check { + info!("Attempting to upgrade session..."); + upgraded = maybe_upgrade_or_renew_session(&mut creds).await?; + info!("Attempting to check session..."); + list_classic_tasks(&creds).await?; // Intent here is to make sure the upgrade request worked. + sora_creds_manager.record_credential_success()?; + } + + if upgraded { + info!("Saving upgraded session to manager and disk..."); + sora_creds_manager.set_credentials(&creds)?; + sora_creds_manager.persist_all_to_disk()?; + } + + let maybe_account_email = creds + .jwt_bearer_token + .map(|token| token.jwt_claims().email.clone()); + + Ok(CheckSoraSessionCommand { + state: SoraSessionState::Valid, + maybe_account_email, + }) +} diff --git a/crates/desktop/artcraft/src/services/sora/commands/mod.rs b/crates/desktop/artcraft/src/services/sora/commands/mod.rs new file mode 100644 index 0000000000..1693c5059c --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod check_sora_session_command; +pub mod open_sora_login_command; +pub mod sora_get_credential_info_command; +pub mod sora_logout_command; diff --git a/crates/desktop/artcraft/src/services/sora/commands/open_sora_login_command.rs b/crates/desktop/artcraft/src/services/sora/commands/open_sora_login_command.rs new file mode 100644 index 0000000000..946a969d2d --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/commands/open_sora_login_command.rs @@ -0,0 +1,37 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use errors::AnyhowResult; +use log::{error, info}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn open_sora_login_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> Result { + info!("open_login_command called"); + + do_open_login(&app, &app_data_root, &sora_creds_manager) + .await + .map_err(|err| { + error!("Error opening login: {:?}", err); + format!("Error opening login: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_login( + app: &AppHandle, + app_data_root: &AppDataRoot, + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult<()> { + info!("Building login window..."); + + open_sora_login_window(app, app_data_root, sora_creds_manager).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/commands/sora_get_credential_info_command.rs b/crates/desktop/artcraft/src/services/sora/commands/sora_get_credential_info_command.rs new file mode 100644 index 0000000000..3675f58ec0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/commands/sora_get_credential_info_command.rs @@ -0,0 +1,59 @@ +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct SoraGetCredentialInfoResponse { + pub maybe_email: Option, + pub can_clear_state: bool, +} + +impl SerializeMarker for SoraGetCredentialInfoResponse {} + +#[tauri::command] +pub async fn sora_get_credential_info_command( + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> ResponseOrErrorMessage { + info!("sora_get_credential_info_command called"); + + let info = get_info(&sora_creds_manager) + .map_err(|err| { + error!("Error getting info: {:?}", err); + "error getting info" + })?; + + Ok(info.into()) +} + +fn get_info( + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult { + + let maybe_creds = sora_creds_manager.get_credentials()?; + + let mut can_clear_state = maybe_creds.is_some(); + let mut maybe_email = None; + + if let Some(creds) = maybe_creds { + if let Some(jwt) = &creds.jwt_bearer_token { + maybe_email = Some(jwt.jwt_claims().email.clone()); + } + + // TODO: This was cargo culted from Midjourney. Probably not quite right. + // We probably need an Option for cookie rather than an empty / partial string as + // well as timestamps, invalidation states, etc. + if creds.jwt_bearer_token.is_none() && creds.sora_sentinel.is_none() { + can_clear_state = false; + } + } + + Ok(SoraGetCredentialInfoResponse { + maybe_email, + can_clear_state, + }) +} diff --git a/crates/desktop/artcraft/src/services/sora/commands/sora_logout_command.rs b/crates/desktop/artcraft/src/services/sora/commands/sora_logout_command.rs new file mode 100644 index 0000000000..cfd8a51c2e --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/commands/sora_logout_command.rs @@ -0,0 +1,28 @@ +use crate::core::commands::response::shorthand::SimpleResponse; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use log::{error, info}; +use tauri::State; + +#[tauri::command] +pub async fn sora_logout_command( + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> SimpleResponse { + info!("sora_logout_command called"); + + if let Err(err) = sora_creds_manager.purge_credentials_from_disk() { + error!("Error purging credentials from disk: {:?}", err); + return Err("error purging credentials from disk".into()); + } + + if let Err(err) = sora_creds_manager.clear_credentials() { + error!("Error clearing credentials from memory: {:?}", err); + return Err("error clearing credentials from memory".into()); + } + + if let Err(err) = sora_creds_manager.reset_credential_stats() { + error!("Error resetting credential stats: {:?}", err); + return Err("error resetting credential stats".into()); + } + + Ok(().into()) +} diff --git a/crates/desktop/artcraft/src/services/sora/events/mod.rs b/crates/desktop/artcraft/src/services/sora/events/mod.rs new file mode 100644 index 0000000000..770ae70f8d --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/events/mod.rs @@ -0,0 +1 @@ +pub mod sora_login_success_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/events/sora_login_success_event.rs b/crates/desktop/artcraft/src/services/sora/events/sora_login_success_event.rs new file mode 100644 index 0000000000..cd09847a48 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/events/sora_login_success_event.rs @@ -0,0 +1,11 @@ +use crate::core::events::sendable_event_trait::SendableEvent; +use serde_derive::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct SoraLoginSuccessEvent { + // TODO: Reason. +} + +impl SendableEvent for SoraLoginSuccessEvent { + const FRONTEND_EVENT_NAME: &'static str = "sora-login-success"; +} diff --git a/crates/desktop/artcraft/src/services/sora/mod.rs b/crates/desktop/artcraft/src/services/sora/mod.rs new file mode 100644 index 0000000000..6f644a1f33 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/mod.rs @@ -0,0 +1,9 @@ +//! Deprecated direct Sora integration. Retained for legacy handlers and task polling. +//! New desktop generations use ArtCraft; account management is no longer exposed in the frontend. + +pub mod commands; +pub mod events; +pub mod state; +pub mod threads; +pub mod windows; +pub mod utils; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/state/initialize_sora_jwt_bearer_token.rs b/crates/desktop/artcraft/src/services/sora/state/initialize_sora_jwt_bearer_token.rs new file mode 100644 index 0000000000..5705cb165f --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/initialize_sora_jwt_bearer_token.rs @@ -0,0 +1,28 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::info; +use openai_sora_client::requests::auth_bearer::generate_bearer_jwt_with_cookie::generate_bearer_jwt_with_cookie; + +/// Loading the JWT bearer token for the first time. +pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> AnyhowResult<()> { + if !app_data_root.get_sora_cookie_file_path().exists() { + return Err(anyhow!("Cookie file does not exist")); + } + + if app_data_root.get_sora_bearer_token_file_path().exists() { + return Ok(()); // Bearer token already exists. + } + + let cookie = std::fs::read_to_string(app_data_root.get_sora_cookie_file_path())? + .trim() + .to_string(); + + info!("Requesting initial JWT bearer token..."); + + let bearer = generate_bearer_jwt_with_cookie(&cookie).await?; + + std::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer)?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/state/mod.rs b/crates/desktop/artcraft/src/services/sora/state/mod.rs new file mode 100644 index 0000000000..ddc54a9f97 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/mod.rs @@ -0,0 +1,7 @@ +pub mod initialize_sora_jwt_bearer_token; +pub mod read_sora_credentials_from_disk; +pub mod read_sora_credentials_from_disk_with_upgrade; +pub mod sora_credential_holder; +pub mod sora_credential_manager; +pub mod sora_credential_stats; +pub mod sora_task_queue; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/state/read_sora_credentials_from_disk.rs b/crates/desktop/artcraft/src/services/sora/state/read_sora_credentials_from_disk.rs new file mode 100644 index 0000000000..7f4e2a6b48 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/read_sora_credentials_from_disk.rs @@ -0,0 +1,60 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use anyhow::anyhow; +use errors::AnyhowResult; +use openai_sora_client::creds::sora_cookies::SoraCookies; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use openai_sora_client::creds::sora_sentinel::SoraSentinel; +use std::fs::read_to_string; +use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken; + +pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult { + let cookie_file = app_data_root.get_sora_cookie_file_path(); + let bearer_file = app_data_root.get_sora_bearer_token_file_path(); + let legacy_sentinel_file = app_data_root.get_sora_legacy_sentinel_file_path(); + let sentinel_token_file = app_data_root.get_sora_sentinel_token_file_path(); + + if !cookie_file.exists() { + return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file)); + } + + let value = read_to_string(cookie_file)? + .trim() + .to_string(); + + let cookie = SoraCookies::new(value); + + let mut bearer = None; + let mut sentinel = None; + let mut sentinel_token = None; + + if bearer_file.exists() { + let value = read_to_string(&bearer_file)? + .trim() + .to_string(); + bearer = Some(SoraJwtBearerToken::new(value)?); + } + + if legacy_sentinel_file.exists() { + let value = read_to_string(&legacy_sentinel_file)? + .trim() + .to_string(); + + sentinel = Some(SoraSentinel::new(value)); + } + + if sentinel_token_file.exists() { + let value = read_to_string(&sentinel_token_file)? + .trim() + .to_string(); + + sentinel_token = Some(SoraSentinelToken::from_persistent_storage_json(&value)?); + } + + Ok(SoraCredentialSet::initialize( + cookie, + bearer, + sentinel, + sentinel_token, + )) +} diff --git a/crates/desktop/artcraft/src/services/sora/state/read_sora_credentials_from_disk_with_upgrade.rs b/crates/desktop/artcraft/src/services/sora/state/read_sora_credentials_from_disk_with_upgrade.rs new file mode 100644 index 0000000000..7538a1a067 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/read_sora_credentials_from_disk_with_upgrade.rs @@ -0,0 +1,120 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{info, warn}; +use openai_sora_client::creds::sora_cookies::SoraCookies; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use openai_sora_client::creds::sora_sentinel::SoraSentinel; +use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use std::fs::{read_to_string, OpenOptions}; +use std::io::Write; + +pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult { + let cookie_file = app_data_root.get_sora_cookie_file_path(); + let bearer_file = app_data_root.get_sora_bearer_token_file_path(); + let legacy_sentinel_file = app_data_root.get_sora_legacy_sentinel_file_path(); + let sentinel_token_file = app_data_root.get_sora_sentinel_token_file_path(); + + if !cookie_file.exists() { + return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file)); + } + + let value = read_to_string(cookie_file)? + .trim() + .to_string(); + + let cookie = SoraCookies::new(value); + + let mut bearer = None; + let mut sentinel = None; + let mut sentinel_token = None; + + if bearer_file.exists() { + let value = read_to_string(&bearer_file)? + .trim() + .to_string(); + bearer = Some(SoraJwtBearerToken::new(value)?); + } + + if legacy_sentinel_file.exists() { + let value = read_to_string(&legacy_sentinel_file)? + .trim() + .to_string(); + + sentinel = Some(SoraSentinel::new(value)); + } + + if sentinel_token_file.exists() { + let value = read_to_string(&sentinel_token_file)? + .trim() + .to_string(); + + sentinel_token = Some(SoraSentinelToken::from_persistent_storage_json(&value)?); + } + + let mut credentials = SoraCredentialSet::initialize( + cookie, + bearer, + sentinel, + sentinel_token, + ); + + let response = maybe_upgrade_or_renew_session(&mut credentials).await; + + if let Err(err) = response { + // NB: Make this portion infallible. Don't die on setup. We can recover downstream. + warn!("Failed to upgrade or renew session: {:?}", err); + } + + if !bearer_file.exists() { + if let Some(bearer) = &credentials.jwt_bearer_token { + info!("Persisting bearer token value..."); + let value = bearer.token_str(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&bearer_file)?; + + file.write_all(value.as_bytes())?; + file.flush()?; + } + } + + if !legacy_sentinel_file.exists() { + if let Some(sentinel) = &credentials.sora_sentinel { + info!("Persisting sentinel value..."); + let value = sentinel.get_sentinel(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&legacy_sentinel_file)?; + + file.write_all(value.as_bytes())?; + file.flush()?; + } + } + + if !sentinel_token_file.exists() { + if let Some(sentinel_token) = &credentials.sora_sentinel_token { + info!("Persisting sentinel token..."); + let value = sentinel_token.to_persistent_storage_json()?; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&sentinel_token_file)?; + + file.write_all(value.as_bytes())?; + file.flush()?; + } + } + + Ok(credentials) +} diff --git a/crates/desktop/artcraft/src/services/sora/state/sora_credential_holder.rs b/crates/desktop/artcraft/src/services/sora/state/sora_credential_holder.rs new file mode 100644 index 0000000000..4efff6a551 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/sora_credential_holder.rs @@ -0,0 +1,73 @@ +use anyhow::anyhow; +use errors::AnyhowResult; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use std::sync::{Arc, RwLock}; + +/// Hold credentials for the application. +#[derive(Clone)] +pub struct SoraCredentialHolder { + credentials: Arc>>, +} + +impl SoraCredentialHolder { + pub fn new() -> Self { + Self { + credentials: Arc::new(RwLock::new(None)), + } + } + + pub fn set_credentials(&self, credentials: &SoraCredentialSet) -> AnyhowResult<()> { + match self.credentials.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut creds) => { + *creds = Some(credentials.clone()); + Ok(()) + } + } + } + + pub fn clear_credentials(&self) -> AnyhowResult<()> { + match self.credentials.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut creds) => { + *creds = None; + Ok(()) + } + } + } + + pub fn get_credentials(&self) -> AnyhowResult> { + match self.credentials.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(creds) => { + Ok(creds.clone()) + } + } + } + + pub fn get_credentials_required(&self) -> AnyhowResult { + match self.credentials.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(creds) => match &*creds { + None => Err(anyhow!("Credentials not set")), + Some(creds) => Ok(creds.clone()), + } + } + } + + /// This does not guarantee the credentials are correct, just that they're set. + pub fn has_apparently_complete_credentials(&self) -> AnyhowResult { + match self.credentials.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(creds) => match &*creds { + None => Ok(false), + Some(creds) => { + let has_cookies = !creds.cookies.as_str().is_empty(); + let has_bearer = creds.jwt_bearer_token.is_some(); + let has_sentinel = creds.sora_sentinel.is_some(); + Ok(has_cookies && has_bearer && has_sentinel) + }, + } + } + } +} diff --git a/crates/desktop/artcraft/src/services/sora/state/sora_credential_manager.rs b/crates/desktop/artcraft/src/services/sora/state/sora_credential_manager.rs new file mode 100644 index 0000000000..e10ddcf3f6 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/sora_credential_manager.rs @@ -0,0 +1,229 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::sora::state::read_sora_credentials_from_disk::read_sora_credentials_from_disk; +use crate::services::sora::state::sora_credential_holder::SoraCredentialHolder; +use crate::services::sora::state::sora_credential_stats::SoraCredentialStats; +use errors::AnyhowResult; +use log::{error, info, warn}; +use memory_store::clone_cell::CloneCell; +use openai_sora_client::creds::sora_cookies::SoraCookies; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken; +use openai_sora_client::creds::sora_sentinel::SoraSentinel; +use openai_sora_client::requests::auth_sentinel::generate_sentinel_token::generate_sentinel_token; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::{fs, io}; + +#[derive(Clone)] +pub struct SoraCredentialManager { + holder: SoraCredentialHolder, + app_data_root: AppDataRoot, + + /// This is meant to help debug credential issues, keep credentials cached, fresh, etc. + // TODO: Put this into the same structure as the credentials and also persist to disk. + stats: CloneCell, +} + +impl SoraCredentialManager { + + pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self { + let holder = SoraCredentialHolder::new(); + + match read_sora_credentials_from_disk(app_data_root) { + Err(err) => warn!("Failed to read credentials from disk: {:?}", err), + Ok(creds) => { + holder.set_credentials(&creds).expect("Failed to set credentials"); + } + } + + Self { + holder, + app_data_root: app_data_root.clone(), + stats: CloneCell::with_owned(SoraCredentialStats::new()), + } + } + + pub fn record_credential_success(&self) -> AnyhowResult<()> { + let mut stats = self.stats.get_clone()?; + stats.record_credential_success(); + self.stats.set_owned(stats)?; + Ok(()) + } + + pub fn record_credential_failure(&self) -> AnyhowResult<()> { + let mut stats = self.stats.get_clone()?; + stats.record_credential_failure(); + self.stats.set_owned(stats)?; + Ok(()) + } + + pub fn reset_credential_stats(&self) -> AnyhowResult<()> { + self.stats.set_owned(SoraCredentialStats::new()) + } + + /// This does not guarantee the credentials are correct + pub fn has_apparently_complete_credentials(&self) -> AnyhowResult { + self.holder.has_apparently_complete_credentials() + } + + pub fn get_credential_stats(&self) -> AnyhowResult { + self.stats.get_clone() + } + + pub fn set_credential_stats(&self, stats: SoraCredentialStats) -> AnyhowResult<()> { + self.stats.set_owned(stats) + } + + pub fn set_credentials(&self, creds: &SoraCredentialSet) -> AnyhowResult<()> { + self.holder.set_credentials(creds) + } + + pub fn clear_credentials(&self) -> AnyhowResult<()> { + self.holder.clear_credentials()?; + Ok(()) + } + + /// This is meant to be infallible. + pub fn purge_credentials_from_disk(&self) -> Result<(), io::Error> { + let creds_dir = self.app_data_root.credentials_dir(); + + remove_file_if_exists(creds_dir.get_sora_cookie_file_path())?; + remove_file_if_exists(creds_dir.get_sora_bearer_token_file_path())?; + remove_file_if_exists(creds_dir.get_sora_legacy_sentinel_file_path())?; + + Ok(()) + } + + /// This is meant to be infallible. + pub fn try_purge_credentials_from_disk(&self) { + let creds_dir = self.app_data_root.credentials_dir(); + if let Err(err) = fs::remove_file(creds_dir.get_sora_cookie_file_path()) { + error!("Failed to remove cookie file: {:?}", err); + } + if let Err(err) = fs::remove_file(creds_dir.get_sora_bearer_token_file_path()) { + error!("Failed to remove bearer token file: {:?}", err); + } + if let Err(err) = fs::remove_file(creds_dir.get_sora_legacy_sentinel_file_path()) { + error!("Failed to remove sentinel file: {:?}", err); + } + } + + pub fn get_credentials(&self) -> AnyhowResult> { + self.holder.get_credentials() + } + + pub fn get_credentials_required(&self) -> AnyhowResult { + self.holder.get_credentials_required() + } + + pub fn reset_from_disk(&self) -> AnyhowResult { + let creds = read_sora_credentials_from_disk(&self.app_data_root)?; + self.holder.set_credentials(&creds)?; + Ok(creds) + } + + pub fn persist_all_to_disk(&self) -> AnyhowResult<()> { + let credentials = self.holder.get_credentials_required()?; + + info!("Persisting cookies to disk..."); + persist_cookies_to_disk(&credentials.cookies, &self.app_data_root)?; + + if let Some(bearer) = &credentials.jwt_bearer_token { + info!("Persisting JWT to disk..."); + persist_jwt_bearer_to_disk(bearer, &self.app_data_root)?; + } + + if let Some(sentinel) = &credentials.sora_sentinel { + info!("Persisting sentinel to disk..."); + persist_sentinel_to_disk(sentinel, &self.app_data_root)?; + } + + Ok(()) + } + + /// Refresh the sentinel token from Sora's API + pub async fn call_sentinel_refresh(&self) -> AnyhowResult { + // NB(bt,2025-04-21): Technically we don't need credentials to get a sentinel. + let mut creds = self.holder.get_credentials_required()?; + + info!("Generating token..."); + + let sentinel = generate_sentinel_token() + .await + .map_err(|err| { + error!("Failed to refresh: {:?}", err); + err + })?; + + info!("Token obtained."); + + creds.sora_sentinel = Some(SoraSentinel::new(sentinel.clone())); + + self.holder.set_credentials(&creds)?; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(self.app_data_root.get_sora_legacy_sentinel_file_path())?; + + file.write_all(sentinel.as_bytes())?; + file.flush()?; + + Ok(creds) + } +} + +fn persist_cookies_to_disk(cookies: &SoraCookies, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.get_sora_cookie_file_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + + file.write_all(cookies.as_bytes())?; + file.flush()?; + + Ok(()) +} + +fn persist_jwt_bearer_to_disk(bearer: &SoraJwtBearerToken, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.get_sora_bearer_token_file_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + + file.write_all(bearer.as_bytes())?; + file.flush()?; + + Ok(()) +} + +fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.get_sora_legacy_sentinel_file_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + + file.write_all(sentinel.as_bytes())?; + file.flush()?; + + Ok(()) +} + +fn remove_file_if_exists>(path: P) -> Result<(), io::Error> { + let p = path.as_ref(); + if p.exists() && p.is_file() { + fs::remove_file(p)?; + } + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/state/sora_credential_stats.rs b/crates/desktop/artcraft/src/services/sora/state/sora_credential_stats.rs new file mode 100644 index 0000000000..dad71f43fd --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/sora_credential_stats.rs @@ -0,0 +1,47 @@ + +/// This is meant to track 401s and other credential failures, not 500s. +#[derive(Clone)] +pub struct SoraCredentialStats { + pub last_credential_failure: Option>, + pub last_credential_success: Option>, + pub credential_success_count: u32, + pub credential_failure_count: u32, + /// This resets on each failure + pub last_consecutive_credential_success: Option>, + /// This resets on each failure + pub consecutive_credential_success_count: u32, + /// This resets on each success + pub consecutive_credential_failure_count: u32, +} + +impl SoraCredentialStats { + pub fn new() -> Self { + Self { + last_credential_failure: None, + last_credential_success: None, + last_consecutive_credential_success: None, + credential_success_count: 0, + credential_failure_count: 0, + consecutive_credential_success_count: 0, + consecutive_credential_failure_count: 0, + } + } + + pub fn record_credential_success(&mut self) { + let now = chrono::Utc::now(); + self.last_credential_success = Some(now); + self.last_consecutive_credential_success = Some(now); + self.credential_success_count += 1; + self.consecutive_credential_success_count += 1; + self.consecutive_credential_failure_count = 0; + } + + pub fn record_credential_failure(&mut self) { + let now = chrono::Utc::now(); + self.last_credential_failure = Some(now); + self.last_consecutive_credential_success = None; + self.credential_failure_count += 1; + self.consecutive_credential_failure_count += 1; + self.consecutive_credential_success_count = 0; + } +} diff --git a/crates/desktop/artcraft/src/services/sora/state/sora_task_queue.rs b/crates/desktop/artcraft/src/services/sora/state/sora_task_queue.rs new file mode 100644 index 0000000000..632272f9be --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/state/sora_task_queue.rs @@ -0,0 +1,231 @@ +use chrono::{DateTime, Utc}; +use errors::AnyhowResult; +use indexmap::IndexMap; +use openai_sora_client::requests::common::task_id::TaskId; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct TaskDetails { + pub enqueue_time: DateTime +} + +impl TaskDetails { + pub fn new() -> Self { + Self { + enqueue_time: Utc::now() + } + } +} + +#[derive(Clone)] +pub struct TaskIdAndDetails { + pub task_id: TaskId, + pub details: TaskDetails, +} + +#[derive(Clone)] +pub struct SoraTaskQueue { + // Insertion-order map of task IDs to task details + queue: Arc>> +} + +impl SoraTaskQueue { + pub fn new() -> Self { + Self { + queue: Arc::new(RwLock::new(IndexMap::new())) + } + } + + /// Determine if queue is empty + /// This is an O(1) operation. + pub fn is_empty(&self) -> AnyhowResult { + match self.queue.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)) + } + Ok(lock) => Ok(lock.is_empty()) + } + } + + /// Get length of set + /// This is an O(1) operation. + pub fn len(&self) -> AnyhowResult { + match self.queue.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)) + } + Ok(lock) => Ok(lock.len()) + } + } + + /// Determine if the key is in the queue + /// This is an O(1) operation. + pub fn contains_key(&self, task_id: &TaskId) -> AnyhowResult { + match self.queue.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)) + } + Ok(lock) => Ok(lock.contains_key(task_id)) + } + } + + /// Get the first enqueued task + /// This is an O(1) operation. + pub fn first(&self) -> AnyhowResult> { + match self.queue.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)) + } + Ok(lock) => match lock.first() { + None => Ok(None), + Some((task_id, task_details)) => Ok(Some(TaskIdAndDetails { + task_id: task_id.clone(), + details: task_details.clone(), + })) + } + } + } + + /// Get the last enqueued task + /// This is an O(1) operation. + pub fn last(&self) -> AnyhowResult> { + match self.queue.read() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)) + } + Ok(lock) => match lock.last() { + None => Ok(None), + Some((task_id, task_details)) => Ok(Some(TaskIdAndDetails { + task_id: task_id.clone(), + details: task_details.clone(), + })) + } + } + } + + /// Insert a new task. If a task already exists, we update its value. + /// This is an O(1) operation. + pub fn insert(&self, task_id: &TaskId) -> AnyhowResult> { + match self.queue.write() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)) + } + Ok(mut lock) => { + // NB: Probably fine to compute TaskDetails here for now. We may eventually expose this if we include additional info. + let task_details = TaskDetails::new(); + Ok(lock.insert(task_id.clone(), task_details)) + } + } + } + + /// Remove a task by ID. + /// This is an O(n) operation. + pub fn remove(&self, task_id: &TaskId) -> AnyhowResult> { + match self.queue.write() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)) + } + Ok(mut lock) => { + Ok(lock.shift_remove(task_id)) + } + } + } + + /// Remove a list of tasks by ID. + /// This is an O(n) operation. + pub fn remove_list(&self, task_ids: &[&TaskId]) -> AnyhowResult<()> { + match self.queue.write() { + Err(err) => { + Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)) + } + Ok(mut lock) => { + // TODO: Cleaner way to do this? + let mut set : HashSet<&TaskId, _> = HashSet::with_capacity(task_ids.len()); + set.extend(task_ids); + lock.retain(|k, v| !set.contains(k)); + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::services::sora::state::sora_task_queue::SoraTaskQueue; + use errors::AnyhowResult; + use openai_sora_client::requests::common::task_id::TaskId; + + #[test] + fn remove_list() -> AnyhowResult<()> { + let task_queue = SoraTaskQueue::new(); + + assert!(task_queue.is_empty()?); + assert_eq!(task_queue.len()?, 0); + + task_queue.insert(&TaskId("a".to_string()))?; + task_queue.insert(&TaskId("b".to_string()))?; + task_queue.insert(&TaskId("c".to_string()))?; + task_queue.insert(&TaskId("d".to_string()))?; + task_queue.insert(&TaskId("e".to_string()))?; + task_queue.insert(&TaskId("f".to_string()))?; + + assert!(!task_queue.is_empty()?); + assert_eq!(task_queue.len()?, 6); + + task_queue.insert(&TaskId("a".to_string()))?; + task_queue.insert(&TaskId("b".to_string()))?; + task_queue.insert(&TaskId("c".to_string()))?; + task_queue.insert(&TaskId("d".to_string()))?; + task_queue.insert(&TaskId("e".to_string()))?; + task_queue.insert(&TaskId("f".to_string()))?; + + assert!(!task_queue.is_empty()?); + assert_eq!(task_queue.len()?, 6); + + task_queue.remove_list(&[ + &TaskId("a".to_string()), + &TaskId("c".to_string()), + &TaskId("e".to_string()), + ])?; + + assert!(!task_queue.is_empty()?); + assert_eq!(task_queue.len()?, 3); + + assert!(task_queue.contains_key(&TaskId("b".to_string()))?); + assert!(task_queue.contains_key(&TaskId("d".to_string()))?); + assert!(task_queue.contains_key(&TaskId("f".to_string()))?); + + assert!(!task_queue.contains_key(&TaskId("a".to_string()))?); + assert!(!task_queue.contains_key(&TaskId("c".to_string()))?); + assert!(!task_queue.contains_key(&TaskId("e".to_string()))?); + + task_queue.remove_list(&[ + &TaskId("foo".to_string()), + &TaskId("bar".to_string()), + &TaskId("baz".to_string()), + ])?; + + assert!(!task_queue.is_empty()?); + assert_eq!(task_queue.len()?, 3); + + assert!(task_queue.contains_key(&TaskId("b".to_string()))?); + assert!(task_queue.contains_key(&TaskId("d".to_string()))?); + assert!(task_queue.contains_key(&TaskId("f".to_string()))?); + + task_queue.remove_list(&[ + &TaskId("b".to_string()), + &TaskId("d".to_string()), + &TaskId("f".to_string()), + ])?; + + assert!(task_queue.is_empty()?); + assert_eq!(task_queue.len()?, 0); + + assert!(!task_queue.contains_key(&TaskId("b".to_string()))?); + assert!(!task_queue.contains_key(&TaskId("d".to_string()))?); + assert!(!task_queue.contains_key(&TaskId("f".to_string()))?); + + Ok(()) + } +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/threads/mod.rs b/crates/desktop/artcraft/src/services/sora/threads/mod.rs new file mode 100644 index 0000000000..eed888f841 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/mod.rs @@ -0,0 +1 @@ +pub mod sora_task_polling; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/download_extension.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/download_extension.rs new file mode 100644 index 0000000000..a6841377a0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/download_extension.rs @@ -0,0 +1,15 @@ + +#[derive(Clone, Copy)] +pub enum DownloadExtension { + Png, + Mp4, +} + +impl DownloadExtension { + pub fn as_extension_without_period(&self) -> &'static str { + match self { + DownloadExtension::Png => "png", + DownloadExtension::Mp4 => "mp4", + } + } +} diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/generation_type.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/generation_type.rs new file mode 100644 index 0000000000..69f04c4911 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/generation_type.rs @@ -0,0 +1,6 @@ + +#[derive(Copy, Clone, Eq, PartialEq)] +pub (super) enum GenerationType { + Image, + Video, +} diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/handle_failed_generations.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/handle_failed_generations.rs new file mode 100644 index 0000000000..f5635c1c66 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/handle_failed_generations.rs @@ -0,0 +1,63 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::task_database::TaskDatabase; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::GenerationItem; +use enums::tauri::tasks::task_status; +use errors::AnyhowResult; +use log::info; +use openai_sora_client::requests::common::task_id::TaskId; +use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::PartialTaskResponse; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::HashMap; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use tauri::AppHandle; + +pub struct FailedGeneration { + pub reason: Option, +} + +pub async fn handle_classic_failed_generations( + app_handle: &AppHandle, + task_database: &TaskDatabase, + local_sqlite_tasks_by_sora_task_id: &HashMap, + sora_failed_tasks_by_id: &HashMap, + sora_task_queue: &SoraTaskQueue, +) -> AnyhowResult<()> { + + for (task_id, task) in sora_failed_tasks_by_id.iter() { + // Emit events for failed tasks. + if local_sqlite_tasks_by_sora_task_id.contains_key(task_id.as_str()) { + let event = GenerationFailedEvent { + action: GenerationAction::GenerateImage, + service: GenerationServiceProvider::Sora, + model: None, + reason: task.reason.clone(), + }; + + event.send_infallible(&app_handle); + } + + // Clear from SQLite task database. + if let Some(local_task) = local_sqlite_tasks_by_sora_task_id.get(task_id.as_str()) { + info!("Marking local task as failed: {:?}", local_task.id); + + let _updated = update_task_status(UpdateTaskArgs { + db: task_database.get_connection(), + task_id: &local_task.id, + status: task_status::TaskStatus::CompleteFailure, + }).await?; + } + } + + // Clear from in memory DB + // TODO: Remove the in-memory queue in favor of SQLite only. + let failed_task_ids: Vec<&TaskId> = sora_failed_tasks_by_id.keys().collect(); + sora_task_queue.remove_list(&failed_task_ids)?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/handle_successful_generations.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/handle_successful_generations.rs new file mode 100644 index 0000000000..64bf2042c8 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/handle_successful_generations.rs @@ -0,0 +1,214 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::helpers::download_extension::DownloadExtension; +use crate::services::sora::threads::sora_task_polling::helpers::generation_type::GenerationType; +use crate::services::sora::threads::sora_task_polling::helpers::upload_generation_to_backend::{upload_generation_to_backend, UploadGenerationToBackendArgs}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use enums::common::generation_provider::GenerationProvider; +use enums::common::generation::common_model_type::CommonModelType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew; +use openai_sora_client::requests::common::task_id::TaskId; +use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::{PartialGeneration, PartialTaskResponse}; +use reqwest::Url; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use tauri::AppHandle; +use tempdir::TempDir; +use url_utils::download_extension::extract_download_extension_from_url::{extract_download_extension_from_url, extract_download_extension_from_url_str}; + +pub struct SuccessfulGeneration { + pub prompt: Option, + pub items: Vec, + pub model_type: CommonModelType, +} + +pub struct GenerationItem { + pub item_id: String, + pub url: String, +} + +pub async fn handle_classic_successful_generations( + app_handle: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + task_database: &TaskDatabase, + storyteller_creds: &StorytellerCredentialSet, + succeeded_tasks_by_id: &HashMap, + sqlite_tasks_by_sora_task_id: &HashMap, + recommended_download_extension: DownloadExtension, +) -> AnyhowResult<()> { + + for (task_id, generation) in succeeded_tasks_by_id.iter() { + if !sqlite_tasks_by_sora_task_id.contains_key(task_id.as_str()) { + continue; // Task is irrelevant - previously completed, generated elsewhere, etc. + } + + info!("Task succeeded: {:?}", task_id); + + let generation_type = match generation.model_type { + CommonModelType::GptImage1 => GenerationType::Image, + CommonModelType::Sora2 => GenerationType::Video, + _ => { + // Fallback + warn!("Unexpected model type: {:?}", generation.model_type); + GenerationType::Image + }, + }; + + let prompt_request = CreatePromptRequest { + uuid_idempotency_token: generate_random_uuid(), + positive_prompt: generation.prompt.clone(), + negative_prompt: None, + model_type: Some(generation.model_type), + generation_provider: Some(GenerationProvider::Sora), + maybe_generation_mode: None, + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_batch_count: None, + maybe_generate_audio: None, + maybe_duration_seconds: None, + }; + + let prompt_response = create_prompt( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + prompt_request + ).await?; + + info!("Created prompt: {:?}", &prompt_response.prompt_token); + + let mut maybe_primary_media_file_token = None; + + for (_i, item) in generation.items.iter().enumerate() { + info!("Downloading generated file..."); + let download_path = download_generation_item(item, &app_data_root, recommended_download_extension).await?; + + info!("Uploading to backend..."); + + let media_token = upload_generation_to_backend(UploadGenerationToBackendArgs { + storyteller_api_host: &app_env_configs.storyteller_host, + storyteller_creds: &storyteller_creds, + upload_path: download_path, + maybe_prompt_token: Some(&prompt_response.prompt_token), + maybe_batch_token: None, // TODO: This should be added soon. + generation_type, + }).await?; + + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(media_token.clone()); + } + } + + // Clear from SQLite task database. + if let Some(local_task) = sqlite_tasks_by_sora_task_id.get(task_id.as_str()) { + info!("Marking local task as succeeded: {:?}", local_task.id); + + let generation_class = match generation_type { + GenerationType::Image => TaskMediaFileClass::Image, + GenerationType::Video => TaskMediaFileClass::Video, + }; + + let mut maybe_cdn_url = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + info!("Looking up file to grab CDN and thumbnail URLs: {:?} ...", media_file_token); + + let lookup_result = get_media_file( + &app_env_configs.storyteller_host, + media_file_token, + ).await; + match lookup_result { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &local_task.id, + maybe_batch_token: None, // TODO: Support when we have batches of items. + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(generation_class), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + maybe_primary_media_file_cdn_url: maybe_cdn_url.as_deref(), + }).await?; + + if updated { + // If anything breaks with queries, don't spam events. + let event = GenerationCompleteEvent { + action: Some(match generation_type { + GenerationType::Image => GenerationAction::GenerateImage, + GenerationType::Video => GenerationAction::GenerateVideo, + }), + service: GenerationServiceProvider::Sora, + model: None, + }; + + event.send_infallible(&app_handle); + } + } + } + + Ok(()) +} + +async fn download_generation_item( + generation: &GenerationItem, + app_data_root: &AppDataRoot, + recommended_download_extension: DownloadExtension +) -> AnyhowResult { + info!("Downloading generation item from URL: {}", generation.url.as_str()); + + let response = reqwest::get(&generation.url).await?; + let image_bytes = response.bytes().await?; + + let extension = extract_download_extension_from_url_str(&generation.url) + .map(|ext| ext.as_extension_without_period()) + .unwrap_or_else(|| recommended_download_extension.as_extension_without_period()); + + let tempdir = app_data_root.temp_dir().path(); + let download_filename = format!("{}.{}", generation.item_id, extension); + let download_path = tempdir.join(download_filename); + + info!("Writing to path: {:?}", download_path); + + let mut file = File::create(&download_path)?; + file.write_all(&image_bytes)?; + + Ok(download_path) +} diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/mod.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/mod.rs new file mode 100644 index 0000000000..af3bc2a813 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/mod.rs @@ -0,0 +1,8 @@ +pub (super) mod handle_successful_generations; +pub (super) mod handle_failed_generations; +pub (super) mod poll_classic_sora_tasks; +pub (super) mod poll_sora_2_tasks; +mod download_extension; +mod upload_metadata; +mod upload_generation_to_backend; +mod generation_type; diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/poll_classic_sora_tasks.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/poll_classic_sora_tasks.rs new file mode 100644 index 0000000000..d09f6256fa --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/poll_classic_sora_tasks.rs @@ -0,0 +1,124 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::helpers::download_extension::DownloadExtension; +use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_generations::{handle_classic_failed_generations, FailedGeneration}; +use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::{handle_classic_successful_generations, GenerationItem, SuccessfulGeneration}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use enums::common::generation::common_model_type::CommonModelType; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew; +use openai_sora_client::requests::common::task_id::TaskId; +use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::TaskStatus; +use reqwest::Url; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use tauri::AppHandle; +use tempdir::TempDir; + +pub async fn poll_classic_sora_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + task_database: &TaskDatabase, + sora_creds_manager: &SoraCredentialManager, + sora_creds: &SoraCredentialSet, + storyteller_creds_manager: &StorytellerCredentialManager, + sora_task_queue: &SoraTaskQueue, + app_data_root: &AppDataRoot, + local_sqlite_tasks_by_sora_task_id: &HashMap, +) -> AnyhowResult<()> { + + let (sora_response, maybe_new_creds) = + list_classic_sora_tasks_with_session_auto_renew(&sora_creds).await?; + + if let Some(new_creds) = maybe_new_creds { + info!("Saving new credentials."); + sora_creds_manager.set_credentials(&new_creds)?; + } + + let sora_items = sora_response.task_responses; + + let mut sora_succeeded_tasks_by_id = HashMap::new(); + let mut sora_failed_tasks_by_id = HashMap::new(); + + let storyteller_creds = storyteller_creds_manager.get_credentials_required()?; + + for task in sora_items.iter() { + + match &task.status { + TaskStatus::Succeeded => { + sora_succeeded_tasks_by_id.insert( + task.id.clone(), + SuccessfulGeneration { + prompt: task.prompt.clone(), + model_type: CommonModelType::GptImage1, + items: task.generations.iter() + .map(|gen| { + GenerationItem { + item_id: gen.id.clone(), + url: gen.url.clone(), + } + }) + .collect(), + }); + } + TaskStatus::Failed => { + sora_failed_tasks_by_id.insert( + task.id.clone(), + FailedGeneration { + reason: None, // TODO: Add reason if available. + } + ); + } + TaskStatus::Queued => {} + TaskStatus::Running => {} + TaskStatus::Unknown(unknown_status) => { + warn!("Unknown task status: {:?}", unknown_status); + } + } + } + + // Clear dead tasks. + handle_classic_failed_generations( + &app_handle, + &task_database, + &local_sqlite_tasks_by_sora_task_id, + &sora_failed_tasks_by_id, + &sora_task_queue, + ).await?; + + // Process succeeded tasks. + handle_classic_successful_generations( + &app_handle, + &app_data_root, + &app_env_configs, + &task_database, + &storyteller_creds, + &sora_succeeded_tasks_by_id, + &local_sqlite_tasks_by_sora_task_id, + DownloadExtension::Png, + ).await?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/poll_sora_2_tasks.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/poll_sora_2_tasks.rs new file mode 100644 index 0000000000..65e96161fd --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/poll_sora_2_tasks.rs @@ -0,0 +1,117 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::helpers::download_extension::DownloadExtension; +use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_generations::{handle_classic_failed_generations, FailedGeneration}; +use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::{handle_classic_successful_generations, GenerationItem, SuccessfulGeneration}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use enums::common::generation::common_model_type::CommonModelType; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew; +use openai_sora_client::recipes::list_sora2_drafts::list_sora2_drafts_with_session_auto_renew::list_sora2_drafts_with_session_auto_renew; +use openai_sora_client::requests::common::task_id::TaskId; +use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::TaskStatus; +use openai_sora_client::requests::list_sora2_drafts::list_sora2_drafts::Draft; +use reqwest::Url; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use tauri::AppHandle; +use tempdir::TempDir; + +pub async fn poll_sora_2_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + task_database: &TaskDatabase, + sora_creds_manager: &SoraCredentialManager, + sora_creds: &SoraCredentialSet, + storyteller_creds_manager: &StorytellerCredentialManager, + sora_task_queue: &SoraTaskQueue, + app_data_root: &AppDataRoot, + local_sqlite_tasks_by_sora_task_id: &HashMap, +) -> AnyhowResult<()> { + + let (sora_response, maybe_new_creds) = + list_sora2_drafts_with_session_auto_renew(&sora_creds).await?; + + if let Some(new_creds) = maybe_new_creds { + info!("Saving new credentials."); + sora_creds_manager.set_credentials(&new_creds)?; + } + + let drafts = sora_response.drafts; + + let mut sora_succeeded_drafts_by_id = HashMap::new(); + let mut sora_failed_drafts_by_id = HashMap::new(); + + let storyteller_creds = storyteller_creds_manager.get_credentials_required()?; + + for draft in drafts.into_iter() { + match draft { + Draft::Success(success_draft) => { + sora_succeeded_drafts_by_id.insert( + success_draft.task_id, + SuccessfulGeneration { + prompt: Some(success_draft.prompt), + model_type: CommonModelType::Sora2, + items: vec![GenerationItem { + item_id: success_draft.id, + url: success_draft.url, + }] + }); + } + Draft::Failure(failed_draft) => { + sora_failed_drafts_by_id.insert( + failed_draft.task_id, + FailedGeneration { + reason: Some(failed_draft.reason_message) + } + ); + } + } + } + + // Clear dead tasks. + handle_classic_failed_generations( + &app_handle, + &task_database, + &local_sqlite_tasks_by_sora_task_id, + &sora_failed_drafts_by_id, + &sora_task_queue, + ).await?; + + // Process succeeded tasks. + handle_classic_successful_generations( + &app_handle, + &app_data_root, + &app_env_configs, + &task_database, + &storyteller_creds, + &sora_succeeded_drafts_by_id, + &local_sqlite_tasks_by_sora_task_id, + DownloadExtension::Mp4, + ).await?; + + Ok(()) +} + diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/upload_generation_to_backend.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/upload_generation_to_backend.rs new file mode 100644 index 0000000000..807feaa7b1 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/upload_generation_to_backend.rs @@ -0,0 +1,72 @@ +use crate::services::sora::threads::sora_task_polling::helpers::generation_type::GenerationType; +use errors::AnyhowResult; +use log::info; +use std::path::Path; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs}; +use artcraft_client::utils::api_host::ApiHost; +use enums::common::generation_provider::GenerationProvider; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +pub(super) struct UploadGenerationToBackendArgs<'a, P: AsRef> { + pub storyteller_api_host: &'a ApiHost, + pub storyteller_creds: &'a StorytellerCredentialSet, + + /// The file we're uploading. + /// NB: Path needs to be owned for the request. + pub upload_path: P, + + /// If provided, this is the prompt that this image is associated with. + /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + pub maybe_prompt_token: Option<&'a PromptToken>, + + // /// If provided, this is the service provider that created the image. + // /// NOTE: Cannot set `is_intermediate_system_file = true` if this is set. + // pub maybe_generation_provider: Option, + + /// If provided, this groups the file into a batch + /// TODO: This shouldn't be set clientside without the backend generating the token + /// and cryptographically securing it. But we need to go fast here. + pub maybe_batch_token: Option<&'a BatchGenerationToken>, + + /// The type of generation (image or video). + pub generation_type: GenerationType, +} + +pub(super) async fn upload_generation_to_backend>(args: UploadGenerationToBackendArgs<'_, P>) -> AnyhowResult { + let media_token; + + match args.generation_type { + GenerationType::Image => { + let result = upload_image_media_file_from_file(UploadImageFromFileArgs { + api_host: &args.storyteller_api_host, + maybe_creds: Some(&args.storyteller_creds), + path: args.upload_path, + is_intermediate_system_file: false, + maybe_prompt_token: args.maybe_prompt_token, + maybe_batch_token: None, // TODO: This should be added soon. + maybe_generation_provider: Some(GenerationProvider::Sora), + }).await?; + + info!("Uploaded image to API backend: {:?}", result.media_file_token); + media_token = result.media_file_token; + } + GenerationType::Video => { + let result = upload_video_media_file_from_file(UploadVideoFromFileArgs { + api_host: &args.storyteller_api_host, + maybe_creds: Some(&args.storyteller_creds), + path: args.upload_path, + maybe_prompt_token: args.maybe_prompt_token, + maybe_generation_provider: Some(GenerationProvider::Sora), + }).await?; + + info!("Uploaded video to API backend: {:?}", result.media_file_token); + media_token = result.media_file_token; + } + } + + Ok(media_token) +} diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/upload_metadata.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/helpers/upload_metadata.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/mod.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/mod.rs new file mode 100644 index 0000000000..15821b2734 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/mod.rs @@ -0,0 +1,2 @@ +pub mod sora_task_polling_thread; +pub mod helpers; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/sora_task_polling_thread.rs b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/sora_task_polling_thread.rs new file mode 100644 index 0000000000..c1cca02a6c --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/threads/sora_task_polling/sora_task_polling_thread.rs @@ -0,0 +1,134 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::state::sora_task_queue::SoraTaskQueue; +use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_generations::handle_classic_failed_generations; +use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::handle_classic_successful_generations; +use crate::services::sora::threads::sora_task_polling::helpers::poll_classic_sora_tasks::poll_classic_sora_tasks; +use crate::services::sora::threads::sora_task_polling::helpers::poll_sora_2_tasks::poll_sora_2_tasks; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::list_classic_sora_tasks_with_session_auto_renew::list_classic_sora_tasks_with_session_auto_renew; +use openai_sora_client::requests::common::task_id::TaskId; +use openai_sora_client::requests::list_classic_tasks::list_classic_tasks::TaskStatus; +use reqwest::Url; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use tauri::AppHandle; +use tempdir::TempDir; + +#[deprecated(note = "Retained to poll legacy direct-provider tasks; new generations use ArtCraft.")] +pub async fn sora_task_polling_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + sora_creds_manager: SoraCredentialManager, + storyteller_creds_manager: StorytellerCredentialManager, + sora_task_queue: SoraTaskQueue, +) -> ! { + loop { + let res = local_task_polling_loop( + &app_handle, + &app_env_configs, + &task_database, + &sora_creds_manager, + &storyteller_creds_manager, + &sora_task_queue, + &app_data_root, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + } +} + +async fn local_task_polling_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + task_database: &TaskDatabase, + sora_creds_manager: &SoraCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, + sora_task_queue: &SoraTaskQueue, + app_data_root: &AppDataRoot, +) -> AnyhowResult<()> { + loop { + let local_sqlite_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs { + db: task_database.get_connection(), + provider: GenerationProvider::Sora, + task_statuses: &TASK_DATABASE_PENDING_STATUSES, + }).await?; + + if local_sqlite_tasks.tasks.is_empty() { + // No need to poll if we don't have pending tasks. + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + continue; + } + + let creds = sora_creds_manager.get_credentials_required()?; + + // Map of Sora Task ID to Local Task. + let local_sqlite_tasks_by_sora_task_id = local_sqlite_tasks.tasks.iter() + .filter_map(|task| { + if let Some(provider_job_id) = &task.provider_job_id { + Some((provider_job_id.clone(), task.clone())) + } else { + None + } + }) + .collect::>(); + + // TODO: Only poll if we have classic items + + poll_classic_sora_tasks( + &app_handle, + &app_env_configs, + &task_database, + &sora_creds_manager, + &creds, + &storyteller_creds_manager, + &sora_task_queue, + &app_data_root, + &local_sqlite_tasks_by_sora_task_id, + ).await?; + + // TODO: Only poll if we have new items + + poll_sora_2_tasks( + &app_handle, + &app_env_configs, + &task_database, + &sora_creds_manager, + &creds, + &storyteller_creds_manager, + &sora_task_queue, + &app_data_root, + &local_sqlite_tasks_by_sora_task_id, + ).await?; + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + diff --git a/crates/desktop/artcraft/src/services/sora/utils/mod.rs b/crates/desktop/artcraft/src/services/sora/utils/mod.rs new file mode 100644 index 0000000000..8b3cb94146 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/utils/mod.rs @@ -0,0 +1 @@ +pub mod upload_images_to_sora; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/utils/upload_images_to_sora.rs b/crates/desktop/artcraft/src/services/sora/utils/upload_images_to_sora.rs new file mode 100644 index 0000000000..668df7c2c3 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/utils/upload_images_to_sora.rs @@ -0,0 +1,114 @@ +use crate::core::state::app_env_configs::app_env_configs_serializeable::StorytellerApiHost; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::utils::get_url_file_extension::get_url_file_extension; +use crate::core::utils::simple_http_download::simple_http_download; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest}; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use std::time::Duration; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::utils::api_host::ApiHost; +use tokens::tokens::media_files::MediaFileToken; + +const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds + +pub struct UploadImagesToSoraArgs<'a> { + pub storyteller_host: &'a ApiHost, + pub image_media_tokens: &'a [MediaFileToken], + + pub app_data_root: &'a AppDataRoot, + pub sora_creds_manager: &'a SoraCredentialManager, +} + +pub struct UploadImagesToSoraResult { + pub sora_media_tokens: Vec, + pub maybe_new_sora_credentials: Option, +} + +pub async fn upload_images_to_sora( + args: UploadImagesToSoraArgs<'_>, +) -> AnyhowResult { + + let mut updated_sora_creds = false; + + info!("Calling get media file API: {:?}", args.storyteller_host); + + let mut files_to_upload_to_sora = Vec::with_capacity(10); + + // TODO(bt,2025-07-07): This is inefficient. Cache and parallelize this. + for media_token in args.image_media_tokens.iter() { + info!("Using media token: {:?}", media_token); + + let response = get_media_file( + args.storyteller_host, + media_token + ).await?; + + let media_file_url = &response.media_file.media_links.cdn_url; + let extension_with_dot = get_url_file_extension(media_file_url) + .map(|ext| format!(".{}", ext)) + .unwrap_or_else(|| ".png".to_string()); + + let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot); + let filename = args.app_data_root.downloads_dir().path().join(&filename); + + simple_http_download(&media_file_url, &filename).await?; + + files_to_upload_to_sora.push(filename); + } + + let mut sora_creds = args.sora_creds_manager.get_credentials_required()?; + + // TODO: We don't need sentinel anymore? + let credential_updated = maybe_upgrade_or_renew_session(&mut sora_creds) + .await + .map_err(|err| { + error!("Failed to upgrade or renew session: {:?}", err); + err + })?; + + if credential_updated { + info!("Storing updated credentials"); + args.sora_creds_manager.set_credentials(&sora_creds)?; + updated_sora_creds = true; + } + + let mut sora_media_tokens = Vec::with_capacity(files_to_upload_to_sora.len()); + + for (i, file_path) in files_to_upload_to_sora.iter().enumerate() { + info!("Uploading image {} of {}...", (i+1), files_to_upload_to_sora.len()); + + let (response, maybe_new_credentials) = + image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest { + file_path, + credentials: &sora_creds, + request_timeout: Some(SORA_IMAGE_UPLOAD_TIMEOUT), // TODO: Centralize and make configurable. + }).await?; + + if let Some(new_creds) = maybe_new_credentials { + info!("Storing updated credentials."); + args.sora_creds_manager.set_credentials(&new_creds)?; + sora_creds = new_creds; + updated_sora_creds = true; + } + + sora_media_tokens.push(response.id); + } + + let maybe_new_sora_credentials = if updated_sora_creds { + Some(sora_creds) + } else { + None + }; + + Ok(UploadImagesToSoraResult { + sora_media_tokens, + maybe_new_sora_credentials, + }) +} diff --git a/crates/desktop/artcraft/src/services/sora/windows/mod.rs b/crates/desktop/artcraft/src/services/sora/windows/mod.rs new file mode 100644 index 0000000000..3843c67df6 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/mod.rs @@ -0,0 +1 @@ +pub mod sora_login_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/clear_browsing_on_test_domain.rs b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/clear_browsing_on_test_domain.rs new file mode 100644 index 0000000000..525ec16752 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/clear_browsing_on_test_domain.rs @@ -0,0 +1,20 @@ +use anyhow::anyhow; +use errors::AnyhowResult; +use log::info; +use tauri::Webview; + +// This is just so we have a way to clear browsing data while debugging / building. +pub fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> { + let url = webview.url()?; + let hostname= url.host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + match hostname.as_str() { + "storyteller.ai" => { + info!("Clearing all browsing data..."); + webview.clear_all_browsing_data()?; + } + _ => {} + } + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/extract_sora_webview_cookies.rs b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/extract_sora_webview_cookies.rs new file mode 100644 index 0000000000..03696c522a --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/extract_sora_webview_cookies.rs @@ -0,0 +1,28 @@ +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use tauri::webview::Cookie; +use tauri::WebviewWindow; + +const CHAT_GPT_ROOT_COOKIE_URL_STR: &str = "https://chatgpt.com"; + +static CHAT_GPT_ROOT_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse(CHAT_GPT_ROOT_COOKIE_URL_STR).expect("URL should parse") +}); + +pub fn extract_sora_webview_cookies(webview: &WebviewWindow) -> AnyhowResult { + // NB(bt): Cookies were originally on sora.com, but now they're located on + // the sora.chatgpt.com / *.chatgpt.com domains. + let cookies = get_all_chatgpt_cookies(webview)?; + let cookie_string = cookies + .iter() + .map(|cookie| format!("{}={}", cookie.name(), cookie.value())) + .collect::>() + .join("; "); + Ok(cookie_string) +} + +fn get_all_chatgpt_cookies(webview: &WebviewWindow) -> AnyhowResult> { + let cookies = webview.cookies_for_url(CHAT_GPT_ROOT_COOKIE_URL.clone())?; + Ok(cookies) +} diff --git a/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/keep_on_login_domains.rs b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/keep_on_login_domains.rs new file mode 100644 index 0000000000..6edb1de357 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/keep_on_login_domains.rs @@ -0,0 +1,28 @@ +use crate::services::sora::windows::sora_login_window::open_sora_login_window::SORA_LOGIN_URL; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::info; +use tauri::Webview; + +// NB: Probably best not to use this. +pub fn keep_on_login_domains(webview: &Webview) -> AnyhowResult<()> { + let url = webview.url()?; + let hostname= url.host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + match hostname.as_str() { + "auth.openai.com" => {}, + "openai.com" => {}, + "sora.com" => {}, + // Third party SSO + "accounts.google.com" => {}, + "accounts.youtube.com" => {}, + "login.live.com" => {}, + "appleid.apple.com" => {}, + _ => { + info!("Non login hostname: {}", hostname); + webview.navigate(SORA_LOGIN_URL.clone())?; + } + } + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/mod.rs b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/mod.rs new file mode 100644 index 0000000000..0ff8648e3b --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/mod.rs @@ -0,0 +1,5 @@ +pub mod clear_browsing_on_test_domain; +pub mod extract_sora_webview_cookies; +pub mod keep_on_login_domains; +pub mod open_sora_login_window; +pub mod sora_login_thread; diff --git a/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/open_sora_login_window.rs b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/open_sora_login_window.rs new file mode 100644 index 0000000000..c18d63745c --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/open_sora_login_window.rs @@ -0,0 +1,78 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::clear_all_webview_cookies::clear_all_webview_cookies; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::sora_login_thread::sora_login_thread; +use anyhow::anyhow; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::time::Duration; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Name of the window +pub (super) const LOGIN_WINDOW_NAME: &str = "login_window"; + +pub (super) static START_URL: Lazy = Lazy::new(|| { + Url::parse("https://google.com").expect("URL should parse") +}); + +pub (super) static SORA_HOMEPAGE_URL : Lazy = Lazy::new(|| { + Url::parse("https://sora.com/").expect("URL should parse") +}); + +pub (super) static SORA_LOGIN_URL: Lazy = Lazy::new(|| { + Url::parse("https://chatgpt.com/auth/login?next=%2Fsora%2F").expect("URL should parse") +}); + +//pub const SORA_ROOT_URL_STR: &str = "https://sora.com/"; +// +//pub static SORA_ROOT_URL: Lazy = Lazy::new(|| { +// Url::parse(SORA_ROOT_URL_STR).expect("URL should parse") +//}); + +pub async fn open_sora_login_window( + app: &AppHandle, + app_data_root: &AppDataRoot, + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult<()> { + if app.get_window(LOGIN_WINDOW_NAME).is_some() { + return Err(anyhow!("Login window already open")); + } + + let url = WebviewUrl::External(START_URL.clone()); + + let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url) + //.user_agent(openai_sora_client::credentials::USER_AGENT) + .always_on_top(true) + .title("Login to OpenAI's Sora") + .center() + .resizable(true) + .visible(true) + .closable(true) + .min_inner_size(200.0, 800.0) + .focused(true) + .devtools(true) + .build()?; + + let webview = window.get_webview(LOGIN_WINDOW_NAME) + .ok_or_else(|| anyhow!("no webview found"))?; + + clear_all_webview_cookies(&webview)?; + + webview.navigate(SORA_HOMEPAGE_URL.clone())?; + + // NB: We're starting to get Cloudflare protection screens. Let's try to avoid. + tokio::time::sleep(Duration::from_millis(100)).await; + + webview.navigate(SORA_LOGIN_URL.clone())?; + + let app_handle = app.clone(); + let app_data_root = app_data_root.clone(); + let sora_creds_manager = sora_creds_manager.clone(); + + let _ = tauri::async_runtime::spawn(async move { + sora_login_thread(app_handle, app_data_root, sora_creds_manager).await; + }); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/sora_login_thread.rs b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/sora_login_thread.rs new file mode 100644 index 0000000000..e7d6fe5614 --- /dev/null +++ b/crates/desktop/artcraft/src/services/sora/windows/sora_login_window/sora_login_thread.rs @@ -0,0 +1,155 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::refresh_account_state_event::RefreshAccountStateEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::window::get_webview_window_hostname::get_webview_window_hostname; +use crate::services::sora::events::sora_login_success_event::SoraLoginSuccessEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::LOGIN_WINDOW_NAME; +use anyhow::anyhow; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use log::{error, info}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +pub async fn sora_login_thread( + app: AppHandle, + app_data_root: AppDataRoot, + sora_creds_manager: SoraCredentialManager +) { + loop { + let login_webview_window = match app.get_webview_window(LOGIN_WINDOW_NAME) { + Some(webview) => webview, + None => { + info!("Exit sora login thread."); + return; // NB: Only exit if we don't have the webview. + } + }; + + let result = check_login_window( + &app, + &login_webview_window, + &app_data_root, + &sora_creds_manager, + ).await; + + match result { + Err(err) => { + error!("Error checking login window: {:?}", err); + } + Ok(false) => {} // Continue iteration and try again... + Ok(true) => { + info!("Successfully saved cookies from login window. Closing."); + if let Err(err) = login_webview_window.close() { + error!("Error closing login window: {:?}", err); + } + return; + } + } + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +/// Returns true if we can exit. +async fn check_login_window( + app_handle: &AppHandle, + webview_window: &WebviewWindow, + app_data_root: &AppDataRoot, + sora_credential_manager: &SoraCredentialManager, +) -> AnyhowResult { + + /* Login flow looks like this: + + 1. Start: https://sora.chatgpt.com/ + 2. Login Start: https://chatgpt.com/auth/login?next=%2Fsora%2F [this is where we start] + 3. Login Continue: https://auth.openai.com/log-in + 4. SSO / Google Login (etc): https://accounts.google.com/v3/signin/challenge/pwd?[query] + 5. Done / Landing: https://sora.chatgpt.com/explore + */ + + /* + - Do not use credential manager until the end (we don't load old cookies!) + - Check if we're on the correct domain, if not exit? (or inverse, that we're not in login flow) + */ + + let hostname = get_webview_window_hostname(webview_window)?; + + let mut maybe_at_destination = false; + + match hostname.as_str() { + "sora.com" | // Old destination + "sora.chatgpt.com" // New destination + => { + maybe_at_destination = true; + } + // chatgpt.com/auth is also an auth domain + "auth.openai.com" | + "accounts.google.com" | + "accounts.youtube.com" | + "login.live.com" | + "appleid.apple.com" + => { + // NB: We're in auth flow. + info!("Sora login webview is in auth flow; hostname `{}`.", hostname); + return Ok(false) + } + _ => {}, // We just don't know... + } + + let webview_cookies = extract_sora_webview_cookies(webview_window)?.trim().to_string(); + + let session_cookie_presence = has_session_cookie(&webview_cookies) + .unwrap_or_else(|err| { + error!("Failed to check for session cookie: {:?}", err); + SessionCookiePresence::MaybePresent + }); + + info!("Sora login webview is at hostname `{}`; cookie status: {:?}.", hostname, session_cookie_presence); + + match session_cookie_presence { + SessionCookiePresence::Absent => { + info!("Session cookies are absent."); + return Ok(false); + } + _ => {}, + } + + let mut new_credentials = + SoraCredentialSet::initialize_with_just_cookies_str(&webview_cookies); + + let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?; + + // TODO(bt): Race conditions ahead. + + sora_credential_manager.clear_credentials()?; + sora_credential_manager.try_purge_credentials_from_disk(); + + sora_credential_manager.set_credentials(&new_credentials)?; + sora_credential_manager.persist_all_to_disk()?; + + send_frontend_login_events(app_handle); + + Ok(true) +} + +fn send_frontend_login_events(app_handle: &AppHandle) { + let event = RefreshAccountStateEvent { + provider: Some(GenerationProvider::Sora), + }; + + event.send_infallible(app_handle); + + // TODO: Remove this legacy event when we're done with it. + // NB: Event sent to the frontend for the login flow. We shouldn't rely on just this + // alone as it could be brittle if the events aren't caught. + let event = SoraLoginSuccessEvent {}; + + if let Err(err) = event.send(app_handle) { + error!("Error sending Sora login success event: {:?}", err); + } +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs b/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs new file mode 100644 index 0000000000..294ba3f6cd --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod storyteller_purge_credentials_command; +pub mod storyteller_get_credits_command; +pub mod storyteller_get_subscription_command; +pub mod stripe_checkout; +pub mod stripe_customer_portal; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_credits_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_credits_command.rs new file mode 100644 index 0000000000..be02b1cd53 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_credits_command.rs @@ -0,0 +1,71 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::Provider; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use enums::common::payments_namespace::PaymentsNamespace; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use artcraft_client::endpoints::credits::get_session_credits::get_session_credits; +use tauri::{AppHandle, State}; +use crate::core::commands::providers::deprecated::get_provider_order_command::GetProviderOrderResponse; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +// TODO(bt,2025-09-12): Cache credits and clear cache on generate calls. + +#[derive(Serialize)] +pub struct GetCreditsResponse { + pub free_credits: u64, + pub monthly_credits: u64, + pub banked_credits: u64, + pub sum_total_credits: u64, +} + +impl SerializeMarker for GetCreditsResponse {} + +#[tauri::command] +pub async fn storyteller_get_credits_command( + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> ResponseOrErrorMessage { + + info!("storyteller_get_credits_command called"); + + let credits = get_credits( + &app_env_configs, + &storyteller_creds_manager) + .await + .map_err(|err| { + error!("Error getting credits: {:?}", err); + format!("Error getting credits: {:?}", err) + })?; + + Ok(credits.into()) +} + +async fn get_credits( + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult { + + let maybe_creds = storyteller_creds_manager.get_credentials()?; + + let response = get_session_credits( + &app_env_configs.storyteller_host, + maybe_creds.as_ref(), + PaymentsNamespace::Artcraft, + ).await?; + + Ok(GetCreditsResponse { + free_credits: response.free_credits, + monthly_credits: response.monthly_credits, + banked_credits: response.banked_credits, + sum_total_credits: response.sum_total_credits, + }) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_subscription_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_subscription_command.rs new file mode 100644 index 0000000000..98cdf3e429 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_subscription_command.rs @@ -0,0 +1,100 @@ +use chrono::{DateTime, Utc}; +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::commands::providers::deprecated::get_provider_order_command::GetProviderOrderResponse; +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::provider_priority::Provider; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use enums::common::payments_namespace::PaymentsNamespace; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use artcraft_client::endpoints::credits::get_session_credits::get_session_credits; +use artcraft_client::endpoints::subscriptions::get_session_subscription::get_session_subscription; +use tauri::{AppHandle, State}; +use tokens::tokens::user_subscriptions::UserSubscriptionToken; +// TODO(bt,2025-09-12): Cache credits and clear cache on generate calls. + +#[derive(Serialize)] +pub struct GetSubscriptionResponse { + /// Information about the subscription, if any. + pub active_subscription: Option, +} + +#[derive(Serialize)] +pub struct ActiveSubscriptionInfo { + pub subscription_token: UserSubscriptionToken, + pub product_slug: ArtcraftSubscriptionSlug, + pub namespace: PaymentsNamespace, + + /// If the subscription is active, this is the next bill date + pub next_bill_at: Option>, + + /// If the subscription is expired or is set to expire, this is the end date of the subscription. + pub subscription_end_at: Option>, +} + +impl SerializeMarker for GetSubscriptionResponse {} + +#[tauri::command] +pub async fn storyteller_get_subscription_command( + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> ResponseOrErrorMessage { + + info!("storyteller_get_subscription_command called"); + + let credits = get( + &app_env_configs, + &storyteller_creds_manager) + .await + .map_err(|err| { + error!("Error getting credits: {:?}", err); + format!("Error getting credits: {:?}", err) + })?; + + Ok(credits.into()) +} + +async fn get( + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult { + + let maybe_creds = storyteller_creds_manager.get_credentials()?; + + let response = get_session_subscription( + &app_env_configs.storyteller_host, + maybe_creds.as_ref(), + PaymentsNamespace::Artcraft, + ).await?; + + Ok(GetSubscriptionResponse { + active_subscription: response.active_subscription + .map(|sub| { + let product_slug = match ArtcraftSubscriptionSlug::from_str(&sub.product_slug) { + Ok(product_slug) => product_slug, + Err(err) => { + error!("Received unknown product slug from storyteller: {:?}, error: {:?}", sub.product_slug, err); + return None; + }, + }; + Some(ActiveSubscriptionInfo { + subscription_token: sub.subscription_token, + product_slug, + namespace: sub.namespace, + next_bill_at: sub.next_bill_at, + subscription_end_at: sub.subscription_end_at, + }) + }) + .flatten(), + }) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_purge_credentials_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_purge_credentials_command.rs new file mode 100644 index 0000000000..f842c948a8 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_purge_credentials_command.rs @@ -0,0 +1,56 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::EnqueueEditImageSuccessResponse; +use crate::core::commands::response::shorthand::ResponseOrErrorMessage; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::clear_main_webview_window_storyteller_cookies::clear_main_webview_window_storyteller_cookies; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +#[derive(Serialize)] +pub struct PurgeStorytellerCredentialsCommandResponse { + pub success: bool, +} + +impl SerializeMarker for PurgeStorytellerCredentialsCommandResponse {} + +#[tauri::command] +pub async fn storyteller_purge_credentials_command( + app: AppHandle, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> ResponseOrErrorMessage { + info!("storyteller_purge_credentials_command called"); + + reset(&app, &storyteller_creds_manager) + .await + .map_err(|err| { + error!("Error purging credentials: {:?}", err); + format!("Error purging credentials: {:?}", err) + })?; + + Ok(PurgeStorytellerCredentialsCommandResponse { + success: true, + }.into()) +} + +async fn reset( + app: &AppHandle, + storyteller_creds_manager: &StorytellerCredentialManager +) -> AnyhowResult<()> { + + clear_main_webview_window_storyteller_cookies(&app)?; + + storyteller_creds_manager.clear_credentials()?; + storyteller_creds_manager.delete_persisted_copies_on_disk()?; + + // NB: Twice to be sure. + storyteller_creds_manager.clear_credentials()?; + storyteller_creds_manager.delete_persisted_copies_on_disk()?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/mod.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/mod.rs new file mode 100644 index 0000000000..698f284ef3 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/mod.rs @@ -0,0 +1,2 @@ +pub mod storyteller_open_credits_purchase_command; +pub mod storyteller_open_subscription_purchase_command; diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/storyteller_open_credits_purchase_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/storyteller_open_credits_purchase_command.rs new file mode 100644 index 0000000000..021e599576 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/storyteller_open_credits_purchase_command.rs @@ -0,0 +1,71 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Deserialize, Debug)] +pub struct StorytellerOpenCreditsPurchaseCommand { + pub credits_pack: Option, +} + +#[tauri::command] +pub async fn storyteller_open_credits_purchase_command( + app: AppHandle, + request: StorytellerOpenCreditsPurchaseCommand, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> Result { + info!("storyteller_open_credits_purchase_command called"); + + let credits_pack = request.credits_pack.ok_or("Credits pack is required")?; + + do_open_billing( + &app, + &app_data_root, + &app_env_configs, + &storyteller_creds_manager, + credits_pack, + ) + .await + .map_err(|err| { + error!("Error opening credits purchase window: {:?}", err); + format!("Error opening credits purchase window: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_billing( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, + credits_pack: ArtcraftCreditsPackSlug, +) -> AnyhowResult<()> { + info!("Building billing window..."); + + open_storyteller_billing_window(OpenStorytellerBillingWindowArgs { + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + billing_window_case: BillingWindowCase::CreditsPack { + credits_pack, + } + }).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/storyteller_open_subscription_purchase_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/storyteller_open_subscription_purchase_command.rs new file mode 100644 index 0000000000..bcb06fdc85 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_checkout/storyteller_open_subscription_purchase_command.rs @@ -0,0 +1,76 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Deserialize, Debug)] +pub struct StorytellerOpenSubscriptionPurchaseCommand { + pub plan: Option, + pub cadence: Option, +} + +#[tauri::command] +pub async fn storyteller_open_subscription_purchase_command( + app: AppHandle, + request: StorytellerOpenSubscriptionPurchaseCommand, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> Result { + info!("storyteller_open_subscription_purchase_command called"); + + let plan = request.plan.ok_or("Plan is required")?; + let cadence = request.cadence.ok_or("Cadence is required")?; + + do_open_billing( + &app, + &app_data_root, + &app_env_configs, + &storyteller_creds_manager, + plan, + cadence, + ) + .await + .map_err(|err| { + error!("Error opening subscription purchase window: {:?}", err); + format!("Error opening subscription purchase window: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_billing( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, + plan: ArtcraftSubscriptionSlug, + cadence: PlanBillingCadence, +) -> AnyhowResult<()> { + info!("Building billing window..."); + + open_storyteller_billing_window(OpenStorytellerBillingWindowArgs { + app, + app_env_configs, + app_data_root, + storyteller_creds_manager, + billing_window_case: BillingWindowCase::Subscription { + plan, + cadence, + } + }).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/mod.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/mod.rs new file mode 100644 index 0000000000..6d4213482e --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/mod.rs @@ -0,0 +1,4 @@ +pub mod storyteller_open_customer_portal_cancel_plan_command; +pub mod storyteller_open_customer_portal_manage_plan_command; +pub mod storyteller_open_customer_portal_switch_plan_command; +pub mod storyteller_open_customer_portal_update_payment_method_command; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_cancel_plan_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_cancel_plan_command.rs new file mode 100644 index 0000000000..8eb091a294 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_cancel_plan_command.rs @@ -0,0 +1,58 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[tauri::command] +pub async fn storyteller_open_customer_portal_cancel_plan_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> Result { + info!("storyteller_open_customer_portal_cancel_plan_command called"); + + do_open_portal( + &app, + &app_data_root, + &app_env_configs, + &storyteller_creds_manager, + ) + .await + .map_err(|err| { + error!("Error opening customer portal window: {:?}", err); + format!("Error opening customer portal window: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_portal( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + info!("Building billing window..."); + + open_storyteller_billing_window(OpenStorytellerBillingWindowArgs { + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + billing_window_case: BillingWindowCase::CustomerPortalCancelPlan, + }).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_manage_plan_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_manage_plan_command.rs new file mode 100644 index 0000000000..2b40db7f45 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_manage_plan_command.rs @@ -0,0 +1,58 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[tauri::command] +pub async fn storyteller_open_customer_portal_manage_plan_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> Result { + info!("storyteller_open_customer_portal_manage_plan_command called"); + + do_open_portal( + &app, + &app_data_root, + &app_env_configs, + &storyteller_creds_manager, + ) + .await + .map_err(|err| { + error!("Error opening customer portal window: {:?}", err); + format!("Error opening customer portal window: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_portal( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + info!("Building billing window..."); + + open_storyteller_billing_window(OpenStorytellerBillingWindowArgs { + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + billing_window_case: BillingWindowCase::CustomerPortalManagePlan, + }).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_switch_plan_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_switch_plan_command.rs new file mode 100644 index 0000000000..4db1bd595c --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_switch_plan_command.rs @@ -0,0 +1,77 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use artcraft_api_defs::stripe_artcraft::customer_portal_switch_plan::PlanBillingCadenceConfirmation; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Deserialize, Debug)] +pub struct StorytellerOpenCustomerPortalSwitchPlanCommand { + pub plan: Option, + pub cadence: Option, +} + +#[tauri::command] +pub async fn storyteller_open_customer_portal_switch_plan_command( + app: AppHandle, + request: StorytellerOpenCustomerPortalSwitchPlanCommand, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> Result { + info!("storyteller_open_customer_portal_switch_plan_command called"); + + let plan = request.plan.ok_or("Plan is required")?; + let cadence = request.cadence.ok_or("Cadence is required")?; + + do_open_portal( + &app, + &app_data_root, + &app_env_configs, + &storyteller_creds_manager, + plan, + cadence, + ) + .await + .map_err(|err| { + error!("Error opening customer portal window: {:?}", err); + format!("Error opening customer portal window: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_portal( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, + plan: ArtcraftSubscriptionSlug, + cadence: PlanBillingCadenceConfirmation, +) -> AnyhowResult<()> { + info!("Building billing window..."); + + open_storyteller_billing_window(OpenStorytellerBillingWindowArgs { + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + billing_window_case: BillingWindowCase::CustomerPortalSwitchPlan { + plan, + cadence, + } + }).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_update_payment_method_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_update_payment_method_command.rs new file mode 100644 index 0000000000..71cde4d1f5 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/stripe_customer_portal/storyteller_open_customer_portal_update_payment_method_command.rs @@ -0,0 +1,59 @@ +use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{ImageEditModel, EditImageQuality, EditImageSize}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::windows::open_midjourney_login_window::open_midjourney_login_window; +use crate::services::sora::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence; +use artcraft_api_defs::stripe_artcraft::customer_portal_switch_plan::PlanBillingCadenceConfirmation; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::{AppHandle, State}; +use tokens::tokens::media_files::MediaFileToken; + +#[tauri::command] +pub async fn storyteller_open_customer_portal_update_payment_method_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + app_env_configs: State<'_, AppEnvConfigs>, + storyteller_creds_manager: State<'_, StorytellerCredentialManager>, +) -> Result { + info!("storyteller_open_customer_portal_update_payment_method_command called"); + + do_open_portal( + &app, + &app_data_root, + &app_env_configs, + &storyteller_creds_manager, + ) + .await + .map_err(|err| { + error!("Error opening customer portal window: {:?}", err); + format!("Error opening customer portal window: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_portal( + app: &AppHandle, + app_data_root: &AppDataRoot, + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + info!("Building billing window..."); + + open_storyteller_billing_window(OpenStorytellerBillingWindowArgs { + app, + app_data_root, + app_env_configs, + storyteller_creds_manager, + billing_window_case: BillingWindowCase::CustomerPortalUpdatePaymentMethod, + }).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/mod.rs b/crates/desktop/artcraft/src/services/storyteller/mod.rs new file mode 100644 index 0000000000..7b5a42c0bf --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/mod.rs @@ -0,0 +1,4 @@ +pub mod state; +pub mod threads; +pub mod windows; +pub mod commands; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/state/mod.rs b/crates/desktop/artcraft/src/services/storyteller/state/mod.rs new file mode 100644 index 0000000000..25b606ffdf --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/state/mod.rs @@ -0,0 +1,3 @@ +pub mod read_storyteller_credentials_from_disk; +pub mod storyteller_credential_holder; +pub mod storyteller_credential_manager; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/state/read_storyteller_credentials_from_disk.rs b/crates/desktop/artcraft/src/services/storyteller/state/read_storyteller_credentials_from_disk.rs new file mode 100644 index 0000000000..ad021dce5e --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/state/read_storyteller_credentials_from_disk.rs @@ -0,0 +1,39 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use std::fs::read_to_string; +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; + +pub fn read_storyteller_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult { + let creds_dir= app_data_root.credentials_dir(); + + let mut avt = None; + + { + let avt_file = creds_dir.get_storyteller_avt_cookie_file_path(); + if avt_file.exists() { + let contents = read_to_string(&avt_file)? + .trim() + .to_string(); + avt = Some(StorytellerAvtCookie::new(contents)); + } + } + + let mut session = None; + + { + let session_file = creds_dir.get_storyteller_session_cookie_file_path(); + if session_file.exists() { + let contents = read_to_string(&session_file)? + .trim() + .to_string(); + session = Some(StorytellerSessionCookie::new(contents)); + } + } + + Ok(StorytellerCredentialSet::initialize( + avt, + session, + )) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/state/storyteller_credential_holder.rs b/crates/desktop/artcraft/src/services/storyteller/state/storyteller_credential_holder.rs new file mode 100644 index 0000000000..5759f413c3 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/state/storyteller_credential_holder.rs @@ -0,0 +1,57 @@ +use anyhow::anyhow; +use errors::AnyhowResult; +use std::sync::{Arc, RwLock}; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + +/// Hold credentials for the application. +#[derive(Clone)] +pub struct StorytellerCredentialHolder { + credentials: Arc>>, +} + +impl StorytellerCredentialHolder { + pub fn new() -> Self { + Self { + credentials: Arc::new(RwLock::new(None)), + } + } + + pub fn set_credentials(&self, credentials: &StorytellerCredentialSet) -> AnyhowResult<()> { + match self.credentials.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut creds) => { + *creds = Some(credentials.clone()); + Ok(()) + } + } + } + + pub fn clear_credentials(&self) -> AnyhowResult<()> { + match self.credentials.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut creds) => { + *creds = None; + Ok(()) + } + } + } + + pub fn get_credentials(&self) -> AnyhowResult> { + match self.credentials.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(creds) => { + Ok(creds.clone()) + } + } + } + + pub fn get_credentials_required(&self) -> AnyhowResult { + match self.credentials.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(creds) => match &*creds { + None => Err(anyhow!("Storyteller credentials not set")), + Some(creds) => Ok(creds.clone()), + } + } + } +} diff --git a/crates/desktop/artcraft/src/services/storyteller/state/storyteller_credential_manager.rs b/crates/desktop/artcraft/src/services/storyteller/state/storyteller_credential_manager.rs new file mode 100644 index 0000000000..cb792bdee6 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/state/storyteller_credential_manager.rs @@ -0,0 +1,129 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::storyteller::state::read_storyteller_credentials_from_disk::read_storyteller_credentials_from_disk; +use crate::services::storyteller::state::storyteller_credential_holder::StorytellerCredentialHolder; +use errors::AnyhowResult; +use log::{info, warn}; +use std::fs::OpenOptions; +use std::io::Write; +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; + +#[derive(Clone)] +pub struct StorytellerCredentialManager { + holder: StorytellerCredentialHolder, + app_data_root: AppDataRoot, +} + +impl StorytellerCredentialManager { + + pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self { + Self { + holder: StorytellerCredentialHolder::new(), + app_data_root: app_data_root.clone(), + } + } + + pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self { + let holder = StorytellerCredentialHolder::new(); + + match read_storyteller_credentials_from_disk(app_data_root) { + Err(err) => warn!("Failed to read credentials from disk: {:?}", err), + Ok(creds) => { + holder.set_credentials(&creds).expect("Failed to set credentials"); + } + } + + Self { + holder, + app_data_root: app_data_root.clone(), + } + } + + pub fn set_credentials(&self, creds: &StorytellerCredentialSet) -> AnyhowResult<()> { + self.holder.set_credentials(creds) + } + + pub fn clear_credentials(&self) -> AnyhowResult<()> { + self.holder.clear_credentials()?; + Ok(()) + } + + pub fn get_credentials(&self) -> AnyhowResult> { + self.holder.get_credentials() + } + + pub fn get_credentials_required(&self) -> AnyhowResult { + self.holder.get_credentials_required() + } + + pub fn reset_from_disk(&self) -> AnyhowResult { + let creds = read_storyteller_credentials_from_disk(&self.app_data_root)?; + self.holder.set_credentials(&creds)?; + Ok(creds) + } + + pub fn persist_all_to_disk(&self) -> AnyhowResult<()> { + let credentials = self.holder.get_credentials_required()?; + + info!("Persisting avt cookie to disk..."); + if let Some(avt) = credentials.avt.as_ref() { + persist_avt_to_disk(&avt, &self.app_data_root)?; + } + + info!("Persisting session cookie to disk..."); + if let Some(session) = credentials.session.as_ref() { + persist_session_to_disk(&session, &self.app_data_root)?; + } + + Ok(()) + } + + pub fn delete_persisted_copies_on_disk(&self) -> AnyhowResult<()> { + let creds_dir = self.app_data_root.credentials_dir(); + + let session_file = creds_dir.get_storyteller_session_cookie_file_path(); + if session_file.exists() { + std::fs::remove_file(session_file)?; + } + + let avt_file = creds_dir.get_storyteller_avt_cookie_file_path(); + if avt_file.exists() { + std::fs::remove_file(avt_file)?; + } + + Ok(()) + } +} + +fn persist_session_to_disk(session: &StorytellerSessionCookie, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let creds_dir = app_data_root.credentials_dir(); + let filename = creds_dir.get_storyteller_session_cookie_file_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + + file.write_all(session.as_bytes())?; + file.flush()?; + + Ok(()) +} + +fn persist_avt_to_disk(avt: &StorytellerAvtCookie, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let creds_dir = app_data_root.credentials_dir(); + let filename = creds_dir.get_storyteller_avt_cookie_file_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + + file.write_all(avt.as_bytes())?; + file.flush()?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs b/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs new file mode 100644 index 0000000000..663bd6cd82 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs @@ -0,0 +1,2 @@ +pub mod storyteller_activity_thread; +pub mod storyteller_task_polling_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs new file mode 100644 index 0000000000..dae9c58222 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs @@ -0,0 +1,129 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; +use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest; +use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use errors::AnyhowResult; +use log::{debug, error, info}; +use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs}; +use std::time::Instant; +use tokens::tokens::app_session::AppSessionToken; + +const CLIENT_NAME : &str = "artcraft"; + +const ERROR_SLEEP_MILLIS : u64 = 1_000 * 60 * 3; // 3 minutes; + +pub async fn storyteller_activity_thread( + app_env_configs: AppEnvConfigs, + artcraft_platform_info: ArtcraftPlatformInfo, + artcraft_usage_tracker: ArtcraftUsageTracker, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + let startup = Instant::now(); + let app_session_token = AppSessionToken::generate(); + + info!("Session started at {:?} with token: {:?}", startup, app_session_token); + + loop { + let res = polling_loop( + &app_env_configs, + &artcraft_usage_tracker, + &storyteller_creds_manager, + startup, + &artcraft_platform_info, + &app_session_token, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await; + } +} + +async fn polling_loop( + app_env_configs: &AppEnvConfigs, + artcraft_usage_tracker: &ArtcraftUsageTracker, + storyteller_creds_manager: &StorytellerCredentialManager, + startup: Instant, + artcraft_platform_info: &ArtcraftPlatformInfo, + app_session_token: &AppSessionToken, +) -> AnyhowResult<()> { + loop { + let creds = storyteller_creds_manager.get_credentials()?; + + let creds = match creds { + None => { + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + Some(creds) => { + if creds.is_empty() { + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + creds + }, + }; + + let usage_data = artcraft_usage_tracker.get()?; + + let time_since_startup = Instant::now().duration_since(startup); + + let request = LogAppActiveUserRequest { + maybe_app_session_token: Some(app_session_token.clone()), + maybe_app_name: Some(CLIENT_NAME.to_string()), + maybe_app_version: Some(artcraft_platform_info.artcraft_version.clone()), + maybe_os_platform: Some(artcraft_platform_info.os_platform.as_str().to_owned()), + maybe_os_version: Some(artcraft_platform_info.os_version.clone()), + maybe_session_duration_seconds: Some(time_since_startup.as_secs()), + total_generation_count: Some(usage_data.total_generation_count), + image_generation_count: Some(usage_data.image_generation_count), + video_generation_count: Some(usage_data.video_generation_count), + object_generation_count: Some(usage_data.object_generation_count), + text_to_image_count: Some(usage_data.text_to_image_count), + image_to_image_count: Some(usage_data.image_to_image_count), + text_to_video_count: Some(usage_data.text_to_video_count), + image_to_video_count: Some(usage_data.image_to_video_count), + text_to_object_count: Some(usage_data.text_to_object_count), + image_to_object_count: Some(usage_data.image_to_object_count), + image_page_prompt_count: Some(usage_data.image_page_prompt_count), + video_page_prompt_count: Some(usage_data.video_page_prompt_count), + edit_page_prompt_count: Some(usage_data.edit_page_prompt_count), + stage_page_prompt_count: Some(usage_data.stage_page_prompt_count), + object_page_prompt_count: Some(usage_data.object_page_prompt_count), + other_page_prompt_count: Some(usage_data.other_page_prompt_count), + }; + + debug!("Logging active user with storyteller."); + + let result = log_active_user_v2( + &app_env_configs.storyteller_host, + Some(&creds), + request, + ).await; + + let wait_millis = match result { + Ok(result) => result.wait_for_retry_millis, + Err(err) => { + match &err { + StorytellerError::Api(ApiError::TooManyRequests(message)) => { + error!("Too many requests (sleeping): {:?}", message); + tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await; + continue; + } + _ => {} + } + return Err(anyhow!(err)); + } + }; + + // Wait at least a minute, no matter what the server tells us. + let wait_millis = std::cmp::min(wait_millis, 60_000); + tokio::time::sleep(std::time::Duration::from_millis(wait_millis)).await; + } +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_frontend_caller_notification.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_frontend_caller_notification.rs new file mode 100644 index 0000000000..efcbff57d5 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_frontend_caller_notification.rs @@ -0,0 +1,276 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent; +use crate::core::events::functional_events::gaussian_generation_complete_event::{GaussianGenerationCompleteEvent, GeneratedGaussian}; +use crate::core::events::functional_events::image_edit_complete_event::{EditedImage, ImageEditCompleteEvent}; +use crate::core::events::functional_events::object_generation_complete_event::{GeneratedObject, ObjectGenerationCompleteEvent}; +use crate::core::events::functional_events::text_to_image_generation_complete_event::{GeneratedImage, TextToImageGenerationCompleteEvent}; +use crate::core::events::functional_events::video_generation_complete_event::{GeneratedVideo, VideoGenerationCompleteEvent}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use anyhow::anyhow; +use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsItem, ListSessionResultDetailsResponse}; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use enums::tauri::tasks::task_type::TaskType; +use errors::AnyhowResult; +use log::{error, warn}; +use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs}; +use sqlite_tasks::queries::task::Task; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn maybe_handle_frontend_caller_notification( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + job: &ListSessionJobsItem, +) -> AnyhowResult<()> { + + let job_result = match job.maybe_result { + Some(ref res) => res, + None => { + warn!("Job result is None for task: {:?}", task); + return Ok(()); // No result, nothing to do + }, + }; + + match task.task_type { + // Audio uses the session-job feed and the common task completion event. + TaskType::AudioGeneration => {}, + TaskType::ImageGeneration => { + let _r = handle_image_generation( + app, + task, + job_result, + app_env_configs, + maybe_creds, + ).await?; + } + TaskType::ImageInpaintEdit => { + let _r = handle_inpaint_image_generation( + app, + task, + job_result, + app_env_configs, + maybe_creds, + ).await?; + } + TaskType::VideoGeneration => { + let _r = handle_video_generation( + app, + task, + job_result, + ).await?; + } + TaskType::ObjectGeneration => { + let _r = handle_object_generation( + app, + task, + job_result, + ).await?; + } + TaskType::GaussianGeneration => { + let _r = handle_gaussian_generation( + app, + task, + job_result, + ).await?; + } + TaskType::BackgroundRemoval => { + let _r = handle_background_removal_generation( + app, + task, + job_result, + ).await?; + } + } + + Ok(()) +} + +async fn handle_image_generation( + app: &AppHandle, + task: &Task, + job_result: &ListSessionResultDetailsResponse, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, +) -> AnyhowResult<()> { + + let generated_images = match job_result.maybe_batch_token.as_ref() { + None => { + vec![GeneratedImage { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: job_result.media_links.maybe_thumbnail_template.clone(), + }] + } + Some(batch_token) => { + let result = list_batch_generated_redux_media_files( + &app_env_configs.storyteller_host, + maybe_creds, + batch_token, + ).await?; + + if result.media_files.is_empty() { + return Err(anyhow!("No media files found for batch token: {}", batch_token)); + } + + result.media_files + .into_iter() + .map(|file| GeneratedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect() + } + }; + + let event = TextToImageGenerationCompleteEvent { + generated_images, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(&app); + + Ok(()) +} + +async fn handle_inpaint_image_generation( + app: &AppHandle, + task: &Task, + job_result: &ListSessionResultDetailsResponse, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, +) -> AnyhowResult<()> { + + let edited_images = match job_result.maybe_batch_token.as_ref() { + None => { + vec![EditedImage { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: job_result.media_links.maybe_thumbnail_template.clone(), + }] + } + Some(batch_token) => { + let result = list_batch_generated_redux_media_files( + &app_env_configs.storyteller_host, + maybe_creds, + batch_token, + ).await?; + + if result.media_files.is_empty() { + return Err(anyhow!("No media files found for batch token: {}", batch_token)); + } + + result.media_files + .into_iter() + .map(|file| EditedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect() + } + }; + + let event = ImageEditCompleteEvent { + edited_images, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(&app); + + Ok(()) +} + +async fn handle_video_generation( + app: &AppHandle, + task: &Task, + job_result: &ListSessionResultDetailsResponse, +) -> AnyhowResult<()> { + + // NB: For now, we only generate one video at a time. + let event = VideoGenerationCompleteEvent { + generated_video: Some(GeneratedVideo { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: media_links_to_thumbnail_template(&job_result.media_links) + .map(|s| s.to_owned()), + }), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(&app); + + Ok(()) +} + +async fn handle_object_generation( + app: &AppHandle, + task: &Task, + job_result: &ListSessionResultDetailsResponse, +) -> AnyhowResult<()> { + + // NB: For now, we only generate one object (3d mesh) at a time. + let event = ObjectGenerationCompleteEvent { + generated_object: Some(GeneratedObject { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: media_links_to_thumbnail_template(&job_result.media_links) + .map(|s| s.to_owned()), + }), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(&app); + + Ok(()) +} + +async fn handle_gaussian_generation( + app: &AppHandle, + task: &Task, + job_result: &ListSessionResultDetailsResponse, +) -> AnyhowResult<()> { + + // NB: For now, we only generate one object (gaussian) at a time. + let event = GaussianGenerationCompleteEvent { + generated_gaussian: Some(GeneratedGaussian { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: media_links_to_thumbnail_template(&job_result.media_links) + .map(|s| s.to_owned()), + }), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(&app); + + Ok(()) +} + +async fn handle_background_removal_generation( + app: &AppHandle, + task: &Task, + job_result: &ListSessionResultDetailsResponse, +) -> AnyhowResult<()> { + + let event = CanvasBackgroundRemovalCompleteEvent { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + image_cdn_url: job_result.media_links.cdn_url.clone(), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + event.send_infallible(&app); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_inpainting_complete_event.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_inpainting_complete_event.rs new file mode 100644 index 0000000000..c1e6e24791 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_inpainting_complete_event.rs @@ -0,0 +1,126 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent; +use crate::core::events::functional_events::image_edit_complete_event::{EditedImage, ImageEditCompleteEvent}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use anyhow::anyhow; +use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsItem, ListSessionResultDetailsResponse}; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowResult; +use log::{error, warn}; +use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs}; +use sqlite_tasks::queries::task::Task; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn maybe_handle_inpainting_complete_event( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + job: &ListSessionJobsItem, +) -> AnyhowResult<()> { + + match task.task_type { + TaskType::ImageInpaintEdit => {} // NB: Fall-through + _ => return Ok(()), + } + + match task.frontend_caller { + Some(TauriCommandCaller::ImageEditor) => {} // NB: Fall-through + _ => return Ok(()), + } + + let job_result = match job.maybe_result { + Some(ref res) => res, + None => { + warn!("Job result is None for task: {:?}", task); + return Ok(()); // No result, nothing to do + }, + }; + + let event = match job_result.maybe_batch_token.as_ref() { + Some(batch_token) => { + handle_batch( + app, + app_env_configs, + maybe_creds, + task, + job, + job_result, + batch_token + ).await? + } + None => { + handle_single( + app, + task, + job, + job_result, + ).await? + } + }; + + if let Err(err) = event.send(&app) { + error!("Failed to send ImageEditCompleteEvent: {:?}", err); // Fail open + } + + Ok(()) +} + +pub async fn handle_single( + app: &AppHandle, + task: &Task, + job: &ListSessionJobsItem, + job_result: &ListSessionResultDetailsResponse, +) -> AnyhowResult { + Ok(ImageEditCompleteEvent { + edited_images: vec![EditedImage { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: job_result.media_links.maybe_thumbnail_template.clone(), + }], + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }) +} + +pub async fn handle_batch( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + job: &ListSessionJobsItem, + job_result: &ListSessionResultDetailsResponse, + batch_token: &BatchGenerationToken, +) -> AnyhowResult { + + let result = list_batch_generated_redux_media_files( + &app_env_configs.storyteller_host, + maybe_creds, + batch_token, + ).await?; + + if result.media_files.is_empty() { + return Err(anyhow!("No media files found for batch token: {}", batch_token)); + } + + let media_files = result.media_files + .into_iter() + .map(|file| EditedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect(); + + Ok(ImageEditCompleteEvent { + edited_images: media_files, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_text_to_image_complete_event.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_text_to_image_complete_event.rs new file mode 100644 index 0000000000..d3bffd2d0f --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_handle_text_to_image_complete_event.rs @@ -0,0 +1,124 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::text_to_image_generation_complete_event::{GeneratedImage, TextToImageGenerationCompleteEvent}; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use anyhow::anyhow; +use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsItem, ListSessionResultDetailsResponse}; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowResult; +use log::{error, warn}; +use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs}; +use sqlite_tasks::queries::task::Task; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn maybe_handle_text_to_image_complete_event( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + job: &ListSessionJobsItem, +) -> AnyhowResult<()> { + + match task.task_type { + TaskType::ImageGeneration => {} // NB: Fall-through + _ => return Ok(()), + } + + match task.frontend_caller { + Some(TauriCommandCaller::TextToImage) => {} // NB: Fall-through + _ => return Ok(()), + } + + let job_result = match job.maybe_result { + Some(ref res) => res, + None => { + warn!("Job result is None for task: {:?}", task); + return Ok(()); // No result, nothing to do + }, + }; + + let event = match job_result.maybe_batch_token.as_ref() { + Some(batch_token) => { + handle_batch( + app, + app_env_configs, + maybe_creds, + task, + job, + job_result, + batch_token + ).await? + } + None => { + handle_single( + app, + task, + job, + job_result, + ).await? + } + }; + + if let Err(err) = event.send(&app) { + error!("Failed to send TextToImageGenerationCompleteEvent: {:?}", err); // Fail open + } + + Ok(()) +} + +async fn handle_single( + app: &AppHandle, + task: &Task, + job: &ListSessionJobsItem, + job_result: &ListSessionResultDetailsResponse, +) -> AnyhowResult { + Ok(TextToImageGenerationCompleteEvent { + generated_images: vec![GeneratedImage { + media_token: MediaFileToken::new_from_str(&job_result.entity_token), + cdn_url: job_result.media_links.cdn_url.clone(), + maybe_thumbnail_template: job_result.media_links.maybe_thumbnail_template.clone(), + }], + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }) +} + +async fn handle_batch( + app: &AppHandle, + app_env_configs: &AppEnvConfigs, + maybe_creds: Option<&StorytellerCredentialSet>, + task: &Task, + job: &ListSessionJobsItem, + job_result: &ListSessionResultDetailsResponse, + batch_token: &BatchGenerationToken, +) -> AnyhowResult { + + let result = list_batch_generated_redux_media_files( + &app_env_configs.storyteller_host, + maybe_creds, + batch_token, + ).await?; + + if result.media_files.is_empty() { + return Err(anyhow!("No media files found for batch token: {}", batch_token)); + } + + let media_files = result.media_files + .into_iter() + .map(|file| GeneratedImage { + media_token: file.token, + cdn_url: file.media_links.cdn_url, + maybe_thumbnail_template: file.media_links.maybe_thumbnail_template, + }) + .collect(); + + Ok(TextToImageGenerationCompleteEvent { + generated_images: media_files, + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_send_background_removal_complete_event.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_send_background_removal_complete_event.rs new file mode 100644 index 0000000000..f5fa250e7d --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/maybe_send_background_removal_complete_event.rs @@ -0,0 +1,49 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use errors::AnyhowResult; +use log::error; +use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs}; +use sqlite_tasks::queries::task::Task; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn maybe_send_background_removal_complete_event( + app: &tauri::AppHandle, + task: &Task, + job: &ListSessionJobsItem, +) -> AnyhowResult<()> { + + match task.task_type { + TaskType::BackgroundRemoval => {} // NB: Fall-through + _ => return Ok(()), + } + + match task.frontend_caller { + Some(TauriCommandCaller::Canvas) => {} // NB: Fall-through + _ => return Ok(()), + } + + let result = match job.maybe_result { + Some(ref res) => res, + None => { + error!("Job result is None for task: {:?}", task); + return Ok(()); // No result, nothing to do + }, + }; + + let event = CanvasBackgroundRemovalCompleteEvent { + media_token: MediaFileToken::new_from_str(&result.entity_token), + image_cdn_url: result.media_links.cdn_url.clone(), + maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(), + maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(), + }; + + if let Err(err) = event.send(&app) { + error!("Failed to send CanvasBackgroundRemovalCompleteEvent: {:?}", err); // Fail open + } + + Ok(()) +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/mod.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/mod.rs new file mode 100644 index 0000000000..c9ae78ea32 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/events/mod.rs @@ -0,0 +1,4 @@ +pub (super) mod maybe_handle_frontend_caller_notification; +pub (super) mod maybe_handle_inpainting_complete_event; +pub (super) mod maybe_handle_text_to_image_complete_event; +pub (super) mod maybe_send_background_removal_complete_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_failed_job.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_failed_job.rs new file mode 100644 index 0000000000..78a3bc71d0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_failed_job.rs @@ -0,0 +1,53 @@ +use crate::core::events::generation_events::common::GenerationModel; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider; +use crate::core::utils::enum_conversion::task_type::to_generation_action; +use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem; +use enums::tauri::tasks::task_status::TaskStatus; +use errors::AnyhowResult; +use log::info; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use tauri::AppHandle; +use enums::tauri::tasks::task_failure_type::TaskFailureType; +use sqlite_tasks::queries::update_task_status_with_rich_failure::{update_task_status_with_rich_failure, UpdateTaskWithRichFailureArgs}; + +pub async fn handle_failed_job( + app_handle: &AppHandle, + job: &ListSessionJobsItem, + task: &Task, + task_database: &TaskDatabase, +) -> AnyhowResult<()> { + info!("Marking storyteller job as failed: {:?}", task.id); + + let maybe_failure_type = job.status + .maybe_failure_category_updated + .as_ref() + .map(|val| TaskFailureType::from_frontend_failure_category_for_api(val)); + + let maybe_failure_message = job.status.maybe_failure_message.as_deref(); + + update_task_status_with_rich_failure(UpdateTaskWithRichFailureArgs { + db: task_database.get_connection(), + task_id: &task.id, + status: TaskStatus::CompleteFailure, + maybe_failure_type, + maybe_failure_message, + }).await?; + + let service = to_generation_service_provider(task.provider); + let action = to_generation_action(task.task_type); + + let event = GenerationFailedEvent { + action, + service, + model: task.model_type.as_ref().map(|model| GenerationModel::Unknown(model.to_str().to_owned())), + reason: None, + }; + + event.send_infallible(app_handle); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs new file mode 100644 index 0000000000..3393bb955c --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs @@ -0,0 +1,179 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationModel}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider; +use crate::core::utils::enum_conversion::task_type::to_generation_action; +use super::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification; +use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use errors::AnyhowResult; +use log::error; +use log::info; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +use tauri::AppHandle; +use tokens::tokens::media_files::MediaFileToken; + +pub async fn handle_successful_job( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + creds: Option<&StorytellerCredentialSet>, + job: &ListSessionJobsItem, + task: &Task, + task_database: &TaskDatabase, +) -> AnyhowResult<()> { + let maybe_primary_media_file_token = job.maybe_result + .as_ref() + .map(|result| MediaFileToken::new_from_str(&result.entity_token)); + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &task.id, + maybe_batch_token: job.maybe_result + .as_ref() + .map(|result| result.maybe_batch_token.as_ref()) + .flatten(), + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: get_media_file_class(job), + maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job), + maybe_primary_media_file_cdn_url: job.maybe_result + .as_ref() + .map(|result| result.media_links.cdn_url.as_str()), + }).await?; + + if !updated { + return Ok(()); // If anything breaks with queries, don't spam events. + } + + send_additional_success_events(app_handle, app_env_configs, creds, job, task).await; + + let service = to_generation_service_provider(task.provider); + let action = to_generation_action(task.task_type); + + let event = GenerationCompleteEvent { + action: Some(action), + service, + model: task.model_type.as_ref().map(|model| GenerationModel::Unknown(model.to_str().to_owned())), + }; + + if let Err(err) = event.send(app_handle) { + error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open + } + + Ok(()) +} + +async fn send_additional_success_events( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + creds: Option<&StorytellerCredentialSet>, + job: &ListSessionJobsItem, + task: &Task +) { + info!("Attempting to dispatch events for completed Storyteller job: {:?}", task); + + let result = maybe_handle_frontend_caller_notification( + app_handle, + app_env_configs, + creds, + task, + job, + ).await; + + if let Err(err) = result { + error!("Failed to send generation complete event: {:?}", err); + } + + //let result = maybe_handle_text_to_image_complete_event( + // app_handle, + // app_env_configs, + // creds, + // task, + // job, + //).await; + + //if let Err(err) = result { + // error!("Failed to send text-to-image complete event: {:?}", err); + //} + + //let result = maybe_handle_inpainting_complete_event( + // app_handle, + // app_env_configs, + // creds, + // task, + // job, + //).await; + + //if let Err(err) = result { + // error!("Failed to send image inpainting complete event: {:?}", err); + //} + + //let result = maybe_send_background_removal_complete_event( + // app_handle, + // task, + // job, + //).await; + + //if let Err(err) = result { + // error!("Failed to send background removal complete event: {:?}", err); + //} +} + +fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> { + let links = match job.maybe_result.as_ref() { + None => return None, + Some(result) => &result.media_links, + }; + + media_links_to_thumbnail_template(links) +} + +fn get_media_file_class(job: &ListSessionJobsItem) -> Option { + match job.request.inference_category { + InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image), + InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image), + InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video), + InferenceCategory::AudioGeneration | InferenceCategory::TextToSpeech => return Some(TaskMediaFileClass::Audio), + InferenceCategory::SplatGeneration => return Some(TaskMediaFileClass::Dimensional), + InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional), + _ => {}, // Fall-through + } + + let result = match job.maybe_result.as_ref() { + None => return None, + Some(result) => result, + }; + + let url = result.media_links.cdn_url.as_str(); + + if url.ends_with("jpg") + || url.ends_with("jpeg") + || url.ends_with("png") + { + return Some(TaskMediaFileClass::Image); + } + + if url.ends_with("mp4") + || url.ends_with("webm") + { + return Some(TaskMediaFileClass::Video); + } + + if url.ends_with("glb") { + return Some(TaskMediaFileClass::Dimensional); + } + + if url.ends_with("wav") + || url.ends_with("mp3") + { + return Some(TaskMediaFileClass::Audio); + } + + None +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/mod.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/mod.rs new file mode 100644 index 0000000000..d0222b171f --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/mod.rs @@ -0,0 +1,4 @@ +mod events; +mod handle_storyteller_failed_job; +mod handle_storyteller_successful_job; +pub mod storyteller_task_polling_thread; diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/storyteller_task_polling_thread.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/storyteller_task_polling_thread.rs new file mode 100644 index 0000000000..ee94c674e6 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_task_polling_thread/storyteller_task_polling_thread.rs @@ -0,0 +1,126 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::task_database::TaskDatabase; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_failed_job::handle_failed_job; +use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_successful_job::handle_successful_job; +use anyhow::anyhow; +use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States}; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use enums::common::generation_provider::GenerationProvider; +use enums::common::job_status_plus::JobStatusPlus; +use enums::tauri::tasks::task_status::TaskStatus; +use errors::AnyhowResult; +use log::error; +use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs}; +use sqlite_tasks::queries::task::Task; +use std::collections::HashMap; +use tauri::AppHandle; + +pub async fn storyteller_task_polling_thread( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + task_database: TaskDatabase, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + loop { + let res = polling_loop( + &app_handle, + &app_env_configs, + &task_database, + &storyteller_creds_manager, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Only sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + } +} + +async fn polling_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + task_database: &TaskDatabase, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + loop { + // Wait before next request for jobs. + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + + let creds = storyteller_creds_manager.get_credentials()?; + + let result = list_session_jobs( + &app_env_configs.storyteller_host, + creds.as_ref(), + States::All, + ).await; + + let jobs = match result { + Ok(result) => result.jobs, + Err(err) => { + match &err { + StorytellerError::Api(ApiError::TooManyRequests(message)) => { + error!("Too many requests (sleeping): {:?}", message); + tokio::time::sleep(std::time::Duration::from_millis(60_000)).await; + } + _ => {} + } + return Err(anyhow!(err)); + } + }; + + let job_ids = jobs.iter() + .map(|job| job.job_token.to_string()) + .collect::>(); + + let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs { + db: task_database.get_connection(), + provider: GenerationProvider::Artcraft, + provider_job_ids: Some(job_ids), + }).await?; + + let tasks = tasks.tasks; + + let jobs_by_id = jobs.iter() + .map(|job| (job.job_token.to_string(), job)) + .collect::>(); + + let tasks_by_provider_job_id = tasks.iter() + .filter_map(|task| { + if let Some(provider_job_id) = &task.provider_job_id { + Some((provider_job_id.clone(), task.clone())) + } else { + None + } + }) + //.map(|task| (task.provider_job_id.clone(), task.clone())) + .collect::>(); + + for job in jobs.iter() { + let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) { + Some(task) => task, + None => continue, + }; + + match job.status.status { + JobStatusPlus::CompleteSuccess => { + match task.status { + TaskStatus::CompleteSuccess => continue, // NB: We're done with this task. + _ => {} + } + handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?; + } + JobStatusPlus::CompleteFailure => { + match task.status { + TaskStatus::CompleteFailure => continue, // NB: We're done with this task. + _ => {} + } + handle_failed_job(app_handle, job, task, task_database).await?; + } + _ => continue, + } + } + + } +} diff --git a/crates/desktop/artcraft/src/services/storyteller/windows/clear_main_webview_window_storyteller_cookies.rs b/crates/desktop/artcraft/src/services/storyteller/windows/clear_main_webview_window_storyteller_cookies.rs new file mode 100644 index 0000000000..1ab9df358b --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/windows/clear_main_webview_window_storyteller_cookies.rs @@ -0,0 +1,59 @@ +use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME; +use errors::AnyhowResult; +use log::warn; +use tauri::webview::Cookie; +use tauri::{AppHandle, Manager}; + +pub fn clear_main_webview_window_storyteller_cookies(app: &AppHandle) -> AnyhowResult<()> { + let main_webview_window = app.get_webview_window(MAIN_WINDOW_NAME) + .ok_or_else(|| anyhow::anyhow!("Main window not found"))?; + + let cookies = main_webview_window.cookies()?; + + let cookies_to_delete = get_cookies_to_delete(cookies); + + let mut maybe_error = None; + + for cookie in cookies_to_delete { + if let Err(err) = main_webview_window.delete_cookie(cookie) { + warn!("Failed to delete cookie: {:?}", err); + maybe_error = Some(err); + } + } + + if let Some(err) = maybe_error { + return Err(anyhow::anyhow!("Failed to delete some cookies. Here's one failure: {:?}", err)); + } + + Ok(()) +} + +// TODO: This will need to handle local development, too. +fn get_cookies_to_delete(cookies: Vec) -> Vec { + cookies.into_iter() + .filter(|cookie| { + // Define your filtering logic here + // For example, delete cookies with a specific name or domain + match cookie.domain() { + // storyteller.ai + Some("api.storyteller.ai") => true, + Some("storyteller.ai") => true, + Some(domain) if domain.ends_with(".storyteller.ai") => true, + // getartcraft.com + Some("api.getartcraft.com") => true, + Some("getartcraft.com") => true, + Some(domain) if domain.ends_with(".getartcraft.com") => true, + // artcraft.ai + Some("api.artcraft.ai") => true, + Some("artcraft.ai") => true, + Some(domain) if domain.ends_with(".artcraft.ai") => true, + // ignore all others + None => false, + Some(domain) => { + warn!("Ignoring cookie with unrecognized domain: {}", domain); + false + } + } + }) + .collect() +} \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/windows/mod.rs b/crates/desktop/artcraft/src/services/storyteller/windows/mod.rs new file mode 100644 index 0000000000..ef56e02566 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/windows/mod.rs @@ -0,0 +1,3 @@ +pub mod clear_main_webview_window_storyteller_cookies; +pub mod open_storyteller_billing_window; +pub mod storyteller_billing_window_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/storyteller/windows/open_storyteller_billing_window.rs b/crates/desktop/artcraft/src/services/storyteller/windows/open_storyteller_billing_window.rs new file mode 100644 index 0000000000..0f228059f7 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/windows/open_storyteller_billing_window.rs @@ -0,0 +1,298 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::sora::windows::sora_login_window::sora_login_thread::sora_login_thread; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::windows::storyteller_billing_window_thread::storyteller_billing_window_thread; +use anyhow::anyhow; +use artcraft_api_defs::stripe_artcraft::create_credits_pack_checkout::StripeArtcraftCreateCreditsPackCheckoutRequest; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::{PlanBillingCadence, StripeArtcraftCreateSubscriptionCheckoutRequest}; +use artcraft_api_defs::stripe_artcraft::customer_portal_cancel_plan::StripeArtcraftCustomerPortalCancelPlanRequest; +use artcraft_api_defs::stripe_artcraft::customer_portal_manage_plan::StripeArtcraftCustomerPortalManagePlanRequest; +use artcraft_api_defs::stripe_artcraft::customer_portal_switch_plan::{PlanBillingCadenceConfirmation, StripeArtcraftCustomerPortalSwitchPlanRequest}; +use artcraft_api_defs::stripe_artcraft::customer_portal_update_payment_method::StripeArtcraftCustomerPortalUpdatePaymentMethodRequest; +use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use errors::AnyhowResult; +use log::info; +use reqwest::Url; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::stripe_artcraft::create_credits_pack_checkout::create_credits_pack_checkout; +use artcraft_client::endpoints::stripe_artcraft::create_subscription_checkout::create_subscription_checkout; +use artcraft_client::endpoints::stripe_artcraft::customer_portal_cancel_plan::customer_portal_cancel_plan; +use artcraft_client::endpoints::stripe_artcraft::customer_portal_manage_plan::customer_portal_manage_plan; +use artcraft_client::endpoints::stripe_artcraft::customer_portal_switch_plan::customer_portal_switch_plan; +use artcraft_client::endpoints::stripe_artcraft::customer_portal_update_payment_method::customer_portal_update_payment_method; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +pub const BILLING_WINDOW_NAME: &str = "artcraft_billing_window"; + +pub struct OpenStorytellerBillingWindowArgs<'a> { + pub app: &'a AppHandle, + pub app_env_configs: &'a AppEnvConfigs, + pub app_data_root: &'a AppDataRoot, + pub storyteller_creds_manager: &'a StorytellerCredentialManager, + pub billing_window_case: BillingWindowCase, +} + +pub enum BillingWindowCase { + /// Purchase a subscription plan with the billing cadence supplied. + Subscription { + plan: ArtcraftSubscriptionSlug, + cadence: PlanBillingCadence, + }, + + /// Purchase a credits pack. + CreditsPack { + credits_pack: ArtcraftCreditsPackSlug, + }, + + /// Cancel the user's subscription. + /// Stripe shows a confirmation and then cancels the subscription. + CustomerPortalCancelPlan, + + /// Manage the user's subscription. + /// Stripe shows complete controls for updating the plan, payment method, etc. + CustomerPortalManagePlan, + + /// Switch the user's subscription plan. + /// Stripe pre-fills to the selected plan and cadence. + CustomerPortalSwitchPlan { + plan: ArtcraftSubscriptionSlug, + cadence: PlanBillingCadenceConfirmation, + }, + + /// Change the payment method attached to the customer / subscription. + CustomerPortalUpdatePaymentMethod, +} + +pub async fn open_storyteller_billing_window( + args: OpenStorytellerBillingWindowArgs<'_>, +) -> AnyhowResult<()> { + + if args.app.get_window(BILLING_WINDOW_NAME).is_some() { + return Err(anyhow!("Billing window already open")); + } + + info!("Building Stripe Checkout session..."); + + let creds = args.storyteller_creds_manager.get_credentials_required()?; + + let checkout_url = match args.billing_window_case { + BillingWindowCase::Subscription { plan, cadence } => { + get_subscription_url( + args.app_env_configs, + &creds, + plan, + cadence, + ).await? + }, + BillingWindowCase::CreditsPack { credits_pack } => { + get_credits_pack_url( + args.app_env_configs, + &creds, + credits_pack, + ).await? + }, + BillingWindowCase::CustomerPortalCancelPlan => { + get_customer_portal_cancel_plan_url( + args.app_env_configs, + &creds, + ).await? + }, + BillingWindowCase::CustomerPortalManagePlan => { + get_customer_portal_manage_plan_url( + args.app_env_configs, + &creds, + ).await? + }, + BillingWindowCase::CustomerPortalSwitchPlan { plan, cadence } => { + get_customer_portal_switch_plan_url( + args.app_env_configs, + &creds, + plan, + cadence, + ).await? + }, + BillingWindowCase::CustomerPortalUpdatePaymentMethod => { + get_customer_portal_update_payment_method_url( + args.app_env_configs, + &creds, + ).await? + } + }; + + do_open_window(args.app, args.app_data_root, checkout_url).await?; + + Ok(()) +} + +async fn get_subscription_url( + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, + plan: ArtcraftSubscriptionSlug, + cadence: PlanBillingCadence, +) -> AnyhowResult { + + info!("Getting URL for subscription checkout..."); + + let request = StripeArtcraftCreateSubscriptionCheckoutRequest { + plan: Some(plan), + cadence: Some(cadence), + }; + + let result = create_subscription_checkout( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request, + ).await?; + + info!("Checkout session created..."); + + let url = Url::parse(&result.stripe_checkout_redirect_url)?; + Ok(url) +} + +async fn get_credits_pack_url( + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, + credits_pack: ArtcraftCreditsPackSlug, +) -> AnyhowResult { + + info!("Getting URL for credits pack checkout..."); + + let request = StripeArtcraftCreateCreditsPackCheckoutRequest { + credits_pack: Some(credits_pack), + }; + + let result = create_credits_pack_checkout( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request, + ).await?; + + info!("Credits pack checkout session created..."); + Ok(Url::parse(&result.stripe_checkout_redirect_url)?) +} + +async fn get_customer_portal_cancel_plan_url( + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, +) -> AnyhowResult { + + info!("Getting customer portal cancel plan session..."); + + let request = StripeArtcraftCustomerPortalCancelPlanRequest { + portal_config_id: None, + }; + + let result = customer_portal_cancel_plan( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request, + ).await?; + + info!("Customer portal cancel plan session created..."); + Ok(Url::parse(&result.stripe_portal_url)?) +} + +async fn get_customer_portal_manage_plan_url( + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, +) -> AnyhowResult { + + info!("Getting customer portal manage plan session..."); + + let request = StripeArtcraftCustomerPortalManagePlanRequest { + portal_config_id: None, + }; + + let result = customer_portal_manage_plan( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request, + ).await?; + + info!("Customer portal manage plan session created..."); + Ok(Url::parse(&result.stripe_portal_url)?) +} + +async fn get_customer_portal_switch_plan_url( + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, + plan: ArtcraftSubscriptionSlug, + cadence: PlanBillingCadenceConfirmation, +) -> AnyhowResult { + + info!("Getting customer portal switch plan session..."); + + let request = StripeArtcraftCustomerPortalSwitchPlanRequest { + portal_config_id: None, + plan: Some(plan), + cadence: Some(cadence), + }; + + let result = customer_portal_switch_plan( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request, + ).await?; + + info!("Customer portal switch plan session created..."); + Ok(Url::parse(&result.stripe_portal_url)?) +} + +async fn get_customer_portal_update_payment_method_url( + app_env_configs: &AppEnvConfigs, + storyteller_creds: &StorytellerCredentialSet, +) -> AnyhowResult { + + info!("Getting customer portal update payment method session..."); + + let request = StripeArtcraftCustomerPortalUpdatePaymentMethodRequest { + portal_config_id: None, + }; + + let result = customer_portal_update_payment_method( + &app_env_configs.storyteller_host, + Some(&storyteller_creds), + request, + ).await?; + + info!("Customer portal update payment method created..."); + Ok(Url::parse(&result.stripe_portal_url)?) +} + +async fn do_open_window( + app: &AppHandle, + app_data_root: &AppDataRoot, + url: Url, +) -> AnyhowResult<()> { + let url = WebviewUrl::External(url); + + info!("Opening checkout window..."); + + // TODO(bt,2025-09-01): We probably need to open this in a real browser to get autocomplete. + let _window = WebviewWindowBuilder::new(app, BILLING_WINDOW_NAME, url) + .always_on_top(false) + .title("ArtCraft Billing") + .center() + .resizable(true) + .visible(true) + .closable(true) + .min_inner_size(1000.0, 800.0) + .focused(true) + .devtools(true) + .build()?; + + //let _webview = window.get_webview(BILLING_WINDOW_NAME) + // .ok_or_else(|| anyhow!("no webview found"))?; + + let app_handle = app.clone(); + let app_data_root = app_data_root.clone(); + + let _ = tauri::async_runtime::spawn(async move { + storyteller_billing_window_thread(app_handle, app_data_root).await; + }); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/windows/storyteller_billing_window_thread.rs b/crates/desktop/artcraft/src/services/storyteller/windows/storyteller_billing_window_thread.rs new file mode 100644 index 0000000000..2f44f0b68f --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/windows/storyteller_billing_window_thread.rs @@ -0,0 +1,125 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent; +use crate::core::events::functional_events::refresh_account_state_event::RefreshAccountStateEvent; +use crate::core::events::functional_events::subscription_plan_changed_event::SubscriptionPlanChangedEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::window::get_webview_window_hostname::get_webview_window_hostname; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::state::midjourney_user_info::MidjourneyUserInfo; +use crate::services::midjourney::windows::extract_midjourney_webview_cookies::extract_midjourney_webview_cookies; +use crate::services::sora::events::sora_login_success_event::SoraLoginSuccessEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies; +use crate::services::storyteller::windows::open_storyteller_billing_window::BILLING_WINDOW_NAME; +use anyhow::anyhow; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::credentials::cookie_store_has_auth_cookies::cookie_store_has_auth_cookies; +use midjourney_client::recipes::get_user_info::{get_user_info, GetUserInfoRequest}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use tauri::{AppHandle, Manager, WebviewWindow}; + +pub async fn storyteller_billing_window_thread( + app: AppHandle, + app_data_root: AppDataRoot, +) { + loop { + let billing_webview_window = match app.get_webview_window(BILLING_WINDOW_NAME) { + Some(webview) => webview, + None => { + info!("Exit billing window thread."); + return; // NB: Only exit if we don't have the webview. + } + }; + + let result = check_billing_window( + &app, + &billing_webview_window, + ).await; + + match result { + Err(err) => { + error!("Error checking billing window: {:?}", err); + } + Ok(false) => {} // Continue iteration and try again... + Ok(true) => { + info!("Checkout complete; exiting"); + if let Err(err) = billing_webview_window.close() { + error!("Error closing billing window: {:?}", err); + } + + // TODO: We can distinguish between subscription and credits impacts if we send a parameter + // from the webview. For now, just refresh everything. + + // Refresh the credits view + CreditsBalanceChangedEvent{}.send_infallible(&app); + SubscriptionPlanChangedEvent{}.send_infallible(&app); + + // And in case there's a race condition (likely), do it again after a delay. + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + CreditsBalanceChangedEvent{}.send_infallible(&app); + SubscriptionPlanChangedEvent{}.send_infallible(&app); + + return; + } + } + + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + } +} + +/// Returns true if we can exit. +async fn check_billing_window( + app_handle: &AppHandle, + webview_window: &WebviewWindow, +) -> AnyhowResult { + + let url = webview_window.url()?; + + let hostname= url + .host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + + match hostname.as_str() { + "getartcraft.com" | + "storyteller.ai" => { + // Checkout done. Fall-through. + }, + "stripe.com" | "checkout.stripe.com" => { + return Ok(false) // Still in checkout flow. + } + _ => { + return Ok(false) // Unknown hostname... + } + } + + let path = url + .path() + .to_string(); + + // TODO: This is brittle. + + let success = path.contains("checkout_success"); + + info!("Checkout success: {}", success); + + // TODO: Send success events: + + // let event = RefreshAccountStateEvent { + // provider: Some(GenerationProvider::Midjourney), + // }; + // + // if let Err(err) = event.send(&app_handle) { + // error!("Failed to send RefreshAccountStateEvent: {:?}", err); // Fail open + // } + + Ok(true) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/commands/mod.rs b/crates/desktop/artcraft/src/services/worldlabs/commands/mod.rs new file mode 100644 index 0000000000..77c3621ea1 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod worldlabs_open_login_command; +pub mod worldlabs_get_credential_info_command; +pub mod worldlabs_clear_credentials_command; +pub mod worldlabs_receive_bearer_command; diff --git a/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_clear_credentials_command.rs b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_clear_credentials_command.rs new file mode 100644 index 0000000000..f829c45249 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_clear_credentials_command.rs @@ -0,0 +1,44 @@ +use crate::core::commands::response::shorthand::{Response, SimpleResponse}; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Deserialize; +use tauri::State; + +#[tauri::command] +pub async fn worldlabs_clear_credentials_command( + root: State<'_, AppDataRoot>, + creds_manager: State<'_, WorldlabsCredentialManager>, +) -> SimpleResponse { + info!("worldlabs_clear_credentials_command called"); + + clear_creds(&root, &creds_manager) + .map_err(|err| { + error!("Error clearing creds: {:?}", err); + "error clearing creds" + })?; + + Ok(().into()) +} + +fn clear_creds( + root: &AppDataRoot, + creds: &WorldlabsCredentialManager, +) -> AnyhowResult<()> { + + info!("Clearing credentials..."); + creds.clear_credentials()?; + + info!("Persisting to disk..."); + creds.persist_to_disk()?; + + let creds_path = root.credentials_dir().get_worldlabs_state_path(); + + if creds_path.exists() { + info!("Removing from disk..."); + std::fs::remove_file(creds_path)?; + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_get_credential_info_command.rs b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_get_credential_info_command.rs new file mode 100644 index 0000000000..5637024096 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_get_credential_info_command.rs @@ -0,0 +1,63 @@ +use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::Serialize; +use tauri::State; +use worldlabs_consumer_client::credentials::jwt_claims::JwtClaims; +use worldlabs_consumer_client::error::world_labs_error::WorldLabsError; + +#[derive(Serialize)] +pub struct WorldlabsGetCredentialInfoResponse { + pub maybe_email: Option, + pub can_clear_state: bool, +} + +impl SerializeMarker for WorldlabsGetCredentialInfoResponse {} + +#[tauri::command] +pub async fn worldlabs_get_credential_info_command( + creds_manager: State<'_, WorldlabsCredentialManager>, +) -> ResponseOrErrorMessage { + info!("worldlabs_get_credential_info_command called"); + + let info = get_info(&creds_manager) + .map_err(|err| { + error!("Error getting info: {:?}", err); + "error getting info" + })?; + + Ok(info.into()) +} + +fn get_info( + creds: &WorldlabsCredentialManager, +) -> AnyhowResult { + let mut can_clear_state = true; + + let maybe_cookies = creds.maybe_copy_cookie_store()?; + let maybe_bearer = creds.maybe_copy_bearer_token()?; + + if maybe_cookies.is_none() && maybe_bearer.is_none() { + can_clear_state = false; + } + + let mut maybe_email = None; + + if let Some(bearer) = maybe_bearer { + match bearer.parse_jwt_claims() { + Ok(claims) => { + maybe_email = claims.email; + } + Err(err) => { + error!("Failed to parse JWT bearer claims: {}", err); + } + } + } + + Ok(WorldlabsGetCredentialInfoResponse { + maybe_email, + can_clear_state, + }) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_open_login_command.rs b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_open_login_command.rs new file mode 100644 index 0000000000..e0a7b5b7bd --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_open_login_command.rs @@ -0,0 +1,45 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use crate::services::worldlabs::windows::worldlabs_login_window::worldlabs_login_window_open::worldlabs_login_window_open; +use errors::AnyhowResult; +use log::{error, info}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn worldlabs_open_login_command( + app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + worldlabs_bearer_bridge: State<'_, WorldlabsBearerBridge>, + worldlabs_creds_manager: State<'_, WorldlabsCredentialManager>, +) -> Result { + info!("worldlabs_open_login_command called"); + + do_open_login(&app, &app_data_root, &worldlabs_bearer_bridge, &worldlabs_creds_manager) + .await + .map_err(|err| { + error!("Error opening login: {:?}", err); + format!("Error opening login: {:?}", err) + })?; + + Ok("result".to_string()) +} + +async fn do_open_login( + app: &AppHandle, + app_data_root: &AppDataRoot, + worldlabs_bearer_bridge: &WorldlabsBearerBridge, + worldlabs_creds_manager: &WorldlabsCredentialManager, +) -> AnyhowResult<()> { + info!("Building login window..."); + + worldlabs_login_window_open( + app, + app_data_root, + worldlabs_bearer_bridge, + worldlabs_creds_manager + ).await?; + + info!("Done."); + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_receive_bearer_command.rs b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_receive_bearer_command.rs new file mode 100644 index 0000000000..0a9004df86 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/commands/worldlabs_receive_bearer_command.rs @@ -0,0 +1,59 @@ +use crate::core::commands::response::shorthand::{Response, SimpleResponse}; +use crate::core::commands::response::success_response_wrapper::SerializeMarker; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::worldlabs::state::worldlabs_bearer_bridge::{WorldlabsBearerBridge, WorldlabsBearerBridgeInner}; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use errors::AnyhowResult; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::State; +use tokens::tokens::media_files::MediaFileToken; + + +#[derive(Deserialize, Debug)] +pub struct WorldlabsReceiveBearerRequest { + /// REQUIRED. + pub bearer_token: String, + + /// REQUIRED. + pub refresh_token: String, +} + +#[derive(Serialize)] +pub struct WorldlabsReceiveBearerResponse { +} + +impl SerializeMarker for WorldlabsReceiveBearerResponse {} + + +#[tauri::command] +pub async fn worldlabs_receive_bearer_command( + root: State<'_, AppDataRoot>, + request: WorldlabsReceiveBearerRequest, + bearer_bridge: State<'_, WorldlabsBearerBridge>, +) -> SimpleResponse { + info!("worldlabs_receive_bearer_command called"); + + info!("Request: {:?}", request); + + set_bearer(request, &bearer_bridge) + .map_err(|err| { + error!("Error setting bearer: {:?}", err); + "error setting bearer" + })?; + + Ok(().into()) +} + +fn set_bearer( + request: WorldlabsReceiveBearerRequest, + bearer_bridge: &WorldlabsBearerBridge, +) -> AnyhowResult<()> { + + bearer_bridge.set(WorldlabsBearerBridgeInner { + bearer_token: request.bearer_token, + refresh_token: request.refresh_token, + })?; + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/mod.rs b/crates/desktop/artcraft/src/services/worldlabs/mod.rs new file mode 100644 index 0000000000..faed1e2dde --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/mod.rs @@ -0,0 +1,7 @@ +//! Deprecated direct WorldLabs integration. Retained for legacy handlers and task polling. +//! New desktop generations use ArtCraft; account management is no longer exposed in the frontend. + +pub mod commands; +pub mod state; +pub mod threads; +pub mod windows; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/worldlabs/state/mod.rs b/crates/desktop/artcraft/src/services/worldlabs/state/mod.rs new file mode 100644 index 0000000000..ef22d19e2b --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/state/mod.rs @@ -0,0 +1,6 @@ +pub mod worldlabs_credential_manager; +pub mod worldlabs_serializable_state; +pub mod worldlabs_bearer_bridge; + +// Private +mod worldlabs_credential_holder; diff --git a/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_bearer_bridge.rs b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_bearer_bridge.rs new file mode 100644 index 0000000000..f177a867ff --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_bearer_bridge.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, RwLock}; +use log::error; +use crate::core::artcraft_error::ArtcraftError; + +#[derive(Clone)] +pub struct WorldlabsBearerBridge { + state: Arc>> +} + +#[derive(Clone, Debug)] +pub struct WorldlabsBearerBridgeInner { + pub bearer_token: String, + pub refresh_token: String, +} + +impl WorldlabsBearerBridge { + pub fn empty() -> Self { + Self { + state: Arc::new(RwLock::new(None)), + } + } + + pub fn clear(&self) -> Result<(), ArtcraftError> { + match self.state.write() { + Ok(mut state) => { + *state = None; + Ok(()) + } + Err(err) => { + error!("Lock poisoned: {:?}", err); + Err(ArtcraftError::RwLockWriteError) + } + } + } + + pub fn get(&self) -> Result, ArtcraftError> { + match self.state.read() { + Ok(state) => { + Ok(state.clone()) + } + Err(err) => { + error!("Lock poisoned: {:?}", err); + Err(ArtcraftError::RwLockReadError) + } + } + } + + pub fn set(&self, new_state: WorldlabsBearerBridgeInner) -> Result<(), ArtcraftError> { + match self.state.write() { + Ok(mut state) => { + *state = Some(new_state); + Ok(()) + } + Err(err) => { + error!("Lock poisoned: {:?}", err); + Err(ArtcraftError::RwLockReadError) + } + } + } +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_credential_holder.rs b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_credential_holder.rs new file mode 100644 index 0000000000..cd2ea123d4 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_credential_holder.rs @@ -0,0 +1,35 @@ +use cookie_store::cookie_store::CookieStore; +use worldlabs_consumer_client::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use worldlabs_consumer_client::credentials::world_labs_cookies::WorldLabsCookies; +use worldlabs_consumer_client::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; + +#[derive(Clone)] +pub struct WorldlabsCredentialHolder { + /// Directly off the Tauri browser session. + /// Read once, write to disk + /// The Worldlabs client consumes a string-only form (rather than this cookie jar) + pub browser_cookies: Option, + + /// Copy of cookies, specifically for the client library. + /// NOT PERSISTED TO DISK. + pub world_labs_cookies: Option, + + /// Bearer token + /// This needs to be persisted to disk. + pub world_labs_bearer_token: Option, + + /// Refresh token + /// This needs to be persisted to disk. + pub world_labs_refresh_token: Option, +} + +impl WorldlabsCredentialHolder { + pub fn empty() -> Self { + Self { + browser_cookies: None, + world_labs_cookies: None, + world_labs_bearer_token: None, + world_labs_refresh_token: None, + } + } +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_credential_manager.rs b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_credential_manager.rs new file mode 100644 index 0000000000..f0b7a87e8c --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_credential_manager.rs @@ -0,0 +1,232 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::services::worldlabs::state::worldlabs_credential_holder::WorldlabsCredentialHolder; +use crate::services::worldlabs::state::worldlabs_serializable_state::{WorldlabsSerializableState, SERIALIZABLE_WORLDLABS_STATE_VERSION}; +use cookie_store::cookie_store::CookieStore; +use errors::AnyhowResult; +use log::{info, warn}; +use std::fs::read_to_string; +use std::sync::{Arc, RwLock}; +use worldlabs_consumer_client::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use worldlabs_consumer_client::credentials::world_labs_cookies::WorldLabsCookies; +use worldlabs_consumer_client::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; + +#[derive(Clone)] +pub struct WorldlabsCredentialManager { + // TODO: Put last write/last read timestamps on these. + credential_data: Arc>, + + //user_info: Arc>>, + app_data_root: AppDataRoot, +} + +impl WorldlabsCredentialManager { + pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self { + Self { + credential_data: Arc::new(RwLock::new(WorldlabsCredentialHolder::empty())), + app_data_root: app_data_root.clone(), + } + } + + pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self { + let mut credential_data; + + let result = WorldlabsSerializableState::read_from_disk(app_data_root); + match result { + Err(err) => { + warn!("Failed to read worldlabs state from disk: {:?}", err); + credential_data = Arc::new(RwLock::new(WorldlabsCredentialHolder::empty())); + }, + Ok(None) => { + credential_data = Arc::new(RwLock::new(WorldlabsCredentialHolder::empty())); + } + Ok(Some(state)) => { + let maybe_browser_cookies = state.user_cookies + .as_ref() + .map(|cookies| cookies.to_cookie_store()); + + let maybe_cookies = maybe_browser_cookies + .as_ref() + .map(|cookies| cookies.to_cookie_string()) + .map(|cookies| WorldLabsCookies::new(cookies)); + + let maybe_bearer = state.bearer_token + .map(|bearer| WorldLabsBearerToken::new(bearer)); + + let maybe_refresh = state.refresh_token + .map(|bearer| WorldLabsRefreshToken::new(bearer)); + + credential_data = Arc::new(RwLock::new(WorldlabsCredentialHolder { + browser_cookies: maybe_browser_cookies, + world_labs_cookies: maybe_cookies, + world_labs_bearer_token: maybe_bearer, + world_labs_refresh_token: maybe_refresh, + })); + } + }; + + Self { + credential_data, + app_data_root: app_data_root.clone(), + } + } + + pub fn maybe_copy_cookie_store(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(holder) => Ok(holder.browser_cookies.clone()), + } + } + + pub fn maybe_copy_cookie_header_string(&self) -> anyhow::Result> { + let maybe_cookies = self.maybe_copy_cookie_store()?; + let maybe_cookies = maybe_cookies.map(|cookies| { + cookies.to_cookie_string() + }); + Ok(maybe_cookies) + } + + pub fn maybe_copy_typed_cookies(&self) -> anyhow::Result> { + let maybe_cookies = self.maybe_copy_cookie_header_string()?; + let maybe_cookies = maybe_cookies.map(|cookies| { + WorldLabsCookies::new(cookies) + }); + Ok(maybe_cookies) + } + + pub fn maybe_copy_bearer_token(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(holder) => Ok(holder.world_labs_bearer_token.clone()), + } + } + + pub fn maybe_copy_refresh_token(&self) -> anyhow::Result> { + match self.credential_data.read() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(holder) => Ok(holder.world_labs_refresh_token.clone()), + } + } + + pub fn replace_cookie_store(&self, store: CookieStore) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.browser_cookies = Some(store); + Ok(()) + } + } + } + + pub fn replace_bearer_token(&self, token: WorldLabsBearerToken) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.world_labs_bearer_token = Some(token); + Ok(()) + } + } + } + + pub fn replace_refresh_token(&self, token: WorldLabsRefreshToken) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.world_labs_refresh_token = Some(token); + Ok(()) + } + } + } + + pub fn replace_bearer_and_refresh_token(&self, bearer_token: WorldLabsBearerToken, refresh_token: WorldLabsRefreshToken) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut holder) => { + holder.world_labs_bearer_token = Some(bearer_token); + holder.world_labs_refresh_token = Some(refresh_token); + Ok(()) + } + } + } + + // NB: This is just a heuristic. We'll add better checks later. + pub fn do_task_polling(&self) -> anyhow::Result { + self.session_appears_active() + } + + // NB: This is just a heuristic. We'll add better checks later. + pub fn session_appears_active(&self) -> anyhow::Result { + let holder = match self.credential_data.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => store.clone(), + }; + + if holder.world_labs_bearer_token.is_some() { + return Ok(true); + } + + if holder.browser_cookies.is_none() { + return Ok(false); + } + + Ok(false) + } + + pub fn clear_credentials(&self) -> anyhow::Result<()> { + match self.credential_data.write() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => *store = WorldlabsCredentialHolder::empty(), + } + Ok(()) + } + + pub fn persist_to_disk(&self) -> anyhow::Result<()> { + let creds = match self.credential_data.read() { + Err(err) => return Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => store.clone(), + }; + + let state = WorldlabsSerializableState { + version: SERIALIZABLE_WORLDLABS_STATE_VERSION, + user_cookies: creds.browser_cookies + .as_ref() + .map(|cookies| cookies.to_serializable()), + user_id: None, // TODO + user_email: None, // TODO + bearer_token: creds.world_labs_bearer_token + .as_ref() + .map(|token| token.to_raw_string()), + refresh_token: creds.world_labs_refresh_token + .as_ref() + .map(|token| token.to_raw_string()), + }; + + let path = self.app_data_root.credentials_dir().get_worldlabs_state_path(); + let serialized = serde_json::to_string(&state)?; + + std::fs::write(path, serialized)?; + + // TODO: This is just for building the client and testing. + { + if let Some(cookies) = creds.browser_cookies.as_ref() { + let cookies_header = cookies.to_cookie_string(); + let path = self.app_data_root.credentials_dir().get_worldlabs_cookies_path(); + std::fs::write(path, cookies_header)?; + } + + // TODO: This is just for building the client and testing. + if let Some(bearer) = creds.world_labs_bearer_token.as_ref() { + let value = bearer.to_raw_string(); + let path = self.app_data_root.credentials_dir().get_worldlabs_bearer_path(); + std::fs::write(path, value)?; + } + + if let Some(refresh) = creds.world_labs_refresh_token.as_ref() { + let value = refresh.to_raw_string(); + let path = self.app_data_root.credentials_dir().get_worldlabs_refresh_path(); + std::fs::write(path, value)?; + } + } + + Ok(()) + } +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_serializable_state.rs b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_serializable_state.rs new file mode 100644 index 0000000000..cbf57429ba --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/state/worldlabs_serializable_state.rs @@ -0,0 +1,49 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use cookie_store::serialized_cookie_store::SerializableCookieStore; +use serde_derive::{Deserialize, Serialize}; +use std::fs::read_to_string; + +/// History +/// v1 - initial +pub (super) const SERIALIZABLE_WORLDLABS_STATE_VERSION: u32 = 1; + +/// We only need to serialize Grok cookies. +/// Most of the other state (verification_token, etc.) can be pulled at runtime. +/// We will cache the `user_id` and `user_email`. +#[derive(Serialize, Deserialize)] +pub (super) struct WorldlabsSerializableState { + pub (super) version: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_cookies: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) bearer_token: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) refresh_token: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub (super) user_email: Option, +} + +impl WorldlabsSerializableState { + pub fn read_from_disk(root: &AppDataRoot) -> anyhow::Result> { + let state_path = root.credentials_dir().get_worldlabs_state_path(); + + if !state_path.exists() { + return Ok(None); + } + + let contents = read_to_string(&state_path)? + .trim() + .to_string(); + + let state: WorldlabsSerializableState = serde_json::from_str(&contents)?; + + Ok(Some(state)) + } +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/threads/mod.rs b/crates/desktop/artcraft/src/services/worldlabs/threads/mod.rs new file mode 100644 index 0000000000..e03acdf874 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/threads/mod.rs @@ -0,0 +1 @@ +pub mod worldlabs_marble_task_polling; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/worldlabs/threads/worldlabs_marble_task_polling.rs b/crates/desktop/artcraft/src/services/worldlabs/threads/worldlabs_marble_task_polling.rs new file mode 100644 index 0000000000..74b1d61fb9 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/threads/worldlabs_marble_task_polling.rs @@ -0,0 +1,381 @@ +use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason}; +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::core::utils::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::grok::util::get_or_upgrade_grok_full_credentials::get_or_update_grok_full_credentials; +use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager; +use crate::services::midjourney::utils::download_midjourney_image::download_midjourney_image; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest; +use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use errors::AnyhowResult; +use futures::poll; +use grok_consumer_client::credentials::grok_full_credentials::GrokFullCredentials; +use grok_consumer_client::error::grok_error::GrokError; +use grok_consumer_client::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs}; +use grok_consumer_client::requests::download_video_file::grok_download_video::GrokDownloadVideo; +use grok_consumer_client::requests::media_posts::list_media_posts::grok_list_media_posts::{GrokMediaPostList, GrokMediaPostListRequest, VideoData}; +use uuid_utils::uuid::generate_random_uuid; +use log::{error, info}; +use midjourney_client::client::midjourney_hostname::MidjourneyHostname; +use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId; +use midjourney_client::endpoints::imagine::{imagine, ImagineItem, ImagineRequest, MidjourneyJobType}; +use midjourney_client::utils::get_image_url::get_image_url; +use midjourney_client::utils::image_downloader_client::ImageDownloaderClient; +use once_cell::sync::Lazy; +use sqlite_tasks::queries::list_tasks_by_provider_and_status::{list_tasks_by_provider_and_status, ListTasksByProviderAndStatusArgs, TaskList}; +use sqlite_tasks::queries::task::Task; +use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs}; +use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::media_files::get_media_file::get_media_file; +use artcraft_client::endpoints::media_files::legacy_upload_media_file_from_file::{legacy_upload_media_file_from_file, LegacyUploadMediaFileFromFileArgs}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs}; +use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs}; +use artcraft_client::endpoints::prompts::create_prompt::create_prompt; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::recipes::upload_media_file_from_file::upload_media_file_from_file; +use tauri::AppHandle; +use tokens::tokens::batch_generations::BatchGenerationToken; +use url::Url; +use url_utils::download_extension::extract_download_extension_from_url::extract_download_extension_from_url_str; +use worldlabs_consumer_client::api::api_types::world_id::WorldObjectId; +use worldlabs_consumer_client::api::requests::worlds::poll_world_status::{poll_world_status, PollWorldStatusArgs}; +use worldlabs_consumer_client::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use worldlabs_consumer_client::credentials::world_labs_cookies::WorldLabsCookies; +use worldlabs_consumer_client::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; + +#[deprecated(note = "Retained to poll legacy direct-provider tasks; new generations use ArtCraft.")] +pub async fn worldlabs_marble_task_polling( + app_handle: AppHandle, + app_env_configs: AppEnvConfigs, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + creds: WorldlabsCredentialManager, + storyteller_creds_manager: StorytellerCredentialManager, +) -> ! { + loop { + let res = polling_loop( + &app_handle, + &app_env_configs, + &app_data_root, + &task_database, + &creds, + &storyteller_creds_manager, + ).await; + if let Err(err) = res { + error!("An error occurred: {:?}", err); + } + // NB: Only sleep if an error occurs. + tokio::time::sleep(Duration::from_millis(30_000)).await; + } +} + +async fn polling_loop( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + worldlabs_creds: &WorldlabsCredentialManager, + storyteller_creds_manager: &StorytellerCredentialManager, +) -> AnyhowResult<()> { + loop { + if !worldlabs_creds.do_task_polling()? { + tokio::time::sleep(Duration::from_millis(10_000)).await; + continue; + } + + // TODO: Graceful wait, fix this long function body + let storyteller_creds = match storyteller_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + error!("No Storyteller credentials found. Cannot proceed with WorldLabs polling."); + tokio::time::sleep(Duration::from_millis(5_000)).await; + continue; + } + }; + + let world_labs_cookies = match worldlabs_creds.maybe_copy_typed_cookies()? { + Some(cookies) => cookies, + None => { + info!("No full WorldLabs cookies"); + tokio::time::sleep(Duration::from_millis(30_000)).await; + continue; + } + }; + + let world_labs_bearer = match worldlabs_creds.maybe_copy_bearer_token()? { + Some(bearer) => bearer, + None => { + info!("No full WorldLabs bearer"); + tokio::time::sleep(Duration::from_millis(30_000)).await; + continue; + } + }; + + let world_labs_refresh = match worldlabs_creds.maybe_copy_refresh_token()? { + Some(bearer) => bearer, + None => { + info!("No full WorldLabs refresh"); + tokio::time::sleep(Duration::from_millis(30_000)).await; + continue; + } + }; + + let local_tasks = list_tasks_by_provider_and_status(ListTasksByProviderAndStatusArgs { + db: task_database.get_connection(), + provider: GenerationProvider::WorldLabs, + task_statuses: &TASK_DATABASE_PENDING_STATUSES, + }).await?; + + poll_grok_tasks( + app_handle, + app_env_configs, + app_data_root, + task_database, + &world_labs_cookies, + &world_labs_bearer, + &world_labs_refresh, + &storyteller_creds, + local_tasks, + ).await?; + + tokio::time::sleep(Duration::from_millis(2_000)).await; + } +} + +async fn poll_grok_tasks( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + world_labs_cookies: &WorldLabsCookies, + world_labs_bearer: &WorldLabsBearerToken, + world_labs_refresh: &WorldLabsRefreshToken, + storyteller_creds: &StorytellerCredentialSet, + local_tasks: TaskList, +) -> AnyhowResult<()> { + let local_tasks = local_tasks.tasks; + + if local_tasks.is_empty() { + return Ok(()) + } + + info!("WorldLabs tasks waiting: {:?}", local_tasks.len()); + + // Map of WorldLabs World ID to Local Task. + let local_tasks_by_world_labs_world_id = local_tasks.iter() + .filter_map(|task| { + if let Some(provider_job_id) = &task.provider_job_id { + Some((provider_job_id.clone(), task.clone())) + } else { + None + } + }) + .collect::>(); + + for (world_id, local_task) in local_tasks_by_world_labs_world_id.iter() { + let world_id = WorldObjectId(world_id.to_string()); + + let poll_world_response = poll_world_status(PollWorldStatusArgs { + cookies: &world_labs_cookies, + bearer_token: &world_labs_bearer, + world_id: &world_id, + request_timeout: None, + }).await?; + + if !poll_world_response.is_complete { + tokio::time::sleep(Duration::from_millis(2_000)).await; + continue; + } + + let spz_url = match poll_world_response.spz_splat_url { + Some(url) => url, + None => { + error!("No spz splat URL despite being marked complete"); + continue; + } + }; + + upload_spz_splat( + &app_handle, + &app_env_configs, + app_data_root, + task_database, + &storyteller_creds, + &local_task, + &spz_url, + ).await?; + } + + tokio::time::sleep(Duration::from_millis(5_000)).await; + + Ok(()) +} + +async fn upload_spz_splat( + app_handle: &AppHandle, + app_env_configs: &AppEnvConfigs, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + storyteller_creds: &StorytellerCredentialSet, + local_task: &Task, + spz_url: &str, +) -> AnyhowResult<()> { + + let mut maybe_primary_media_file_token = None; + + info!("Downloading generated spz splat ..."); + + let spz_download_filename = download_spz(spz_url, app_data_root).await?; + + let mut wait_delay = 0; + + loop { + info!("Uploading to backend..."); + + // TODO: media_files.origin_category + // TODO: media_files.maybe_prompt_token + // TODO: media_files.maybe_generation_provider + // TODO: media_files.maybe_origin_model_type + // TODO: media_files.maybe_origin_model_token (sref?) + // TODO: media_files.maybe_batch_token + // TODO: media_files.is_user_upload + + // TODO: batch_generations.token + // TODO: batch_generations.entity_type + // TODO: batch_generations.entity_token + + let result = legacy_upload_media_file_from_file(LegacyUploadMediaFileFromFileArgs { + api_host: &app_env_configs.storyteller_host, + maybe_creds: Some(&storyteller_creds), + path: &spz_download_filename, + maybe_generation_provider: Some(GenerationProvider::WorldLabs), + }).await; + + match result { + Ok(result) => { + info!("Successfully uploaded to backend: {:?}", result.media_file_token); + if maybe_primary_media_file_token.is_none() { + maybe_primary_media_file_token = Some(result.media_file_token); + } + break; + }, + Err(StorytellerError::Api(ApiError::TooManyRequests(_))) => { + error!("Too many requests, retrying upload after delay..."); + // If we hit a rate limit, we can retry after a short delay. + wait_delay += 10; + if wait_delay > 60 { + wait_delay = 60; + } + tokio::time::sleep(Duration::from_secs(wait_delay)).await; + continue; // Retry the upload. + } + Err(err) => { + error!("Failed to upload to backend: {:?}", err); + return Err(err.into()) + }, + } + } // End loop + + let mut maybe_cdn_url = None; + let mut maybe_thumbnail_url_template = None; + + if let Some(media_file_token) = maybe_primary_media_file_token.as_ref() { + info!("Looking up file to grab CDN and thumbnail URLs: {:?} ...", media_file_token); + + let lookup_result = get_media_file( + &app_env_configs.storyteller_host, + media_file_token, + ).await; + match lookup_result { + Ok(response) => { + maybe_cdn_url = Some(response.media_file.media_links.cdn_url.to_string()); + maybe_thumbnail_url_template = media_links_to_thumbnail_template(&response.media_file.media_links) + .map(|s| s.to_string()); + } + Err(err) => { + error!("Failed to look up media file after upload: {:?} (failing open)", err); + } + } + } + + let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs { + db: task_database.get_connection(), + task_id: &local_task.id, + maybe_batch_token: None, + maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(), + maybe_primary_media_file_class: Some(TaskMediaFileClass::Dimensional), + maybe_primary_media_file_thumbnail_url_template: maybe_thumbnail_url_template.as_deref(), + maybe_primary_media_file_cdn_url: maybe_cdn_url.as_deref(), + }).await?; + + if !updated { + return Ok(()); // If anything breaks with queries, don't spam events. + } + + let event = GenerationCompleteEvent { + //media_file_token: result.media_file_token, + action: Some(GenerationAction::GenerateGaussian), + service: GenerationServiceProvider::WorldLabs, + model: None, + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open + } + + //let result = maybe_handle_text_to_image_complete_event( + // app_handle, + // app_env_configs, + // Some(storyteller_creds), + // local_task, + // &batch_token, + //).await; + + //if let Err(err) = result { + // error!("Failed to send text-to-image complete event: {:?}", err); + //} + + Ok(()) +} + +async fn download_spz( + spz_url: &str, + app_data_root: &AppDataRoot, +) -> AnyhowResult { + info!("Downloading splat from URL: {}", spz_url); + + let response = reqwest::get(spz_url).await?; + let image_bytes = response.bytes().await?; + + let uuid = generate_random_uuid(); + let extension_without_period = "ceramic.spz"; + + let tempdir = app_data_root.temp_dir().path(); + let download_filename = format!("{}.{}", uuid, extension_without_period); + let download_path = tempdir.join(download_filename); + + info!("Writing to path: {:?}", download_path); + + let mut file = File::create(&download_path)?; + file.write_all(&image_bytes)?; + + Ok(download_path) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/windows/mod.rs b/crates/desktop/artcraft/src/services/worldlabs/windows/mod.rs new file mode 100644 index 0000000000..b4a0713003 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/windows/mod.rs @@ -0,0 +1 @@ +pub mod worldlabs_login_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/mod.rs b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/mod.rs new file mode 100644 index 0000000000..b1a1c8bbc7 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/mod.rs @@ -0,0 +1,4 @@ +pub mod worldlabs_login_window_open; +pub (super) mod worldlabs_login_window_thread; +pub (super) mod worldlabs_login_webview_extract_cookies; +pub (super) mod worldlabs_javascript; diff --git a/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_javascript.rs b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_javascript.rs new file mode 100644 index 0000000000..097520e099 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_javascript.rs @@ -0,0 +1,91 @@ + + + +pub (super) const WORLDLABS_JAVASCRIPT_EXPORT_BEARER_TOKENS : &str = r#" + (async () => { + if (window.tokensExported) { + return; + } + + const request = indexedDB.open("firebaseLocalStorageDb"); + + request.onerror = (event) => {}; + + request.onsuccess = (event) => { + const db = event.target.result; + + const tx = db.transaction("firebaseLocalStorage", "readonly"); + const store = tx.objectStore("firebaseLocalStorage"); + + const keysRequest = store.getAllKeys(); + + keysRequest.onerror = (event) => {}; + + keysRequest.onsuccess = () => { + const keys = keysRequest.result; // array of keys + if (keys.length < 1) { + return; + } + + const key = keys[0]; + const getKeyRequest = store.getKey(key); + + getKeyRequest.onsuccess = () => {}; + + store.openCursor().onsuccess = async (event) => { + const cursor = event.target.result; + + if (cursor) { + let tokens = cursor.value?.value?.stsTokenManager; + + if (tokens?.accessToken && tokens?.refreshToken) { + // Send to Tauri + let result = await window.__TAURI__.core.invoke("worldlabs_receive_bearer_command", { + request: { + bearer_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + } + }); + window.tokensExported = true; + } + cursor.continue(); + } + }; // end cursor + + }; // end keyRequest + }; // end DB + })(); + "#; + + +/// Take the Bearer tokens and send them to Tauri +pub (super) const WORLDLABS_JAVASCRIPT_SEND_BEARER_TO_TAURI : &str = r#" + (async () => { + if (!(window.tokens?.accessToken && window.tokens?.refreshToken)) { + return; + } + + let result = await window.__TAURI__.core.invoke("worldlabs_receive_bearer_command", { + request: { + bearer_token: window.tokens.accessToken, + refresh_token: window.tokens.refreshToken, + } + }); + + error.log('>>> result', result); + + })(); + "#; + + +/// See if the pricing button exists. +/// This is only for the logged-in state. +/// We can use redirection to detect completion. +pub (super) const WORLDLABS_JAVASCRIPT_SEE_IF_PRICING_EXISTS : &str = r#" + (() => { + let pricing = document.querySelectorAll("a[href='/pricing']"); + if (pricing.length > 0) { + window.location.href = "https://www.worldlabs.ai/about"; + } + })(); + "#; diff --git a/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_webview_extract_cookies.rs b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_webview_extract_cookies.rs new file mode 100644 index 0000000000..cf2e62f475 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_webview_extract_cookies.rs @@ -0,0 +1,39 @@ +use cookie_store::cookie_store::CookieStore; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use tauri::webview::Cookie; +use tauri::WebviewWindow; + +static ROOT_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse("https://worldlabs.ai").expect("URL should parse") +}); + +pub fn worldlabs_login_webview_extract_cookies(webview: &WebviewWindow) -> AnyhowResult { + let mut cookie_store = CookieStore::empty(); + let cookies = get_all_worldlabs_cookies(webview)?; + for cookie in cookies.iter() { + cookie_store.add_cookie_name_and_value( + cookie.name().to_string(), + cookie.value().to_string(), + ); + } + Ok(cookie_store) +} + +fn get_all_worldlabs_cookies(webview: &WebviewWindow) -> AnyhowResult> { + //let www_cookies = webview.cookies_for_url(WWW_COOKIE_URL.clone())?; + let root_cookies = webview.cookies_for_url(ROOT_COOKIE_URL.clone())?; + + let mut all_cookies = root_cookies; + //let mut cookie_names = HashSet::new(); + + //for cookie in root_cookies.iter() { + // if !cookie_names.contains(cookie.name()) { + // cookie_names.insert(cookie.name().to_string()); + // all_cookies.push(cookie.clone()); + // } + //} + + Ok(all_cookies) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_window_open.rs b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_window_open.rs new file mode 100644 index 0000000000..6fb5a3e208 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_window_open.rs @@ -0,0 +1,108 @@ +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::clear_all_webview_cookies::clear_all_webview_cookies; +use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use crate::services::worldlabs::windows::worldlabs_login_window::worldlabs_login_window_thread::worldlabs_login_window_thread; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::info; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::time::Duration; +use tauri::webview::NewWindowResponse; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Name of the window +pub (super) const WORLDLABS_LOGIN_WINDOW_NAME: &str = "worldlabs_login_window"; + +pub (super) static START_URL: Lazy = Lazy::new(|| { + Url::parse("https://google.com").expect("URL should parse") +}); + +pub (super) static WORLDLABS_HOMEPAGE_URL: Lazy = Lazy::new(|| { + Url::parse("https://marble.worldlabs.ai/").expect("URL should parse") +}); + +pub (super) static WORLDLABS_LOGIN_URL: Lazy = Lazy::new(|| { + Url::parse("https://marble.worldlabs.ai/").expect("URL should parse") +}); + +pub async fn worldlabs_login_window_open( + app: &AppHandle, + app_data_root: &AppDataRoot, + worldlabs_bearer_bridge: &WorldlabsBearerBridge, + worldlabs_creds_manager: &WorldlabsCredentialManager, +) -> AnyhowResult<()> { + + if app.get_window(WORLDLABS_LOGIN_WINDOW_NAME).is_some() { + return Err(anyhow!("Login window already open")); + } + + info!("Clearing bearer bridge..."); + + worldlabs_bearer_bridge.clear()?; + + let url = WebviewUrl::External(START_URL.clone()); + + let window = WebviewWindowBuilder::new(app, WORLDLABS_LOGIN_WINDOW_NAME, url) + //.user_agent(openai_sora_client::credentials::USER_AGENT) + .on_new_window(move |url, features| { + // WorldLabs needs popups as they (1) don't have a distinct login page + // (it's embedded) and (2) they don't redirect with OAuth, but rather + // open a popup to Google. It still works, but it's annoying. + NewWindowResponse::Allow + }) + .always_on_top(false) + .title("Login to WorldLabs") + .center() + .resizable(true) + .visible(true) + .closable(true) + .min_inner_size(800.0, 800.0) + .focused(true) + .devtools(true) + .build()?; + + let webview = window.get_webview(WORLDLABS_LOGIN_WINDOW_NAME) + .ok_or_else(|| anyhow!("no webview found"))?; + + clear_all_webview_cookies(&webview)?; + + webview.navigate(WORLDLABS_HOMEPAGE_URL.clone())?; + + tokio::time::sleep(Duration::from_millis(200)).await; + + info!("Running script..."); + + for _ in 0 .. 10 { + tokio::time::sleep(Duration::from_millis(100)).await; + + // Close the "welcome" modal + webview.eval(r#"document.querySelectorAll("div:has(h2) ~ button[data-slot=dialog-close]").forEach((el) => el.click())"#)?; + } + + for _ in 0 .. 10 { + tokio::time::sleep(Duration::from_millis(100)).await; + + // Open the "login" modal + webview.eval(r#"document.querySelectorAll("button:has(span[data-slot=avatar])").forEach((el) => el.click())"#)?; + } + + info!("Script done..."); + + let app_handle = app.clone(); + let app_data_root = app_data_root.clone(); + let worldlabs_creds_manager = worldlabs_creds_manager.clone(); + let worldlabs_bearer_bridge = worldlabs_bearer_bridge.clone(); + + let _ = tauri::async_runtime::spawn(async move { + worldlabs_login_window_thread( + app_handle, + app_data_root, + worldlabs_bearer_bridge, + worldlabs_creds_manager + ).await; + }); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_window_thread.rs b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_window_thread.rs new file mode 100644 index 0000000000..d8d0640d77 --- /dev/null +++ b/crates/desktop/artcraft/src/services/worldlabs/windows/worldlabs_login_window/worldlabs_login_window_thread.rs @@ -0,0 +1,129 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::events::functional_events::refresh_account_state_event::RefreshAccountStateEvent; +use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; +use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::events::sendable_event_trait::SendableEvent; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::utils::window::get_webview_window_hostname::get_webview_window_hostname; +use crate::core::utils::window::get_webview_window_url_path::get_webview_window_url_path; +use crate::services::grok::state::grok_credential_manager::GrokCredentialManager; +use crate::services::sora::events::sora_login_success_event::SoraLoginSuccessEvent; +use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; +use crate::services::sora::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies; +use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge; +use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; +use crate::services::worldlabs::windows::worldlabs_login_window::worldlabs_javascript::WORLDLABS_JAVASCRIPT_EXPORT_BEARER_TOKENS; +use crate::services::worldlabs::windows::worldlabs_login_window::worldlabs_login_webview_extract_cookies::worldlabs_login_webview_extract_cookies; +use crate::services::worldlabs::windows::worldlabs_login_window::worldlabs_login_window_open::WORLDLABS_LOGIN_WINDOW_NAME; +use anyhow::anyhow; +use cookie_store::cookie_store::CookieStore; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use log::{error, info}; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; +use tauri::{AppHandle, Manager, WebviewWindow}; +use worldlabs_consumer_client::credentials::world_labs_bearer_token::WorldLabsBearerToken; +use worldlabs_consumer_client::credentials::worldlabs_refresh_token::WorldLabsRefreshToken; + +pub async fn worldlabs_login_window_thread( + app: AppHandle, + app_data_root: AppDataRoot, + worldlabs_bearer_bridge: WorldlabsBearerBridge, + worldlabs_creds_manager: WorldlabsCredentialManager, +) { + let mut visited_login = false; + + loop { + let login_webview_window = match app.get_webview_window(WORLDLABS_LOGIN_WINDOW_NAME) { + Some(webview) => webview, + None => { + info!("Exit WorldLabs login thread."); + return; // NB: Only exit if we don't have the webview. + } + }; + + let result = check_login_window( + &app, + &login_webview_window, + &app_data_root, + &worldlabs_bearer_bridge, + &worldlabs_creds_manager, + &mut visited_login, + ).await; + + match result { + Err(err) => { + error!("Error checking Grok login window: {:?}", err); + } + Ok(false) => {} // Continue iteration and try again... + Ok(true) => { + info!("Successfully saved cookies from login window. Closing."); + if let Err(err) = login_webview_window.close() { + error!("Error closing login window: {:?}", err); + } + return; + } + } + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +/// NOTE: THERE IS THIS - https://marble.worldlabs.ai/sign-in?redirect=%2Fworlds + +/// Returns true if we can exit. +async fn check_login_window( + app_handle: &AppHandle, + webview_window: &WebviewWindow, + app_data_root: &AppDataRoot, + worldlabs_bearer_bridge: &WorldlabsBearerBridge, + worldlabs_creds_manager: &WorldlabsCredentialManager, + visited_login: &mut bool, +) -> AnyhowResult { + + // World labs has no distinct login page. Everything is in a single SPA. + + webview_window.eval(WORLDLABS_JAVASCRIPT_EXPORT_BEARER_TOKENS)?; + + let maybe_bearer = worldlabs_bearer_bridge.get()?; + + let bearer_info = match maybe_bearer { + Some(bearer) => bearer, + None => { + return Ok(false); // Not logged in. + } + }; + + let cookie_store = worldlabs_login_webview_extract_cookies(webview_window)?; + + info!("Current cookies (len {}): {:?}", cookie_store.len(), cookie_store.to_cookie_string()); + + worldlabs_creds_manager.replace_cookie_store(cookie_store)?; + + let bearer_token = WorldLabsBearerToken::new(bearer_info.bearer_token); + + worldlabs_creds_manager.replace_bearer_token(bearer_token)?; + + let refresh_token = WorldLabsRefreshToken::new(bearer_info.refresh_token); + + worldlabs_creds_manager.replace_refresh_token(refresh_token)?; + + let result = worldlabs_creds_manager.persist_to_disk(); + + if let Err(err) = result { + error!("Error persisting WorldLabs credentials to disk: {:?}", err); + return Ok(false); + } + + let event = RefreshAccountStateEvent { + provider: Some(GenerationProvider::WorldLabs), + }; + + if let Err(err) = event.send(&app_handle) { + error!("Failed to send RefreshAccountStateEvent: {:?}", err); // Fail open + } + + Ok(true) +} diff --git a/crates/desktop/artcraft/src/version.rs b/crates/desktop/artcraft/src/version.rs new file mode 100644 index 0000000000..0d6316ccd5 --- /dev/null +++ b/crates/desktop/artcraft/src/version.rs @@ -0,0 +1,4 @@ + +/// The version of the ArtCraft desktop app reported in the UI and via Analytics. +/// This should match the CI builds. +pub const ARTCRAFT_VERSION: &str = "0.37.0"; diff --git a/crates/desktop/artcraft/tauri-dev-hot-reload.conf.json b/crates/desktop/artcraft/tauri-dev-hot-reload.conf.json new file mode 100644 index 0000000000..cc9f901ccb --- /dev/null +++ b/crates/desktop/artcraft/tauri-dev-hot-reload.conf.json @@ -0,0 +1,95 @@ +{ + "build": { + "frontendDist": "../../../frontend/apps/artcraft/dist", + "beforeDevCommand": "echo 'no dev'", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "npx nx run artcraft:build" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + ], + "security": { + "assetProtocol": { + "enable": true, + "scope": ["**"] + }, + "csp": { + "default-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "ipc://localhost", + "tauri://localhost" + ], + "connect-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "asset:", + "blob:", + "data:", + "http://ipc.localhost", + "https://*.clarity.ms", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://asset.localhost", + "https://unpkg.com/detect-gpu@5.0.70/", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "ipc://localhost", + "tauri://localhost" + ], + "font-src": "*", + "img-src": [ + "", + "'self'", + "'unsafe-inline'", + "*", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "ipc://localhost", + "tauri://localhost" + ], + "script-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", + "asset:", + "blob:", + "data:", + "https://*.clarity.ms", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://*.unpkg.com", + "https://clarity.ms", + "https://unpkg.com", + "ipc://localhost", + "tauri:", + "tauri://localhost" + ], + "style-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://fonts.googleapis.com", + "ipc://localhost", + "tauri://localhost" + ] + } + } + } +} diff --git a/crates/desktop/artcraft/tauri-dev-static.conf.json b/crates/desktop/artcraft/tauri-dev-static.conf.json new file mode 100644 index 0000000000..a6cb5eb3e1 --- /dev/null +++ b/crates/desktop/artcraft/tauri-dev-static.conf.json @@ -0,0 +1,13 @@ +{ + "build": { + "frontendDist": "../../../frontend/apps/artcraft/dist", + "beforeDevCommand": "echo 'this is static development; no live reload'", + "beforeBuildCommand": "echo 'this is static development; no live reload'", + "devUrl": null + }, + "app": { + "withGlobalTauri": true, + "windows": [ + ] + } +} diff --git a/crates/desktop/artcraft/tauri-mac.conf.json b/crates/desktop/artcraft/tauri-mac.conf.json new file mode 100644 index 0000000000..3ba4a1728f --- /dev/null +++ b/crates/desktop/artcraft/tauri-mac.conf.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://schema.tauri.app/config/2.0.0-rc", + "productName": "ArtCraft", + "version": "0.37.0", + "identifier": "ai.artcraft.app", + "build": { + "frontendDist": "../../../frontend/apps/artcraft/dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "nx run artcraft:dev", + "beforeBuildCommand": "nx run artcraft:build" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + ], + "security": { + "assetProtocol": { + "enable": true, + "scope": ["**"] + }, + "csp": { + "default-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "ipc://localhost", + "tauri://localhost" + ], + "connect-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "asset:", + "blob:", + "data:", + "http://ipc.localhost", + "https://*.clarity.ms", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://asset.localhost", + "https://unpkg.com/detect-gpu@5.0.70/", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "ipc://localhost", + "tauri://localhost" + ], + "font-src": "*", + "img-src": [ + "", + "'self'", + "'unsafe-inline'", + "*", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "ipc://localhost", + "tauri://localhost" + ], + "script-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", + "asset:", + "blob:", + "data:", + "https://*.clarity.ms", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://*.unpkg.com", + "https://clarity.ms", + "https://unpkg.com", + "ipc://localhost", + "tauri:", + "tauri://localhost" + ], + "style-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://fonts.googleapis.com", + "ipc://localhost", + "tauri://localhost" + ] + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/crates/desktop/artcraft/tauri.conf.json b/crates/desktop/artcraft/tauri.conf.json new file mode 100644 index 0000000000..8014dc0fdf --- /dev/null +++ b/crates/desktop/artcraft/tauri.conf.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://schema.tauri.app/config/2.0.0-rc", + "productName": "ArtCraft", + "version": "0.37.0", + "identifier": "ai.artcraft.app", + "build": { + "frontendDist": "../../../frontend/apps/artcraft/dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "nx run artcraft:dev", + "beforeBuildCommand": "npx nx run artcraft:build" + }, + "app": { + "withGlobalTauri": true, + "windows": [], + "security": { + "assetProtocol": { + "enable": true, + "scope": ["**"] + }, + "csp": { + "default-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "ipc://localhost", + "tauri://localhost" + ], + "connect-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "asset:", + "blob:", + "data:", + "http://ipc.localhost", + "https://*.clarity.ms", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://asset.localhost", + "https://unpkg.com/detect-gpu@5.0.70/", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "ipc://localhost", + "tauri://localhost" + ], + "font-src": "*", + "img-src": [ + "", + "'self'", + "'unsafe-inline'", + "*", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "ipc://localhost", + "tauri://localhost" + ], + "script-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", + "asset:", + "blob:", + "data:", + "https://*.clarity.ms", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://*.unpkg.com", + "https://clarity.ms", + "https://unpkg.com", + "ipc://localhost", + "tauri:", + "tauri://localhost" + ], + "style-src": [ + "'self'", + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "data:", + "https://*.fakeyou.com", + "https://*.storyteller.ai", + "https://fonts.googleapis.com", + "ipc://localhost", + "tauri://localhost" + ] + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/crates/lib/build_metadata/Cargo.toml b/crates/lib/build_metadata/Cargo.toml new file mode 100644 index 0000000000..55c2cc1f39 --- /dev/null +++ b/crates/lib/build_metadata/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "build_metadata" +resolver = "2" # NB: Required by `build-info` +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "build_metadata" +path = "src/lib.rs" + +[dependencies] + +# Internal +# (None yet) + +# External +build-info = "0.0.40" + +[dev-dependencies] +# None yet + +[build-dependencies] +build-info-build = "0.0.40" + diff --git a/crates/lib/build_metadata/build.rs b/crates/lib/build_metadata/build.rs new file mode 100644 index 0000000000..3ed1729c88 --- /dev/null +++ b/crates/lib/build_metadata/build.rs @@ -0,0 +1,9 @@ + +fn main() { + // Tell Cargo that if the given file changes, to rerun this build script. + // TODO: Use this to test the GIT_SHA, etc. files that CI dumps into the working directory + // println!("cargo::rerun-if-changed=Cargo.toml"); + + // Make build info available in the main crate sources. + build_info_build::build_script(); +} diff --git a/crates/lib/build_metadata/src/lib.rs b/crates/lib/build_metadata/src/lib.rs new file mode 100644 index 0000000000..91d84b5aa8 --- /dev/null +++ b/crates/lib/build_metadata/src/lib.rs @@ -0,0 +1,43 @@ +use std::ops::Deref; +use build_info::chrono::{DateTime, Utc}; + +build_info::build_info!(fn version); + +pub fn build_timestamp() -> DateTime { + version().timestamp +} + +pub fn git_commit_id() -> Option<&'static str> { + version().version_control + .as_ref() + .map(|vc| vc.git()) + .flatten() + .map(|git| git.commit_id.deref()) +} + +pub fn git_commit_short_id() -> Option<&'static str> { + version().version_control + .as_ref() + .map(|vc| vc.git()) + .flatten() + .map(|git| git.commit_short_id.deref()) +} + +pub fn git_commit_timestamp() -> Option> { + version().version_control + .as_ref() + .map(|vc| vc.git()) + .flatten() + .map(|git| git.commit_timestamp) +} + +#[cfg(test)] +mod tests { + use crate::git_commit_id; + + #[test] + #[ignore] // Run manually for testing, not in CI + fn test_git_commit_id() { + assert_eq!(git_commit_id(), None); + } +} diff --git a/crates/lib/caching/memory_store/Cargo.toml b/crates/lib/caching/memory_store/Cargo.toml new file mode 100644 index 0000000000..129f11ebd9 --- /dev/null +++ b/crates/lib/caching/memory_store/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "memory_store" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "memory_store" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors = { workspace = true } + +# External +anyhow = { workspace = true } diff --git a/crates/lib/caching/memory_store/src/clone_cell.rs b/crates/lib/caching/memory_store/src/clone_cell.rs new file mode 100644 index 0000000000..aba8cdb954 --- /dev/null +++ b/crates/lib/caching/memory_store/src/clone_cell.rs @@ -0,0 +1,54 @@ +use anyhow::anyhow; +use errors::AnyhowResult; +use std::sync::{Arc, RwLock}; + +/// Store a threadsafe value that can be passed around and cloned. +/// The object is interior mutable so it can be changed without mut/&mut references. +/// This is useful for storing a value that may or may not be set between threads. +/// +/// Unlike `CloneSlot`, this always holds an object. +#[derive(Clone)] +pub struct CloneCell { + pub store: Arc>, +} + +impl CloneCell { + pub fn with_clone(object: &T) -> Self { + Self { + store: Arc::new(RwLock::new(object.clone())), + } + } + + pub fn with_owned(object: T) -> Self { + Self { + store: Arc::new(RwLock::new(object)), + } + } + + pub fn get_clone(&self) -> AnyhowResult { + match self.store.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => Ok(store.clone()), + } + } + + pub fn set_clone(&self, value: &T) -> AnyhowResult<()> { + match self.store.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => { + *store = value.clone(); + Ok(()) + } + } + } + + pub fn set_owned(&self, value: T) -> AnyhowResult<()> { + match self.store.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => { + *store = value; + Ok(()) + } + } + } +} diff --git a/crates/lib/caching/memory_store/src/clone_slot.rs b/crates/lib/caching/memory_store/src/clone_slot.rs new file mode 100644 index 0000000000..ef2a3d1001 --- /dev/null +++ b/crates/lib/caching/memory_store/src/clone_slot.rs @@ -0,0 +1,87 @@ +use anyhow::anyhow; +use errors::AnyhowResult; +use std::sync::{Arc, RwLock}; + +/// Store a threadsafe value that can be passed around and cloned. +/// The object is interior mutable so it can be changed without mut/&mut references. +/// This is useful for storing a value that may or may not be set between threads. +/// +/// Unlike `CloneCell`, this holds an optional object. +#[derive(Clone)] +pub struct CloneSlot { + pub store: Arc>>, +} + +impl CloneSlot { + pub fn empty() -> Self { + Self { + store: Arc::new(RwLock::new(None)), + } + } + + pub fn with_clone(object: &T) -> Self { + Self { + store: Arc::new(RwLock::new(Some(object.clone()))), + } + } + + pub fn get_clone(&self) -> AnyhowResult> { + match self.store.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => Ok(store.clone()), + } + } + + pub fn get_clone_required(&self) -> AnyhowResult { + match self.store.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => match &*store { + None => Err(anyhow!("Required stored item is not set")), + Some(inner) => Ok(inner.clone()), + } + } + } + + pub fn set_clone(&self, value: &T) -> AnyhowResult<()> { + match self.store.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => { + *store = Some(value.clone()); + Ok(()) + } + } + } + + pub fn clear(&self) -> AnyhowResult<()> { + match self.store.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => { + *store = None; + Ok(()) + } + } + } + + pub fn take(&self) -> AnyhowResult> { + match self.store.write() { + Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)), + Ok(mut store) => { + Ok(store.take()) + } + } + } + + pub fn is_some(&self) -> AnyhowResult { + match self.store.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => Ok(store.is_some()), + } + } + + pub fn is_none(&self) -> AnyhowResult { + match self.store.read() { + Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)), + Ok(store) => Ok(store.is_none()), + } + } +} diff --git a/crates/lib/caching/memory_store/src/lib.rs b/crates/lib/caching/memory_store/src/lib.rs new file mode 100644 index 0000000000..19f70cae0f --- /dev/null +++ b/crates/lib/caching/memory_store/src/lib.rs @@ -0,0 +1,2 @@ +pub mod clone_cell; +pub mod clone_slot; \ No newline at end of file diff --git a/crates/lib/cloudflare_errors/Cargo.toml b/crates/lib/cloudflare_errors/Cargo.toml new file mode 100644 index 0000000000..a969de9a05 --- /dev/null +++ b/crates/lib/cloudflare_errors/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cloudflare_errors" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "cloudflare_errors" +path = "src/lib.rs" + +[dependencies] + +# Internal +# (None) + +# External +# (None) + +[dev-dependencies] +# (None) diff --git a/crates/lib/cloudflare_errors/src/_metadata/cloudflare_301_response_body.txt b/crates/lib/cloudflare_errors/src/_metadata/cloudflare_301_response_body.txt new file mode 100644 index 0000000000..0bb46277be --- /dev/null +++ b/crates/lib/cloudflare_errors/src/_metadata/cloudflare_301_response_body.txt @@ -0,0 +1,7 @@ + +301 Moved Permanently + +

301 Moved Permanently

+
cloudflare
+ + diff --git a/crates/lib/cloudflare_errors/src/cloudflare_error.rs b/crates/lib/cloudflare_errors/src/cloudflare_error.rs new file mode 100644 index 0000000000..3a16367016 --- /dev/null +++ b/crates/lib/cloudflare_errors/src/cloudflare_error.rs @@ -0,0 +1,45 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum CloudflareError { + /// Cloudflare returned a 301 Moved Permanently response. + /// TODO: This needs to include "location" and "set-cookie" headers. + MovedPermanently301, + + /// Cloudflare wants to verify the request with a CAPTCHA challenge. + ChallengeInterstitial403, + + /// Cloudflare returned a 502 Bad Gateway response. + BadGateway502, + + /// Cloudflare could not form a connection to the backend server. + GatewayTimeout504, + + /// Cloudflare formed a TCP connection to the backend server, but no payload was delivered before timeout + TimeoutOccurred524, +} + +impl Error for CloudflareError {} + +impl Display for CloudflareError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::MovedPermanently301 => { + write!(f, "Cloudflare Moved Permanently (301)") + } + Self::ChallengeInterstitial403 => { + write!(f, "Cloudflare Challenge Interstitial (403); Cloudflare wants to verify the request with a CAPTCHA challenge.") + } + Self::BadGateway502 => { + write!(f, "Cloudflare Bad Gateway (502); This is likely a backend server issue.") + } + Self::GatewayTimeout504 => { + write!(f, "Cloudflare Gateway Timeout (504); This is likely a backend server issue.") + } + Self::TimeoutOccurred524 => { + write!(f, "Cloudflare Timeout (524); This is likely a backend server issue. Cloudflare connected, but did not receive a response from the server in time.") + } + } + } +} diff --git a/crates/lib/cloudflare_errors/src/filter_cloudflare_errors.rs b/crates/lib/cloudflare_errors/src/filter_cloudflare_errors.rs new file mode 100644 index 0000000000..a0c1e58485 --- /dev/null +++ b/crates/lib/cloudflare_errors/src/filter_cloudflare_errors.rs @@ -0,0 +1,48 @@ +use crate::cloudflare_error::CloudflareError; + +/// This only filters out Cloudflare errors. Any other errors will be returned as `Ok(())`. +pub fn filter_cloudflare_errors(status_code: u16, body: &str) -> Result<(), CloudflareError> { + if status_code >= 200 && status_code < 300 { + return Ok(()); + } + + match status_code { + 403 => { + if body.contains("challenge-platform") + || body.contains("challenge-error-text") + || body.contains("cType: 'managed'") + || body.contains("Just a moment...") { + return Err(CloudflareError::ChallengeInterstitial403); + } + } + _ => {}, // Fall-through + } + + // TODO: This is a really bad heuristic. + let is_cloudflare = body.contains("cloudflare") + || body.contains("Cloudflare"); + + // let is_cloudflare = body.contains("cloudflare.com") + // || body.contains("Cloudflare Ray ID"); + + if !is_cloudflare { + return Ok(()); + } + + match status_code { + 301 => return Err(CloudflareError::MovedPermanently301), // TODO: Include location header. + 502 => return Err(CloudflareError::BadGateway502), + 504 => return Err(CloudflareError::GatewayTimeout504), + 524 => return Err(CloudflareError::TimeoutOccurred524), + _ => {}, + } + + if body.contains("errorcode_504") + || body.contains("Gateway time-out") + || body.contains("Error code 504") + { + return Err(CloudflareError::GatewayTimeout504); + } + + Ok(()) +} diff --git a/crates/lib/cloudflare_errors/src/lib.rs b/crates/lib/cloudflare_errors/src/lib.rs new file mode 100644 index 0000000000..c2f39194d1 --- /dev/null +++ b/crates/lib/cloudflare_errors/src/lib.rs @@ -0,0 +1,22 @@ +//! cloudflare_errors +//! +//! Classify common cloudflare errors so clients can handle appropriately. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod cloudflare_error; +pub mod filter_cloudflare_errors; \ No newline at end of file diff --git a/crates/lib/cloudflare_mitigation/Cargo.toml b/crates/lib/cloudflare_mitigation/Cargo.toml new file mode 100644 index 0000000000..f608de2f97 --- /dev/null +++ b/crates/lib/cloudflare_mitigation/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cloudflare_mitigation" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "cloudflare_mitigation" +path = "src/lib.rs" + +[dependencies] + +# Internal +# (None) + +# External +once_cell.workspace = true +wreq.workspace = true + +[dev-dependencies] +# (None) diff --git a/crates/lib/cloudflare_mitigation/src/headers/firefox_websocket_http_1_1_headers.rs b/crates/lib/cloudflare_mitigation/src/headers/firefox_websocket_http_1_1_headers.rs new file mode 100644 index 0000000000..3502b9fe06 --- /dev/null +++ b/crates/lib/cloudflare_mitigation/src/headers/firefox_websocket_http_1_1_headers.rs @@ -0,0 +1,33 @@ +use once_cell::sync::Lazy; +use wreq::header::OrigHeaderMap; + +/// Header casing and order needed to bypass Cloudflare WebSocket HTTP/1.1 checks. +/// This is the set of headers sent by Firefox 143 on macOS. +static FIREFOX_WEBSOCKET_HTTP_1_1_HEADERS : Lazy = Lazy::new(|| { + let mut original_headers = OrigHeaderMap::new(); + + original_headers.insert("Host"); + original_headers.insert("User-Agent"); + original_headers.insert("Accept"); + original_headers.insert("Accept-Language"); + original_headers.insert("Accept-Encoding"); + original_headers.insert("Sec-WebSocket-Version"); + original_headers.insert("Origin"); + original_headers.insert("Sec-WebSocket-Extensions"); + original_headers.insert("Sec-WebSocket-Key"); + original_headers.insert("Connection"); + original_headers.insert("Cookie"); + original_headers.insert("Sec-Fetch-Dest"); + original_headers.insert("Sec-Fetch-Mode"); + original_headers.insert("Sec-Fetch-Site"); + original_headers.insert("Pragma"); + original_headers.insert("Cache-Control"); + original_headers.insert("Upgrade"); + + original_headers +}); + +/// Wreq expects an owned copy, so we are required to clone. +pub fn get_firefox_websocket_http_1_1_headers() -> OrigHeaderMap { + FIREFOX_WEBSOCKET_HTTP_1_1_HEADERS.clone() +} \ No newline at end of file diff --git a/crates/lib/cloudflare_mitigation/src/headers/mod.rs b/crates/lib/cloudflare_mitigation/src/headers/mod.rs new file mode 100644 index 0000000000..6352f64f1d --- /dev/null +++ b/crates/lib/cloudflare_mitigation/src/headers/mod.rs @@ -0,0 +1 @@ +pub mod firefox_websocket_http_1_1_headers; \ No newline at end of file diff --git a/crates/lib/cloudflare_mitigation/src/lib.rs b/crates/lib/cloudflare_mitigation/src/lib.rs new file mode 100644 index 0000000000..a97581c44d --- /dev/null +++ b/crates/lib/cloudflare_mitigation/src/lib.rs @@ -0,0 +1 @@ +pub mod headers; \ No newline at end of file diff --git a/crates/lib/cookie_store/Cargo.toml b/crates/lib/cookie_store/Cargo.toml new file mode 100644 index 0000000000..7dcf429f8f --- /dev/null +++ b/crates/lib/cookie_store/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cookie_store" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "cookie_store" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true + +# External +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# None yet diff --git a/crates/lib/cookie_store/src/cookie.rs b/crates/lib/cookie_store/src/cookie.rs new file mode 100644 index 0000000000..e292df4068 --- /dev/null +++ b/crates/lib/cookie_store/src/cookie.rs @@ -0,0 +1,34 @@ + +#[derive(Clone)] +pub struct Cookie { + pub name: String, + pub value: String, + + // TODO: Later. + // pub domain: String, + // pub path: String, + // pub secure: bool, + // pub http_only: bool, + // pub expires: Option, +} + +impl Cookie { + pub fn new(name: String, value: String) -> Self { + Self { name, value } + } + + pub fn new_from_str(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn value(&self) -> &str { + &self.value + } +} diff --git a/crates/lib/cookie_store/src/cookie_set.rs b/crates/lib/cookie_store/src/cookie_set.rs new file mode 100644 index 0000000000..3c1efa991a --- /dev/null +++ b/crates/lib/cookie_store/src/cookie_set.rs @@ -0,0 +1,36 @@ +use crate::cookie::Cookie; +use std::collections::HashMap; + +pub struct CookieSet { + // TODO: This doesn't yet handle partial paths, subddomain rules, etc. + pub cookies: HashMap, +} + +impl CookieSet { + pub fn new() -> Self { + Self { + cookies: HashMap::new(), + } + } + + pub fn add_cookie(&mut self, cookie: Cookie) { + self.cookies.insert(cookie.name.clone(), cookie); + } + + pub fn get_cookie(&self, name: &str) -> Option<&Cookie> { + self.cookies.get(name) + } + + pub fn remove_cookie(&mut self, name: &str) { + self.cookies.remove(name); + } + + pub fn to_cookie_string(&self) -> String { + self + .cookies + .values() + .map(|cookie| format!("{}={}", cookie.name, cookie.value)) + .collect::>() + .join("; ") + } +} diff --git a/crates/lib/cookie_store/src/cookie_store.rs b/crates/lib/cookie_store/src/cookie_store.rs new file mode 100644 index 0000000000..ca1c0757c7 --- /dev/null +++ b/crates/lib/cookie_store/src/cookie_store.rs @@ -0,0 +1,78 @@ +use crate::cookie::Cookie; +use crate::serialized_cookie_store::{SerializableCookieStore, SerializedCookie}; +use std::collections::HashMap; + +#[derive(Clone)] +pub struct CookieStore { + // TODO: This doesn't yet handle partial paths, subdomain rules, etc. + pub cookies: HashMap, +} + +impl CookieStore { + pub fn empty() -> Self { + Self { + cookies: HashMap::new(), + } + } + + pub fn add_cookie(&mut self, cookie: Cookie) { + self.cookies.insert(cookie.name.clone(), cookie); + } + + pub fn add_cookie_name_and_value(&mut self, name: String, value: String) { + self.add_cookie(Cookie { + name, + value, + }); + } + + pub fn get_cookie(&self, name: &str) -> Option<&Cookie> { + self.cookies.get(name) + } + + pub fn has_cookie(&self, name: &str) -> bool { + self.cookies.contains_key(name) + } + + pub fn remove_cookie(&mut self, name: &str) { + self.cookies.remove(name); + } + + pub fn len(&self) -> usize { + self.cookies.len() + } + + pub fn clear_all(&mut self) { + self.cookies.clear(); + } + + /// NB: Just use this as a heuristic, and do not call it in a loop. + pub fn calculate_approx_cookie_character_length(&self) -> usize { + const COOKIE_SEP_LENGTH: usize = 2; // '=' and ';' + self.cookies + .values() + .map(|cookie| cookie.name.len() + cookie.value.len() + COOKIE_SEP_LENGTH) + .sum() + } + + pub fn to_cookie_string(&self) -> String { + self + .cookies + .values() + .map(|cookie| format!("{}={}", cookie.name, cookie.value)) + .collect::>() + .join("; ") + } + + pub fn to_serializable(&self) -> SerializableCookieStore { + let cookies = self + .cookies + .values() + .map(|cookie| SerializedCookie { + name: cookie.name.clone(), + value: cookie.value.clone(), + }) + .collect(); + SerializableCookieStore { cookies } + } +} diff --git a/crates/lib/cookie_store/src/lib.rs b/crates/lib/cookie_store/src/lib.rs new file mode 100644 index 0000000000..4fdfa2f6c6 --- /dev/null +++ b/crates/lib/cookie_store/src/lib.rs @@ -0,0 +1,24 @@ +//! cookie_store +//! +//! Lightweight cookie jar +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod cookie; +pub mod cookie_set; +pub mod cookie_store; +pub mod serialized_cookie_store; \ No newline at end of file diff --git a/crates/lib/cookie_store/src/serialized_cookie_store.rs b/crates/lib/cookie_store/src/serialized_cookie_store.rs new file mode 100644 index 0000000000..22956c115d --- /dev/null +++ b/crates/lib/cookie_store/src/serialized_cookie_store.rs @@ -0,0 +1,66 @@ +use crate::cookie::Cookie; +use crate::cookie_store::CookieStore; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fmt::{Debug, Display, Formatter}; +use std::path::Path; + +#[derive(Serialize, Deserialize)] +pub struct SerializableCookieStore { + pub (crate) cookies: Vec, +} + +#[derive(Serialize, Deserialize)] +pub (crate) struct SerializedCookie { + pub (crate) name: String, + pub (crate) value: String, +} + +#[derive(Debug)] +pub enum SerializableCookieStoreError { + IoError(std::io::Error), + SerializationError(serde_json::Error), +} + +impl Error for SerializableCookieStoreError {} + +impl Display for SerializableCookieStoreError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoError(err) => write!(f, "IO error: {}", err), + Self::SerializationError(err) => write!(f, "Serialization error: {}", err), + } + } +} + +impl SerializableCookieStore { + pub fn read_from_file>( + file_path: P, + ) -> Result { + let content = std::fs::read_to_string(file_path) + .map_err(|err| SerializableCookieStoreError::IoError(err))?; + serde_json::from_str(&content) + .map_err(|err| SerializableCookieStoreError::SerializationError(err)) + } + + pub fn write_to_file>( + &self, + file_path: P, + ) -> Result<(), SerializableCookieStoreError> { + let serialized = serde_json::to_string(self) + .map_err(|err| SerializableCookieStoreError::SerializationError(err))?; + std::fs::write(file_path, serialized) + .map_err(|err| SerializableCookieStoreError::IoError(err)) + } + + pub fn to_cookie_store(&self) -> CookieStore { + let mut store = CookieStore::empty(); + for serialized_cookie in &self.cookies { + store.add_cookie(Cookie::new_from_str( + &serialized_cookie.name, + &serialized_cookie.value + )); + } + store + } +} diff --git a/crates/lib/data/primitives/Cargo.toml b/crates/lib/data/primitives/Cargo.toml new file mode 100644 index 0000000000..0f7d84f307 --- /dev/null +++ b/crates/lib/data/primitives/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "primitives" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "primitives" crate does not exist, so we're fine to use this internally. +name = "primitives" +path = "src/lib.rs" + +[dependencies] + +# NB: This is designed to be a central crate with few dependencies. +# Consider the dependencies here wisely. +# This is used in MySQL, etc., so dependencies are ill-advised. + +# Internal +# **DO NOT ADD ANY!** +# (None) + +# External +# **DO NOT ADD ANY!** +# (None) + +[dev-dependencies] +# Internal +test_utils.workspace = true + +# External +assertor.workspace = true diff --git a/crates/lib/data/primitives/src/bool_to_str.rs b/crates/lib/data/primitives/src/bool_to_str.rs new file mode 100644 index 0000000000..2bf36c6d83 --- /dev/null +++ b/crates/lib/data/primitives/src/bool_to_str.rs @@ -0,0 +1,24 @@ + +/// Convert a boolean to a lower case string, with values of either "true" or "false". +pub fn bool_to_str(value: bool) -> &'static str { + if value { + "true" + } else { + "false" + } +} + +#[cfg(test)] +mod tests { + use crate::bool_to_str::bool_to_str; + + #[test] + fn test_true() { + assert_eq!(bool_to_str(true), "true"); + } + + #[test] + fn test_false() { + assert_eq!(bool_to_str(false), "false"); + } +} diff --git a/crates/lib/data/primitives/src/iterators/iterate_trimmed_lines_without_comments.rs b/crates/lib/data/primitives/src/iterators/iterate_trimmed_lines_without_comments.rs new file mode 100644 index 0000000000..bd384d68f6 --- /dev/null +++ b/crates/lib/data/primitives/src/iterators/iterate_trimmed_lines_without_comments.rs @@ -0,0 +1,42 @@ + + +/// Read lines from a file in an iterator. +/// Remove comment lines prefixed with `#`. +pub fn iterate_trimmed_lines_without_comments<'a>(iterator: impl IntoIterator) + -> impl Iterator +{ + iterator.into_iter() + .map(|line| line.trim()) + .filter(|line| !(line.starts_with("#") || line.is_empty())) +} + +#[cfg(test)] +mod tests { + use crate::iterators::iterate_trimmed_lines_without_comments::iterate_trimmed_lines_without_comments; + use std::fs::File; + use std::io::Read; + use test_utils::test_file_path::test_file_path; + + #[test] + fn load_file() { + let filename = test_file_path("test_data/text_files/cidr_ban_example/local_cidrs_example.txt") + .expect("should be a valid path"); + + let mut file = File::open(filename) + .expect("file should open"); + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .expect("should be able to read file"); + + let lines = contents.lines(); + let lines = iterate_trimmed_lines_without_comments(lines); + + assert_eq!( + lines.collect::>(), + vec![ + "127.0.0.0/24", + "192.168.0.0/24", + ]); + } +} diff --git a/crates/lib/data/primitives/src/iterators/mod.rs b/crates/lib/data/primitives/src/iterators/mod.rs new file mode 100644 index 0000000000..3261fee9bb --- /dev/null +++ b/crates/lib/data/primitives/src/iterators/mod.rs @@ -0,0 +1 @@ +pub mod iterate_trimmed_lines_without_comments; \ No newline at end of file diff --git a/crates/lib/data/primitives/src/lazy_any_option_true.rs b/crates/lib/data/primitives/src/lazy_any_option_true.rs new file mode 100644 index 0000000000..05f97849ad --- /dev/null +++ b/crates/lib/data/primitives/src/lazy_any_option_true.rs @@ -0,0 +1,94 @@ + +//pub type LazyOptional = Box Option + Send>; +pub type LazyOptional<'a> = Box Option + 'a>; + +/// Lazily evaluate a list of boxed closures that return Option. +/// +/// - If any result in true, return Some(true). +/// The first true will end the evaluation. +/// +/// - If none are true, but any result in false, return Some(false). +/// +/// - Else, return None +pub fn lazy_any_option_true(functions: &[LazyOptional]) -> Option +{ + let mut opt_false_found = false; + for func in functions.iter() { + match func() { + Some(true) => return Some(true), + Some(false) => if !opt_false_found { opt_false_found = true }, + None => continue, + } + } + if opt_false_found { + Some(false) + } else { + None + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn empty() { + let result = super::lazy_any_option_true(&[]); + assert_eq!(result, None); + } + + #[test] + fn all_empty() { + let result = super::lazy_any_option_true(&[ + Box::new(|| None), + Box::new(|| None), + ]); + assert_eq!(result, None); + } + + #[test] + fn all_false() { + let result = super::lazy_any_option_true(&[ + Box::new(|| Some(false)), + Box::new(|| Some(false)), + ]); + assert_eq!(result, Some(false)); + } + + #[test] + fn mix_false_none() { + let result = super::lazy_any_option_true(&[ + Box::new(|| None), + Box::new(|| Some(false)), + Box::new(|| Some(false)), + Box::new(|| None), + ]); + assert_eq!(result, Some(false)); + } + + #[test] + fn mix() { + let result = super::lazy_any_option_true(&[ + Box::new(|| None), + Box::new(|| Some(false)), + Box::new(|| Some(true)), + Box::new(|| Some(false)), + Box::new(|| None), + ]); + assert_eq!(result, Some(true)); + } + + #[test] + fn mix_closure_capture() { + let truthy = Some(true); + let falsy = Some(true); + + let result = super::lazy_any_option_true(&[ + Box::new(|| None), + Box::new(|| (&falsy).map(|inner| inner.clone())), + Box::new(|| (&truthy).map(|inner| inner.clone())), + Box::new(|| (&falsy).map(|inner| inner.clone())), + Box::new(|| None), + ]); + assert_eq!(result, Some(true)); + } +} diff --git a/crates/lib/data/primitives/src/lib.rs b/crates/lib/data/primitives/src/lib.rs new file mode 100644 index 0000000000..38f1165027 --- /dev/null +++ b/crates/lib/data/primitives/src/lib.rs @@ -0,0 +1,31 @@ +//! primitives +//! +//! The purpose of this library is to package converters and utilities around Rust's primitive types. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod bool_to_str; +pub mod iterators; +pub mod lazy_any_option_true; +pub mod numerics; +pub mod optional_false_to_none; +pub mod str; +pub mod str_to_bool; +pub mod traits; +pub mod trim_or_empty; +pub mod truncate_str; +pub mod try_str_to_num; diff --git a/crates/lib/data/primitives/src/numerics/i32_to_u32_zero_clamped.rs b/crates/lib/data/primitives/src/numerics/i32_to_u32_zero_clamped.rs new file mode 100644 index 0000000000..31a5876fd0 --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/i32_to_u32_zero_clamped.rs @@ -0,0 +1,43 @@ +/// Convert an i32 to a u32. If the i32 is negative, return 0. +pub fn i32_to_u32_zero_clamped(num: i32) -> u32 { + if num < 0 { + return 0 + } else { + num as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero() { + assert_eq!(i32_to_u32_zero_clamped(0), 0); + } + + #[test] + fn one() { + assert_eq!(i32_to_u32_zero_clamped(1), 1); + } + + #[test] + fn negative_one() { + assert_eq!(i32_to_u32_zero_clamped(-1), 0); + } + + #[test] + fn i32_min() { + assert_eq!(i32_to_u32_zero_clamped(i32::MIN), 0); + } + + #[test] + fn i32_max() { + assert_eq!(i32_to_u32_zero_clamped(i32::MAX), u32::MAX / 2); + } + + #[test] + fn i32_max_minus_one() { + assert_eq!(i32_to_u32_zero_clamped(i32::MAX - 1), u32::MAX / 2 - 1); + } +} diff --git a/crates/lib/data/primitives/src/numerics/i64_to_u64_zero_clamped.rs b/crates/lib/data/primitives/src/numerics/i64_to_u64_zero_clamped.rs new file mode 100644 index 0000000000..6b4c3b360f --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/i64_to_u64_zero_clamped.rs @@ -0,0 +1,43 @@ +/// Convert an i64 to a u64. If the i64 is negative, return 0. +pub fn i64_to_u64_zero_clamped(num: i64) -> u64 { + if num < 0 { + return 0 + } else { + num as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero() { + assert_eq!(i64_to_u64_zero_clamped(0), 0); + } + + #[test] + fn one() { + assert_eq!(i64_to_u64_zero_clamped(1), 1); + } + + #[test] + fn negative_one() { + assert_eq!(i64_to_u64_zero_clamped(-1), 0); + } + + #[test] + fn i32_min() { + assert_eq!(i64_to_u64_zero_clamped(i64::MIN), 0); + } + + #[test] + fn i32_max() { + assert_eq!(i64_to_u64_zero_clamped(i64::MAX), u64::MAX / 2); + } + + #[test] + fn i32_max_minus_one() { + assert_eq!(i64_to_u64_zero_clamped(i64::MAX - 1), u64::MAX / 2 - 1); + } +} diff --git a/crates/lib/data/primitives/src/numerics/mod.rs b/crates/lib/data/primitives/src/numerics/mod.rs new file mode 100644 index 0000000000..d4b2921c49 --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/mod.rs @@ -0,0 +1,6 @@ +pub mod i32_to_u32_zero_clamped; +pub mod i64_to_u64_zero_clamped; +pub mod remap_range_f64; +pub mod u64_to_i32_saturating; +pub mod u64_to_i64_saturating; +pub mod u64_to_u32_saturating; \ No newline at end of file diff --git a/crates/lib/data/primitives/src/numerics/remap_range_f64.rs b/crates/lib/data/primitives/src/numerics/remap_range_f64.rs new file mode 100644 index 0000000000..19a48adf67 --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/remap_range_f64.rs @@ -0,0 +1,131 @@ + +pub struct Args { + pub old_min: f64, + pub old_max: f64, + pub new_min: f64, + pub new_max: f64, + + /// The value to remap to the new range. + pub value: f64, +} + +pub fn remap_range_f64(args: Args) -> f64 { + let old_range = args.old_max - args.old_min; + let new_range = args.new_max - args.new_min; + + let new_value = (args.value - args.old_min) * new_range / old_range + args.new_min; + + if new_value > args.new_max { + args.new_max + } else if new_value < args.new_min { + args.new_min + } else { + new_value + } +} + +#[cfg(test)] +mod tests { + use crate::numerics::remap_range_f64::{remap_range_f64, Args}; + use assertor::{assert_that, FloatAssertion}; + + #[test] + fn test_base_cases() { + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: 0.0, + new_max: 1.0, + value: 0.0, + })).with_abs_tol(0.01).is_approx_equal_to(0.0); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: 0.0, + new_max: 1.0, + value: 1.0, + })).with_abs_tol(0.01).is_approx_equal_to(1.0); + } + + #[test] + fn test_maxima() { + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 100.0, + new_min: 0.0, + new_max: 1.0, + value: 100.0, + })).with_abs_tol(0.01).is_approx_equal_to(1.0); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: 0.0, + new_max: 50.0, + value: 1.0, + })).with_abs_tol(0.01).is_approx_equal_to(50.0); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: -100.0, + new_max: 100.0, + value: 1.0, + })).with_abs_tol(0.01).is_approx_equal_to(100.0); + } + + #[test] + fn test_minima() { + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 100.0, + new_min: 0.0, + new_max: 1.0, + value: 0.0, + })).with_abs_tol(0.01).is_approx_equal_to(0.0); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: 0.0, + new_max: 50.0, + value: 0.0, + })).with_abs_tol(0.01).is_approx_equal_to(0.0); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: -100.0, + new_max: 100.0, + value: 0.0, + })).with_abs_tol(0.01).is_approx_equal_to(-100.0); + } + + #[test] + fn test_other_values() { + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 100.0, + new_min: 0.0, + new_max: 1.0, + value: 50.0, + })).with_abs_tol(0.01).is_approx_equal_to(0.50); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: 0.0, + new_max: 50.0, + value: 0.10, + })).with_abs_tol(0.01).is_approx_equal_to(5.0); + + assert_that!(remap_range_f64(Args { + old_min: 0.0, + old_max: 1.0, + new_min: -100.0, + new_max: 100.0, + value: 0.25, + })).with_abs_tol(0.01).is_approx_equal_to(-50.0); + } +} diff --git a/crates/lib/data/primitives/src/numerics/u64_to_i32_saturating.rs b/crates/lib/data/primitives/src/numerics/u64_to_i32_saturating.rs new file mode 100644 index 0000000000..f3498af0be --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/u64_to_i32_saturating.rs @@ -0,0 +1,40 @@ +const MAX_I32 : u64 = i32::MAX as u64; + +/// Convert a u64 to a positive i32, saturating to i32::MAX if the u64 overflows the container. +pub fn u64_to_i32_saturating(num: u64) -> i32 { + if num > MAX_I32 { + return i32::MAX; + } else { + num as i32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero() { + assert_eq!(u64_to_i32_saturating(0), 0); + } + + #[test] + fn u64_max() { + assert_eq!(u64_to_i32_saturating(u64::MAX), i32::MAX); + } + + #[test] + fn i32_max() { + assert_eq!(u64_to_i32_saturating(i32::MAX as u64), i32::MAX); + } + + #[test] + fn i32_max_plus_one() { + assert_eq!(u64_to_i32_saturating(i32::MAX as u64 + 1), i32::MAX); + } + + #[test] + fn i32_max_minus_one() { + assert_eq!(u64_to_i32_saturating(i32::MAX as u64 - 1), i32::MAX - 1); + } +} diff --git a/crates/lib/data/primitives/src/numerics/u64_to_i64_saturating.rs b/crates/lib/data/primitives/src/numerics/u64_to_i64_saturating.rs new file mode 100644 index 0000000000..23c3540cbd --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/u64_to_i64_saturating.rs @@ -0,0 +1,45 @@ +const MAX_I64 : u64 = i64::MAX as u64; + +/// Convert a u64 to a positive i64, saturating to i64::MAX if the u64 overflows the container. +pub fn u64_to_i64_saturating(num: u64) -> i64 { + if num > MAX_I64 { + return i64::MAX; + } else { + num as i64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero() { + assert_eq!(u64_to_i64_saturating(0), 0); + } + + #[test] + fn one() { + assert_eq!(u64_to_i64_saturating(1), 1); + } + + #[test] + fn u64_max() { + assert_eq!(u64_to_i64_saturating(u64::MAX), i64::MAX); + } + + #[test] + fn i32_max() { + assert_eq!(u64_to_i64_saturating(i64::MAX as u64), i64::MAX); + } + + #[test] + fn i32_max_plus_one() { + assert_eq!(u64_to_i64_saturating(i64::MAX as u64 + 1), i64::MAX); + } + + #[test] + fn i32_max_minus_one() { + assert_eq!(u64_to_i64_saturating(i64::MAX as u64 - 1), i64::MAX - 1); + } +} diff --git a/crates/lib/data/primitives/src/numerics/u64_to_u32_saturating.rs b/crates/lib/data/primitives/src/numerics/u64_to_u32_saturating.rs new file mode 100644 index 0000000000..6bb5bab4a7 --- /dev/null +++ b/crates/lib/data/primitives/src/numerics/u64_to_u32_saturating.rs @@ -0,0 +1,50 @@ +const MAX_U32 : u64 = u32::MAX as u64; + +/// Convert a u64 to a u32, saturating to u32::MAX if the u64 overflows the container. +pub fn u64_to_u32_saturating(num: u64) -> u32 { + if num > MAX_U32 { + return u32::MAX; + } else { + num as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero() { + assert_eq!(u64_to_u32_saturating(0), 0); + } + + #[test] + fn one() { + assert_eq!(u64_to_u32_saturating(1), 1); + } + + #[test] + fn u32_max() { + assert_eq!(u64_to_u32_saturating(u32::MAX as u64), u32::MAX); + } + + #[test] + fn u32_max_plus_one() { + assert_eq!(u64_to_u32_saturating(u32::MAX as u64 + 1), u32::MAX); + } + + #[test] + fn u32_max_minus_one() { + assert_eq!(u64_to_u32_saturating(u32::MAX as u64 - 1), u32::MAX - 1); + } + + #[test] + fn u64_max() { + assert_eq!(u64_to_u32_saturating(u64::MAX), u32::MAX); + } + + #[test] + fn u64_max_minus_one() { + assert_eq!(u64_to_u32_saturating(u64::MAX - 1), u32::MAX); + } +} diff --git a/crates/lib/data/primitives/src/optional_false_to_none.rs b/crates/lib/data/primitives/src/optional_false_to_none.rs new file mode 100644 index 0000000000..45e52a0711 --- /dev/null +++ b/crates/lib/data/primitives/src/optional_false_to_none.rs @@ -0,0 +1,14 @@ + +pub fn optional_false_to_none(optional: Option) -> Option { + optional.filter(|&value| value) +} + +#[cfg(test)] +mod test { + #[test] + fn test_optional_false_to_none() { + assert_eq!(super::optional_false_to_none(None), None); + assert_eq!(super::optional_false_to_none(Some(false)), None); + assert_eq!(super::optional_false_to_none(Some(true)), Some(true)); + } +} diff --git a/crates/lib/data/primitives/src/str/first_letter_uppercase.rs b/crates/lib/data/primitives/src/str/first_letter_uppercase.rs new file mode 100644 index 0000000000..5f566966f8 --- /dev/null +++ b/crates/lib/data/primitives/src/str/first_letter_uppercase.rs @@ -0,0 +1,37 @@ + +/// Converts the first letter of a string to uppercase. +/// eg, "foobar" is converted to "Foobar". +/// From https://stackoverflow.com/a/53570840 +pub fn first_letter_uppercase(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +#[cfg(test)] +mod tests { + use crate::str::first_letter_uppercase::first_letter_uppercase; + + #[test] + fn empty() { + assert_eq!(first_letter_uppercase(""), ""); + } + + #[test] + fn lowercase() { + assert_eq!(first_letter_uppercase("f"), "F"); + assert_eq!(first_letter_uppercase("foo"), "Foo"); + assert_eq!(first_letter_uppercase("fOO"), "FOO"); + assert_eq!(first_letter_uppercase("asdf"), "Asdf"); + } + + #[test] + fn uppercase() { + assert_eq!(first_letter_uppercase("F"), "F"); + assert_eq!(first_letter_uppercase("Foo"), "Foo"); + assert_eq!(first_letter_uppercase("FOO"), "FOO"); + assert_eq!(first_letter_uppercase("ASDF"), "ASDF"); + } +} diff --git a/crates/lib/data/primitives/src/str/mod.rs b/crates/lib/data/primitives/src/str/mod.rs new file mode 100644 index 0000000000..f1267184a9 --- /dev/null +++ b/crates/lib/data/primitives/src/str/mod.rs @@ -0,0 +1 @@ +pub mod first_letter_uppercase; \ No newline at end of file diff --git a/crates/lib/data/primitives/src/str_to_bool.rs b/crates/lib/data/primitives/src/str_to_bool.rs new file mode 100644 index 0000000000..2095e365ac --- /dev/null +++ b/crates/lib/data/primitives/src/str_to_bool.rs @@ -0,0 +1,31 @@ + +/// Convert string values to bool. +/// Empty, "0", "false", "False", and "FALSE" are considered false +pub fn str_to_bool(input: &str) -> bool { + match input { + "" | "0" | "false" | "False" | "FALSE" => false, + _ => true, + } +} + +#[cfg(test)] +mod tests { + use crate::str_to_bool::str_to_bool; + + #[test] + fn false_values() { + assert!(!str_to_bool("")); + assert!(!str_to_bool("0")); + assert!(!str_to_bool("false")); + assert!(!str_to_bool("False")); + assert!(!str_to_bool("FALSE")); + } + + #[test] + fn true_values() { + assert!(str_to_bool(" ")); + assert!(str_to_bool("true")); + assert!(str_to_bool("TRUE")); + assert!(str_to_bool("whatever")); + } +} diff --git a/crates/lib/data/primitives/src/traits/mod.rs b/crates/lib/data/primitives/src/traits/mod.rs new file mode 100644 index 0000000000..a3713933e7 --- /dev/null +++ b/crates/lib/data/primitives/src/traits/mod.rs @@ -0,0 +1 @@ +pub mod trim_or_emptyable; \ No newline at end of file diff --git a/crates/lib/data/primitives/src/traits/trim_or_emptyable.rs b/crates/lib/data/primitives/src/traits/trim_or_emptyable.rs new file mode 100644 index 0000000000..66147ad609 --- /dev/null +++ b/crates/lib/data/primitives/src/traits/trim_or_emptyable.rs @@ -0,0 +1,131 @@ +/// Something that can be trimmed +pub trait TrimOrEmptyable { + /// Trim the string. If it trims to empty, return None. + fn trim_or_empty(&self) -> Option<&str>; + + /// Trim the string. If it trims to empty, return None. + fn new_string_trim_or_empty(&self) -> Option { + self.trim_or_empty().map(|s| s.to_string()) + } +} + +// TODO: Can't create this due to conflicting implementations +// impl TrimOrEmptyable for T +// where T: AsRef +// { +// fn trim_or_empty(&self) -> Option<&str> { +// self.as_ref().trim_or_empty() +// } +// } + +impl TrimOrEmptyable for Option<&str> { + fn trim_or_empty(&self) -> Option<&str> { + self.map(|s| s.trim()) + .filter(|s| !s.is_empty()) + } +} + +impl TrimOrEmptyable for Option { + fn trim_or_empty(&self) -> Option<&str> { + self.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + } +} + +impl TrimOrEmptyable for &str { + fn trim_or_empty(&self) -> Option<&str> { + match self.trim() { + "" => None, + s => Some(s), + } + } +} + +impl TrimOrEmptyable for String { + fn trim_or_empty(&self) -> Option<&str> { + match self.trim() { + "" => None, + s => Some(s), + } + } +} + +#[cfg(test)] +mod tests { + use crate::traits::trim_or_emptyable::TrimOrEmptyable; + + mod str_primitive { + use super::*; + + #[test] + fn empty() { + assert_eq!("".trim_or_empty(), None); + assert_eq!(" ".trim_or_empty(), None); + assert_eq!(" \n\t \t ".trim_or_empty(), None); + } + + #[test] + fn trimmed() { + assert_eq!("hello".trim_or_empty(), Some("hello")); + assert_eq!(" world ".trim_or_empty(), Some("world")); + assert_eq!(" \n\tsuper\nmario\tbros\n\t \n ".trim_or_empty(), Some("super\nmario\tbros")); + } + } + + mod string_primitive { + use super::*; + + #[test] + fn empty() { + assert_eq!("".to_string().trim_or_empty(), None); + assert_eq!(" ".to_string().trim_or_empty(), None); + assert_eq!(" \n\t \t ".to_string().trim_or_empty(), None); + } + + #[test] + fn trimmed() { + assert_eq!("hello".to_string().trim_or_empty(), Some("hello")); + assert_eq!(" world ".to_string().trim_or_empty(), Some("world")); + assert_eq!(" \n\tsuper\nmario\tbros\n\t \n ".to_string().trim_or_empty(), Some("super\nmario\tbros")); + } + } + + mod option_str_primitive { + use super::*; + + #[test] + fn empty() { + assert_eq!(Some("").trim_or_empty(), None); + assert_eq!(Some(" ").trim_or_empty(), None); + assert_eq!(Some(" \n\t \t ").trim_or_empty(), None); + assert_eq!(None::<&str>.trim_or_empty(), None); + + } + + #[test] + fn trimmed() { + assert_eq!(Some("hello").trim_or_empty(), Some("hello")); + assert_eq!(Some(" world ").trim_or_empty(), Some("world")); + assert_eq!(Some(" \n\tsuper\nmario\tbros\n\t \n ").trim_or_empty(), Some("super\nmario\tbros")); + } + } + + mod option_string_primitive { + use super::*; + #[test] + fn empty() { + assert_eq!(Some("".to_string()).trim_or_empty(), None); + assert_eq!(Some(" ".to_string()).trim_or_empty(), None); + assert_eq!(Some(" \n\t \t ".to_string()).trim_or_empty(), None); + assert_eq!(None::.trim_or_empty(), None); + } + + #[test] + fn trimmed() { + assert_eq!(Some("hello".to_string()).trim_or_empty(), Some("hello")); + assert_eq!(Some(" world ".to_string()).trim_or_empty(), Some("world")); + assert_eq!(Some(" \n\tsuper\nmario\tbros\n\t \n ".to_string()).trim_or_empty(), Some("super\nmario\tbros")); + } + } +} diff --git a/crates/lib/data/primitives/src/trim_or_empty.rs b/crates/lib/data/primitives/src/trim_or_empty.rs new file mode 100644 index 0000000000..5b91e00c42 --- /dev/null +++ b/crates/lib/data/primitives/src/trim_or_empty.rs @@ -0,0 +1,28 @@ + +/// Trim the string. If it trims to empty, return None. +pub fn trim_or_empty(input: &str) -> Option<&str> { + let trimmed = input.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use crate::trim_or_empty::trim_or_empty; + + #[test] + fn empty() { + assert_eq!(trim_or_empty(""), None); + assert_eq!(trim_or_empty(" "), None); + assert_eq!(trim_or_empty(" \n\t \t "), None); + } + + fn trimmed() { + assert_eq!(trim_or_empty("hello"), Some("hello")); + assert_eq!(trim_or_empty(" world "), Some("world")); + assert_eq!(trim_or_empty(" \n\tsuper\nmario\tbros\n\t \n "), Some("super\nmario\tbros")); + } +} \ No newline at end of file diff --git a/crates/lib/data/primitives/src/truncate_str.rs b/crates/lib/data/primitives/src/truncate_str.rs new file mode 100644 index 0000000000..bbdfb4f8f5 --- /dev/null +++ b/crates/lib/data/primitives/src/truncate_str.rs @@ -0,0 +1,24 @@ + +/// Truncate a string based on character length, not byte length. +/// Taken from https://stackoverflow.com/a/38461750 +pub fn truncate_str(s: &str, max_chars: usize) -> &str { + match s.char_indices().nth(max_chars) { + None => s, + Some((idx, _)) => &s[..idx], + } +} + +#[cfg(test)] +mod tests { + use crate::truncate_str::truncate_str; + + #[test] + fn truncate() { + assert_eq!(truncate_str("hello", 0), ""); + assert_eq!(truncate_str("hello", 1), "h"); + assert_eq!(truncate_str("hello", 3), "hel"); + assert_eq!(truncate_str("hello", 5), "hello"); + assert_eq!(truncate_str("hello", 10), "hello"); + assert_eq!(truncate_str("hello", 10000), "hello"); + } +} diff --git a/crates/lib/data/primitives/src/try_str_to_num.rs b/crates/lib/data/primitives/src/try_str_to_num.rs new file mode 100644 index 0000000000..2fa77dfad0 --- /dev/null +++ b/crates/lib/data/primitives/src/try_str_to_num.rs @@ -0,0 +1,80 @@ +use std::ops::{AddAssign, MulAssign}; +use std::str::FromStr; + +/// Convert string values to numerics +/// The trait bounds aren't perfect, but are close enough. +/// This is probably inferior to just using `u32::from_str` directly, but it +/// serves as good documentation on generics. +pub fn try_str_to_num(input: &str) -> Result::Err> + where T: FromStr + Copy + AddAssign + MulAssign +{ + let value = T::from_str(input)?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use crate::try_str_to_num::try_str_to_num; + + mod u8 { + use super::*; + + #[test] + fn parse() { + assert_eq!(try_str_to_num::("0"), Ok(0u8)); + assert_eq!(try_str_to_num::("1"), Ok(1u8)); + assert_eq!(try_str_to_num::("5"), Ok(5u8)); + assert_eq!(try_str_to_num::("123"), Ok(123u8)); + assert_eq!(try_str_to_num::("255"), Ok(255u8)); + } + + #[test] + fn true_values() { + assert!(try_str_to_num::("").is_err()); + assert!(try_str_to_num::("-1").is_err()); + assert!(try_str_to_num::("256").is_err()); + assert!(try_str_to_num::("foo").is_err()); + } + } + + mod i8 { + use super::*; + + #[test] + fn parse() { + assert_eq!(try_str_to_num::("0"), Ok(0i8)); + assert_eq!(try_str_to_num::("1"), Ok(1i8)); + assert_eq!(try_str_to_num::("5"), Ok(5i8)); + assert_eq!(try_str_to_num::("123"), Ok(123i8)); + assert_eq!(try_str_to_num::("-1"), Ok(-1i8)); + } + + #[test] + fn true_values() { + assert!(try_str_to_num::("").is_err()); + assert!(try_str_to_num::("128").is_err()); + assert!(try_str_to_num::("256").is_err()); + assert!(try_str_to_num::("foo").is_err()); + } + } + + mod u32 { + use super::*; + + #[test] + fn parse() { + assert_eq!(try_str_to_num::("0"), Ok(0u32)); + assert_eq!(try_str_to_num::("1"), Ok(1u32)); + assert_eq!(try_str_to_num::("5"), Ok(5u32)); + assert_eq!(try_str_to_num::("123"), Ok(123u32)); + assert_eq!(try_str_to_num::("255"), Ok(255u32)); + } + + #[test] + fn true_values() { + assert!(try_str_to_num::("").is_err()); + assert!(try_str_to_num::("-1").is_err()); + assert!(try_str_to_num::("foo").is_err()); + } + } +} diff --git a/crates/lib/data/web_base64/Cargo.toml b/crates/lib/data/web_base64/Cargo.toml new file mode 100644 index 0000000000..7df17cb942 --- /dev/null +++ b/crates/lib/data/web_base64/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "web_base64" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "web_base64" +path = "src/lib.rs" + +[dependencies] + +# Internal +# (None) + +# External +base64 = { workspace = true } + +[dev-dependencies] +# (None) diff --git a/crates/lib/data/web_base64/src/lib.rs b/crates/lib/data/web_base64/src/lib.rs new file mode 100644 index 0000000000..c5c08aeb78 --- /dev/null +++ b/crates/lib/data/web_base64/src/lib.rs @@ -0,0 +1,5 @@ +//! Base64 encoding and decoding library that handles data from the web. +//! Most of the base64 we receive from the web is of a particular encoding +//! type, and we also have to juggle URL prefixes. + +pub mod web_base64_decode; diff --git a/crates/lib/data/web_base64/src/web_base64_decode.rs b/crates/lib/data/web_base64/src/web_base64_decode.rs new file mode 100644 index 0000000000..f80161118c --- /dev/null +++ b/crates/lib/data/web_base64/src/web_base64_decode.rs @@ -0,0 +1,14 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; + +pub fn web_base64_decode(base64_string: &str) -> Result, base64::DecodeError> { + // Remove the data URL prefix if it exists + // eg: "data:image/png;base64,..." + let base64_data = if base64_string.starts_with("data:") { + base64_string.split(',').nth(1).unwrap_or(base64_string) + } else { + base64_string + }; + + BASE64_STANDARD.decode(base64_data) +} diff --git a/crates/lib/errors/Cargo.toml b/crates/lib/errors/Cargo.toml new file mode 100644 index 0000000000..462d7b4bd0 --- /dev/null +++ b/crates/lib/errors/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "errors" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "errors" crate is name-squatted, so we're fine to use this internally. +name = "errors" +path = "src/lib.rs" + +[dependencies] + +# NB: This is one of the most central crates in the monorepo and everything depends on it. +# Consider the dependencies here wisely. +# Ideally we have almost nothing as a dependency of this crate. +anyhow.workspace = true + +[dev-dependencies] +# None yet diff --git a/crates/lib/errors/src/lib.rs b/crates/lib/errors/src/lib.rs new file mode 100644 index 0000000000..70a8cea868 --- /dev/null +++ b/crates/lib/errors/src/lib.rs @@ -0,0 +1,31 @@ +//! errors +//! +//! The purpose of this library is to pin to a single 'anyhow' and also develop +//! common error utilities. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +/// Useful re-export. +pub use anyhow::anyhow; +pub use anyhow::bail; + +/// Easier to import than anyhow::Result. +/// (Naming things "Result" pollutes the import scope or requires nasty renames. Gross.) +pub type AnyhowResult = anyhow::Result; + +pub type AnyhowError = anyhow::Error; + diff --git a/crates/lib/files/file_to_base64_url/Cargo.toml b/crates/lib/files/file_to_base64_url/Cargo.toml new file mode 100644 index 0000000000..08c17e2922 --- /dev/null +++ b/crates/lib/files/file_to_base64_url/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "file_to_base64_url" +edition = "2021" +version = "0.0.1" +publish = false + +[lib] +name = "fal_client" +path = "src/lib.rs" + +[dependencies] + + +# Internal +errors.workspace = true + +# External +anyhow.workspace = true +base64.workspace = true +mimetypes.workspace = true + +[dev-dependencies] +test_utils = { path = "../../../testing/test_utils" } diff --git a/crates/lib/files/file_to_base64_url/src/file_to_base64_url.rs b/crates/lib/files/file_to_base64_url/src/file_to_base64_url.rs new file mode 100644 index 0000000000..a5a4ce1d29 --- /dev/null +++ b/crates/lib/files/file_to_base64_url/src/file_to_base64_url.rs @@ -0,0 +1,45 @@ +use anyhow::anyhow; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use errors::AnyhowResult; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use std::path::Path; + +/// Read a file into a base64 URL-safe string. +pub fn file_to_base64_url>(path: P) -> AnyhowResult { + let bytes = std::fs::read(path.as_ref())?; + + let mime = match MimetypeInfo::get_for_bytes(&bytes) { + Some(mime) => mime, + None => { + return Err(anyhow!("could not determine mimetype")); + }, + }; + + let base64_bytes = BASE64_STANDARD.encode(&bytes); + + match mime.mime_type() { + "image/png" => Ok(format!("data:image/png;base64,{}", base64_bytes)), + "image/jpeg" => Ok(format!("data:image/jpeg;base64,{}", base64_bytes)), + _ => Err(anyhow!("unknown or unsupported mime type: {}", mime.mime_type())), + } +} + +#[cfg(test)] +mod tests { + use errors::AnyhowResult; + use test_utils::test_file_path::test_file_path; + use crate::file_to_base64_url::file_to_base64_url; + + #[test] + fn test_file_to_base64_url() -> AnyhowResult<()> { + let path = test_file_path("test_data/image/juno.jpg")?; + let base64_url = file_to_base64_url(path)?; + + assert!(base64_url.starts_with("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEBLAEsAAD")); + assert!(base64_url.ends_with("z8c2LAteJ5zOIDcX5m04K3P/2Q==")); + assert_eq!(base64_url.len(), 223991); + + Ok(()) + } +} \ No newline at end of file diff --git a/crates/lib/files/file_to_base64_url/src/lib.rs b/crates/lib/files/file_to_base64_url/src/lib.rs new file mode 100644 index 0000000000..ad02798042 --- /dev/null +++ b/crates/lib/files/file_to_base64_url/src/lib.rs @@ -0,0 +1 @@ +pub mod file_to_base64_url; \ No newline at end of file diff --git a/crates/lib/files/filesys/Cargo.toml b/crates/lib/files/filesys/Cargo.toml new file mode 100644 index 0000000000..8cc7c250e9 --- /dev/null +++ b/crates/lib/files/filesys/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "filesys" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "filesys" crate does not exist, so it's safe to use for now. +name = "filesys" +path = "src/lib.rs" + +[dependencies] + +# Internal (keep this to a minimum!) +errors.workspace = true + +# External +# KEEP THIS TO A MINIMUM OR NIL. +log.workspace = true +walkdir.workspace = true + +[dev-dependencies] +test_utils = { path = "../../../testing/test_utils" } diff --git a/crates/lib/files/filesys/src/check_directory_exists.rs b/crates/lib/files/filesys/src/check_directory_exists.rs new file mode 100644 index 0000000000..adef055017 --- /dev/null +++ b/crates/lib/files/filesys/src/check_directory_exists.rs @@ -0,0 +1,15 @@ +use std::path::Path; + +use errors::{AnyhowResult, bail}; + +pub fn check_directory_exists>(path: P) -> AnyhowResult<()> { + let check_path = path.as_ref(); + + if !check_path.exists() { + bail!("Path doesn't exist: {:?}", check_path); + } + if !check_path.is_dir() { + bail!("Path isn't a directory: {:?}", check_path); + } + Ok(()) +} diff --git a/crates/lib/files/filesys/src/check_file_exists.rs b/crates/lib/files/filesys/src/check_file_exists.rs new file mode 100644 index 0000000000..62805c4caf --- /dev/null +++ b/crates/lib/files/filesys/src/check_file_exists.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +use errors::AnyhowResult; +use errors::bail; + +pub fn check_file_exists(path: &PathBuf) -> AnyhowResult<()> { + if !path.exists() { + bail!("Path doesn't exist: {:?}", path); + } + if !path.is_file() { + bail!("Path isn't a file: {:?}", path); + } + Ok(()) +} diff --git a/crates/lib/files/filesys/src/create_dir_all_if_missing.rs b/crates/lib/files/filesys/src/create_dir_all_if_missing.rs new file mode 100644 index 0000000000..f320e6790a --- /dev/null +++ b/crates/lib/files/filesys/src/create_dir_all_if_missing.rs @@ -0,0 +1,17 @@ +use std::path::Path; + +use crate::directory_exists::directory_exists; + +/// create_dir_all, but only if it doesn't already exist. +/// (maybe this will miss permission errors.) +pub fn create_dir_all_if_missing>(path: P) -> std::io::Result<()> { + if directory_exists(&path) { + return Ok(()) + } + std::fs::create_dir_all(&path) +} + +#[cfg(test)] +mod tests { + // TODO +} diff --git a/crates/lib/files/filesys/src/directory_exists.rs b/crates/lib/files/filesys/src/directory_exists.rs new file mode 100644 index 0000000000..66cbb00864 --- /dev/null +++ b/crates/lib/files/filesys/src/directory_exists.rs @@ -0,0 +1,53 @@ +use std::path::Path; + +/// Check if the path exists and if it's a directory. +#[inline] +pub fn directory_exists>(path: P) -> bool { + let path_ref = path.as_ref(); + if !path_ref.exists() { + return false; + } + if !path_ref.is_dir() { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::directory_exists::directory_exists; + + #[test] + fn test_directory_exists() { + // Common directories + assert_eq!(true, directory_exists("../../../../..")); + assert_eq!(true, directory_exists("./")); + assert_eq!(true, directory_exists("../")); + assert_eq!(true, directory_exists("/")); + + // As PathBuf + assert_eq!(true, directory_exists(PathBuf::from("../../../../.."))); + assert_eq!(true, directory_exists(PathBuf::from("./"))); + assert_eq!(true, directory_exists(PathBuf::from("../"))); + assert_eq!(true, directory_exists(PathBuf::from("/"))); + + // Other directories + assert_eq!(true, directory_exists("../../../../test_data/audio/flac")); + assert_eq!(true, directory_exists(PathBuf::from("../../../../test_data/audio/flac"))); + } + + #[test] + fn test_file_does_not_exist() { + assert_eq!(false, directory_exists("")); + assert_eq!(false, directory_exists(" ")); + assert_eq!(false, directory_exists("foo")); + assert_eq!(false, directory_exists("foo/bar/baz")); + assert_eq!(false, directory_exists("/foo/bar/baz")); + + assert_eq!(false, directory_exists(PathBuf::from(""))); + assert_eq!(false, directory_exists(PathBuf::from(" "))); + assert_eq!(false, directory_exists(PathBuf::from("foo/bar/baz"))); + } +} diff --git a/crates/lib/files/filesys/src/file_deletion/mod.rs b/crates/lib/files/filesys/src/file_deletion/mod.rs new file mode 100644 index 0000000000..845eb593ca --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/mod.rs @@ -0,0 +1,6 @@ +pub mod safe_delete_directory; +pub mod safe_delete_file; +pub mod safe_delete_files_and_directories; +pub mod safe_delete_possible_file; +pub mod safe_delete_possible_files_and_directories; +pub mod safe_recursively_delete_files; \ No newline at end of file diff --git a/crates/lib/files/filesys/src/file_deletion/safe_delete_directory.rs b/crates/lib/files/filesys/src/file_deletion/safe_delete_directory.rs new file mode 100644 index 0000000000..6f977d463e --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/safe_delete_directory.rs @@ -0,0 +1,16 @@ +use std::fs; +use std::path::Path; + +use log::info; +use log::warn; + +/// Safely deletes a directory without panicking. Errors are logged. +/// This is an infallible, idempotent function. +pub fn safe_delete_directory>(directory_path: P) { + let printable_name = directory_path.as_ref().to_str().unwrap_or("bad directory"); + match fs::remove_dir_all(&directory_path) { + Ok(_) => info!("Temp directory deleted: {}", printable_name), + Err(e) => warn!("Could not delete temp directory{:?} (not a fatal error): {:?}", + printable_name, e), + } +} diff --git a/crates/lib/files/filesys/src/file_deletion/safe_delete_file.rs b/crates/lib/files/filesys/src/file_deletion/safe_delete_file.rs new file mode 100644 index 0000000000..bc0c92d090 --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/safe_delete_file.rs @@ -0,0 +1,15 @@ +use std::path::Path; + +use log::info; +use log::warn; + +/// Safely deletes a file without panicking. Errors are logged. +/// This is an infallible, idempotent function. +pub fn safe_delete_file>(file_path: P) { + let printable_name = file_path.as_ref().to_str().unwrap_or("bad filename"); + match std::fs::remove_file(&file_path) { + Ok(_) => info!("Temp file deleted: {}", printable_name), + Err(e) => warn!("Could not delete temp file {:?} (not a fatal error): {:?}", + printable_name, e), + } +} diff --git a/crates/lib/files/filesys/src/file_deletion/safe_delete_files_and_directories.rs b/crates/lib/files/filesys/src/file_deletion/safe_delete_files_and_directories.rs new file mode 100644 index 0000000000..75503bdba8 --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/safe_delete_files_and_directories.rs @@ -0,0 +1,39 @@ +use std::path::Path; + +use log::warn; + +use crate::file_deletion::safe_delete_directory::safe_delete_directory; +use crate::file_deletion::safe_delete_file::safe_delete_file; +use crate::file_deletion::safe_recursively_delete_files::safe_recursively_delete_files; + +/// Safely deletes all files and directories passed to the function without panicking. Errors are logged. +/// Directories are recursively deleted before being removed. +/// This is an infallible, idempotent function. +/// +/// Note on usage: since this is a generic function with only one generic type, `P`, you can only pass +/// a list of paths that are all the same type. If you have a mix of `PathBuf` and `&Path`, it's best to +/// convert the PathBuf to &Path references. Mixing other `AsRef` types will be more complicated, +/// so it may be worth calling `as_ref()` on them, e.g.: +/// +/// ```rust +/// use std::path::{Path, PathBuf}; +/// use filesys::file_deletion::safe_delete_files_and_directories::safe_delete_files_and_directories; +/// +/// safe_delete_files_and_directories(&[ +/// Path::new("1234_foo_bar_baz.bash"), +/// &PathBuf::from("1234_foo_bar_baz.bin"), +/// ]); +/// ``` +pub fn safe_delete_files_and_directories>(paths: &[P]) { + for path in paths { + let p = path.as_ref(); + if p.is_file() { + safe_delete_file(p); + } else if p.is_dir() { + safe_recursively_delete_files(p); + safe_delete_directory(p); + } else { + warn!("Path {:?} is neither a file nor a directory", p); + } + } +} diff --git a/crates/lib/files/filesys/src/file_deletion/safe_delete_possible_file.rs b/crates/lib/files/filesys/src/file_deletion/safe_delete_possible_file.rs new file mode 100644 index 0000000000..d5b8a2a296 --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/safe_delete_possible_file.rs @@ -0,0 +1,11 @@ +use std::path::Path; + +use crate::file_deletion::safe_delete_file::safe_delete_file; + +/// Safely deletes a "possible" file (calling on None is a no-op) without panicking. Errors are logged. +/// This is an infallible, idempotent function. +pub fn safe_delete_possible_file>(maybe_file_path: Option

) { + if let Some(file_path) = maybe_file_path { + safe_delete_file(file_path) + } +} diff --git a/crates/lib/files/filesys/src/file_deletion/safe_delete_possible_files_and_directories.rs b/crates/lib/files/filesys/src/file_deletion/safe_delete_possible_files_and_directories.rs new file mode 100644 index 0000000000..d8c6371abe --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/safe_delete_possible_files_and_directories.rs @@ -0,0 +1,45 @@ +use std::path::Path; + +use log::warn; + +use crate::file_deletion::safe_delete_directory::safe_delete_directory; +use crate::file_deletion::safe_delete_file::safe_delete_file; +use crate::file_deletion::safe_recursively_delete_files::safe_recursively_delete_files; + +/// Safely deletes all files and directories passed to the function without panicking. Errors are logged. +/// Directories are recursively deleted before being removed. +/// This is an infallible, idempotent function. +/// +/// Note on usage: since this is a generic function with only one generic type, `P`, you can only pass +/// a list of paths that are all the same type. If you have a mix of `PathBuf` and `&Path`, it's best to +/// convert the PathBuf to &Path references. Mixing other `AsRef` types will be more complicated, +/// so it may be worth calling `as_ref()` on them, e.g.: +/// +/// ```rust +/// use std::path::{Path, PathBuf}; +/// use filesys::file_deletion::safe_delete_possible_files_and_directories::safe_delete_possible_files_and_directories; +/// +/// safe_delete_possible_files_and_directories(&[ +/// Some(Path::new("1234_foo_bar_baz.bash")), +/// None, +/// Some(&PathBuf::from("1234_foo_bar_baz.bin")), +/// None, +/// ]); +/// ``` +pub fn safe_delete_possible_files_and_directories>(possible_paths: &[Option

]) { + for possible_path in possible_paths { + let path = match possible_path { + None => continue, + Some(path) => path, + }; + let p = path.as_ref(); + if p.is_file() { + safe_delete_file(p); + } else if p.is_dir() { + safe_recursively_delete_files(p); + safe_delete_directory(p); + } else { + warn!("Path {:?} is neither a file nor a directory", p); + } + } +} diff --git a/crates/lib/files/filesys/src/file_deletion/safe_recursively_delete_files.rs b/crates/lib/files/filesys/src/file_deletion/safe_recursively_delete_files.rs new file mode 100644 index 0000000000..be4477fce1 --- /dev/null +++ b/crates/lib/files/filesys/src/file_deletion/safe_recursively_delete_files.rs @@ -0,0 +1,29 @@ +use std::path::Path; + +use log::warn; +use walkdir::WalkDir; + +use crate::file_deletion::safe_delete_file::safe_delete_file; + +/// Deletes all files in the directory tree at the given path *without deleting the directory itself*, +/// and *without deleting any subdirectories*. This is an infallible, idempotent function. +pub fn safe_recursively_delete_files(path: &Path) { + if !path.exists() { + warn!("Path does not exist: {:?}", path); + return; + } + if !path.is_dir() { + warn!("Path is not a directory: {:?}", path); + return; + } + + let walk = WalkDir::new(path); + + for entry in walk { + if let Ok(entry) = entry { + if entry.path().is_file() { + safe_delete_file(entry.path()); + } + } + } +} diff --git a/crates/lib/files/filesys/src/file_exists.rs b/crates/lib/files/filesys/src/file_exists.rs new file mode 100644 index 0000000000..9eb5dd2e42 --- /dev/null +++ b/crates/lib/files/filesys/src/file_exists.rs @@ -0,0 +1,40 @@ +use std::path::Path; + +pub fn file_exists>(path: P) -> bool { + let path_ref = path.as_ref(); + if !path_ref.exists() { + return false; + } + if !path_ref.is_file() { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::file_exists::file_exists; + + #[test] + fn test_file_exists() { + assert_eq!(true, file_exists("../../../../test_data/audio/flac/zelda_ocarina_small_item.flac")); + assert_eq!(true, file_exists(PathBuf::from("../../../../test_data/audio/flac/zelda_ocarina_small_item.flac"))); + } + + #[test] + fn test_file_does_not_exist() { + assert_eq!(false, file_exists("")); + assert_eq!(false, file_exists(" ")); + assert_eq!(false, file_exists("./")); // Current directory is not a file + assert_eq!(false, file_exists("foo")); + assert_eq!(false, file_exists("foo/bar/baz")); + assert_eq!(false, file_exists("/foo/bar/baz")); + + assert_eq!(false, file_exists(PathBuf::from(""))); + assert_eq!(false, file_exists(PathBuf::from(" "))); + assert_eq!(false, file_exists(PathBuf::from("./"))); + assert_eq!(false, file_exists(PathBuf::from("foo/bar/baz"))); + } +} \ No newline at end of file diff --git a/crates/lib/files/filesys/src/file_lines/iterate_file_lines.rs b/crates/lib/files/filesys/src/file_lines/iterate_file_lines.rs new file mode 100644 index 0000000000..0f3eef6723 --- /dev/null +++ b/crates/lib/files/filesys/src/file_lines/iterate_file_lines.rs @@ -0,0 +1,38 @@ +use std::fs::File; +use std::io::{self, BufRead}; +use std::path::Path; + +/// Read lines from a file in an iterator. +/// From Rust manual: https://doc.rust-lang.org/rust-by-example/std_misc/file/read_lines.html +pub fn iterate_file_lines

(filename: P) -> io::Result>> +where P: AsRef, { + let file = File::open(filename)?; + Ok(io::BufReader::new(file).lines()) +} + +#[cfg(test)] +mod tests { + use test_utils::test_file_path::test_file_path; + use crate::file_lines::iterate_file_lines::iterate_file_lines; + + #[test] + fn load_file() { + let filename = test_file_path("test_data/text_files/cidr_ban_example/local_cidrs_example.txt") + .expect("should be a valid path"); + + let lines = iterate_file_lines(filename) + .expect("should be readable file"); + + let lines: Vec = lines.map(|line| line.unwrap()).collect(); + + assert_eq!( + lines, + vec![ + "# this is test data".to_string(), + "".to_string(), + "# local cidrs".to_string(), + "127.0.0.0/24".to_string(), + "192.168.0.0/24".to_string(), + ]); + } +} diff --git a/crates/lib/files/filesys/src/file_lines/iterate_trimmed_file_lines_without_comments.rs b/crates/lib/files/filesys/src/file_lines/iterate_trimmed_file_lines_without_comments.rs new file mode 100644 index 0000000000..f86ddbbb77 --- /dev/null +++ b/crates/lib/files/filesys/src/file_lines/iterate_trimmed_file_lines_without_comments.rs @@ -0,0 +1,55 @@ +use crate::file_lines::iterate_file_lines::iterate_file_lines; +use std::io; +use std::path::Path; + +/// Read lines from a file in an iterator. +/// Remove comment lines prefixed with `#`. +pub fn iterate_trimmed_file_lines_without_comments

(filename: P) + -> io::Result>> +where + P: AsRef, +{ + // NB: This is a little bit nasty because we don't want to call ::collect() on + // the iterator to coalesce any errors in the stream. + let it = iterate_file_lines(filename)? + .map(|maybe_line| match maybe_line { + Ok(line) => Ok(line.trim().to_string()), + Err(e) => Err(e), + }) + .filter_map(|maybe_line| match maybe_line { + Err(e) => Some(Err(e)), + Ok(line) => { + if line.starts_with("#") || line.is_empty() { + None + } else { + Some(Ok(line)) + } + }, + }); + + Ok(it) +} + +#[cfg(test)] +mod tests { + use crate::file_lines::iterate_trimmed_file_lines_without_comments::iterate_trimmed_file_lines_without_comments; + use test_utils::test_file_path::test_file_path; + + #[test] + fn load_file() { + let filename = test_file_path("test_data/text_files/cidr_ban_example/local_cidrs_example.txt") + .expect("should be a valid path"); + + let lines = iterate_trimmed_file_lines_without_comments(filename) + .expect("should be readable file"); + + let lines: Vec = lines.filter_map(|line| line.ok()).collect(); + + assert_eq!( + lines, + vec![ + "127.0.0.0/24".to_string(), + "192.168.0.0/24".to_string(), + ]); + } +} diff --git a/crates/lib/files/filesys/src/file_lines/mod.rs b/crates/lib/files/filesys/src/file_lines/mod.rs new file mode 100644 index 0000000000..e89c1f6889 --- /dev/null +++ b/crates/lib/files/filesys/src/file_lines/mod.rs @@ -0,0 +1,2 @@ +pub mod iterate_file_lines; +pub mod iterate_trimmed_file_lines_without_comments; diff --git a/crates/lib/files/filesys/src/file_read_bytes.rs b/crates/lib/files/filesys/src/file_read_bytes.rs new file mode 100644 index 0000000000..00fd8d737f --- /dev/null +++ b/crates/lib/files/filesys/src/file_read_bytes.rs @@ -0,0 +1,16 @@ +use std::fs::File; +use std::io; +use std::io::{BufReader, Read}; +use std::path::Path; + +/// Read a file into a byte buffer +pub fn file_read_bytes>(file_path: P) -> io::Result> { + let f = File::open(file_path)?; + let mut reader = BufReader::new(f); + let mut buffer = Vec::new(); + + // Read file into vector. + reader.read_to_end(&mut buffer)?; + + Ok(buffer) +} diff --git a/crates/lib/files/filesys/src/file_size.rs b/crates/lib/files/filesys/src/file_size.rs new file mode 100644 index 0000000000..f657eb4ea1 --- /dev/null +++ b/crates/lib/files/filesys/src/file_size.rs @@ -0,0 +1,42 @@ +use std::path::Path; + +use errors::{anyhow, AnyhowResult}; + +/// Return the file size of the file. +/// Only works for *files*, not directories or symlinks +pub fn file_size>(filename: P) -> AnyhowResult { + let metadata = std::fs::metadata(filename.as_ref())?; + + // TODO(bt,2023-11-29): Return io::Result and errors here. + if !metadata.is_file() { + Err(anyhow!("path {:?} is not a file", filename.as_ref())) + } else if metadata.is_symlink() { + Err(anyhow!("path {:?} is a symlink", filename.as_ref())) + } else { + Ok(metadata.len()) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::file_size::file_size; + + fn test_file(path_from_repo_root: &str) -> PathBuf { + // https://doc.rust-lang.org/cargo/reference/environment-variables.html + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push(format!("../../../../{}", path_from_repo_root)); + path + } + + #[test] + pub fn test_file_size() { + // Success case + let path = test_file("test_data/audio/flac/zelda_ocarina_small_item.flac"); + assert_eq!(file_size(path).unwrap(), 115); + + // Error case + assert!(file_size("non/existing/path").is_err()); + } +} diff --git a/crates/lib/files/filesys/src/filename_concat.rs b/crates/lib/files/filesys/src/filename_concat.rs new file mode 100644 index 0000000000..40d7ca8966 --- /dev/null +++ b/crates/lib/files/filesys/src/filename_concat.rs @@ -0,0 +1,84 @@ +use std::path::{Path, PathBuf}; + +use crate::path_to_string::path_to_string; + +/// Concatenate filenames while preserving path information. Do not join paths as directories. +/// This contains no file normalizing logic, it's purely a concatenation. +/// +/// eg. "foo" + "bar" results in "foobar" instead of the "foo/bar" that Path::join() returns. +/// eg. "/usr/bin" + "baz" results in "/usr/binbaz" instead of "/usr/bin/baz" +/// +/// This is not efficient. +pub fn filename_concat, Q: AsRef>(part_1: P, part_2: Q) -> String { + let mut base = path_to_string(part_1); + let suffix= path_to_string(part_2); + base.push_str(&suffix); + base +} + +pub fn filename_concat_pathbuf, Q: AsRef>(part_1: P, part_2: Q) -> PathBuf { + PathBuf::from(filename_concat(part_1, part_2)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::filename_concat::filename_concat; + + // Layout: Base, Suffix, Expected Result + const TEST_CASES : [(&'static str, &'static str, &'static str); 11] = [ + // Absolute + ("/", "foo", "/foo"), + ("/usr/bin", "baz", "/usr/binbaz"), + ("/usr/local", "/bin", "/usr/local/bin"), + // Relative + ("picture", ".jpg", "picture.jpg"), + ("/", "..", "/.."), + ("../", "..", "../.."), + ("./", "bin", "./bin"), + (".", "bar", ".bar"), + // Misc + ("", "", ""), + ("/", "", "/"), + ("", "/", "/"), + ]; + + #[test] + fn pathbuf() { + for (part_1, part_2, expected) in TEST_CASES.iter() { + let test_part_1 = PathBuf::from(part_1); + let test_part_2 = PathBuf::from(part_2); + assert_eq!(filename_concat(test_part_1, test_part_2), expected.to_string()); + } + } + + #[test] + fn path() { + for (part_1, part_2, expected) in TEST_CASES.iter() { + let test_part_1 = PathBuf::from(part_1); + let test_part_2 = PathBuf::from(part_2); + + assert_eq!(filename_concat(test_part_1.as_path(), test_part_2.as_path()), + expected.to_string()); + } + } + + #[test] + fn str() { + for (part_1, part_2, expected) in TEST_CASES.iter() { + let test_part_1 = *part_1; + let test_part_2 = *part_2; + assert_eq!(filename_concat(test_part_1, test_part_2), expected.to_string()); + } + } + + #[test] + fn mixed_type() { + for (part_1, part_2, expected) in TEST_CASES.iter() { + let test_part_1 = PathBuf::from(part_1); + let test_part_2 = *part_2; + assert_eq!(filename_concat(test_part_1, test_part_2), expected.to_string()); + } + } +} diff --git a/crates/lib/files/filesys/src/is_filesystem_full_error.rs b/crates/lib/files/filesys/src/is_filesystem_full_error.rs new file mode 100644 index 0000000000..fdbc459e79 --- /dev/null +++ b/crates/lib/files/filesys/src/is_filesystem_full_error.rs @@ -0,0 +1,16 @@ + +// Nearly a third of `ErrorKind` is nightly rust, so we're going to have to match on +// unix error codes until the rest of the ErrorKind enum variants are stable. +// The error codes are maintained in sys/unix/voice_conversion_to_weights - decode_error_kind(i32) + +const E_FILESYSTEM_QUOTA_EXCEEDED : i32 = 122; // pub const EDQUOT: ::c_int = 122; +const E_STORAGE_FULL : i32 = 28; // pub const ENOSPC: ::c_int = 28; + +/// Reports if an io::Error was caused by the filesystem being full. +pub fn is_filesystem_full_error(err: &std::io::Error) -> bool { + match err.raw_os_error() { + Some(E_FILESYSTEM_QUOTA_EXCEEDED) => true, + Some(E_STORAGE_FULL) => true, + _ => false, + } +} diff --git a/crates/lib/files/filesys/src/lib.rs b/crates/lib/files/filesys/src/lib.rs new file mode 100644 index 0000000000..0d5c3fc5b1 --- /dev/null +++ b/crates/lib/files/filesys/src/lib.rs @@ -0,0 +1,36 @@ +//! filesys +//! +//! The purpose of this library is to make basic filesystem operations easier. +//! We won't be including content type or magic type features that incur a higher cost. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod check_directory_exists; +pub mod check_file_exists; +pub mod create_dir_all_if_missing; +pub mod directory_exists; +pub mod file_deletion; +pub mod file_exists; +pub mod file_lines; +pub mod file_read_bytes; +pub mod file_size; +pub mod filename_concat; +pub mod is_filesystem_full_error; +pub mod path_to_string; +pub mod read_to_trimmed_string; +pub mod recursively_find_file_by_name; +pub mod rename_across_devices; diff --git a/crates/lib/files/filesys/src/path_to_string.rs b/crates/lib/files/filesys/src/path_to_string.rs new file mode 100644 index 0000000000..d5635a2895 --- /dev/null +++ b/crates/lib/files/filesys/src/path_to_string.rs @@ -0,0 +1,53 @@ +use std::path::Path; + +/// Convert a path to a string without adding quotes or garbage. +pub fn path_to_string>(path: P) -> String { + path.as_ref().display().to_string() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::path_to_string::path_to_string; + + const TEST_CASES : [&'static str; 9] = [ + // Absolute + "/", + "/usr/bin", + // Relative + "picture.jpg", + "foo/bar/baz", + "../", + "./", + ".", + // Misc + "", + ".././../foo/.././bar", + ]; + + #[test] + fn pathbuf() { + for example in TEST_CASES.iter() { + let test = PathBuf::from(example); + assert_eq!(path_to_string(test), example.to_string()); + } + } + + #[test] + fn path() { + for example in TEST_CASES.iter() { + let path = PathBuf::from(example); + let test = path.as_path(); + assert_eq!(path_to_string(test), example.to_string()); + } + } + + #[test] + fn str() { + for example in TEST_CASES.iter() { + let test = *example; + assert_eq!(path_to_string(test), example.to_string()); + } + } +} diff --git a/crates/lib/files/filesys/src/read_to_trimmed_string.rs b/crates/lib/files/filesys/src/read_to_trimmed_string.rs new file mode 100644 index 0000000000..737755fe1e --- /dev/null +++ b/crates/lib/files/filesys/src/read_to_trimmed_string.rs @@ -0,0 +1,8 @@ +use std::fs::read_to_string; +use std::io; +use std::path::Path; + +pub fn read_to_trimmed_string>(path: P) -> io::Result { + let content = read_to_string(path)?; + Ok(content.trim().to_string()) +} diff --git a/crates/lib/files/filesys/src/recursively_find_file_by_name.rs b/crates/lib/files/filesys/src/recursively_find_file_by_name.rs new file mode 100644 index 0000000000..5b93eee0df --- /dev/null +++ b/crates/lib/files/filesys/src/recursively_find_file_by_name.rs @@ -0,0 +1,56 @@ +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use errors::AnyhowResult; + +pub fn recursively_find_file_by_name, Q: AsRef>( + search_filename: P, + search_root: Q, + max_depth: usize, +) -> AnyhowResult> { + + let walker = WalkDir::new(search_root) + .max_depth(max_depth); + + for entry in walker { + let entry = entry?; + if entry.path().is_dir() { + let check_path = entry.path().join(search_filename.as_ref()); + if check_path.exists() && check_path.is_file() { + return Ok(Some(check_path)); + } + } + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use test_utils::test_file_path::test_file_path; + use crate::recursively_find_file_by_name::recursively_find_file_by_name; + + #[test] + fn test_path_exists() { + let test_data_path = test_file_path("test_data/").expect("should work"); + let result = recursively_find_file_by_name( + "super_mario_rpg_beware_the_forests_mushrooms.mp3", + test_data_path, + 5 + ); + let result = result.expect("should be ok"); + let result = result.expect("should exist"); + assert!(result.ends_with("test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3")); + } + + #[test] + fn test_path_does_not_exist() { + let test_data_path = test_file_path("test_data/").expect("should work"); + let result = recursively_find_file_by_name( + "fake_name.foo", + test_data_path, + 5 + ); + let result = result.expect("should be ok"); + assert!(result.is_none()); + } +} \ No newline at end of file diff --git a/crates/lib/files/filesys/src/rename_across_devices.rs b/crates/lib/files/filesys/src/rename_across_devices.rs new file mode 100644 index 0000000000..13c53b84ed --- /dev/null +++ b/crates/lib/files/filesys/src/rename_across_devices.rs @@ -0,0 +1,81 @@ +use std::error::Error; +use std::fmt; +use std::path::Path; + +use log::error; + +use crate::is_filesystem_full_error::is_filesystem_full_error; + +#[derive(Debug)] +pub enum RenameError { + StorageFull, + IoError(std::io::Error), +} + +impl Error for RenameError {} + +impl fmt::Display for RenameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RenameError::StorageFull => write!(f, "RenameError: storage is full"), + RenameError::IoError(err) => write!(f, "{:?}", err), + } + } +} + +impl From for RenameError { + fn from(error: std::io::Error) -> Self { + RenameError::IoError(error) + } +} + +/// Rename a file. +/// Ordinary rename will fail in Linux if it is across physical devices. +/// This function will perform a copy followed by delete in that case. +/// In both cases, this function will overwrite the destination if it already exists. +pub fn rename_across_devices, Q: AsRef>(from: P, to: Q) -> Result<(), RenameError> { + let result = std::fs::rename(&from, &to); + + let err = match result { + Ok(_) => return Ok(()), // Rename succeeded. + Err(err) => err, + }; + + // Nearly a third of `ErrorKind` is nightly rust, so we're going to have to match on + // unix error codes until the rest of the ErrorKind enum variants are stable. + // The error codes are maintained in sys/unix/voice_conversion_to_weights - decode_error_kind(i32) + const E_CROSSES_DEVICES : i32 = 18; // pub const EXDEV: ::c_int = 18; + + if is_filesystem_full_error(&err) { + error!("Filesystem is full during rename."); + return Err(RenameError::StorageFull); + } + + match err.raw_os_error() { + Some(E_CROSSES_DEVICES) => { + // NB: Fall through. + } + _ => { + // Something else happened. Return original error. + return Err(RenameError::IoError(err)); + } + } + + // NB: In production we've seen std::fs::copy silently succeed, yet copy zero bytes. The reason + // is more than likely that the filesystem is full. We'll check the number of copied bytes to + // confirm. Moreover, the Rust docs say wrt std::fs::copy, "On success, the total number of bytes + // copied is returned and it is equal to the length of the to file as reported by metadata." + let original_file_size = std::fs::metadata(from.as_ref())?.len(); + + let num_bytes_copied = std::fs::copy(&from, &to)?; + + if num_bytes_copied != original_file_size { + error!("Filesystem is full during copy. Not all bytes copied."); + return Err(RenameError::StorageFull); + } + + // An emulated "rename" would delete the source file. + std::fs::remove_file(&from)?; + + Ok(()) +} diff --git a/crates/lib/files/images/Cargo.toml b/crates/lib/files/images/Cargo.toml new file mode 100644 index 0000000000..3bedf4fb4c --- /dev/null +++ b/crates/lib/files/images/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "images" +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "images" crate doesn't exist; "image" crate is the heavily used public library, +# so perhaps this naming scheme causes confusion (?) +name = "images" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors = { workspace = true} +mimetypes = { workspace = true } + +# External +image = { version = "0.25.6" , features = ["jpeg", "png", "tiff", "webp"] } + +[dev-dependencies] +anyhow = { workspace = true } +test_utils = { path = "../../../testing/test_utils" } diff --git a/crates/lib/files/images/src/encoding/image_bytes_to_png_bytes.rs b/crates/lib/files/images/src/encoding/image_bytes_to_png_bytes.rs new file mode 100644 index 0000000000..5c497c44e4 --- /dev/null +++ b/crates/lib/files/images/src/encoding/image_bytes_to_png_bytes.rs @@ -0,0 +1,18 @@ +use crate::error::ImagesError; +use image::{ImageFormat, ImageReader}; +use std::io::Cursor; + +pub fn image_bytes_to_png_bytes( + arbitrary_image_bytes: &[u8], +) -> Result, ImagesError> { + let reader = ImageReader::new(Cursor::new(arbitrary_image_bytes)); + + let image = reader + .with_guessed_format()? // NB: Can raise IoError. + .decode()?; + + let mut output_bytes: Vec = Vec::new(); + image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?; + + Ok(output_bytes) +} diff --git a/crates/lib/files/images/src/encoding/image_bytes_to_png_bytes_with_dimensions.rs b/crates/lib/files/images/src/encoding/image_bytes_to_png_bytes_with_dimensions.rs new file mode 100644 index 0000000000..06bcfbd3f6 --- /dev/null +++ b/crates/lib/files/images/src/encoding/image_bytes_to_png_bytes_with_dimensions.rs @@ -0,0 +1,29 @@ +use std::io::Cursor; +use image::{ImageFormat, ImageReader}; +use crate::error::ImagesError; + +pub struct PngBytesWithDimensions { + pub png_bytes: Vec, + pub width: u32, + pub height: u32, +} + +pub fn image_bytes_to_png_bytes_with_dimensions( + arbitrary_image_bytes: &[u8], +) -> Result { + + let reader = ImageReader::new(Cursor::new(arbitrary_image_bytes)); + + let image = reader + .with_guessed_format()? // NB: Can raise IoError. + .decode()?; + + let mut output_bytes: Vec = Vec::new(); + image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?; + + Ok(PngBytesWithDimensions { + png_bytes: output_bytes, + width: image.width(), + height: image.height(), + }) +} diff --git a/crates/lib/files/images/src/encoding/mod.rs b/crates/lib/files/images/src/encoding/mod.rs new file mode 100644 index 0000000000..1e6618af49 --- /dev/null +++ b/crates/lib/files/images/src/encoding/mod.rs @@ -0,0 +1,3 @@ +pub mod image_bytes_to_png_bytes; +pub mod image_bytes_to_png_bytes_with_dimensions; +pub mod webp_bytes_to_png_bytes; \ No newline at end of file diff --git a/crates/lib/files/images/src/encoding/webp_bytes_to_png_bytes.rs b/crates/lib/files/images/src/encoding/webp_bytes_to_png_bytes.rs new file mode 100644 index 0000000000..66e96d06d6 --- /dev/null +++ b/crates/lib/files/images/src/encoding/webp_bytes_to_png_bytes.rs @@ -0,0 +1,21 @@ +use crate::error::ImagesError; +use image::{ImageFormat, ImageReader}; +use std::io::Cursor; + +pub fn webp_bytes_to_png_bytes( + webp_bytes: &[u8], +) -> Result, ImagesError> { + + let mut reader = ImageReader::new(Cursor::new(webp_bytes)); + + reader.set_format(ImageFormat::WebP); // Default to WebP format (as fallback). + + let image = reader + .with_guessed_format()? // NB: Can raise IoError. + .decode()?; + + let mut output_bytes: Vec = Vec::new(); + image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?; + + Ok(output_bytes) +} \ No newline at end of file diff --git a/crates/lib/files/images/src/error.rs b/crates/lib/files/images/src/error.rs new file mode 100644 index 0000000000..e52a09c046 --- /dev/null +++ b/crates/lib/files/images/src/error.rs @@ -0,0 +1,31 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum ImagesError { + IoError(std::io::Error), + ImageError(image::ImageError), +} + +impl Error for ImagesError {} + +impl Display for ImagesError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ImagesError::IoError(err) => write!(f, "IO Error: {}", err), + ImagesError::ImageError(err) => write!(f, "Image Error: {}", err), + } + } +} + +impl From for ImagesError { + fn from(err: std::io::Error) -> Self { + ImagesError::IoError(err) + } +} + +impl From for ImagesError { + fn from(err: image::ImageError) -> Self { + ImagesError::ImageError(err) + } +} diff --git a/crates/lib/files/images/src/image_info/image_info.rs b/crates/lib/files/images/src/image_info/image_info.rs new file mode 100644 index 0000000000..7893f126f9 --- /dev/null +++ b/crates/lib/files/images/src/image_info/image_info.rs @@ -0,0 +1,100 @@ +use crate::image_info::image_info_error::ImageInfoError; +use image::{GenericImageView, ImageReader}; +use mimetypes::mimetype_info::file_extension::FileExtension; +use mimetypes::mimetype_info::mimetype_info::MimetypeInfo; +use std::io::Cursor; +use std::path::Path; + +#[derive(Clone, Debug)] +pub struct ImageInfo { + /// The mimetype we determined + mimetype_info: MimetypeInfo, + + /// The width of the image in pixels + width: u32, + + /// The height of the image in pixels + height: u32, +} + +impl ImageInfo { + /// Note: This has to do a full file read, and we throw the parsed image on the floor. + pub fn decode_image_from_bytes(bytes: &[u8]) -> Result { + let image = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?.decode()?; + let (width, height) = image.dimensions(); + Ok(ImageInfo { + mimetype_info: MimetypeInfo::get_for_bytes(bytes) + .ok_or_else(|| ImageInfoError::CouldNotDetermineMimetype)?, + width, + height, + }) + } + + /// Note: This has to do a full file read, and we throw the parsed image on the floor. + pub fn decode_image_from_path>(path: P) -> Result { + let image = ImageReader::open(&path)?.decode()?; + let (width, height) = image.dimensions(); + Ok(ImageInfo { + mimetype_info: MimetypeInfo::get_for_path(path)? + .ok_or_else(|| ImageInfoError::CouldNotDetermineMimetype)?, + width, + height, + }) + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } + + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + + pub fn mime_type(&self) -> &str { + self.mimetype_info.mime_type() + } + + pub fn file_extension(&self) -> Option { + self.mimetype_info.file_extension() + } +} + +#[cfg(test)] +mod tests { + use crate::image_info::image_info::ImageInfo; + use mimetypes::mimetype_info::file_extension::FileExtension; + use test_utils::test_file_path::test_file_path; + + #[test] + fn jpg_file() -> anyhow::Result<()> { + let path = test_file_path("test_data/image/mochi.jpg")?; + let info = ImageInfo::decode_image_from_path(path)?; + assert_eq!(info.width(), 753); + assert_eq!(info.height(), 1000); + assert_eq!(info.mime_type(), "image/jpeg"); + let ext = info.file_extension().expect("should have extension"); + assert_eq!(ext, FileExtension::Jpg); + assert_eq!(ext.extension_with_period(), ".jpg"); + assert_eq!(ext.extension_without_period(), "jpg"); + Ok(()) + } + + #[test] + fn jpg_bytes() -> anyhow::Result<()> { + let path = test_file_path("test_data/image/juno.jpg")?; + let bytes = std::fs::read(path)?; + let info = ImageInfo::decode_image_from_bytes(&bytes)?; + assert_eq!(info.width(), 1000); + assert_eq!(info.height(), 750); + assert_eq!(info.mime_type(), "image/jpeg"); + let ext = info.file_extension().expect("should have extension"); + assert_eq!(ext, FileExtension::Jpg); + assert_eq!(ext.extension_with_period(), ".jpg"); + assert_eq!(ext.extension_without_period(), "jpg"); + Ok(()) + } +} diff --git a/crates/lib/files/images/src/image_info/image_info_error.rs b/crates/lib/files/images/src/image_info/image_info_error.rs new file mode 100644 index 0000000000..e1d84cd3c9 --- /dev/null +++ b/crates/lib/files/images/src/image_info/image_info_error.rs @@ -0,0 +1,39 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; + +#[derive(Debug)] +pub enum ImageInfoError { + CouldNotDetermineMimetype, + ImageError(image::ImageError), + IoError(io::Error), +} + +impl Error for ImageInfoError {} + +impl Display for ImageInfoError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ImageInfoError::CouldNotDetermineMimetype => { + write!(f, "ImageInfoError::CouldNotDetermineMimetype") + } + ImageInfoError::ImageError(err) => { + write!(f, "ImageInfoError::ImageError: {}", err) + } + ImageInfoError::IoError(err) => { + write!(f, "ImageInfoError::IoError: {}", err) + } + } + } +} + +impl From for ImageInfoError { + fn from(err: image::ImageError) -> Self { + ImageInfoError::ImageError(err) + } +} +impl From for ImageInfoError { + fn from(err: io::Error) -> Self { + ImageInfoError::IoError(err) + } +} diff --git a/crates/lib/files/images/src/image_info/mod.rs b/crates/lib/files/images/src/image_info/mod.rs new file mode 100644 index 0000000000..1d5cdbac30 --- /dev/null +++ b/crates/lib/files/images/src/image_info/mod.rs @@ -0,0 +1,2 @@ +pub mod image_info; +pub mod image_info_error; \ No newline at end of file diff --git a/crates/lib/files/images/src/lib.rs b/crates/lib/files/images/src/lib.rs new file mode 100644 index 0000000000..c65665cf34 --- /dev/null +++ b/crates/lib/files/images/src/lib.rs @@ -0,0 +1,32 @@ +//! images +//! +//! Even though the "image" library is the most widely used crate, if we declare a dependency here +//! we can update every downstream caller in unison. This will also let us collect common algorithms +//! and functions. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +// Re-export image to everyone importing this library +pub use image; + +pub mod encoding; +pub mod error; +pub mod image_info; +pub mod mask_images; +pub mod resize_image_file_preserving_aspect; +pub mod resize_preserving_aspect; + diff --git a/crates/lib/files/images/src/mask_images/mod.rs b/crates/lib/files/images/src/mask_images/mod.rs new file mode 100644 index 0000000000..b2b6d6f17f --- /dev/null +++ b/crates/lib/files/images/src/mask_images/mod.rs @@ -0,0 +1 @@ +pub mod normalize_image_bytes_to_flux_mask; \ No newline at end of file diff --git a/crates/lib/files/images/src/mask_images/normalize_image_bytes_to_flux_mask.rs b/crates/lib/files/images/src/mask_images/normalize_image_bytes_to_flux_mask.rs new file mode 100644 index 0000000000..fa631099b0 --- /dev/null +++ b/crates/lib/files/images/src/mask_images/normalize_image_bytes_to_flux_mask.rs @@ -0,0 +1,40 @@ +use crate::error::ImagesError; +use image::{GenericImage, GenericImageView, ImageFormat, ImageReader, Rgba}; +use std::io::Cursor; + +pub struct PngBytes(pub Vec); + +/// Normalize arbitrary image bytes to a PNG format image. +/// Make sure the bitmap is in the correct mask format. +pub fn normalize_image_bytes_to_flux_mask( + arbitrary_image_bytes: &[u8], +) -> Result { + + let reader = ImageReader::new(Cursor::new(arbitrary_image_bytes)); + + let mut image = reader + .with_guessed_format()? // NB: Can raise IoError. + .decode()?; + + let (width, height) = image.dimensions(); + + for y in 0..height { + for x in 0..width { + let rgba_pixel = image.get_pixel(x, y); + + if rgba_pixel[3] == 0 { + // Alpha pixel is transparent + // Set to black. Mask ignore region. + image.put_pixel(x, y, Rgba([0, 0, 0, 255])); + } else { + // Set to white. Mask interest region. + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + } + + let mut output_bytes: Vec = Vec::new(); + image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?; + + Ok(PngBytes(output_bytes)) +} diff --git a/crates/lib/files/images/src/resize_image_file_preserving_aspect.rs b/crates/lib/files/images/src/resize_image_file_preserving_aspect.rs new file mode 100644 index 0000000000..2703547253 --- /dev/null +++ b/crates/lib/files/images/src/resize_image_file_preserving_aspect.rs @@ -0,0 +1,43 @@ +use std::fs::File; +use std::io::BufReader; +use std::path::Path; + +use image::DynamicImage; +use image::io::Reader; + +use errors::AnyhowResult; + +use crate::resize_preserving_aspect::resize_preserving_aspect; + +pub fn resize_image_file_preserving_aspect>( + source_image_file: P, + new_width: u32, + new_height: u32, + exact_fit: bool, +) -> AnyhowResult { + let file = File::open(source_image_file)?; + let reader = BufReader::new(file); + + let reader = Reader::new(reader) + .with_guessed_format()?; + + let img = reader.decode()?; + + Ok(resize_preserving_aspect(&img, new_width, new_height, exact_fit)) +} + +#[cfg(test)] +mod tests { + use test_utils::test_file_path::test_file_path; + + use crate::resize_image_file_preserving_aspect::resize_image_file_preserving_aspect; + + #[test] + fn test_happy_path() { + // NB: The other function does most of the testing + let path = test_file_path("test_data/image/mochi.jpg").unwrap(); + let image = resize_image_file_preserving_aspect(path, 100, 100, true).unwrap(); + assert_eq!(image.width(), 100); + assert_eq!(image.height(), 100); + } +} \ No newline at end of file diff --git a/crates/lib/files/images/src/resize_preserving_aspect.rs b/crates/lib/files/images/src/resize_preserving_aspect.rs new file mode 100644 index 0000000000..2470e5506c --- /dev/null +++ b/crates/lib/files/images/src/resize_preserving_aspect.rs @@ -0,0 +1,134 @@ +use image::DynamicImage; +use image::imageops::FilterType; + +/// Resize an image preserving the exact aspect ratio of the source image. +/// +/// If `exact_fit` is true, the image will be resized to fit the largest dimension, +/// then cropped to fit the precise dimensions specified. +pub fn resize_preserving_aspect( + source_image: &DynamicImage, + new_width: u32, + new_height: u32, + exact_fit: bool, +) -> DynamicImage { + if exact_fit { + source_image.resize_to_fill(new_width, new_height, FilterType::Lanczos3) + } else { + source_image.resize(new_width, new_height, FilterType::Lanczos3) + } +} + +#[cfg(test)] +mod tests { + use image::DynamicImage; + + use errors::AnyhowResult; + use test_utils::test_file_path::test_file_path; + + use crate::resize_preserving_aspect::resize_preserving_aspect; + + fn open_test_image(filename: &str) -> AnyhowResult { + let path = test_file_path(filename)?; + Ok(image::open(path)?) + } + + fn open_tall_image() -> AnyhowResult { + let image = open_test_image("test_data/image/mochi.jpg").unwrap(); + assert!(image.width() < image.height()); + assert_eq!(image.width(), 753); + assert_eq!(image.height(), 1000); + Ok(image) + } + + fn open_wide_image() -> AnyhowResult { + let image = open_test_image("test_data/image/juno.jpg").unwrap(); + assert!(image.width() > image.height()); + assert_eq!(image.width(), 1000); + assert_eq!(image.height(), 750); + Ok(image) + } + + #[test] + fn downsize_wide_image_preserving_aspect() { + let image = open_wide_image().unwrap(); + let resized = resize_preserving_aspect(&image, 500, 500, false); + assert!(resized.width() > resized.height()); + assert_eq!(resized.width(), 500); + assert_eq!(resized.height(), 375); + } + + #[test] + fn downsize_tall_image_preserving_aspect() { + let image = open_tall_image().unwrap(); + let resized = resize_preserving_aspect(&image, 500, 500, false); + assert!(resized.width() < resized.height()); + assert_eq!(resized.width(), 377); + assert_eq!(resized.height(), 500); + } + + #[test] + fn enlarge_wide_image_preserving_aspect() { + let image = open_wide_image().unwrap(); + let resized = resize_preserving_aspect(&image, 1200, 1200, false); + assert!(resized.width() > resized.height()); + assert_eq!(resized.width(), 1200); + assert_eq!(resized.height(), 900); + } + + #[test] + fn enlarge_tall_image_preserving_aspect() { + let image = open_tall_image().unwrap(); + let resized = resize_preserving_aspect(&image, 1200, 1200, false); + assert!(resized.width() < resized.height()); + assert_eq!(resized.width(), 904); + assert_eq!(resized.height(), 1200); + } + + #[test] + fn resize_wide_image_preserving_aspect_exact() { + // Downscale + let image = open_wide_image().unwrap(); + let resized = resize_preserving_aspect(&image, 500, 500, true); + assert_eq!(resized.width(), 500); + assert_eq!(resized.height(), 500); + + // Upscale + let resized = resize_preserving_aspect(&image, 1200, 1200, true); + assert_eq!(resized.width(), 1200); + assert_eq!(resized.height(), 1200); + + // Really wide + let resized = resize_preserving_aspect(&image, 1200, 100, true); + assert_eq!(resized.width(), 1200); + assert_eq!(resized.height(), 100); + + // Really tall + let resized = resize_preserving_aspect(&image, 100, 1200, true); + assert_eq!(resized.width(), 100); + assert_eq!(resized.height(), 1200); + } + + #[test] + fn resize_tall_image_preserving_aspect_exact() { + // Downscale + let image = open_tall_image().unwrap(); + let resized = resize_preserving_aspect(&image, 500, 500, true); + assert_eq!(resized.width(), 500); + assert_eq!(resized.height(), 500); + + // Upscale + let resized = resize_preserving_aspect(&image, 1200, 1200, true); + assert_eq!(resized.width(), 1200); + assert_eq!(resized.height(), 1200); + + // Really wide + let resized = resize_preserving_aspect(&image, 1200, 100, true); + assert_eq!(resized.width(), 1200); + assert_eq!(resized.height(), 100); + + // Really tall + let resized = resize_preserving_aspect(&image, 100, 1200, true); + assert_eq!(resized.width(), 100); + assert_eq!(resized.height(), 1200); + } +} diff --git a/crates/lib/files/mimetypes/Cargo.toml b/crates/lib/files/mimetypes/Cargo.toml new file mode 100644 index 0000000000..0f80f6d5d5 --- /dev/null +++ b/crates/lib/files/mimetypes/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "mimetypes" +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "mimetypes" crate does not exist, so it's safe to use for now. +name = "mimetypes" +path = "src/lib.rs" + +[dependencies] + +# External +infer = "0.19.0" # Mimetype detection + +[dev-dependencies] +test_utils = { path = "../../../testing/test_utils" } +anyhow = { workspace = true } diff --git a/crates/lib/files/mimetypes/src/custom/custom_infer.rs b/crates/lib/files/mimetypes/src/custom/custom_infer.rs new file mode 100644 index 0000000000..97d805ec1a --- /dev/null +++ b/crates/lib/files/mimetypes/src/custom/custom_infer.rs @@ -0,0 +1,78 @@ +use infer::Infer; +use std::sync::LazyLock; + +pub (crate) static CUSTOM_INFER: LazyLock = LazyLock::new(|| { + let mut infer = Infer::new(); + add_custom_types(&mut infer); + infer +}); + +fn add_custom_types(infer: &mut Infer) { + infer.add("model/gltf-binary", "glb", gltf_matcher); + infer.add("model/fbx", "fbx", fbx_matcher); + infer.add("model/ply", "ply", ply_matcher); +} + +fn gltf_matcher(buf: &[u8]) -> bool { + // Magic is 'glTF' (103 108 84 70) + // https://github.com/KhronosGroup/glTF/blob/main/extensions/1.0/Khronos/KHR_binary_glTF/README.md + buf.len() >= 4 && buf[0] == 103 && buf[1] == 108 && buf[2] == 84 && buf[3] == 70 +} + +fn ply_matcher(buf: &[u8]) -> bool { + // PLY files (ASCII and binary alike) start with the line "ply". + // https://paulbourke.net/dataformats/ply/ + buf.len() >= 4 + && &buf[..3] == b"ply" + && (buf[3] == b'\n' || buf[3] == b'\r') +} + +fn fbx_matcher(buf: &[u8]) -> bool { + // Binary FBX magic: "Kaydara FBX Binary " (two trailing spaces). + // https://code.blender.org/2013/08/fbx-binary-file-format-specification/ + // NB: ASCII FBX files have no magic and are not detected here. + const FBX_MAGIC: &[u8] = b"Kaydara FBX Binary "; + buf.len() >= FBX_MAGIC.len() && &buf[..FBX_MAGIC.len()] == FBX_MAGIC +} + +#[cfg(test)] +mod tests { + use crate::custom::custom_infer::CUSTOM_INFER; + use test_utils::test_file_path::test_file_path; + + #[test] + fn test_gltf() -> anyhow::Result<()> { + let path = test_file_path("test_data/3d/hanashi.glb")?; + let maybe_type = CUSTOM_INFER.get_from_path(path)?.unwrap(); + assert_eq!(maybe_type.mime_type(), "model/gltf-binary"); + Ok(()) + } + + #[test] + fn test_binary_fbx_magic() { + let mut bytes = b"Kaydara FBX Binary ".to_vec(); + bytes.extend_from_slice(&[0x00, 0x1a, 0x00]); + let maybe_type = CUSTOM_INFER.get(&bytes).unwrap(); + assert_eq!(maybe_type.mime_type(), "model/fbx"); + } + + #[test] + fn test_ply_magic() { + let bytes = b"ply\nformat binary_little_endian 1.0\n".to_vec(); + let maybe_type = CUSTOM_INFER.get(&bytes).unwrap(); + assert_eq!(maybe_type.mime_type(), "model/ply"); + } + + #[test] + fn test_ply_prefix_without_newline_is_not_detected() { + let bytes = b"plywood texture".to_vec(); + assert!(CUSTOM_INFER.get(&bytes).is_none()); + } + + #[test] + fn test_ascii_fbx_is_not_detected() { + // ASCII FBX files have no magic bytes. + let bytes = b"; FBX 7.3.0 project file".to_vec(); + assert!(CUSTOM_INFER.get(&bytes).is_none()); + } +} diff --git a/crates/lib/files/mimetypes/src/custom/mod.rs b/crates/lib/files/mimetypes/src/custom/mod.rs new file mode 100644 index 0000000000..69519270dd --- /dev/null +++ b/crates/lib/files/mimetypes/src/custom/mod.rs @@ -0,0 +1 @@ +pub (crate) mod custom_infer; \ No newline at end of file diff --git a/crates/lib/files/mimetypes/src/lib.rs b/crates/lib/files/mimetypes/src/lib.rs new file mode 100644 index 0000000000..286b1abf88 --- /dev/null +++ b/crates/lib/files/mimetypes/src/lib.rs @@ -0,0 +1,26 @@ +//! mimetypes +//! +//! The purpose of this library is to perform mimetype detection routines on files, raw bytes, +//! streams, etc. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub (crate) mod custom; +pub mod mimetype_for_bytes; +pub mod mimetype_for_file; +pub mod mimetype_info; +pub mod mimetype_to_extension; diff --git a/crates/lib/files/mimetypes/src/mimetype_for_bytes.rs b/crates/lib/files/mimetypes/src/mimetype_for_bytes.rs new file mode 100644 index 0000000000..66cf9bc4bf --- /dev/null +++ b/crates/lib/files/mimetypes/src/mimetype_for_bytes.rs @@ -0,0 +1,41 @@ + +const DEFAULT_BINARY_MIMETYPE : &str = "application/octet-stream"; + +pub fn get_mimetype_for_bytes(bytes: &[u8]) -> Option<&'static str> { + infer::get(bytes) + .map(|typ| typ.mime_type()) +} + +pub fn get_mimetype_for_bytes_or_default(bytes: &[u8]) -> &'static str { + infer::get(bytes) + .map(|typ| typ.mime_type()) + .unwrap_or(DEFAULT_BINARY_MIMETYPE) +} + +#[cfg(test)] +mod tests { + use crate::mimetype_for_bytes::{get_mimetype_for_bytes, get_mimetype_for_bytes_or_default}; + + #[test] + fn test_mimetype_png() { + // NB: From "Hanashi Mask.png" + let bytes : Vec = vec![ + 137, 80, 78, 71, 13, 10, 26, 10, + 0, 0, 0, 13, 73, 72, 68, 82, + 0, 0, 20, 193, 0, 0, 15, 239, + 8, 6, 0, 0, 0, 163, 131, 119, + 122, 0, 0, 0, + ]; + + assert_eq!(get_mimetype_for_bytes(&bytes), Some("image/png")); + assert_eq!(get_mimetype_for_bytes_or_default(&bytes), "image/png"); + } + + #[test] + fn test_mimetype_unknown() { + let bytes : Vec = vec![]; + + assert_eq!(get_mimetype_for_bytes(&bytes), None); + assert_eq!(get_mimetype_for_bytes_or_default(&bytes), "application/octet-stream"); + } +} diff --git a/crates/lib/files/mimetypes/src/mimetype_for_file.rs b/crates/lib/files/mimetypes/src/mimetype_for_file.rs new file mode 100644 index 0000000000..83af7721d6 --- /dev/null +++ b/crates/lib/files/mimetypes/src/mimetype_for_file.rs @@ -0,0 +1,44 @@ +use std::path::Path; + +const DEFAULT_BINARY_MIMETYPE : &str = "application/octet-stream"; + +pub fn get_mimetype_for_file>(file: P) -> std::io::Result> { + infer::get_from_path(file) + .map(|maybe_type| maybe_type.map(|typ| typ.mime_type())) +} + +pub fn get_mimetype_for_file_or_default>(file: P) -> std::io::Result<&'static str> { + get_mimetype_for_file(file) + .map(|maybe_type| match maybe_type { + Some(mimetype) => mimetype, + None => DEFAULT_BINARY_MIMETYPE, + }) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::mimetype_for_file::get_mimetype_for_file; + + fn test_file(path_from_repo_root: &str) -> PathBuf { + // https://doc.rust-lang.org/cargo/reference/environment-variables.html + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push(format!("../../../../{}", path_from_repo_root)); + path + } + + #[test] + fn test_mimetype_mp3() { + let path = test_file("test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3"); + + assert_eq!(get_mimetype_for_file(&path).unwrap(), Some("audio/mpeg")); + } + + #[test] + fn test_mimetype_unknown() { + let path = test_file("test_data/video/mp4/golden_sun_garoh.mp4"); + + assert_eq!(get_mimetype_for_file(&path).unwrap(), Some("video/mp4")); + } +} diff --git a/crates/lib/files/mimetypes/src/mimetype_info/file_extension.rs b/crates/lib/files/mimetypes/src/mimetype_info/file_extension.rs new file mode 100644 index 0000000000..2aa1825d86 --- /dev/null +++ b/crates/lib/files/mimetypes/src/mimetype_info/file_extension.rs @@ -0,0 +1,81 @@ + +/// This is a constrained set of file extensions that: +/// 1. we care about +/// 2. we can detect with magic bytes +/// 3. are not text formats / are binary formats +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub enum FileExtension { + Aac, + Fbx, + Flac, + Gif, + Glb, + Jpg, + Mov, + Mp3, + Mp4, + Obj, + Ogg, + Opus, + Ply, + Png, + Wav, + Webm, + Webp, +} + +impl FileExtension { + pub fn from_mimetype(mimetype: &str) -> Option { + match mimetype { + "audio/aac" => Some(FileExtension::Aac), + "model/fbx" => Some(FileExtension::Fbx), + "application/fbx" => Some(FileExtension::Fbx), + "audio/flac" => Some(FileExtension::Flac), + "image/gif" => Some(FileExtension::Gif), + "model/gltf-binary" => Some(FileExtension::Glb), + "image/jpeg" => Some(FileExtension::Jpg), + "video/quicktime" => Some(FileExtension::Mov), + "audio/mpeg" => Some(FileExtension::Mp3), + "video/mp4" => Some(FileExtension::Mp4), + "model/obj" => Some(FileExtension::Obj), + "model/ply" => Some(FileExtension::Ply), + "audio/ogg" => Some(FileExtension::Ogg), + "audio/opus" => Some(FileExtension::Opus), + "image/png" => Some(FileExtension::Png), + "audio/wav" => Some(FileExtension::Wav), + "video/webm" => Some(FileExtension::Webm), + "image/webp" => Some(FileExtension::Webp), + _ => None, + } + } + + pub fn extension_with_period(&self) -> &'static str { + self.with_and_without_period().0 + } + + pub fn extension_without_period(&self) -> &'static str { + self.with_and_without_period().1 + } + + fn with_and_without_period(&self) -> (&'static str, &'static str) { + match self { + FileExtension::Aac => (".aac", "aac"), + FileExtension::Fbx => (".fbx", "fbx"), + FileExtension::Flac => (".flac", "flac"), + FileExtension::Gif => (".gif", "gif"), + FileExtension::Glb => (".glb", "glb"), + FileExtension::Jpg => (".jpg", "jpg"), + FileExtension::Mov => (".mov", "mov"), + FileExtension::Mp3 => (".mp3", "mp3"), + FileExtension::Mp4 => (".mp4", "mp4"), + FileExtension::Obj => (".obj", "obj"), + FileExtension::Ogg => (".ogg", "ogg"), + FileExtension::Opus => (".opus", "opus"), + FileExtension::Ply => (".ply", "ply"), + FileExtension::Png => (".png", "png"), + FileExtension::Wav => (".wav", "wav"), + FileExtension::Webm => (".webm", "webm"), + FileExtension::Webp => (".webp", "webp"), + } + } +} diff --git a/crates/lib/files/mimetypes/src/mimetype_info/mimetype_info.rs b/crates/lib/files/mimetypes/src/mimetype_info/mimetype_info.rs new file mode 100644 index 0000000000..6369dc7f0c --- /dev/null +++ b/crates/lib/files/mimetypes/src/mimetype_info/mimetype_info.rs @@ -0,0 +1,93 @@ +use crate::custom::custom_infer::CUSTOM_INFER; +use crate::mimetype_info::file_extension::FileExtension; +use infer::Type; +use std::io; +use std::path::Path; + +#[derive(Clone, Debug)] +pub struct MimetypeInfo { + /// The mimetype we determined + mime_type: String, + + /// The extension for the mimetype, if we could determine it. + file_extension: Option, +} + +impl MimetypeInfo { + pub fn get_for_bytes(bytes: &[u8]) -> Option { + CUSTOM_INFER.get(bytes) + .map(|t| Self::for_type(t)) + } + + pub fn get_for_path>(path: P) -> io::Result> { + CUSTOM_INFER.get_from_path(path) + .map(|maybe_type| maybe_type.map(|t| Self::for_type(t))) + } + + fn for_type(r#type: Type) -> Self { + let mime_type = r#type.mime_type().to_string(); + MimetypeInfo { + file_extension: FileExtension::from_mimetype(&mime_type), + mime_type, + } + } + + pub fn mime_type(&self) -> &str { + &self.mime_type + } + + pub fn file_extension(&self) -> Option { + self.file_extension + } +} + +#[cfg(test)] +mod tests { + use crate::mimetype_info::file_extension::FileExtension; + use crate::mimetype_info::mimetype_info::MimetypeInfo; + + mod for_bytes { + use super::*; + + #[test] + fn png() { + // NB: From "Hanashi Mask.png" + let bytes: Vec = vec![137, 80, 78, 71, 13, 10, 26, 10]; + let t = MimetypeInfo::get_for_bytes(&bytes).expect("should detect bytes"); + assert_eq!(t.mime_type(), "image/png"); + let ext = t.file_extension.expect("should have extension"); + assert_eq!(ext, FileExtension::Png); + assert_eq!(ext.extension_with_period(), ".png"); + assert_eq!(ext.extension_without_period(), "png"); + } + } + + mod for_files { + use super::*; + use test_utils::test_file_path::test_file_path; + + #[test] + fn mp3() -> anyhow::Result<()> { + let path = test_file_path("test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3")?; + let t = MimetypeInfo::get_for_path(path)?.expect("should detect type"); + assert_eq!(t.mime_type(), "audio/mpeg"); + let ext = t.file_extension.expect("should have extension"); + assert_eq!(ext, FileExtension::Mp3); + assert_eq!(ext.extension_with_period(), ".mp3"); + assert_eq!(ext.extension_without_period(), "mp3"); + Ok(()) + } + + #[test] + fn glb() -> anyhow::Result<()> { + let path = test_file_path("test_data/3d/hanashi.glb")?; + let t = MimetypeInfo::get_for_path(path)?.expect("should detect type"); + assert_eq!(t.mime_type(), "model/gltf-binary"); + let ext = t.file_extension.expect("should have extension"); + assert_eq!(ext, FileExtension::Glb); + assert_eq!(ext.extension_with_period(), ".glb"); + assert_eq!(ext.extension_without_period(), "glb"); + Ok(()) + } + } +} diff --git a/crates/lib/files/mimetypes/src/mimetype_info/mod.rs b/crates/lib/files/mimetypes/src/mimetype_info/mod.rs new file mode 100644 index 0000000000..910eaea54f --- /dev/null +++ b/crates/lib/files/mimetypes/src/mimetype_info/mod.rs @@ -0,0 +1,2 @@ +pub mod file_extension; +pub mod mimetype_info; \ No newline at end of file diff --git a/crates/lib/files/mimetypes/src/mimetype_to_extension.rs b/crates/lib/files/mimetypes/src/mimetype_to_extension.rs new file mode 100644 index 0000000000..7e53f35ef7 --- /dev/null +++ b/crates/lib/files/mimetypes/src/mimetype_to_extension.rs @@ -0,0 +1,49 @@ + +/// Provide a best guess extension for the given mime type. +/// Extensions do not include a "." (period) prefix. +pub fn mimetype_to_extension(mimetype: &str) -> Option<&'static str> { + // TODO(bt,2023-11-17): maybe make a bidirectional map + match mimetype { + // Image + "image/gif" => Some("gif"), + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/webp" => Some("webp"), + // Audio + "audio/aac" => Some("aac"), + "audio/m4a" => Some("m4a"), + "audio/mpeg" => Some("mp3"), + "audio/ogg" => Some("ogg"), + "audio/opus" => Some("opus"), + "audio/wav" => Some("wav"), + "audio/x-flac" => Some("flac"), + "audio/x-wav" => Some("wav"), + // Video + "video/mp4" /* .mp4 */ => Some("mp4"), + "video/quicktime" /* .mov */ => Some("mov"), + "video/webm" => Some("webm"), // TODO: Browsers send this for audio? + _ => None + } +} + +#[cfg(test)] +mod test { + use crate::mimetype_to_extension::mimetype_to_extension; + + #[test] + fn no_value() { + assert_eq!(mimetype_to_extension(""), None); + assert_eq!(mimetype_to_extension("blah"), None); + assert_eq!(mimetype_to_extension("nothing"), None); + } + + #[test] + fn mime_type_spot_check() { + assert_eq!(mimetype_to_extension("audio/mpeg"), Some("mp3")); + assert_eq!(mimetype_to_extension("audio/wav"), Some("wav")); + assert_eq!(mimetype_to_extension("audio/x-wav"), Some("wav")); + assert_eq!(mimetype_to_extension("image/png"), Some("png")); + assert_eq!(mimetype_to_extension("image/jpeg"), Some("jpg")); + assert_eq!(mimetype_to_extension("video/mp4"), Some("mp4")); + } +} \ No newline at end of file diff --git a/crates/lib/http_headers/Cargo.toml b/crates/lib/http_headers/Cargo.toml new file mode 100644 index 0000000000..a4ac14a90a --- /dev/null +++ b/crates/lib/http_headers/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "http_headers" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +# NB: public `http_headers` crate appears to be abandoned, so this name seems fine. Wish we had namespacing, though. +name = "http_headers" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true + +# External +# (None yet) + +[dev-dependencies] +# (None yet) diff --git a/crates/lib/http_headers/src/lib.rs b/crates/lib/http_headers/src/lib.rs new file mode 100644 index 0000000000..3a85c60c0f --- /dev/null +++ b/crates/lib/http_headers/src/lib.rs @@ -0,0 +1,23 @@ +//! http_headers +//! +//! The purpose of this library is to centralize the definition of common header names +//! and header values that are not already represented by common libraries. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod names; +pub mod values; \ No newline at end of file diff --git a/crates/lib/http_headers/src/names/mod.rs b/crates/lib/http_headers/src/names/mod.rs new file mode 100644 index 0000000000..6f5b8ca8d4 --- /dev/null +++ b/crates/lib/http_headers/src/names/mod.rs @@ -0,0 +1,20 @@ + +/// `Priority` header name +/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Priority +pub const PRIORITY: &str = "Priority"; + +/// `Sec-Fetch-Dest` header name +/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Dest +pub const SEC_FETCH_DEST : &str = "Sec-Fetch-Dest"; + +/// `Sec-Fetch-Mode` header name +/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Mode +pub const SEC_FETCH_MODE: &str = "Sec-Fetch-Mode"; + +/// `Sec-Fetch-Site` header name +/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site +pub const SEC_FETCH_SITE : &str = "Sec-Fetch-Site"; + +/// `Sec-GPC` header name +/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-GPC +pub const SEC_GPC : &str = "Sec-GPC"; diff --git a/crates/lib/http_headers/src/values/accept.rs b/crates/lib/http_headers/src/values/accept.rs new file mode 100644 index 0000000000..c8ebae389f --- /dev/null +++ b/crates/lib/http_headers/src/values/accept.rs @@ -0,0 +1,2 @@ + +pub const ACCEPT_ALL : &str = "*/*"; diff --git a/crates/lib/http_headers/src/values/accept_encoding.rs b/crates/lib/http_headers/src/values/accept_encoding.rs new file mode 100644 index 0000000000..a7783a0fce --- /dev/null +++ b/crates/lib/http_headers/src/values/accept_encoding.rs @@ -0,0 +1,5 @@ + + +/// Accept-Encoding +/// Sent by Firefox for en-US on Mac +pub const ACCEPT_ENCODING_GZIP_ETC : &str = "gzip, deflate, br, zstd"; diff --git a/crates/lib/http_headers/src/values/accept_language.rs b/crates/lib/http_headers/src/values/accept_language.rs new file mode 100644 index 0000000000..da1629ceaa --- /dev/null +++ b/crates/lib/http_headers/src/values/accept_language.rs @@ -0,0 +1,4 @@ + +/// Accept-Language +/// Sent by Firefox for en-US on Mac +pub const ACCEPT_LANGUAGE_EN_US: &str = "en-US,en;q=0.5"; diff --git a/crates/lib/http_headers/src/values/cache_control.rs b/crates/lib/http_headers/src/values/cache_control.rs new file mode 100644 index 0000000000..dfd8e50922 --- /dev/null +++ b/crates/lib/http_headers/src/values/cache_control.rs @@ -0,0 +1,3 @@ + +/// eg. -H 'Cache-Control: no-cache' +pub const CACHE_CONTROL_NO_CACHE: &str = "no-cache"; diff --git a/crates/lib/http_headers/src/values/connection.rs b/crates/lib/http_headers/src/values/connection.rs new file mode 100644 index 0000000000..0ab994abf8 --- /dev/null +++ b/crates/lib/http_headers/src/values/connection.rs @@ -0,0 +1,2 @@ + +pub const CONNECTION_KEEP_ALIVE : &str = "keep-alive"; diff --git a/crates/lib/http_headers/src/values/content_type.rs b/crates/lib/http_headers/src/values/content_type.rs new file mode 100644 index 0000000000..1753912c1e --- /dev/null +++ b/crates/lib/http_headers/src/values/content_type.rs @@ -0,0 +1,2 @@ + +pub const CONTENT_TYPE_APPLICATION_JSON: &str = "application/json"; diff --git a/crates/lib/http_headers/src/values/mod.rs b/crates/lib/http_headers/src/values/mod.rs new file mode 100644 index 0000000000..1e78973685 --- /dev/null +++ b/crates/lib/http_headers/src/values/mod.rs @@ -0,0 +1,10 @@ +pub mod accept; +pub mod accept_encoding; +pub mod accept_language; +pub mod cache_control; +pub mod connection; +pub mod content_type; +pub mod pragma; +pub mod priority; +pub mod sec; +pub mod te; diff --git a/crates/lib/http_headers/src/values/pragma.rs b/crates/lib/http_headers/src/values/pragma.rs new file mode 100644 index 0000000000..28408e6214 --- /dev/null +++ b/crates/lib/http_headers/src/values/pragma.rs @@ -0,0 +1,3 @@ + +/// eg. -H 'Pragma: no-cache' +pub const PRAGMA_NO_CACHE: &str = "no-cache"; \ No newline at end of file diff --git a/crates/lib/http_headers/src/values/priority.rs b/crates/lib/http_headers/src/values/priority.rs new file mode 100644 index 0000000000..197c90cc80 --- /dev/null +++ b/crates/lib/http_headers/src/values/priority.rs @@ -0,0 +1,10 @@ +/// eg. -H 'Priority: u=0' +pub const PRIORITY_HIGHEST : &str = "u=0"; + +/// eg. -H 'Priority: u=4' +/// "The default priority value for requests is 3." +pub const PRIORITY_4: &str = "u=4"; + +/// eg. -H 'Priority: u=6' +/// "The default priority value for requests is 3." +pub const PRIORITY_6: &str = "u=6"; diff --git a/crates/lib/http_headers/src/values/sec.rs b/crates/lib/http_headers/src/values/sec.rs new file mode 100644 index 0000000000..72bc5637d6 --- /dev/null +++ b/crates/lib/http_headers/src/values/sec.rs @@ -0,0 +1,9 @@ + +/// eg. -H 'Sec-Fetch-Dest: empty' +pub const SEC_FETCH_DEST_EMPTY : &str = "empty"; + +/// eg. -H 'Sec-Fetch-Mode: cors' +pub const SEC_FETCH_MODE_CORS : &str = "cors"; + +/// eg. -H 'Sec-Fetch-Site: cross-site' +pub const SEC_FETCH_SITE_CROSS_SITE : &str = "cross-site"; diff --git a/crates/lib/http_headers/src/values/te.rs b/crates/lib/http_headers/src/values/te.rs new file mode 100644 index 0000000000..007c53f027 --- /dev/null +++ b/crates/lib/http_headers/src/values/te.rs @@ -0,0 +1,3 @@ + +/// eg. -H 'TE: trailers' +pub const TE_TRAILERS : &str = "trailers"; diff --git a/crates/lib/jwt_light/Cargo.toml b/crates/lib/jwt_light/Cargo.toml new file mode 100644 index 0000000000..885ae06762 --- /dev/null +++ b/crates/lib/jwt_light/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "jwt_light" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "jwt_light" +path = "src/lib.rs" + +[dependencies] + + +# Internal +# (None) + +# External +base64.workspace = true +chrono.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# (None) \ No newline at end of file diff --git a/crates/lib/jwt_light/src/common_claims.rs b/crates/lib/jwt_light/src/common_claims.rs new file mode 100644 index 0000000000..a2caf3fd02 --- /dev/null +++ b/crates/lib/jwt_light/src/common_claims.rs @@ -0,0 +1,109 @@ +use crate::error::JwtError; +use crate::utils::json_number_to_datetime::json_number_to_datetime; +use chrono::{DateTime, Utc}; +use serde_json::Value; + +pub struct CommonClaims { + /// From the "iat" field + pub created: DateTime, + + /// From the "exp" field + pub expiration: DateTime, +} + +impl CommonClaims { + pub fn from_json(raw_json_claims: &str) -> Result { + let claims : serde_json::Map = serde_json::from_str(&raw_json_claims) + .map_err(|err| JwtError::CommonFieldError( + format!("failure to parse claims json: {:?}", err)))?; + + // NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats + let iat = { + let iat = claims.get("iat") + .map(|val| val.as_number()) + .flatten() + .ok_or_else(|| JwtError::CommonFieldError("no iat claim".to_string()))?; + + json_number_to_datetime(iat, "iat")? + }; + + let exp = { + let exp = claims.get("exp") + .map(|val| val.as_number()) + .flatten() + .ok_or_else(|| JwtError::CommonFieldError("no exp claim".to_string()))?; + + json_number_to_datetime(exp, "exp")? + }; + + Ok(Self { + created: iat, + expiration: exp, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::common_claims::CommonClaims; + use crate::utils::raw_jwt_to_raw_json::raw_jwt_to_raw_json; + use chrono::DateTime; + + #[test] + fn test_runwayml() { + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s"; + + let json = raw_jwt_to_raw_json(jwt).unwrap(); + + let claims = CommonClaims::from_json(&json).unwrap(); + + // jwt payload: + // { + // "id": 23129405, + // "email": "echelon@gmail.com", + // "exp": 1750201256.49, + // "iat": 1747609256.49, + // "sso": false + // } + + assert_eq!(claims.created, DateTime::from_timestamp(1747609256, 0).unwrap()); + assert_eq!(claims.expiration, DateTime::from_timestamp(1750201256, 0).unwrap()); + } + + #[test] + fn test_sora() { + let token = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6Ik\ + pXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfTTFuUTN0UjV2\ + VzdYOWpMMnBFNmdIOGRLNCIsImV4cCI6MTc0NTgwMDQ3MiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InV\ + zZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2\ + ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6M\ + Tc0NDkzNjQ3MSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiI0NTZmZjY5Yi0xZTZhLTQzOWYt\ + YTYzMC1jNmFjYzI3NzU1YjAiLCJuYmYiOjE3NDQ5MzY0NzEsInB3ZF9hdXRoX3RpbWUiOjE3NDQwNTkzMTc3NTAsInN\ + jcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyJdLCJzZXNzaW9uX2lkIjoiYXV0aH\ + Nlc3NfcEE4UGl3V0trNmJsSE1qQnVCQnVSRmh2Iiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTMxMDE5Njc2MTIzOTY3O\ + TM3NzcifQ.JOxYfrQSIsHdf-VN1zcOqDOS3tF1HpUYnRVfPKgi65am3sNiniOM1kWTNrKE5-sh4DaZ33G_XniGkhg-9\ + QKF8tspXpZ6BnTNkf-QdGqR_v89AIO8pZgQ7oECLXhnmg-31G-LAHlGfwWedJ6qPVy4dL5zoR0wVyoQ8gYv5QzQC-zd\ + IF7uY-umKKloAAYP_tkKLlQHackjTynN-bpu2mKv55-h7a3hSEPfxvgX0SsvL69xtNsZkwb43bqyP1x_c2ErfYoPyzg\ + rqgMIRZjCw9TI33vISKM_BfQXBiZT_BHkX4s0Jng3ph4vrwvnBsa0HpNxBhQYKb1u9gZXs7XjLZhjZJZsDTQI1V4Wov\ + iAL7ihvZkLxJRtDCOmX7-5BTEsMInFgXUAhCDhQUMwSfLLJuinBS96NGSX5_TfIVMkbCl-HWzdQx7KZAsahNe_CmuYT\ + lZH32NuRn78ohY4eViRyfC8OXVLwoLxxpen2CWlV2dxV5wwXeij_kHt5wOXSGYpSfxz37cK0CaK8K2pwudasxQ0lv2V\ + _Xx_z4UaEOr5wqT_BK1A1HO4HRjvFX2soF-qm4GCSWAbmFZs7DVEClrotxRx2romifRVb8XRia2M6YTmDyIT0YphFgG\ + nnrDIZNKO23uDYT60LVMVYpCCY2RiwxCR9bn7AeGbmi7cpL9aYZ5uJRM"; + + let json = raw_jwt_to_raw_json(token).unwrap(); + + // jwt payload : + // - "https://api.openai.com/auth".user_id = "user-6NxJflAHoEBDJzZl9iXhqbDG" + // - "https://api.openai.com/profile".email = "vocodes2020@gmail.com" + // - "https://api.openai.com/profile".email_verified = true + // - "iat": 1744936471, - issued at claim (5 days ago) + // - "nbf": 1744936471, - not before, don't use before date + // - "exp": 1745800472, - in 5 days, expiry + + let claims = CommonClaims::from_json(&json).unwrap(); + + assert_eq!(claims.created, DateTime::from_timestamp(1744936471, 0).unwrap()); + assert_eq!(claims.expiration, DateTime::from_timestamp(1745800472, 0).unwrap()); + } +} diff --git a/crates/lib/jwt_light/src/error.rs b/crates/lib/jwt_light/src/error.rs new file mode 100644 index 0000000000..6c89ffea8e --- /dev/null +++ b/crates/lib/jwt_light/src/error.rs @@ -0,0 +1,51 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum JwtError { + /// Generic error parsing the JWT + ParseError(String), + /// Error parsing the JWT JSON payload specifically + JsonParseError(serde_json::Error), + /// Error decoding the base64 payload + Base64DecodeError(base64::DecodeError), + /// Error decoding from UTF-8 (should be exceedingly rare) + Utf8Error(std::string::FromUtf8Error), + /// Error parsing a common field (iat, exp, etc.) + CommonFieldError(String), + /// Error parsing a custom claims field. + CustomClaimsFieldError(String), +} + +impl Error for JwtError {} + +impl Display for JwtError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ParseError(reason) => { write!(f, "JwtError::ParseError : {}", reason) } + Self::JsonParseError(err) => { write!(f, "JwtError::JsonParseError : {:?}", err) } + Self::Base64DecodeError(err) => { write!(f, "JwtError::Base64DecodeError : {:?}", err) } + Self::Utf8Error(err) => { write!(f, "JwtError::Utf8Error : {:?}", err) } + Self::CommonFieldError(reason) => { write!(f, "JwtError::CommonFieldError : {}", reason) } + Self::CustomClaimsFieldError(reason) => { write!(f, "JwtError::CustomClaimsFieldError : {}", reason) } + } + } +} + +impl From for JwtError { + fn from(err: serde_json::Error) -> Self { + JwtError::JsonParseError(err) + } +} + +impl From for JwtError { + fn from(err: base64::DecodeError) -> Self { + JwtError::Base64DecodeError(err) + } +} + +impl From for JwtError { + fn from(err: std::string::FromUtf8Error) -> Self { + JwtError::Utf8Error(err) + } +} diff --git a/crates/lib/jwt_light/src/lib.rs b/crates/lib/jwt_light/src/lib.rs new file mode 100644 index 0000000000..65cc31ec17 --- /dev/null +++ b/crates/lib/jwt_light/src/lib.rs @@ -0,0 +1,12 @@ +//! Lightweight JWT library for Rust. +//! No cryptographic verification, no OpenSSL, etc. +//! +//! DO NOT USE THIS FOR OUR OWN JWT VERIFICATION !!! +//! It's okay to parse external vendors since we're not validating, but +//! do not ever use this to secure our own server. + +pub (crate) mod utils; + +pub mod common_claims; +pub mod error; +pub mod parse_jwt_claims_trait; diff --git a/crates/lib/jwt_light/src/parse_jwt_claims_trait.rs b/crates/lib/jwt_light/src/parse_jwt_claims_trait.rs new file mode 100644 index 0000000000..ee71dbba3c --- /dev/null +++ b/crates/lib/jwt_light/src/parse_jwt_claims_trait.rs @@ -0,0 +1,27 @@ +use crate::common_claims::CommonClaims; +use crate::error::JwtError; +use crate::utils::decode_base64::decode_base64; +use crate::utils::split_components::split_components; +use serde_json::Value; + +/// Trait for simple JWT claims parsing +pub trait ParseJwtClaims : Sized { + /// Parse any special fields relevant to the struct + /// Implement this method to complete the parsing. + fn extract_claims( + common_claims: CommonClaims, + extra_claims: serde_json::Map, + ) -> Result; + + fn parse_claims(raw_jwt: &str) -> Result { + let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?; + + let raw_json = decode_base64(claims_str)?; + + let common_claims = CommonClaims::from_json(&raw_json)?; + + let extra_claims : serde_json::Map = serde_json::from_str(&raw_json)?; + + Self::extract_claims(common_claims, extra_claims) + } +} diff --git a/crates/lib/jwt_light/src/utils/decode_base64.rs b/crates/lib/jwt_light/src/utils/decode_base64.rs new file mode 100644 index 0000000000..50e5958bf1 --- /dev/null +++ b/crates/lib/jwt_light/src/utils/decode_base64.rs @@ -0,0 +1,9 @@ +use crate::error::JwtError; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use base64::Engine; + +pub fn decode_base64(raw: &str) -> Result { + let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?; + let json_str = String::from_utf8(json_bytes.clone())?; + Ok(json_str) +} diff --git a/crates/lib/jwt_light/src/utils/json_number_to_datetime.rs b/crates/lib/jwt_light/src/utils/json_number_to_datetime.rs new file mode 100644 index 0000000000..47be8ca547 --- /dev/null +++ b/crates/lib/jwt_light/src/utils/json_number_to_datetime.rs @@ -0,0 +1,30 @@ +use crate::error::JwtError; +use chrono::{DateTime, Utc}; +use serde_json::Number; + +// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats +pub (crate) fn json_number_to_datetime(timestamp: &Number, field_name: &str) -> Result, JwtError> { + let result; + + if timestamp.is_i64() { + // Integer case + let timestamp = timestamp.as_i64() + .ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?; + + result = DateTime::from_timestamp(timestamp, 0) + .ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?; + } else if timestamp.is_f64() { + // Float case + let timestamp = timestamp.as_f64() + .ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?; + + let timestamp = timestamp.round() as i64; + + result = DateTime::from_timestamp(timestamp, 0) + .ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?; + } else { + return Err(JwtError::CommonFieldError(format!("{} is not a number", field_name))); + } + + Ok(result) +} diff --git a/crates/lib/jwt_light/src/utils/mod.rs b/crates/lib/jwt_light/src/utils/mod.rs new file mode 100644 index 0000000000..17fa7884e8 --- /dev/null +++ b/crates/lib/jwt_light/src/utils/mod.rs @@ -0,0 +1,4 @@ +pub (crate) mod decode_base64; +pub (crate) mod json_number_to_datetime; +pub (crate) mod raw_jwt_to_raw_json; +pub (crate) mod split_components; \ No newline at end of file diff --git a/crates/lib/jwt_light/src/utils/raw_jwt_to_raw_json.rs b/crates/lib/jwt_light/src/utils/raw_jwt_to_raw_json.rs new file mode 100644 index 0000000000..7189cf7ba9 --- /dev/null +++ b/crates/lib/jwt_light/src/utils/raw_jwt_to_raw_json.rs @@ -0,0 +1,9 @@ +use crate::error::JwtError; +use crate::utils::decode_base64::decode_base64; +use crate::utils::split_components::split_components; + +pub (crate) fn raw_jwt_to_raw_json(raw_jwt: &str) -> Result { + let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?; + let raw_json = decode_base64(claims_str)?; + Ok(raw_json) +} diff --git a/crates/lib/jwt_light/src/utils/split_components.rs b/crates/lib/jwt_light/src/utils/split_components.rs new file mode 100644 index 0000000000..321fb4a4bf --- /dev/null +++ b/crates/lib/jwt_light/src/utils/split_components.rs @@ -0,0 +1,25 @@ +use crate::error::JwtError; + +const SEPARATOR: &str = "."; + +pub fn split_components(token: &str) -> Result<[&str; 3], JwtError> { + let mut components = token.split(SEPARATOR); + let header = components.next() + .ok_or_else(|| jwt_error("no header component"))?; + + let claims = components.next() + .ok_or_else(|| jwt_error("no claims component"))?; + + let signature = components.next() + .ok_or_else(|| jwt_error("no signature component"))?; + + if components.next().is_some() { + return Err(jwt_error("too many components")); + } + + Ok([header, claims, signature]) +} + +fn jwt_error(reason: &str) -> JwtError { + JwtError::ParseError(reason.to_string()) +} diff --git a/crates/lib/url_utils/Cargo.toml b/crates/lib/url_utils/Cargo.toml new file mode 100644 index 0000000000..7e5559749a --- /dev/null +++ b/crates/lib/url_utils/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "url_utils" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "url_utils" +path = "src/lib.rs" + +[dependencies] + + +# Internal +# NB: DO NOT ALLOW ANY OTHER DEPS - THIS IS URL LOGIC ONLY +# (None) + +# External +# NB: DO NOT ALLOW ANY OTHER DEPS - THIS IS URL LOGIC ONLY +url.workspace = true + +[dev-dependencies] +# (None) diff --git a/crates/lib/url_utils/src/download_extension/download_extension.rs b/crates/lib/url_utils/src/download_extension/download_extension.rs new file mode 100644 index 0000000000..4193b07467 --- /dev/null +++ b/crates/lib/url_utils/src/download_extension/download_extension.rs @@ -0,0 +1,72 @@ + +/// When downloading a remote file, it makes sense to try to match the same file extension (with reasonable safeguards). +#[deprecated(note = "Use `Extension` in `url_utils::extension::extension` instead")] +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum DownloadExtension { + // Image + Jpg, + Png, + // Video + Mp4, + Webm, + // Audio + Mp3, + Wav, + // Text + Json, + Txt, +} + +impl DownloadExtension { + pub fn as_extension_without_period(&self) -> &'static str { + match self { + // Image + Self::Jpg => "jpg", + Self::Png => "png", + // Video + Self::Mp4 => "mp4", + Self::Webm => "webm", + // Audio + Self::Mp3 => "mp3", + Self::Wav => "wav", + // Text + Self::Json => "json", + Self::Txt => "txt", + } + } + + pub fn as_extension_with_period(&self) -> &'static str { + match self { + // Image + Self::Jpg => ".jpg", + Self::Png => ".png", + // Video + Self::Mp4 => ".mp4", + Self::Webm => ".webm", + // Audio + Self::Mp3 => ".mp3", + Self::Wav => ".wav", + // Text + Self::Json => ".json", + Self::Txt => ".txt", + } + } + + pub fn try_from_str(extension: &str) -> Option { + match extension.to_lowercase().as_str() { + // Image + "jpg" | "jpeg" => Some(Self::Jpg), + "png" => Some(Self::Png), + // Video + "mp4" => Some(Self::Mp4), + "webm" => Some(Self::Webm), + // Audio + "mp3" => Some(Self::Mp3), + "wav" => Some(Self::Wav), + // Text + "json" => Some(Self::Json), + "txt" => Some(Self::Txt), + _ => None, + } + } +} diff --git a/crates/lib/url_utils/src/download_extension/extract_download_extension_from_url.rs b/crates/lib/url_utils/src/download_extension/extract_download_extension_from_url.rs new file mode 100644 index 0000000000..1d0c1b6465 --- /dev/null +++ b/crates/lib/url_utils/src/download_extension/extract_download_extension_from_url.rs @@ -0,0 +1,39 @@ +use crate::download_extension::DownloadExtension; +use url::Url; + +/// This should be used for naming files on the local file system based on their URL. +#[deprecated(note = "Use `extract_extension_from_url_str` in `url_utils::extension::extract_extension_from_url` instead")] +pub fn extract_download_extension_from_url_str(url: &str) -> Option { + let parsed_url = Url::parse(url).ok()?; + extract_download_extension_from_url(&parsed_url) +} + +/// This should be used for naming files on the local file system based on their URL. +#[deprecated(note = "Use `extract_extension_from_url` in `url_utils::extension::extract_extension_from_url` instead")] +pub fn extract_download_extension_from_url(url: &Url) -> Option { + let path = url.path(); + let extension = std::path::Path::new(path) + .extension() + .and_then(|ext| ext.to_str())?; + DownloadExtension::try_from_str(extension) +} + +#[cfg(test)] +mod tests { + use crate::download_extension::DownloadExtension; + use crate::download_extension::extract_download_extension_from_url::extract_download_extension_from_url_str; + + #[test] + fn success_case() { + let url = "https://storyteller.ai/example.jpg"; + let maybe_extension= extract_download_extension_from_url_str(url); + assert_eq!(maybe_extension, Some(DownloadExtension::Jpg)); + } + + #[test] + fn difficult_url() { + let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b&skoid=8cfff87a-01fc-27c1-9091-32999dcd1389&sktid=aa8ccaf6-e6da-484e-ab11-9c7496c21c53&skt=2025-11-17T21%3A09%3A08Z&ske=2025-10-23T21%3A14%3A08Z&sks=b&skv=2024-08-04&sig=T2U6%2Brw9WJdJagBkCL4/GlFr72KhZGP3QPlobIO7JlU%3D&ac=oaisdsorprnorthcentralus"; + let maybe_extension= extract_download_extension_from_url_str(url); + assert!(maybe_extension.is_none()); + } +} diff --git a/crates/lib/url_utils/src/download_extension/mod.rs b/crates/lib/url_utils/src/download_extension/mod.rs new file mode 100644 index 0000000000..3706f2dddb --- /dev/null +++ b/crates/lib/url_utils/src/download_extension/mod.rs @@ -0,0 +1,4 @@ +mod download_extension; +pub mod extract_download_extension_from_url; + +pub use download_extension::DownloadExtension; diff --git a/crates/lib/url_utils/src/extension/extension.rs b/crates/lib/url_utils/src/extension/extension.rs new file mode 100644 index 0000000000..a92df6d43c --- /dev/null +++ b/crates/lib/url_utils/src/extension/extension.rs @@ -0,0 +1,146 @@ +use std::borrow::Cow; +use std::fmt; + +/// A file extension that can be either a compile-time constant or a runtime-parsed value. +/// +/// ``` +/// use url_utils::extension::extension::Extension; +/// +/// // Static constants (no allocation) +/// const PNG: Extension = Extension::from_static("png", ".png"); +/// assert_eq!(PNG.without_period(), "png"); +/// assert_eq!(PNG.with_period(), ".png"); +/// +/// // Runtime parsing (allocates only if needed) +/// let ext = Extension::new("mp4"); +/// assert_eq!(ext.without_period(), "mp4"); +/// assert_eq!(ext.with_period(), ".mp4"); +/// ``` +#[derive(Clone, Eq, Hash)] +pub struct Extension { + /// The extension without the leading period, e.g. "png", "mp4". + value: Cow<'static, str>, + + /// The extension with the leading period, e.g. ".png", ".mp4". + dotted: Cow<'static, str>, +} + +impl Extension { + /// Creates an `Extension` from static strings at compile time. + /// Pass both the bare extension and the dotted form. + /// + /// ``` + /// use url_utils::extension::extension::Extension; + /// const MP4: Extension = Extension::from_static("mp4", ".mp4"); + /// ``` + pub const fn from_static(ext: &'static str, dotted: &'static str) -> Self { + Self { + value: Cow::Borrowed(ext), + dotted: Cow::Borrowed(dotted), + } + } + + /// Creates an `Extension` from a `&str` at runtime. + /// The input must NOT include a leading period. + pub fn new(ext: &str) -> Self { + let dotted = format!(".{}", ext); + Self { + value: Cow::Owned(ext.to_string()), + dotted: Cow::Owned(dotted), + } + } + + /// Returns the extension without the leading period, e.g. `"png"`. + pub fn without_period(&self) -> &str { + &self.value + } + + /// Returns the extension with the leading period, e.g. `".png"`. + pub fn with_period(&self) -> &str { + &self.dotted + } +} + +impl PartialEq for Extension { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl fmt::Debug for Extension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Extension(\"{}\")", self.value) + } +} + +impl fmt::Display for Extension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PNG: Extension = Extension::from_static("png", ".png"); + const MP4: Extension = Extension::from_static("mp4", ".mp4"); + const MP3: Extension = Extension::from_static("mp3", ".mp3"); + + #[test] + fn static_without_period() { + assert_eq!(PNG.without_period(), "png"); + assert_eq!(MP4.without_period(), "mp4"); + assert_eq!(MP3.without_period(), "mp3"); + } + + #[test] + fn static_with_period() { + assert_eq!(PNG.with_period(), ".png"); + assert_eq!(MP4.with_period(), ".mp4"); + assert_eq!(MP3.with_period(), ".mp3"); + } + + #[test] + fn runtime_without_period() { + let ext = Extension::new("webm"); + assert_eq!(ext.without_period(), "webm"); + } + + #[test] + fn runtime_with_period() { + let ext = Extension::new("webm"); + assert_eq!(ext.with_period(), ".webm"); + } + + #[test] + fn unknown_extension() { + let ext = Extension::new("foobar"); + assert_eq!(ext.without_period(), "foobar"); + assert_eq!(ext.with_period(), ".foobar"); + } + + #[test] + fn equality() { + let a = Extension::new("png"); + assert_eq!(a, PNG); + } + + #[test] + fn clone() { + let a = Extension::new("wav"); + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn display() { + assert_eq!(format!("{}", PNG), "png"); + assert_eq!(format!("{}", Extension::new("xyz")), "xyz"); + } + + #[test] + fn debug() { + assert_eq!(format!("{:?}", PNG), "Extension(\"png\")"); + } +} diff --git a/crates/lib/url_utils/src/extension/extract_extension_from_url.rs b/crates/lib/url_utils/src/extension/extract_extension_from_url.rs new file mode 100644 index 0000000000..928aa583fe --- /dev/null +++ b/crates/lib/url_utils/src/extension/extract_extension_from_url.rs @@ -0,0 +1,244 @@ +use std::collections::HashSet; + +use crate::extension::extension::Extension; +use url::Url; + +// TODO: Lookups against these as lists will be slow + +/// Known image extensions. +const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[ + Extension::from_static("png", ".png"), + Extension::from_static("jpg", ".jpg"), + Extension::from_static("jpeg", ".jpeg"), + Extension::from_static("webp", ".webp"), +]; + +/// Known audio extensions. +const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[ + Extension::from_static("wav", ".wav"), + Extension::from_static("mp3", ".mp3"), +]; + +/// Known video extensions. +const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[ + Extension::from_static("mp4", ".mp4"), + Extension::from_static("webm", ".webm"), +]; + +/// Controls which extensions are accepted when extracting from a URL. +pub enum ExtractExtensions { + /// Accept any extension-like string found in the URL path. + All, + + /// Accept only extensions that appear in the given set. + Set(HashSet), + + /// Accept only known image extensions (png, jpg, jpeg, webp). + KnownImage, + + /// Accept only known audio extensions (wav, mp3). + KnownAudio, + + /// Accept only known video extensions (mp4, webm). + KnownVideo, + + /// Accept any known media extension (image, audio, or video). + KnownMedia, +} + +impl ExtractExtensions { + /// Creates a `Set` variant from a `Vec`. + pub fn from_vec(extensions: Vec) -> Self { + Self::Set(extensions.into_iter().collect()) + } +} + +/// Extracts a file extension from a URL string, filtered by `accept`. +pub fn extract_extension_from_url_str(url: &str, accept: &ExtractExtensions) -> Option { + let parsed_url = Url::parse(url).ok()?; + extract_extension_from_url(&parsed_url, accept) +} + +/// Extracts a file extension from a parsed `Url`, filtered by `accept`. +pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Option { + let path = url.path(); + let raw_ext = std::path::Path::new(path) + .extension() + .and_then(|ext| ext.to_str())?; + + let lower = raw_ext.to_lowercase(); + let candidate = Extension::new(&lower); + + match accept { + ExtractExtensions::All => Some(candidate), + ExtractExtensions::Set(set) => { + if set.contains(&candidate) { Some(candidate) } else { None } + } + ExtractExtensions::KnownImage => { + if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) { Some(candidate) } else { None } + } + ExtractExtensions::KnownAudio => { + if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) { Some(candidate) } else { None } + } + ExtractExtensions::KnownVideo => { + if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) { Some(candidate) } else { None } + } + ExtractExtensions::KnownMedia => { + if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) + || slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) + || slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) + { + Some(candidate) + } else { + None + } + } + } +} + +fn slice_contains(extensions: &[Extension], candidate: &Extension) -> bool { + extensions.iter().any(|e| e == candidate) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_extracts_any_extension() { + let ext = extract_extension_from_url_str("https://example.com/file.png", &ExtractExtensions::All); + assert_eq!(ext.unwrap().without_period(), "png"); + } + + #[test] + fn all_extracts_unknown_extension() { + let ext = extract_extension_from_url_str("https://example.com/file.foobar", &ExtractExtensions::All); + assert_eq!(ext.unwrap().without_period(), "foobar"); + } + + #[test] + fn all_extracts_unknown_extension_all_caps() { + let ext = extract_extension_from_url_str("https://example.com/file.FOOBAR", &ExtractExtensions::All); + assert_eq!(ext.unwrap().without_period(), "foobar"); + } + + #[test] + fn all_lowercases() { + let ext = extract_extension_from_url_str("https://example.com/file.PNG", &ExtractExtensions::All); + assert_eq!(ext.unwrap().without_period(), "png"); + } + + #[test] + fn known_image_matches_png() { + let ext = extract_extension_from_url_str("https://example.com/photo.png", &ExtractExtensions::KnownImage); + assert_eq!(ext.unwrap().without_period(), "png"); + } + + #[test] + fn known_image_rejects_mp4() { + let ext = extract_extension_from_url_str("https://example.com/video.mp4", &ExtractExtensions::KnownImage); + assert!(ext.is_none()); + } + + #[test] + fn known_audio_matches_wav() { + let ext = extract_extension_from_url_str("https://example.com/sound.wav", &ExtractExtensions::KnownAudio); + assert_eq!(ext.unwrap().without_period(), "wav"); + } + + #[test] + fn known_audio_rejects_png() { + let ext = extract_extension_from_url_str("https://example.com/photo.png", &ExtractExtensions::KnownAudio); + assert!(ext.is_none()); + } + + #[test] + fn known_video_matches_mp4() { + let ext = extract_extension_from_url_str("https://example.com/clip.mp4", &ExtractExtensions::KnownVideo); + assert_eq!(ext.unwrap().without_period(), "mp4"); + } + + #[test] + fn known_video_rejects_mp3() { + let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownVideo); + assert!(ext.is_none()); + } + + #[test] + fn known_media_matches_image() { + let ext = extract_extension_from_url_str("https://example.com/photo.jpg", &ExtractExtensions::KnownMedia); + assert_eq!(ext.unwrap().without_period(), "jpg"); + } + + #[test] + fn known_media_matches_audio() { + let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownMedia); + assert_eq!(ext.unwrap().without_period(), "mp3"); + } + + #[test] + fn known_media_matches_video() { + let ext = extract_extension_from_url_str("https://example.com/clip.webm", &ExtractExtensions::KnownMedia); + assert_eq!(ext.unwrap().without_period(), "webm"); + } + + #[test] + fn known_media_rejects_unknown() { + let ext = extract_extension_from_url_str("https://example.com/data.xyz", &ExtractExtensions::KnownMedia); + assert!(ext.is_none()); + } + + #[test] + fn set_matches_custom_list() { + let accept = ExtractExtensions::from_vec(vec![ + Extension::from_static("csv", ".csv"), + Extension::from_static("tsv", ".tsv"), + ]); + let ext = extract_extension_from_url_str("https://example.com/data.csv", &accept); + assert_eq!(ext.unwrap().without_period(), "csv"); + } + + #[test] + fn set_rejects_non_member() { + let accept = ExtractExtensions::from_vec(vec![ + Extension::from_static("csv", ".csv"), + ]); + let ext = extract_extension_from_url_str("https://example.com/data.png", &accept); + assert!(ext.is_none()); + } + + #[test] + fn no_extension_returns_none() { + let ext = extract_extension_from_url_str("https://example.com/file", &ExtractExtensions::All); + assert!(ext.is_none()); + } + + #[test] + fn query_params_ignored() { + let ext = extract_extension_from_url_str( + "https://example.com/file.mp4?token=abc&format=hd", + &ExtractExtensions::KnownVideo, + ); + assert_eq!(ext.unwrap().without_period(), "mp4"); + } + + #[test] + fn difficult_url_returns_none() { + let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b"; + let ext = extract_extension_from_url_str(url, &ExtractExtensions::All); + assert!(ext.is_none()); + } + + fn extract_extension_all(url: &str) -> Option { + extract_extension_from_url_str(url, &ExtractExtensions::All) + .map(|ext| ext.without_period().to_string()) + } + + #[test] + fn test_extract_various_extensions() { + assert_eq!(extract_extension_all("https://example.com/image.png").as_deref(), Some("png")); + assert_eq!(extract_extension_all("https://example.com/video.mp4?token=abc").as_deref(), Some("mp4")); + assert_eq!(extract_extension_all("https://example.com/path/to/file.jpg").as_deref(), Some("jpg")); + assert_eq!(extract_extension_all("https://example.com/noext"), None); + } +} diff --git a/crates/lib/url_utils/src/extension/mod.rs b/crates/lib/url_utils/src/extension/mod.rs new file mode 100644 index 0000000000..824c6d227e --- /dev/null +++ b/crates/lib/url_utils/src/extension/mod.rs @@ -0,0 +1,2 @@ +pub mod extension; +pub mod extract_extension_from_url; \ No newline at end of file diff --git a/crates/lib/url_utils/src/lib.rs b/crates/lib/url_utils/src/lib.rs new file mode 100644 index 0000000000..2f3ba5d097 --- /dev/null +++ b/crates/lib/url_utils/src/lib.rs @@ -0,0 +1,2 @@ +pub mod extension; +pub mod download_extension; \ No newline at end of file diff --git a/crates/lib/uuid_utils/Cargo.toml b/crates/lib/uuid_utils/Cargo.toml new file mode 100644 index 0000000000..62922be577 --- /dev/null +++ b/crates/lib/uuid_utils/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "uuid_utils" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "uuid_utils" +path = "src/lib.rs" + +[dependencies] +# Internal +# errors = { path = "../../lib/errors" } +# (None yet) + + +# External +uuid = { version = "1.8.0", features = ["v4"] } + +[dev-dependencies] +# None yet +# (None yet) diff --git a/crates/lib/uuid_utils/src/lib.rs b/crates/lib/uuid_utils/src/lib.rs new file mode 100644 index 0000000000..b9ad7ad871 --- /dev/null +++ b/crates/lib/uuid_utils/src/lib.rs @@ -0,0 +1,22 @@ +//! idempotency +//! +//! The purpose of this library is to hold tools for generating idempotency tokens, +//! making requests retryable, etc. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod uuid; diff --git a/crates/lib/uuid_utils/src/uuid.rs b/crates/lib/uuid_utils/src/uuid.rs new file mode 100644 index 0000000000..8ece4abf92 --- /dev/null +++ b/crates/lib/uuid_utils/src/uuid.rs @@ -0,0 +1,44 @@ +use uuid::Uuid; + +/// Generate a 36 character hyphenated UUID (v4) that can be used as an idempotency token. +pub fn generate_random_uuid() -> String { + let uuid = Uuid::new_v4(); + let mut buffer = Uuid::encode_buffer(); + let hyphenated_uuid = uuid.hyphenated() + .encode_lower(&mut buffer); + hyphenated_uuid.to_string() +} + +#[cfg(test)] +mod tests { + use crate::uuid::generate_random_uuid; + + mod generate_random_uuid { + use std::collections::HashSet; + + use super::*; + + #[test] + fn length() { + for _ in 0..10 { + assert_eq!(generate_random_uuid().len(), 36); + } + } + + #[test] + fn hyphenated() { + for _ in 0..10 { + assert_eq!(generate_random_uuid().contains("-"), true); + } + } + + #[test] + fn no_collision() { + let mut set = HashSet::new(); + for _ in 0..1000 { + set.insert(generate_random_uuid()); + } + assert_eq!(set.len(), 1000); + } + } +} \ No newline at end of file diff --git a/crates/schema/database/sqlite_tasks/Cargo.toml b/crates/schema/database/sqlite_tasks/Cargo.toml new file mode 100644 index 0000000000..aa9c83b3ed --- /dev/null +++ b/crates/schema/database/sqlite_tasks/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "sqlite_tasks" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "sqlite_tasks" +path = "src/lib.rs" + +[dependencies] + +# Internal +enums.workspace = true +errors.workspace = true +tokens.workspace = true + +# External +anyhow.workspace = true +chrono.workspace = true +log.workspace = true + +# TODO(bt,2025-07-09): Update sqlx ASAP. We're stuck on 0.7 because of the complex use of internal types and macros in the database crate. +# We're going to embed in-memory SQLite +sqlx = { package = "sqlx", version = "0.7.4", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } + +# Needed to safety check some queries we can't use escaping for +once_cell.workspace = true +regex.workspace = true + +# Serialization +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# serial_test = "3.0.0" + +[build-dependencies] +tempfile.workspace = true +sqlx = { package = "sqlx", version = "0.7.4", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } +#rusqlite = "0.25.4" # NB: For the build.rs file diff --git a/crates/schema/database/sqlite_tasks/build.rs b/crates/schema/database/sqlite_tasks/build.rs new file mode 100644 index 0000000000..2296d83bc1 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/build.rs @@ -0,0 +1,40 @@ +use std::env; +use std::path::PathBuf; + +pub fn main() { + // NB: This should help JetBrains' RustRover from highlighting failing query macros, + // but we do not want to interfere with the following: + // + // (1) User development builds + // (2) Tooling to cache offline queries + // (3) CI builds + let family = env::var("CARGO_CFG_TARGET_FAMILY").ok(); + + match family.as_deref() { + Some("unix") => unix_temp_database_pathing(), + Some("windows") => windows_temp_database_pathing(), + _ => { + println!("cargo:warning=Unsupported target family, using Unix temp database pathing."); + unix_temp_database_pathing(); + }, + } +} + +fn unix_temp_database_pathing() { + println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite"); +} + +fn windows_temp_database_pathing() { + if let Ok(local_app_data) = env::var("LOCALAPPDATA") { + let path = PathBuf::from(local_app_data); + let path = path.join("Temp\\tasks.sqlite"); + let path = path + .to_str() + .expect("path should be valid") + .to_string(); + println!("cargo:warning=LocalAppData path: {}", path); + println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path); + } else { + panic!("LOCALAPPDATA environment variable not set"); + } +} diff --git a/crates/schema/database/sqlite_tasks/src/connection.rs b/crates/schema/database/sqlite_tasks/src/connection.rs new file mode 100644 index 0000000000..bd9af2bbb7 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/connection.rs @@ -0,0 +1,64 @@ +use errors::AnyhowResult; +use log::{error, info}; +use sqlx::migrate::MigrateError; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode}; +use sqlx::{Pool, Sqlite, SqlitePool}; +use std::path::Path; + +#[derive(Clone)] +pub struct TaskDbConnection { + pool: Pool, +} + +impl TaskDbConnection { + pub async fn connect_and_migrate>(database_file: P) -> AnyhowResult { + { + match run_migrations(&database_file).await { + Ok(pool) => return Ok(Self { pool }), + Err(err) => { + error!("Error running SQLite migrations: {:?}", err); + } + } + + info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref()); + + if let Err(err) = std::fs::remove_file(&database_file) { + error!("Error deleting SQLite database file: {:?}", err); + } + + // NB: Scope change here is an attempt to drop the open file connection for Windows. + // On Windows, we've observed that the file might still be open, preventing deletion: + // [2025-08-08][06:42:29][sqlite_tasks::connection][INFO] Deleting and recreating SQLite + // database at "C:\Users\User\Artcraft\state\tasks_v2.sqlite" + // [2025-08-08][06:42:29][sqlite_tasks::connection][ERROR] Error deleting SQLite database + // file: Os { code: 32, kind: Uncategorized, message: "The process cannot access the file + // because it is being used by another process." } + } + + let pool = run_migrations(database_file).await?; + + Ok(Self { pool }) + } + + pub fn get_pool(&self) -> &Pool { + &self.pool + } +} + +async fn run_migrations>(database_file: P) -> Result { + let connection_options = SqliteConnectOptions::new() + .filename(database_file) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal); + + let pool = SqlitePool::connect_with(connection_options).await?; + + // Run migrations regardless of whether the database is new, SQLx will track which migrations + // have been run. + // The migrations text get compiled into the binary, so no worries about build inclusion. + // Since the task database is being treated as ephemeral, we can always run migrations without + // worrying about previous state if we simply blow away old versions of the schema. + sqlx::migrate!("../../../../_database/sql/artcraft_migrations").run(&pool).await?; + + Ok(pool) +} diff --git a/crates/schema/database/sqlite_tasks/src/error.rs b/crates/schema/database/sqlite_tasks/src/error.rs new file mode 100644 index 0000000000..7224709e95 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/error.rs @@ -0,0 +1,37 @@ +use enums::error::enum_error::EnumError; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum SqliteTasksError { + SqlxError(sqlx::Error), + EnumError(EnumError), + //TaskNotFound, + //TaskAlreadyExists, + //InvalidTaskStatus, + //InvalidTaskType, + //InvalidGenerationProvider, +} + +impl Error for SqliteTasksError {} + +impl Display for SqliteTasksError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + SqliteTasksError::SqlxError(err) => write!(f, "SQLx error: {:?}", err), + SqliteTasksError::EnumError(err) => write!(f, "Error parsing enum: {:?}", err), + } + } +} + +impl From for SqliteTasksError { + fn from(err: sqlx::Error) -> Self { + SqliteTasksError::SqlxError(err) + } +} + +impl From for SqliteTasksError { + fn from(err: EnumError) -> Self { + SqliteTasksError::EnumError(err) + } +} diff --git a/crates/schema/database/sqlite_tasks/src/lib.rs b/crates/schema/database/sqlite_tasks/src/lib.rs new file mode 100644 index 0000000000..9bb94dc5b6 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/lib.rs @@ -0,0 +1,23 @@ +//! sqlite_tasks +//! +//! sqlx is happier with different compilation units? So be it! +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod connection; +pub mod error; +pub mod queries; diff --git a/crates/schema/database/sqlite_tasks/src/queries/create_task.rs b/crates/schema/database/sqlite_tasks/src/queries/create_task.rs new file mode 100644 index 0000000000..457d2d29f6 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/create_task.rs @@ -0,0 +1,74 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct CreateTaskArgs<'a> { + pub db: &'a TaskDbConnection, + pub status: TaskStatus, + pub task_type: TaskType, + pub model_type: Option, + pub provider: GenerationProvider, + pub provider_job_id: Option<&'a str>, + pub queue_status_url: Option<&'a str>, + pub queue_response_url: Option<&'a str>, + pub prompt_token: Option<&'a PromptToken>, + pub frontend_caller: Option, + pub frontend_subscriber_id: Option<&'a str>, + pub frontend_subscriber_payload: Option<&'a str>, +} + +pub async fn create_task( + args: CreateTaskArgs<'_>, +) -> Result { + let task_id = TaskId::generate(); + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let task_id_temp = task_id.as_str(); + let status_temp = args.status.to_str(); + let task_type_temp = args.task_type.to_str(); + let model_type_temp = args.model_type.as_ref().map(|s| s.to_str()); + let provider_temp = args.provider.to_string(); + let prompt_token_temp = args.prompt_token.map(|t| t.as_str()); + let frontend_caller_temp = args.frontend_caller.map(|s| s.to_str()); + + let query = sqlx::query!(r#" + INSERT INTO tasks ( + id, + task_status, + task_type, + model_type, + provider, + provider_job_id, + queue_status_url, + queue_response_url, + prompt_token, + frontend_caller, + frontend_subscriber_id, + frontend_subscriber_payload + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + task_id_temp, + status_temp, + task_type_temp, + model_type_temp, + provider_temp, + args.provider_job_id, + args.queue_status_url, + args.queue_response_url, + prompt_token_temp, + frontend_caller_temp, + args.frontend_subscriber_id, + args.frontend_subscriber_payload + ); + + let _r = query.execute(args.db.get_pool()).await?; + + Ok(task_id) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/dismiss_all_tasks.rs b/crates/schema/database/sqlite_tasks/src/queries/dismiss_all_tasks.rs new file mode 100644 index 0000000000..fb2e7c7e5d --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/dismiss_all_tasks.rs @@ -0,0 +1,19 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; + +/// Mark everything as dismissed. +pub async fn dismiss_all_tasks( + db: &TaskDbConnection, +) -> Result { + + let query = sqlx::query!(r#" + UPDATE tasks + SET is_dismissed_by_user = 1 + WHERE is_dismissed_by_user != 1 + "#); + + let res = query.execute(db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/get_task_by_provider_and_provider_job_id.rs b/crates/schema/database/sqlite_tasks/src/queries/get_task_by_provider_and_provider_job_id.rs new file mode 100644 index 0000000000..d110377bf7 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/get_task_by_provider_and_provider_job_id.rs @@ -0,0 +1,84 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use crate::queries::task::Task; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use sqlx::Error; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct GetTaskByProviderAndProviderJobIdArgs<'a> { + pub db: &'a TaskDbConnection, + pub provider: GenerationProvider, + pub provider_job_id: &'a str, +} + + +pub async fn get_task_by_provider_and_provider_job_id( + args: GetTaskByProviderAndProviderJobIdArgs<'_>, +) -> Result, SqliteTasksError> { + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let temp_provider = args.provider.to_str(); + let temp_provider_job_id = args.provider_job_id; + + let query = sqlx::query!(r#" + SELECT + id, + task_status, + task_type, + model_type, + provider, + provider_job_id, + frontend_caller, + queue_status_url, + queue_response_url, + prompt_token, + frontend_subscriber_id, + frontend_subscriber_payload + FROM tasks + WHERE + provider = ? + AND provider_job_id = ? + "#, + temp_provider, + temp_provider_job_id, + ); + + // info!("Query: {:?}", query.sql()); + + let result = query.fetch_one(args.db.get_pool()).await; + + let record = match result { + Ok(record) => record, + Err(err) => return match err { + Error::RowNotFound => Ok(None), + _ => Err(err.into()), + }, + }; + + // NB: Not sure why query can't figure out this isn't nullable + let provider = record.provider.as_deref() + .unwrap_or_else(|| args.provider.to_str()); + + Ok(Some(Task { + id: TaskId::new_from_str(&record.id), + status: TaskStatus::from_str(&record.task_status)?, + task_type: TaskType::from_str(&record.task_type)?, + model_type: record.model_type + .map(|model| TaskModelType::from_str(&model)) + .transpose()?, + provider: GenerationProvider::from_str(provider)?, + provider_job_id: record.provider_job_id, + queue_status_url: record.queue_status_url, + queue_response_url: record.queue_response_url, + prompt_token: record.prompt_token, + frontend_caller: record.frontend_caller + .map(|caller| TauriCommandCaller::from_str(&caller)) + .transpose()?, + frontend_subscriber_id: record.frontend_subscriber_id, + frontend_subscriber_payload: record.frontend_subscriber_payload, + })) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/list_non_artcraft_pending_tasks.rs b/crates/schema/database/sqlite_tasks/src/queries/list_non_artcraft_pending_tasks.rs new file mode 100644 index 0000000000..3467258099 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/list_non_artcraft_pending_tasks.rs @@ -0,0 +1,84 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use crate::queries::task::{RawTask, Task}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use sqlx::{QueryBuilder, Sqlite}; +use std::collections::HashSet; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct ListNonArtcraftPendingTasksArgs<'a> { + pub db: &'a TaskDbConnection, + pub task_statuses: &'a HashSet, +} + +pub struct NonArtcraftTaskList { + pub tasks: Vec, +} + +/// List all tasks that are NOT from the Artcraft provider and match the given statuses. +pub async fn list_non_artcraft_pending_tasks( + args: ListNonArtcraftPendingTasksArgs<'_>, +) -> Result { + let artcraft_provider = GenerationProvider::Artcraft.to_string(); + + let mut query_builder: QueryBuilder = QueryBuilder::new(r#" + SELECT + id, + task_status, + task_type, + model_type, + provider, + provider_job_id, + queue_status_url, + queue_response_url, + prompt_token, + frontend_caller, + frontend_subscriber_id, + frontend_subscriber_payload + FROM tasks + WHERE provider != + "#); + + query_builder.push_bind(artcraft_provider); + + if !args.task_statuses.is_empty() { + query_builder.push(" AND task_status IN ("); + let mut separated = query_builder.separated(", "); + for task_status in args.task_statuses.into_iter() { + separated.push_bind(task_status.to_str()); + } + separated.push_unseparated(") "); + } + + let query = query_builder.build_query_as::(); + let results = query.fetch_all(args.db.get_pool()).await?; + + let mut tasks: Vec = Vec::new(); + + for task in results { + tasks.push(Task { + id: TaskId::new_from_str(&task.id), + status: TaskStatus::from_str(&task.task_status)?, + task_type: TaskType::from_str(&task.task_type)?, + model_type: task.model_type + .map(|model| TaskModelType::from_str(&model)) + .transpose()?, + provider: GenerationProvider::from_str(&task.provider)?, + provider_job_id: task.provider_job_id, + queue_status_url: task.queue_status_url, + queue_response_url: task.queue_response_url, + prompt_token: task.prompt_token, + frontend_caller: task.frontend_caller + .map(|caller| TauriCommandCaller::from_str(&caller)) + .transpose()?, + frontend_subscriber_id: task.frontend_subscriber_id, + frontend_subscriber_payload: task.frontend_subscriber_payload, + }); + } + + Ok(NonArtcraftTaskList { tasks }) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/list_tasks_by_provider_and_status.rs b/crates/schema/database/sqlite_tasks/src/queries/list_tasks_by_provider_and_status.rs new file mode 100644 index 0000000000..2a63aed853 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/list_tasks_by_provider_and_status.rs @@ -0,0 +1,93 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use crate::queries::task::{RawTask, Task}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use sqlx::{QueryBuilder, Sqlite}; +use std::collections::HashSet; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct ListTasksByProviderAndStatusArgs<'a> { + pub db: &'a TaskDbConnection, + pub provider: GenerationProvider, + pub task_statuses: &'a HashSet, +} + +pub struct TaskList { + pub tasks: Vec, +} + +pub async fn list_tasks_by_provider_and_status( + args: ListTasksByProviderAndStatusArgs<'_>, +) -> Result { + + let mut query_builder: QueryBuilder = QueryBuilder::new(r#" + SELECT + id, + task_status, + task_type, + model_type, + provider, + provider_job_id, + queue_status_url, + queue_response_url, + prompt_token, + frontend_caller, + frontend_subscriber_id, + frontend_subscriber_payload + FROM tasks + WHERE provider = + "#); + + // TODO(bt,2025-07-15): Fix this. The sqlx mysql queries never required temporaries + let provider = args.provider.to_string(); + + query_builder.push_bind(provider); + + if !args.task_statuses.is_empty() { + query_builder.push(" AND task_status IN ("); + let mut separated = query_builder.separated(", "); + + for task_status in args.task_statuses.into_iter() { + separated.push_bind(task_status.to_str()); + } + + separated.push_unseparated(") "); + } + + let query = query_builder.build_query_as::(); + + // info!("Query: {:?}", query.sql()); + + let results = query.fetch_all(args.db.get_pool()).await?; + + let mut tasks: Vec = Vec::new(); + + for task in results { + tasks.push(Task { + id: TaskId::new_from_str(&task.id), + status: TaskStatus::from_str(&task.task_status)?, + task_type: TaskType::from_str(&task.task_type)?, + model_type: task.model_type + .map(|model| TaskModelType::from_str(&model)) + .transpose()?, + provider: GenerationProvider::from_str(&task.provider)?, + provider_job_id: task.provider_job_id, + queue_status_url: task.queue_status_url, + queue_response_url: task.queue_response_url, + prompt_token: task.prompt_token, + frontend_caller: task.frontend_caller + .map(|caller| TauriCommandCaller::from_str(&caller)) + .transpose()?, + frontend_subscriber_id: task.frontend_subscriber_id, + frontend_subscriber_payload: task.frontend_subscriber_payload, + }); + } + + Ok(TaskList { + tasks, + }) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/list_tasks_by_provider_and_tokens.rs b/crates/schema/database/sqlite_tasks/src/queries/list_tasks_by_provider_and_tokens.rs new file mode 100644 index 0000000000..bb9419af69 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/list_tasks_by_provider_and_tokens.rs @@ -0,0 +1,92 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use crate::queries::task::{RawTask, Task}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use sqlx::{QueryBuilder, Sqlite}; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct ListTasksArgs<'a> { + pub db: &'a TaskDbConnection, + pub provider: GenerationProvider, + pub provider_job_ids: Option>, +} + +pub struct TaskList { + pub tasks: Vec, +} + +pub async fn list_tasks_by_provider_and_tokens( + args: ListTasksArgs<'_>, +) -> Result { + + let mut query_builder: QueryBuilder = QueryBuilder::new(r#" + SELECT + id, + task_status, + task_type, + model_type, + provider, + provider_job_id, + frontend_caller, + queue_status_url, + queue_response_url, + prompt_token, + frontend_subscriber_id, + frontend_subscriber_payload + FROM tasks + WHERE provider = + "#); + + // TODO(bt,2025-07-15): Fix this. The sqlx mysql queries never required temporaries + let provider = args.provider.to_string(); + + query_builder.push_bind(provider); + + if let Some(provider_job_ids) = args.provider_job_ids { + query_builder.push(" AND provider_job_id IN ("); + let mut separated = query_builder.separated(", "); + + for job_id in provider_job_ids.into_iter() { + separated.push_bind(job_id); + } + + separated.push_unseparated(") "); + } + + let query = query_builder.build_query_as::(); + + // info!("Query: {:?}", query.sql()); + + let results = query.fetch_all(args.db.get_pool()).await?; + + let mut tasks: Vec = Vec::new(); + + for task in results { + tasks.push(Task { + id: TaskId::new_from_str(&task.id), + status: TaskStatus::from_str(&task.task_status)?, + task_type: TaskType::from_str(&task.task_type)?, + model_type: task.model_type + .map(|model| TaskModelType::from_str(&model)) + .transpose()?, + provider: GenerationProvider::from_str(&task.provider)?, + provider_job_id: task.provider_job_id, + queue_status_url: task.queue_status_url, + queue_response_url: task.queue_response_url, + prompt_token: task.prompt_token, + frontend_caller: task.frontend_caller + .map(|caller| TauriCommandCaller::from_str(&caller)) + .transpose()?, + frontend_subscriber_id: task.frontend_subscriber_id, + frontend_subscriber_payload: task.frontend_subscriber_payload, + }); + } + + Ok(TaskList { + tasks, + }) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/list_tasks_for_frontend.rs b/crates/schema/database/sqlite_tasks/src/queries/list_tasks_for_frontend.rs new file mode 100644 index 0000000000..6156218347 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/list_tasks_for_frontend.rs @@ -0,0 +1,135 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use chrono::{DateTime, Utc}; +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_failure_type::TaskFailureType; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct TaskList { + pub tasks: Vec, +} + +pub struct TaskItem { + pub id: TaskId, + pub status: TaskStatus, + pub task_type: TaskType, + pub model_type: Option, + pub provider: Option, + pub provider_job_id: Option, + pub frontend_caller: Option, + pub frontend_subscriber_id: Option, + pub frontend_subscriber_payload: Option, + pub on_complete_primary_media_file_token: Option, + pub on_complete_primary_media_file_class: Option, + pub on_complete_batch_token: Option, + pub on_complete_primary_media_file_cdn_url: Option, + pub on_complete_primary_media_file_thumbnail_url_template: Option, + pub on_failure_type: Option, + pub on_failure_message: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +pub async fn list_tasks_for_frontend( + db: & TaskDbConnection +) -> Result { + let query = sqlx::query_as!( + TaskItemRaw, + r#" + SELECT + id, + task_status, + task_type, + model_type, + provider, + provider_job_id, + frontend_caller, + frontend_subscriber_id, + frontend_subscriber_payload, + on_complete_primary_media_file_token, + on_complete_primary_media_file_class, + on_complete_batch_token, + on_complete_primary_media_file_cdn_url, + on_complete_primary_media_file_thumbnail_url_template, + on_failure_type, + on_failure_message, + created_at as "created_at: DateTime", + updated_at as "updated_at: DateTime", + completed_at as "completed_at: DateTime" + FROM tasks + WHERE is_dismissed_by_user == 0 + "#); + + let result = query.fetch_all(db.get_pool()) + .await?; + + let mut tasks = Vec::with_capacity(result.len()); + + for raw in result.into_iter() { + tasks.push(TaskItem { + id: TaskId::new(raw.id), + status: TaskStatus::from_str(&raw.task_status)?, + task_type: TaskType::from_str(&raw.task_type)?, + model_type: raw.model_type + .map(|model| TaskModelType::from_str(&model)) + .transpose()?, + provider: raw.provider + .map(|provider| GenerationProvider::from_str(&provider)) + .transpose()?, + provider_job_id: raw.provider_job_id, + frontend_caller: raw.frontend_caller + .map(|caller| TauriCommandCaller::from_str(&caller)) + .transpose()?, + frontend_subscriber_id: raw.frontend_subscriber_id, + frontend_subscriber_payload: raw.frontend_subscriber_payload, + on_complete_primary_media_file_token: raw.on_complete_primary_media_file_token.map(|t| MediaFileToken::new_from_str(&t)), + on_complete_primary_media_file_class: raw.on_complete_primary_media_file_class + .map(|c| TaskMediaFileClass::from_str(&c)) + .transpose()?, + on_complete_batch_token: raw.on_complete_batch_token.map(|t| BatchGenerationToken::new_from_str(&t)), + on_complete_primary_media_file_cdn_url: raw.on_complete_primary_media_file_cdn_url, + on_complete_primary_media_file_thumbnail_url_template: raw.on_complete_primary_media_file_thumbnail_url_template, + on_failure_type: raw.on_failure_type + .map(|t| TaskFailureType::from_str(&t)) + .transpose()?, + on_failure_message: raw.on_failure_message, + created_at: raw.created_at, + updated_at: raw.updated_at, + completed_at: raw.completed_at, + }) + } + + Ok(TaskList { + tasks, + }) +} + +struct TaskItemRaw { + id: String, + task_status: String, + task_type: String, + model_type: Option, + provider: Option, + provider_job_id: Option, + frontend_caller: Option, + frontend_subscriber_id: Option, + frontend_subscriber_payload: Option, + on_complete_primary_media_file_token: Option, + on_complete_primary_media_file_class: Option, + on_complete_batch_token: Option, + on_complete_primary_media_file_cdn_url: Option, + on_complete_primary_media_file_thumbnail_url_template: Option, + on_failure_type: Option, + on_failure_message: Option, + created_at: DateTime, + updated_at: DateTime, + completed_at: Option>, +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/mark_task_as_dismissed.rs b/crates/schema/database/sqlite_tasks/src/queries/mark_task_as_dismissed.rs new file mode 100644 index 0000000000..26f3c0b88b --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/mark_task_as_dismissed.rs @@ -0,0 +1,27 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use tokens::tokens::sqlite::tasks::TaskId; + +/// Returns true if rows were updated. +pub async fn mark_task_as_dismissed( + db: &TaskDbConnection, + task_id: &TaskId, +) -> Result { + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let task_id_temp = task_id.as_str(); + + // TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ? + let query = sqlx::query!(r#" + UPDATE tasks + SET is_dismissed_by_user = 1 + WHERE id = ? + "#, + task_id_temp, + ); + + let res = query.execute(db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/mod.rs b/crates/schema/database/sqlite_tasks/src/queries/mod.rs new file mode 100644 index 0000000000..86b20be6e8 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/mod.rs @@ -0,0 +1,14 @@ +pub mod create_task; +pub mod dismiss_all_tasks; +pub mod list_non_artcraft_pending_tasks; +pub mod get_task_by_provider_and_provider_job_id; +pub mod list_tasks_by_provider_and_status; +pub mod list_tasks_by_provider_and_tokens; +pub mod list_tasks_for_frontend; +pub mod mark_task_as_dismissed; +pub mod nuke_all_tasks; +pub mod task; +pub mod update_successful_task_status_with_metadata; +pub mod update_successful_task_status_with_metadata_by_provider; +pub mod update_task_status; +pub mod update_task_status_with_rich_failure; \ No newline at end of file diff --git a/crates/schema/database/sqlite_tasks/src/queries/nuke_all_tasks.rs b/crates/schema/database/sqlite_tasks/src/queries/nuke_all_tasks.rs new file mode 100644 index 0000000000..48edfb749c --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/nuke_all_tasks.rs @@ -0,0 +1,28 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; + +/// Abandon all in-progress tasks. +/// Marked as user cancellation. +pub async fn nuke_all_tasks( + db: &TaskDbConnection, +) -> Result { + + // TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ? + let query = sqlx::query!(r#" + UPDATE tasks + SET task_status = "cancelled_by_user" + WHERE task_status NOT IN ( + "complete_success", + "complete_failure", + "dead", + "cancelled_by_user", + "cancelled_by_provider", + "cancelled_by_us" + ) + "#); + + let res = query.execute(db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/task.rs b/crates/schema/database/sqlite_tasks/src/queries/task.rs new file mode 100644 index 0000000000..06bc701bd3 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/task.rs @@ -0,0 +1,39 @@ +use enums::common::generation_provider::GenerationProvider; +use enums::tauri::tasks::task_model_type::TaskModelType; +use enums::tauri::tasks::task_status::TaskStatus; +use enums::tauri::tasks::task_type::TaskType; +use enums::tauri::ux::tauri_command_caller::TauriCommandCaller; +use tokens::tokens::sqlite::tasks::TaskId; + +#[derive(Debug, Clone)] +pub struct Task { + pub id: TaskId, + pub status: TaskStatus, + pub task_type: TaskType, + pub model_type: Option, + pub provider: GenerationProvider, + pub provider_job_id: Option, + pub queue_status_url: Option, + pub queue_response_url: Option, + pub prompt_token: Option, + pub frontend_caller: Option, + pub frontend_subscriber_id: Option, + pub frontend_subscriber_payload: Option, +} + +#[derive(Debug)] +#[derive(sqlx::FromRow)] +pub (crate) struct RawTask { + pub (crate) id: String, + pub (crate) task_status: String, + pub (crate) task_type: String, + pub (crate) model_type: Option, + pub (crate) provider: String, + pub (crate) provider_job_id: Option, + pub (crate) queue_status_url: Option, + pub (crate) queue_response_url: Option, + pub (crate) prompt_token: Option, + pub (crate) frontend_caller: Option, + pub (crate) frontend_subscriber_id: Option, + pub (crate) frontend_subscriber_payload: Option, +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/update_successful_task_status_with_metadata.rs b/crates/schema/database/sqlite_tasks/src/queries/update_successful_task_status_with_metadata.rs new file mode 100644 index 0000000000..7b46d0b690 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/update_successful_task_status_with_metadata.rs @@ -0,0 +1,59 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use enums::tauri::tasks::task_status::TaskStatus; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::sqlite::tasks::TaskId; + +const SUCCESSFUL_STATUS : &str = TaskStatus::CompleteSuccess.to_str(); + +pub struct UpdateSuccessfulTaskArgs<'a> { + pub db: &'a TaskDbConnection, + pub task_id: &'a TaskId, + pub maybe_batch_token: Option<&'a BatchGenerationToken>, + pub maybe_primary_media_file_token: Option<&'a MediaFileToken>, + pub maybe_primary_media_file_class: Option, + pub maybe_primary_media_file_cdn_url: Option<&'a str>, + pub maybe_primary_media_file_thumbnail_url_template: Option<&'a str>, +} + +/// Returns true if rows were updated. +pub async fn update_successful_task_status_with_metadata( + args: UpdateSuccessfulTaskArgs<'_>, +) -> Result { + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let task_id_temp = args.task_id.as_str(); + let maybe_batch_token_temp = args.maybe_batch_token.map(|t| t.as_str()); + let maybe_primary_media_token_temp = args.maybe_primary_media_file_token.map(|t| t.as_str()); + let maybe_primary_media_class = args.maybe_primary_media_file_class.map(|c| c.to_str()); + + // TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ? + let query = sqlx::query!(r#" + UPDATE tasks + SET + task_status = ?, + on_complete_batch_token = ?, + on_complete_primary_media_file_token = ?, + on_complete_primary_media_file_class = ?, + on_complete_primary_media_file_cdn_url = ?, + on_complete_primary_media_file_thumbnail_url_template = ? + WHERE id = ? + "#, + SUCCESSFUL_STATUS, + maybe_batch_token_temp, + maybe_primary_media_token_temp, + maybe_primary_media_class, + args.maybe_primary_media_file_cdn_url, + args.maybe_primary_media_file_thumbnail_url_template, + task_id_temp, + ); + + // info!("query: {:?}", query.sql()); + + let res = query.execute(args.db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/update_successful_task_status_with_metadata_by_provider.rs b/crates/schema/database/sqlite_tasks/src/queries/update_successful_task_status_with_metadata_by_provider.rs new file mode 100644 index 0000000000..12b25cc63f --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/update_successful_task_status_with_metadata_by_provider.rs @@ -0,0 +1,64 @@ +use enums::common::generation_provider::GenerationProvider; +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass; +use enums::tauri::tasks::task_status::TaskStatus; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +const SUCCESSFUL_STATUS : &str = TaskStatus::CompleteSuccess.to_str(); + +pub struct UpdateSuccessfulTaskByProviderArgs<'a> { + pub db: &'a TaskDbConnection, + pub provider: GenerationProvider, + pub provider_job_id: &'a str, + pub maybe_batch_token: Option<&'a BatchGenerationToken>, + pub maybe_primary_media_file_token: Option<&'a MediaFileToken>, + pub maybe_primary_media_file_class: Option, + pub maybe_primary_media_file_cdn_url: Option<&'a str>, + pub maybe_primary_media_file_thumbnail_url_template: Option<&'a str>, +} + +/// Returns true if rows were updated. +pub async fn update_successful_task_status_with_metadata_by_provider( + args: UpdateSuccessfulTaskByProviderArgs<'_>, +) -> Result { + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let provider_temp = args.provider.to_str(); + let provider_job_id_temp = args.provider_job_id; + let maybe_batch_token_temp = args.maybe_batch_token.map(|t| t.as_str()); + let maybe_primary_media_token_temp = args.maybe_primary_media_file_token.map(|t| t.as_str()); + let maybe_primary_media_class = args.maybe_primary_media_file_class.map(|c| c.to_str()); + + // TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ? + let query = sqlx::query!(r#" + UPDATE tasks + SET + task_status = ?, + on_complete_batch_token = ?, + on_complete_primary_media_file_token = ?, + on_complete_primary_media_file_class = ?, + on_complete_primary_media_file_cdn_url = ?, + on_complete_primary_media_file_thumbnail_url_template = ? + WHERE + provider = ? + AND provider_job_id = ? + "#, + SUCCESSFUL_STATUS, + maybe_batch_token_temp, + maybe_primary_media_token_temp, + maybe_primary_media_class, + args.maybe_primary_media_file_cdn_url, + args.maybe_primary_media_file_thumbnail_url_template, + provider_temp, + provider_job_id_temp, + ); + + // info!("query: {:?}", query.sql()); + + let res = query.execute(args.db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/update_task_status.rs b/crates/schema/database/sqlite_tasks/src/queries/update_task_status.rs new file mode 100644 index 0000000000..ead78ece46 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/update_task_status.rs @@ -0,0 +1,39 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use enums::tauri::tasks::task_status::TaskStatus; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct UpdateTaskArgs<'a> { + pub db: &'a TaskDbConnection, + pub task_id: &'a TaskId, + pub status: TaskStatus, +} + +/// Returns true if rows were updated. +pub async fn update_task_status( + args: UpdateTaskArgs<'_>, +) -> Result { + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let task_id_temp = args.task_id.as_str(); + let status_temp = args.status.to_str(); + + // info!("Update task id: {}, status: {}", task_id_temp, status_temp); + + // TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ? + let query = sqlx::query!(r#" + UPDATE tasks + SET task_status = ? + WHERE id = ? + "#, + status_temp, + task_id_temp, + ); + + // info!("query: {:?}", query.sql()); + + let res = query.execute(args.db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/database/sqlite_tasks/src/queries/update_task_status_with_rich_failure.rs b/crates/schema/database/sqlite_tasks/src/queries/update_task_status_with_rich_failure.rs new file mode 100644 index 0000000000..896d6c5cd4 --- /dev/null +++ b/crates/schema/database/sqlite_tasks/src/queries/update_task_status_with_rich_failure.rs @@ -0,0 +1,51 @@ +use enums::tauri::tasks::task_failure_type::TaskFailureType; +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use enums::tauri::tasks::task_status::TaskStatus; +use tokens::tokens::sqlite::tasks::TaskId; + +pub struct UpdateTaskWithRichFailureArgs<'a> { + pub db: &'a TaskDbConnection, + pub task_id: &'a TaskId, + pub status: TaskStatus, + pub maybe_failure_type: Option, + pub maybe_failure_message: Option<&'a str>, +} + +/// Returns true if rows were updated. +pub async fn update_task_status_with_rich_failure( + args: UpdateTaskWithRichFailureArgs<'_>, +) -> Result { + + // TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries + let task_id_temp = args.task_id.as_str(); + let status_temp = args.status.to_str(); + let maybe_failure_type_temp = args.maybe_failure_type.map(|val| val.to_str()); + + // Trim the message so it doesn't overflow the database column + let maybe_failure_message_temp = args.maybe_failure_message + .map(|s| if s.len() > 255 { &s[..255] } else { s }); + + // info!("Update task id: {}, status: {}", task_id_temp, status_temp); + + // TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ? + let query = sqlx::query!(r#" + UPDATE tasks + SET task_status = ?, + on_failure_type = ?, + on_failure_message = ? + WHERE id = ? + "#, + status_temp, + maybe_failure_type_temp, + maybe_failure_message_temp, + task_id_temp, + ); + + // info!("query: {:?}", query.sql()); + + let res = query.execute(args.db.get_pool()).await?; + let rows_updated = res.rows_affected() > 0; + + Ok(rows_updated) +} diff --git a/crates/schema/public/enums/AGENTS.md b/crates/schema/public/enums/AGENTS.md new file mode 100644 index 0000000000..0d59f19765 --- /dev/null +++ b/crates/schema/public/enums/AGENTS.md @@ -0,0 +1,26 @@ +# Enums Crate + +Database-backed enums stored as VARCHAR fields in MySQL. + +## Adding a New Enum + +1. Create file: `src/by_table/{table_name}/{field_name}.rs` +2. Define the enum with required derives: + ```rust + #[cfg_attr(test, derive(EnumIter, EnumCount))] + #[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] + pub enum MyEnum { ... } + ``` +3. Implement `to_str()` and `from_str()` manually +4. Add macros: `impl_enum_display_and_debug_using_to_str!(MyEnum);` and `impl_mysql_enum_coders!(MyEnum);` +5. Add to parent `mod.rs` +6. Write tests: serialization, round-trip, `all_variants()` count + +## Rules + +- **NEVER change existing variant string values** - they are stored in the database +- New variants CAN be added freely - but be very careful about the Tauri API callers + ingesting new values. Make sure to pair the client with variants with an "Unknown(String)" + catch-all / future-proof bucket. +- All string values must be snake_case +- Use `#[serde(rename = "snake_case_value")]` on each variant diff --git a/crates/schema/public/enums/CLAUDE.md b/crates/schema/public/enums/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/crates/schema/public/enums/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/schema/public/enums/Cargo.toml b/crates/schema/public/enums/Cargo.toml new file mode 100644 index 0000000000..229afaec4c --- /dev/null +++ b/crates/schema/public/enums/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "enums" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "enums" +path = "src/lib.rs" + +[features] +default = [] +database = ["sqlx", "sqlx-core", "sqlx-mysql"] + +[dependencies] + +# Internal +# (None yet) + +# Serialization +serde.workspace = true +serde_derive.workspace = true + +# Database conversion of enum types is optional +sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ], optional = true } +sqlx-core = { version = "0.7.4" , optional = true } +sqlx-mysql = { version = "0.7.4" , optional = true } + +log.workspace = true + +utoipa.workspace = true + +[dev-dependencies] + +# Enum utilities +strum.workspace = true + +# Test serialization +serde_json.workspace = true +toml.workspace = true +regex.workspace = true diff --git a/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_api_clients.rs b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_api_clients.rs new file mode 100644 index 0000000000..ae4fe9d1f4 --- /dev/null +++ b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_api_clients.rs @@ -0,0 +1,137 @@ +use crate::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; +use utoipa::ToSchema; + +/// A forward-compatible version of `FrontendFailureCategory` for API clients. +/// +/// Contains all known variants plus an `Unknown(String)` catch-all so that +/// newer server-side values never cause deserialization failures on the client. +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FrontendFailureCategoryForApiClients { + FaceNotDetected, + NoForegroundSubjectDetected, + FormatNotSupported, + KeepAliveElapsed, + NotYetImplemented, + RetryableWorkerError, + ModelRulesViolation, + RuleBansUserImage, + RuleBansUserImageWithFaces, + RuleBansUserTextPrompt, + RuleBansUserContent, + RuleBansGeneratedVideo, + RuleBansGeneratedAudio, + RuleBansGeneratedContent, + FilesizeTooLarge, + ImageDimensionsTooSmall, + ImageDimensionsTooLarge, + GenerationFailed, + + /// Catch-all for values the client doesn't yet know about. + /// The contained string is the raw serialized value from the server. + #[serde(untagged)] + Unknown(String), +} + +impl FrontendFailureCategoryForApiClients { + pub fn from_db_enum(value: FrontendFailureCategory) -> Self { + match value { + FrontendFailureCategory::FaceNotDetected => Self::FaceNotDetected, + FrontendFailureCategory::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected, + FrontendFailureCategory::FormatNotSupported => Self::FormatNotSupported, + FrontendFailureCategory::KeepAliveElapsed => Self::KeepAliveElapsed, + FrontendFailureCategory::NotYetImplemented => Self::NotYetImplemented, + FrontendFailureCategory::RetryableWorkerError => Self::RetryableWorkerError, + FrontendFailureCategory::ModelRulesViolation => Self::ModelRulesViolation, + FrontendFailureCategory::RuleBansUserImage => Self::RuleBansUserImage, + FrontendFailureCategory::RuleBansUserImageWithFaces => Self::RuleBansUserImageWithFaces, + FrontendFailureCategory::RuleBansUserTextPrompt => Self::RuleBansUserTextPrompt, + FrontendFailureCategory::RuleBansUserContent => Self::RuleBansUserContent, + FrontendFailureCategory::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo, + FrontendFailureCategory::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio, + FrontendFailureCategory::RuleBansGeneratedContent => Self::RuleBansGeneratedContent, + FrontendFailureCategory::FilesizeTooLarge => Self::FilesizeTooLarge, + FrontendFailureCategory::ImageDimensionsTooSmall => Self::ImageDimensionsTooSmall, + FrontendFailureCategory::ImageDimensionsTooLarge => Self::ImageDimensionsTooLarge, + FrontendFailureCategory::GenerationFailed => Self::GenerationFailed, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_known_variants() { + let cases = vec![ + ("\"face_not_detected\"", FrontendFailureCategoryForApiClients::FaceNotDetected), + ("\"no_foreground_subject_detected\"", FrontendFailureCategoryForApiClients::NoForegroundSubjectDetected), + ("\"format_not_supported\"", FrontendFailureCategoryForApiClients::FormatNotSupported), + ("\"keep_alive_elapsed\"", FrontendFailureCategoryForApiClients::KeepAliveElapsed), + ("\"not_yet_implemented\"", FrontendFailureCategoryForApiClients::NotYetImplemented), + ("\"retryable_worker_error\"", FrontendFailureCategoryForApiClients::RetryableWorkerError), + ("\"model_rules_violation\"", FrontendFailureCategoryForApiClients::ModelRulesViolation), + ("\"rule_bans_user_image\"", FrontendFailureCategoryForApiClients::RuleBansUserImage), + ("\"rule_bans_user_image_with_faces\"", FrontendFailureCategoryForApiClients::RuleBansUserImageWithFaces), + ("\"rule_bans_user_text_prompt\"", FrontendFailureCategoryForApiClients::RuleBansUserTextPrompt), + ("\"rule_bans_user_content\"", FrontendFailureCategoryForApiClients::RuleBansUserContent), + ("\"rule_bans_generated_video\"", FrontendFailureCategoryForApiClients::RuleBansGeneratedVideo), + ("\"rule_bans_generated_audio\"", FrontendFailureCategoryForApiClients::RuleBansGeneratedAudio), + ("\"rule_bans_generated_content\"", FrontendFailureCategoryForApiClients::RuleBansGeneratedContent), + ("\"filesize_too_large\"", FrontendFailureCategoryForApiClients::FilesizeTooLarge), + ("\"image_dimensions_too_small\"", FrontendFailureCategoryForApiClients::ImageDimensionsTooSmall), + ("\"image_dimensions_too_large\"", FrontendFailureCategoryForApiClients::ImageDimensionsTooLarge), + ("\"generation_failed\"", FrontendFailureCategoryForApiClients::GenerationFailed), + ]; + + for (json, expected) in cases { + let parsed: FrontendFailureCategoryForApiClients = serde_json::from_str(json) + .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e)); + assert_eq!(parsed, expected, "mismatch for {}", json); + } + } + + #[test] + fn test_deserialize_unknown_variant() { + let json = "\"some_future_category\""; + let parsed: FrontendFailureCategoryForApiClients = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, FrontendFailureCategoryForApiClients::Unknown("some_future_category".to_string())); + } + + #[test] + fn test_deserialize_another_unknown_variant() { + let json = "\"totally_new_thing\""; + let parsed: FrontendFailureCategoryForApiClients = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, FrontendFailureCategoryForApiClients::Unknown("totally_new_thing".to_string())); + } + + #[test] + fn test_serialize_known_variants() { + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForApiClients::FaceNotDetected).unwrap(), "\"face_not_detected\""); + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForApiClients::ModelRulesViolation).unwrap(), "\"model_rules_violation\""); + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForApiClients::GenerationFailed).unwrap(), "\"generation_failed\""); + } + + #[test] + fn test_serialize_unknown_variant() { + let unknown = FrontendFailureCategoryForApiClients::Unknown("some_future_category".to_string()); + assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"some_future_category\""); + } + + #[test] + fn test_from_db_enum() { + assert_eq!( + FrontendFailureCategoryForApiClients::from_db_enum(FrontendFailureCategory::FaceNotDetected), + FrontendFailureCategoryForApiClients::FaceNotDetected + ); + assert_eq!( + FrontendFailureCategoryForApiClients::from_db_enum(FrontendFailureCategory::GenerationFailed), + FrontendFailureCategoryForApiClients::GenerationFailed + ); + assert_eq!( + FrontendFailureCategoryForApiClients::from_db_enum(FrontendFailureCategory::RuleBansGeneratedContent), + FrontendFailureCategoryForApiClients::RuleBansGeneratedContent + ); + } +} diff --git a/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs new file mode 100644 index 0000000000..a00e495fb3 --- /dev/null +++ b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs @@ -0,0 +1,86 @@ +use crate::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; +use utoipa::ToSchema; + +/// A restricted subset of `FrontendFailureCategory` that only includes values +/// known to old deployed clients. New failure categories are omitted so that +/// old clients never receive an enum value they cannot deserialize. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize, ToSchema)] +pub enum FrontendFailureCategoryForOldClients { + #[serde(rename = "face_not_detected")] + FaceNotDetected, + + #[serde(rename = "keep_alive_elapsed")] + KeepAliveElapsed, + + #[serde(rename = "not_yet_implemented")] + NotYetImplemented, + + #[serde(rename = "retryable_worker_error")] + RetryableWorkerError, +} + +impl FrontendFailureCategoryForOldClients { + /// Attempts to convert a database-level `FrontendFailureCategory` into the + /// restricted old-client enum. Returns `None` for categories that old + /// clients do not understand. + pub fn try_from_db_enum(value: FrontendFailureCategory) -> Option { + match value { + FrontendFailureCategory::FaceNotDetected => Some(Self::FaceNotDetected), + FrontendFailureCategory::KeepAliveElapsed => Some(Self::KeepAliveElapsed), + FrontendFailureCategory::NotYetImplemented => Some(Self::NotYetImplemented), + FrontendFailureCategory::RetryableWorkerError => Some(Self::RetryableWorkerError), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_variants_convert() { + assert_eq!( + FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::FaceNotDetected), + Some(FrontendFailureCategoryForOldClients::FaceNotDetected) + ); + assert_eq!( + FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::KeepAliveElapsed), + Some(FrontendFailureCategoryForOldClients::KeepAliveElapsed) + ); + assert_eq!( + FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::NotYetImplemented), + Some(FrontendFailureCategoryForOldClients::NotYetImplemented) + ); + assert_eq!( + FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RetryableWorkerError), + Some(FrontendFailureCategoryForOldClients::RetryableWorkerError) + ); + } + + #[test] + fn test_new_variants_return_none() { + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::NoForegroundSubjectDetected), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::FormatNotSupported), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::ModelRulesViolation), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserImage), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserImageWithFaces), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserTextPrompt), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserContent), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansGeneratedVideo), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansGeneratedAudio), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansGeneratedContent), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::GenerationFailed), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::FilesizeTooLarge), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::ImageDimensionsTooSmall), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::ImageDimensionsTooLarge), None); + } + + #[test] + fn test_serialization() { + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForOldClients::FaceNotDetected).unwrap(), "\"face_not_detected\""); + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForOldClients::KeepAliveElapsed).unwrap(), "\"keep_alive_elapsed\""); + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForOldClients::NotYetImplemented).unwrap(), "\"not_yet_implemented\""); + assert_eq!(serde_json::to_string(&FrontendFailureCategoryForOldClients::RetryableWorkerError).unwrap(), "\"retryable_worker_error\""); + } +} diff --git a/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/mod.rs b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/mod.rs new file mode 100644 index 0000000000..78e030c31f --- /dev/null +++ b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/mod.rs @@ -0,0 +1,2 @@ +pub mod frontend_failure_category_for_old_clients; +pub mod frontend_failure_category_for_api_clients; \ No newline at end of file diff --git a/crates/schema/public/enums/src/api_safe/by_table/mod.rs b/crates/schema/public/enums/src/api_safe/by_table/mod.rs new file mode 100644 index 0000000000..319789d2cf --- /dev/null +++ b/crates/schema/public/enums/src/api_safe/by_table/mod.rs @@ -0,0 +1 @@ +pub mod generic_inference_jobs; \ No newline at end of file diff --git a/crates/schema/public/enums/src/api_safe/mod.rs b/crates/schema/public/enums/src/api_safe/mod.rs new file mode 100644 index 0000000000..dc279ee573 --- /dev/null +++ b/crates/schema/public/enums/src/api_safe/mod.rs @@ -0,0 +1 @@ +pub mod by_table; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/audit_logs/audit_log_entity_action.rs b/crates/schema/public/enums/src/by_table/audit_logs/audit_log_entity_action.rs new file mode 100644 index 0000000000..b047175eb9 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/audit_logs/audit_log_entity_action.rs @@ -0,0 +1,110 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `audit_logs` table in a `VARCHAR(32)` field named `entity_action`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum AuditLogEntityAction { + /// Create action + #[serde(rename = "create")] + Create, + + /// Edit action + #[serde(rename = "edit")] + Edit, + + /// Edit features (eg. user feature flags) + #[serde(rename = "edit_features")] + EditFeatures, + + /// Delete action + #[serde(rename = "delete")] + Delete, + + /// Create featured item + #[serde(rename = "featured_item_create")] + FeaturedItemCreate, + + /// Delete featured item + #[serde(rename = "featured_item_delete")] + FeaturedItemDelete, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(AuditLogEntityAction); +impl_mysql_enum_coders!(AuditLogEntityAction); + +/// NB: Legacy API for older code. +impl AuditLogEntityAction { + pub fn to_str(&self) -> &'static str { + match self { + Self::Create => "create", + Self::Edit => "edit", + Self::EditFeatures => "edit_features", + Self::Delete => "delete", + Self::FeaturedItemCreate => "featured_item_create", + Self::FeaturedItemDelete => "featured_item_delete", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "create" => Ok(Self::Create), + "edit" => Ok(Self::Edit), + "edit_features" => Ok(Self::EditFeatures), + "delete" => Ok(Self::Delete), + "featured_item_create" => Ok(Self::FeaturedItemCreate), + "featured_item_delete" => Ok(Self::FeaturedItemDelete), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::audit_logs::audit_log_entity_action::AuditLogEntityAction; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(AuditLogEntityAction::Create, "create"); + assert_serialization(AuditLogEntityAction::Edit, "edit"); + assert_serialization(AuditLogEntityAction::EditFeatures, "edit_features"); + assert_serialization(AuditLogEntityAction::Delete, "delete"); + assert_serialization(AuditLogEntityAction::FeaturedItemCreate, "featured_item_create"); + assert_serialization(AuditLogEntityAction::FeaturedItemDelete, "featured_item_delete"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(AuditLogEntityAction::Create.to_str(), "create"); + assert_eq!(AuditLogEntityAction::Edit.to_str(), "edit"); + assert_eq!(AuditLogEntityAction::EditFeatures.to_str(), "edit_features"); + assert_eq!(AuditLogEntityAction::Delete.to_str(), "delete"); + assert_eq!(AuditLogEntityAction::FeaturedItemCreate.to_str(), "featured_item_create"); + assert_eq!(AuditLogEntityAction::FeaturedItemDelete.to_str(), "featured_item_delete"); + } + + #[test] + fn test_from_str() { + assert_eq!(AuditLogEntityAction::from_str("create").unwrap(), AuditLogEntityAction::Create); + assert_eq!(AuditLogEntityAction::from_str("edit").unwrap(), AuditLogEntityAction::Edit); + assert_eq!(AuditLogEntityAction::from_str("edit_features").unwrap(), AuditLogEntityAction::EditFeatures); + assert_eq!(AuditLogEntityAction::from_str("delete").unwrap(), AuditLogEntityAction::Delete); + assert_eq!(AuditLogEntityAction::from_str("featured_item_create").unwrap(), AuditLogEntityAction::FeaturedItemCreate); + assert_eq!(AuditLogEntityAction::from_str("featured_item_delete").unwrap(), AuditLogEntityAction::FeaturedItemDelete); + assert!(AuditLogEntityAction::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/audit_logs/audit_log_entity_type.rs b/crates/schema/public/enums/src/by_table/audit_logs/audit_log_entity_type.rs new file mode 100644 index 0000000000..2c2c957dde --- /dev/null +++ b/crates/schema/public/enums/src/by_table/audit_logs/audit_log_entity_type.rs @@ -0,0 +1,92 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `audit_logs` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum AuditLogEntityType { + /// Comment system + #[serde(rename = "comment")] + Comment, + + /// Media file + #[serde(rename = "media_file")] + MediaFile, + + /// Model weight + #[serde(rename = "model_weight")] + ModelWeight, + + /// User + #[serde(rename = "user")] + User, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(AuditLogEntityType); +impl_mysql_enum_coders!(AuditLogEntityType); + +/// NB: Legacy API for older code. +impl AuditLogEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Comment => "comment", + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + Self::User => "user", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "comment" => Ok(Self::Comment), + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + "user" => Ok(Self::User), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::audit_logs::audit_log_entity_type::AuditLogEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(AuditLogEntityType::Comment, "comment"); + assert_serialization(AuditLogEntityType::MediaFile, "media_file"); + assert_serialization(AuditLogEntityType::ModelWeight, "model_weight"); + assert_serialization(AuditLogEntityType::User, "user"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(AuditLogEntityType::Comment.to_str(), "comment"); + assert_eq!(AuditLogEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(AuditLogEntityType::ModelWeight.to_str(), "model_weight"); + assert_eq!(AuditLogEntityType::User.to_str(), "user"); + } + + #[test] + fn test_from_str() { + assert_eq!(AuditLogEntityType::from_str("comment").unwrap(), AuditLogEntityType::Comment); + assert_eq!(AuditLogEntityType::from_str("media_file").unwrap(), AuditLogEntityType::MediaFile); + assert_eq!(AuditLogEntityType::from_str("model_weight").unwrap(), AuditLogEntityType::ModelWeight); + assert_eq!(AuditLogEntityType::from_str("user").unwrap(), AuditLogEntityType::User); + assert!(AuditLogEntityType::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/audit_logs/mod.rs b/crates/schema/public/enums/src/by_table/audit_logs/mod.rs new file mode 100644 index 0000000000..1aa6f4477f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/audit_logs/mod.rs @@ -0,0 +1,2 @@ +pub mod audit_log_entity_action; +pub mod audit_log_entity_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/batch_generations/batch_generation_entity_type.rs b/crates/schema/public/enums/src/by_table/batch_generations/batch_generation_entity_type.rs new file mode 100644 index 0000000000..41633225f9 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/batch_generations/batch_generation_entity_type.rs @@ -0,0 +1,109 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `batch_generations` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize, Ord, PartialOrd, ToSchema)] +pub enum BatchGenerationEntityType { + /// Media files + /// This will probably be the only type supported, but we'll leave the possibility of future types. + #[serde(rename = "media_file")] + MediaFile, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(BatchGenerationEntityType); +impl_mysql_enum_coders!(BatchGenerationEntityType); +impl_mysql_from_row!(BatchGenerationEntityType); + +/// NB: Legacy API for older code. +impl BatchGenerationEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::MediaFile, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::batch_generations::batch_generation_entity_type::BatchGenerationEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(BatchGenerationEntityType::MediaFile, "media_file"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(BatchGenerationEntityType::MediaFile.to_str(), "media_file"); + } + + #[test] + fn from_str() { + assert_eq!(BatchGenerationEntityType::from_str("media_file").unwrap(), BatchGenerationEntityType::MediaFile); + assert!(BatchGenerationEntityType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = BatchGenerationEntityType::all_variants(); + assert_eq!(variants.len(), 1); + assert_eq!(variants.pop_first(), Some(BatchGenerationEntityType::MediaFile)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(BatchGenerationEntityType::all_variants().len(), BatchGenerationEntityType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in BatchGenerationEntityType::all_variants() { + assert_eq!(variant, BatchGenerationEntityType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, BatchGenerationEntityType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, BatchGenerationEntityType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/batch_generations/mod.rs b/crates/schema/public/enums/src/by_table/batch_generations/mod.rs new file mode 100644 index 0000000000..2babf4fabc --- /dev/null +++ b/crates/schema/public/enums/src/by_table/batch_generations/mod.rs @@ -0,0 +1 @@ +pub mod batch_generation_entity_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/beta_keys/beta_key_product.rs b/crates/schema/public/enums/src/by_table/beta_keys/beta_key_product.rs new file mode 100644 index 0000000000..3c9f00b514 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/beta_keys/beta_key_product.rs @@ -0,0 +1,109 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `batch_generations` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize, Ord, PartialOrd, ToSchema)] +pub enum BetaKeyProduct { + /// Media files + /// This will probably be the only type supported, but we'll leave the possibility of future types. + #[serde(rename = "studio")] + Studio, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(BetaKeyProduct); +impl_mysql_enum_coders!(BetaKeyProduct); +impl_mysql_from_row!(BetaKeyProduct); + +/// NB: Legacy API for older code. +impl BetaKeyProduct { + pub fn to_str(&self) -> &'static str { + match self { + Self::Studio => "studio", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "studio" => Ok(Self::Studio), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Studio, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::beta_keys::beta_key_product::BetaKeyProduct; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(BetaKeyProduct::Studio, "studio"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(BetaKeyProduct::Studio.to_str(), "studio"); + } + + #[test] + fn from_str() { + assert_eq!(BetaKeyProduct::from_str("studio").unwrap(), BetaKeyProduct::Studio); + assert!(BetaKeyProduct::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = BetaKeyProduct::all_variants(); + assert_eq!(variants.len(), 1); + assert_eq!(variants.pop_first(), Some(BetaKeyProduct::Studio)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(BetaKeyProduct::all_variants().len(), BetaKeyProduct::iter().len()); + } + + #[test] + fn round_trip() { + for variant in BetaKeyProduct::all_variants() { + assert_eq!(variant, BetaKeyProduct::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, BetaKeyProduct::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, BetaKeyProduct::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/beta_keys/mod.rs b/crates/schema/public/enums/src/by_table/beta_keys/mod.rs new file mode 100644 index 0000000000..a3484beb5d --- /dev/null +++ b/crates/schema/public/enums/src/by_table/beta_keys/mod.rs @@ -0,0 +1 @@ +pub mod beta_key_product; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/characters/character_type.rs b/crates/schema/public/enums/src/by_table/characters/character_type.rs new file mode 100644 index 0000000000..294389014e --- /dev/null +++ b/crates/schema/public/enums/src/by_table/characters/character_type.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `characters` table in `VARCHAR(24)` field `character_type`. +/// +/// Identifies the type/backend used to create the character. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum CharacterType { + /// Character created via Kinovi / Seedance2Pro. + #[serde(rename = "kinovi_seedance")] + KinoviSeedance, +} + +impl_enum_display_and_debug_using_to_str!(CharacterType); +impl_mysql_enum_coders!(CharacterType); + +impl CharacterType { + pub fn to_str(&self) -> &'static str { + match self { + Self::KinoviSeedance => "kinovi_seedance", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "kinovi_seedance" => Ok(Self::KinoviSeedance), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::KinoviSeedance, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::characters::character_type::CharacterType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(CharacterType::KinoviSeedance, "kinovi_seedance"); + } + + #[test] + fn to_str() { + assert_eq!(CharacterType::KinoviSeedance.to_str(), "kinovi_seedance"); + } + + #[test] + fn from_str() { + assert_eq!(CharacterType::from_str("kinovi_seedance").unwrap(), CharacterType::KinoviSeedance); + assert!(CharacterType::from_str("invalid").is_err()); + } + + #[test] + fn all_variants() { + const EXPECTED_COUNT: usize = 1; + + assert_eq!(CharacterType::all_variants().len(), EXPECTED_COUNT); + + use strum::IntoEnumIterator; + assert_eq!(CharacterType::all_variants().len(), CharacterType::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CharacterType::all_variants().len(), CharacterType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CharacterType::all_variants() { + assert_eq!(variant, CharacterType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CharacterType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CharacterType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 24; + for variant in CharacterType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/characters/mod.rs b/crates/schema/public/enums/src/by_table/characters/mod.rs new file mode 100644 index 0000000000..d7b62c5e2b --- /dev/null +++ b/crates/schema/public/enums/src/by_table/characters/mod.rs @@ -0,0 +1 @@ +pub mod character_type; diff --git a/crates/schema/public/enums/src/by_table/comments/comment_entity_type.rs b/crates/schema/public/enums/src/by_table/comments/comment_entity_type.rs new file mode 100644 index 0000000000..f3c384825e --- /dev/null +++ b/crates/schema/public/enums/src/by_table/comments/comment_entity_type.rs @@ -0,0 +1,120 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `comments` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize, ToSchema)] +pub enum CommentEntityType { + /// User + #[serde(rename = "user")] + User, + + /// Media files + #[serde(rename = "media_file")] + MediaFile, + + /// Model weights + #[serde(rename = "model_weight")] + ModelWeight, + + /// TTS model (architecture does not matter) + #[serde(rename = "tts_model")] + TtsModel, + + /// TTS result (architecture does not matter) + #[serde(rename = "tts_result")] + TtsResult, + + /// W2L template + #[serde(rename = "w2l_template")] + W2lTemplate, + + /// W2L result + #[serde(rename = "w2l_result")] + W2lResult, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(CommentEntityType); +impl_mysql_enum_coders!(CommentEntityType); + +/// NB: Legacy API for older code. +impl CommentEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + Self::TtsModel => "tts_model", + Self::TtsResult => "tts_result", + Self::W2lTemplate => "w2l_template", + Self::W2lResult => "w2l_result", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "user" => Ok(Self::User), + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + "tts_model" => Ok(Self::TtsModel), + "tts_result" => Ok(Self::TtsResult), + "w2l_template" => Ok(Self::W2lTemplate), + "w2l_result" => Ok(Self::W2lResult), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::comments::comment_entity_type::CommentEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(CommentEntityType::User, "user"); + assert_serialization(CommentEntityType::MediaFile, "media_file"); + assert_serialization(CommentEntityType::ModelWeight, "model_weight"); + assert_serialization(CommentEntityType::TtsModel, "tts_model"); + assert_serialization(CommentEntityType::TtsResult, "tts_result"); + assert_serialization(CommentEntityType::W2lTemplate, "w2l_template"); + assert_serialization(CommentEntityType::W2lResult, "w2l_result"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(CommentEntityType::User.to_str(), "user"); + assert_eq!(CommentEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(CommentEntityType::ModelWeight.to_str(), "model_weight"); + assert_eq!(CommentEntityType::TtsModel.to_str(), "tts_model"); + assert_eq!(CommentEntityType::TtsResult.to_str(), "tts_result"); + assert_eq!(CommentEntityType::W2lTemplate.to_str(), "w2l_template"); + assert_eq!(CommentEntityType::W2lResult.to_str(), "w2l_result"); + } + + #[test] + fn test_from_str() { + assert_eq!(CommentEntityType::from_str("user").unwrap(), CommentEntityType::User); + assert_eq!(CommentEntityType::from_str("media_file").unwrap(), CommentEntityType::MediaFile); + assert_eq!(CommentEntityType::from_str("model_weight").unwrap(), CommentEntityType::ModelWeight); + assert_eq!(CommentEntityType::from_str("tts_model").unwrap(), CommentEntityType::TtsModel); + assert_eq!(CommentEntityType::from_str("tts_result").unwrap(), CommentEntityType::TtsResult); + assert_eq!(CommentEntityType::from_str("w2l_template").unwrap(), CommentEntityType::W2lTemplate); + assert_eq!(CommentEntityType::from_str("w2l_result").unwrap(), CommentEntityType::W2lResult); + assert!(CommentEntityType::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/comments/mod.rs b/crates/schema/public/enums/src/by_table/comments/mod.rs new file mode 100644 index 0000000000..2d9897d210 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/comments/mod.rs @@ -0,0 +1 @@ +pub mod comment_entity_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/debug_logs/debug_log_level.rs b/crates/schema/public/enums/src/by_table/debug_logs/debug_log_level.rs new file mode 100644 index 0000000000..5e7ddc1609 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/debug_logs/debug_log_level.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 16; + +/// Used in the `debug_logs` table in a `VARCHAR(16)` field (`maybe_log_level`). +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DebugLogLevel { + Info, + Warn, + Error, + Debug, + Trace, +} + +impl_enum_display_and_debug_using_to_str!(DebugLogLevel); +impl_mysql_enum_coders!(DebugLogLevel); +impl_mysql_from_row!(DebugLogLevel); + +impl DebugLogLevel { + pub fn to_str(&self) -> &'static str { + match self { + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + Self::Debug => "debug", + Self::Trace => "trace", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "info" => Ok(Self::Info), + "warn" => Ok(Self::Warn), + "error" => Ok(Self::Error), + "debug" => Ok(Self::Debug), + "trace" => Ok(Self::Trace), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Info, + Self::Warn, + Self::Error, + Self::Debug, + Self::Trace, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::debug_logs::debug_log_level::DebugLogLevel; + use crate::by_table::debug_logs::debug_log_level::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(DebugLogLevel::Info, "info"); + assert_serialization(DebugLogLevel::Warn, "warn"); + assert_serialization(DebugLogLevel::Error, "error"); + assert_serialization(DebugLogLevel::Debug, "debug"); + assert_serialization(DebugLogLevel::Trace, "trace"); + } + + #[test] + fn to_str() { + assert_eq!(DebugLogLevel::Info.to_str(), "info"); + assert_eq!(DebugLogLevel::Warn.to_str(), "warn"); + assert_eq!(DebugLogLevel::Error.to_str(), "error"); + assert_eq!(DebugLogLevel::Debug.to_str(), "debug"); + assert_eq!(DebugLogLevel::Trace.to_str(), "trace"); + } + + #[test] + fn from_str() { + assert_eq!(DebugLogLevel::from_str("info").unwrap(), DebugLogLevel::Info); + assert_eq!(DebugLogLevel::from_str("warn").unwrap(), DebugLogLevel::Warn); + assert_eq!(DebugLogLevel::from_str("error").unwrap(), DebugLogLevel::Error); + assert_eq!(DebugLogLevel::from_str("debug").unwrap(), DebugLogLevel::Debug); + assert_eq!(DebugLogLevel::from_str("trace").unwrap(), DebugLogLevel::Trace); + } + + #[test] + fn from_str_err() { + let result = DebugLogLevel::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = DebugLogLevel::all_variants(); + assert_eq!(variants.len(), 5); + assert_eq!(variants.pop_first(), Some(DebugLogLevel::Info)); + assert_eq!(variants.pop_first(), Some(DebugLogLevel::Warn)); + assert_eq!(variants.pop_first(), Some(DebugLogLevel::Error)); + assert_eq!(variants.pop_first(), Some(DebugLogLevel::Debug)); + assert_eq!(variants.pop_first(), Some(DebugLogLevel::Trace)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(DebugLogLevel::all_variants().len(), DebugLogLevel::iter().len()); + } + + #[test] + fn round_trip() { + for variant in DebugLogLevel::all_variants() { + assert_eq!(variant, DebugLogLevel::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, DebugLogLevel::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, DebugLogLevel::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn max_length() { + for variant in DebugLogLevel::all_variants() { + assert!(variant.to_str().len() <= MAX_LENGTH); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs b/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs new file mode 100644 index 0000000000..2a825681c2 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs @@ -0,0 +1,192 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 24; + +/// Used in the `debug_logs` table in a `VARCHAR(24)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DebugLogType { + // ========== First party ========== + + /// A user request, typically the Json actix_web handlers read. + HttpRequest, + + /// A backend/pipeline failure while serving a request. + BackendFailure, + + // ========== Third party ========== + + FalRequest, + KinoviRequest, + GrokApiRequest, + FalWebhook, + BeebleWebhook, +} + +impl_enum_display_and_debug_using_to_str!(DebugLogType); +impl_mysql_enum_coders!(DebugLogType); +impl_mysql_from_row!(DebugLogType); + +impl DebugLogType { + pub fn to_str(&self) -> &'static str { + match self { + Self::HttpRequest => "http_request", + Self::BackendFailure => "backend_failure", + Self::FalRequest => "fal_request", + Self::KinoviRequest => "kinovi_request", + Self::GrokApiRequest => "grok_api_request", + Self::FalWebhook => "fal_webhook", + Self::BeebleWebhook => "beeble_webhook", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "http_request" => Ok(Self::HttpRequest), + "backend_failure" => Ok(Self::BackendFailure), + "fal_request" => Ok(Self::FalRequest), + "kinovi_request" => Ok(Self::KinoviRequest), + "grok_api_request" => Ok(Self::GrokApiRequest), + "fal_webhook" => Ok(Self::FalWebhook), + "beeble_webhook" => Ok(Self::BeebleWebhook), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::HttpRequest, + Self::BackendFailure, + Self::FalRequest, + Self::KinoviRequest, + Self::GrokApiRequest, + Self::FalWebhook, + Self::BeebleWebhook, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::debug_logs::debug_log_type::DebugLogType; + use crate::by_table::debug_logs::debug_log_type::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(DebugLogType::HttpRequest, "http_request"); + assert_serialization(DebugLogType::BackendFailure, "backend_failure"); + assert_serialization(DebugLogType::FalRequest, "fal_request"); + assert_serialization(DebugLogType::KinoviRequest, "kinovi_request"); + assert_serialization(DebugLogType::GrokApiRequest, "grok_api_request"); + assert_serialization(DebugLogType::FalWebhook, "fal_webhook"); + assert_serialization(DebugLogType::BeebleWebhook, "beeble_webhook"); + } + + #[test] + fn to_str() { + assert_eq!(DebugLogType::HttpRequest.to_str(), "http_request"); + assert_eq!(DebugLogType::BackendFailure.to_str(), "backend_failure"); + assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request"); + assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request"); + assert_eq!(DebugLogType::GrokApiRequest.to_str(), "grok_api_request"); + assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook"); + assert_eq!(DebugLogType::BeebleWebhook.to_str(), "beeble_webhook"); + } + + #[test] + fn from_str() { + assert_eq!(DebugLogType::from_str("http_request").unwrap(), DebugLogType::HttpRequest); + assert_eq!(DebugLogType::from_str("backend_failure").unwrap(), DebugLogType::BackendFailure); + assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest); + assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest); + assert_eq!(DebugLogType::from_str("grok_api_request").unwrap(), DebugLogType::GrokApiRequest); + assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook); + assert_eq!(DebugLogType::from_str("beeble_webhook").unwrap(), DebugLogType::BeebleWebhook); + } + + #[test] + fn from_str_err() { + let result = DebugLogType::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = DebugLogType::all_variants(); + assert_eq!(variants.len(), 7); + assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest)); + assert_eq!(variants.pop_first(), Some(DebugLogType::BackendFailure)); + assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest)); + assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest)); + assert_eq!(variants.pop_first(), Some(DebugLogType::GrokApiRequest)); + assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook)); + assert_eq!(variants.pop_first(), Some(DebugLogType::BeebleWebhook)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(DebugLogType::all_variants().len(), DebugLogType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in DebugLogType::all_variants() { + assert_eq!(variant, DebugLogType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, DebugLogType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, DebugLogType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in DebugLogType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in DebugLogType::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in DebugLogType::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/debug_logs/mod.rs b/crates/schema/public/enums/src/by_table/debug_logs/mod.rs new file mode 100644 index 0000000000..f67cbb4a6c --- /dev/null +++ b/crates/schema/public/enums/src/by_table/debug_logs/mod.rs @@ -0,0 +1,2 @@ +pub mod debug_log_level; +pub mod debug_log_type; diff --git a/crates/schema/public/enums/src/by_table/email_sender_jobs/email_category.rs b/crates/schema/public/enums/src/by_table/email_sender_jobs/email_category.rs new file mode 100644 index 0000000000..87fedc8144 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/email_sender_jobs/email_category.rs @@ -0,0 +1,115 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `email_sender_jobs` table in `VARCHAR(32)` field `id_category`. +/// +/// This denotes the type of email being sent. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum EmailCategory { + /// User is recently registered + #[serde(rename = "welcome")] + Welcome, + + /// User is resetting their password + #[serde(rename = "password_reset")] + PasswordReset, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(EmailCategory); +impl_mysql_enum_coders!(EmailCategory); + +/// NB: Legacy API for older code. +impl EmailCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::Welcome => "welcome", + Self::PasswordReset => "password_reset", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "welcome" => Ok(Self::Welcome), + "password_reset" => Ok(Self::PasswordReset), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Welcome, + Self::PasswordReset, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::email_sender_jobs::email_category::EmailCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(EmailCategory::Welcome, "welcome"); + assert_serialization(EmailCategory::PasswordReset, "password_reset"); + } + + #[test] + fn to_str() { + assert_eq!(EmailCategory::Welcome.to_str(), "welcome"); + assert_eq!(EmailCategory::PasswordReset.to_str(), "password_reset"); + } + + #[test] + fn from_str() { + assert_eq!(EmailCategory::from_str("welcome").unwrap(), EmailCategory::Welcome); + assert_eq!(EmailCategory::from_str("password_reset").unwrap(), EmailCategory::PasswordReset); + } + + #[test] + fn all_variants() { + // Static check + let mut variants = EmailCategory::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(EmailCategory::Welcome)); + assert_eq!(variants.pop_first(), Some(EmailCategory::PasswordReset)); + assert_eq!(variants.pop_first(), None); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(EmailCategory::all_variants().len(), EmailCategory::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(EmailCategory::all_variants().len(), EmailCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in EmailCategory::all_variants() { + assert_eq!(variant, EmailCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, EmailCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, EmailCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/email_sender_jobs/mod.rs b/crates/schema/public/enums/src/by_table/email_sender_jobs/mod.rs new file mode 100644 index 0000000000..18d5731795 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/email_sender_jobs/mod.rs @@ -0,0 +1 @@ +pub mod email_category; diff --git a/crates/schema/public/enums/src/by_table/entity_stats/mod.rs b/crates/schema/public/enums/src/by_table/entity_stats/mod.rs new file mode 100644 index 0000000000..12b1d9cad7 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/entity_stats/mod.rs @@ -0,0 +1 @@ +pub mod stats_entity_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/entity_stats/stats_entity_type.rs b/crates/schema/public/enums/src/by_table/entity_stats/stats_entity_type.rs new file mode 100644 index 0000000000..f07214e9a3 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/entity_stats/stats_entity_type.rs @@ -0,0 +1,122 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_stats` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize, ToSchema, Ord, PartialOrd)] +pub enum StatsEntityType { + /// Comment + #[serde(rename = "comment")] + Comment, + + /// MediaFile + #[serde(rename = "media_file")] + MediaFile, + + /// ModelWeight (the new way to store models) + #[serde(rename = "model_weight")] + ModelWeight, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(StatsEntityType); +impl_mysql_enum_coders!(StatsEntityType); +impl_mysql_from_row!(StatsEntityType); + +/// NB: Legacy API for older code. +impl StatsEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Comment => "comment", + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "comment" => Ok(Self::Comment), + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Comment, + Self::MediaFile, + Self::ModelWeight, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::entity_stats::stats_entity_type::StatsEntityType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(StatsEntityType::Comment, "comment"); + assert_serialization(StatsEntityType::MediaFile, "media_file"); + assert_serialization(StatsEntityType::ModelWeight, "model_weight"); + } + + #[test] + fn test_to_str() { + assert_eq!(StatsEntityType::Comment.to_str(), "comment"); + assert_eq!(StatsEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(StatsEntityType::ModelWeight.to_str(), "model_weight"); + } + + #[test] + fn test_from_str() { + assert_eq!(StatsEntityType::from_str("comment").unwrap(), StatsEntityType::Comment); + assert_eq!(StatsEntityType::from_str("media_file").unwrap(), StatsEntityType::MediaFile); + assert_eq!(StatsEntityType::from_str("model_weight").unwrap(), StatsEntityType::ModelWeight); + assert!(StatsEntityType::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = StatsEntityType::all_variants(); + assert_eq!(variants.len(), 3); + assert_eq!(variants.pop_first(), Some(StatsEntityType::Comment)); + assert_eq!(variants.pop_first(), Some(StatsEntityType::MediaFile)); + assert_eq!(variants.pop_first(), Some(StatsEntityType::ModelWeight)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StatsEntityType::all_variants().len(), StatsEntityType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StatsEntityType::all_variants() { + assert_eq!(variant, StatsEntityType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StatsEntityType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StatsEntityType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/featured_items/featured_item_entity_type.rs b/crates/schema/public/enums/src/by_table/featured_items/featured_item_entity_type.rs new file mode 100644 index 0000000000..012fa4debf --- /dev/null +++ b/crates/schema/public/enums/src/by_table/featured_items/featured_item_entity_type.rs @@ -0,0 +1,126 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_bookmarks` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum FeaturedItemEntityType { + /// MediaFile + #[serde(rename = "media_file")] + MediaFile, + + /// ModelWeight (the new way to store models) + #[serde(rename = "model_weight")] + ModelWeight, + + /// User + #[serde(rename = "user")] + User, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(FeaturedItemEntityType); +impl_mysql_enum_coders!(FeaturedItemEntityType); +impl_mysql_from_row!(FeaturedItemEntityType); + +/// NB: Legacy API for older code. +impl FeaturedItemEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + Self::User => "user", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + "user" => Ok(Self::User), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::MediaFile, + Self::ModelWeight, + Self::User, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(FeaturedItemEntityType::MediaFile, "media_file"); + assert_serialization(FeaturedItemEntityType::ModelWeight, "model_weight"); + assert_serialization(FeaturedItemEntityType::User, "user"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(FeaturedItemEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(FeaturedItemEntityType::ModelWeight.to_str(), "model_weight"); + assert_eq!(FeaturedItemEntityType::User.to_str(), "user"); + } + + #[test] + fn test_from_str() { + assert_eq!(FeaturedItemEntityType::from_str("media_file").unwrap(), FeaturedItemEntityType::MediaFile); + assert_eq!(FeaturedItemEntityType::from_str("model_weight").unwrap(), FeaturedItemEntityType::ModelWeight); + assert_eq!(FeaturedItemEntityType::from_str("user").unwrap(), FeaturedItemEntityType::User); + assert!(FeaturedItemEntityType::from_str("foo").is_err()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(FeaturedItemEntityType::all_variants().len(), FeaturedItemEntityType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in FeaturedItemEntityType::all_variants() { + assert_eq!(variant, FeaturedItemEntityType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, FeaturedItemEntityType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, FeaturedItemEntityType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in FeaturedItemEntityType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/featured_items/mod.rs b/crates/schema/public/enums/src/by_table/featured_items/mod.rs new file mode 100644 index 0000000000..71c7785bce --- /dev/null +++ b/crates/schema/public/enums/src/by_table/featured_items/mod.rs @@ -0,0 +1 @@ +pub mod featured_item_entity_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/generic_download_jobs/generic_download_type.rs b/crates/schema/public/enums/src/by_table/generic_download_jobs/generic_download_type.rs new file mode 100644 index 0000000000..355bdc8ff5 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_download_jobs/generic_download_type.rs @@ -0,0 +1,175 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +// TODO: Use macro-derived impls + +/// Our "generic downloads" pipeline supports a wide variety of ML models and other media. +/// They are serialized in the database table `generic_download_jobs` as a VARCHAR(32). +/// +/// Each type of download is identified by the following enum variants. +/// These types are present in the HTTP API and database columns as serialized here. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[derive(Clone, Copy, Eq, PartialEq, Debug, Deserialize, Serialize, Hash, Ord, PartialOrd)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +pub enum GenericDownloadType { + /// NB: Note - this is hifigan for Tacotron2. + /// Some work will be needed to unify this with other hifigan types. + #[serde(rename = "hifigan")] + #[cfg_attr(feature = "database", sqlx(rename = "hifigan"))] + HifiGan, + + /// NB: Note - this is hifigan for SoftVC (our internal codename is "rocketvc"). + /// Some work will need to be done to unify this with other hifigan types. + #[serde(rename = "hifigan_rocket_vc")] + #[cfg_attr(feature = "database", sqlx(rename = "hifigan_rocket_vc"))] + HifiGanRocketVc, + + /// NB: Note - this is hifigan for SoVitsSvc + /// Some work will need to be done to unify this with other hifigan types. + #[serde(rename = "hifigan_so_vits_svc")] + #[cfg_attr(feature = "database", sqlx(rename = "hifigan_so_vits_svc"))] + HifiGanSoVitsSvc, + + //#[serde(rename = "melgan_vocodes")] + //#[sqlx(rename = "melgan_vocodes")] + //MelGanVocodes, + + /// NB: Our external-facing name for "softvc" is rocketvc. + /// I wish we could stop being stupid about this. + #[serde(rename = "rocket_vc")] + #[cfg_attr(feature = "database", sqlx(rename = "rocket_vc"))] + RocketVc, + + /// RVC (v2) voice conversion models + #[serde(rename = "rvc_v2")] + #[cfg_attr(feature = "database", sqlx(rename = "rvc_v2"))] + RvcV2, + + /// so-vits-svc voice conversion models + #[serde(rename = "so_vits_svc")] + #[cfg_attr(feature = "database", sqlx(rename = "so_vits_svc"))] + SoVitsSvc, + + /// Tacotron TTS models. + #[serde(rename = "tacotron2")] + #[cfg_attr(feature = "database", sqlx(rename = "tacotron2"))] + Tacotron2, + + /// VITS TTS models. + #[serde(rename = "vits")] + #[cfg_attr(feature = "database", sqlx(rename = "vits"))] + Vits, +} + +/// NB: Legacy API for older code. +impl GenericDownloadType { + pub fn to_str(&self) -> &'static str { + match self { + Self::HifiGan => "hifigan", + Self::HifiGanRocketVc => "hifigan_rocket_vc", + Self::HifiGanSoVitsSvc => "hifigan_so_vits_svc", + Self::RocketVc => "rocket_vc", + Self::RvcV2 => "rvc_v2", + Self::SoVitsSvc => "so_vits_svc", + Self::Tacotron2 => "tacotron2", + Self::Vits => "vits", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "hifigan" => Ok(Self::HifiGan), + "hifigan_rocket_vc" => Ok(Self::HifiGanRocketVc), + "hifigan_so_vits_svc" => Ok(Self::HifiGanSoVitsSvc), + "rocket_vc" => Ok(Self::RocketVc), + "rvc_v2" => Ok(Self::RvcV2), + "so_vits_svc" => Ok(Self::SoVitsSvc), + "tacotron2" => Ok(Self::Tacotron2), + "vits" => Ok(Self::Vits), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::HifiGan, + Self::HifiGanRocketVc, + Self::HifiGanSoVitsSvc, + Self::RocketVc, + Self::RvcV2, + Self::SoVitsSvc, + Self::Tacotron2, + Self::Vits, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_download_jobs::generic_download_type::GenericDownloadType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(GenericDownloadType::HifiGan, "hifigan"); + assert_serialization(GenericDownloadType::HifiGanRocketVc, "hifigan_rocket_vc"); + assert_serialization(GenericDownloadType::HifiGanSoVitsSvc, "hifigan_so_vits_svc"); + assert_serialization(GenericDownloadType::RocketVc, "rocket_vc"); + assert_serialization(GenericDownloadType::RvcV2, "rvc_v2"); + assert_serialization(GenericDownloadType::SoVitsSvc, "so_vits_svc"); + assert_serialization(GenericDownloadType::Tacotron2, "tacotron2"); + assert_serialization(GenericDownloadType::Vits, "vits"); + } + + #[test] + fn to_str() { + assert_eq!(GenericDownloadType::HifiGan.to_str(), "hifigan"); + assert_eq!(GenericDownloadType::HifiGanRocketVc.to_str(), "hifigan_rocket_vc"); + assert_eq!(GenericDownloadType::HifiGanSoVitsSvc.to_str(), "hifigan_so_vits_svc"); + assert_eq!(GenericDownloadType::RocketVc.to_str(), "rocket_vc"); + assert_eq!(GenericDownloadType::RvcV2.to_str(), "rvc_v2"); + assert_eq!(GenericDownloadType::SoVitsSvc.to_str(), "so_vits_svc"); + assert_eq!(GenericDownloadType::Tacotron2.to_str(), "tacotron2"); + assert_eq!(GenericDownloadType::Vits.to_str(), "vits"); + } + + #[test] + fn from_str() { + assert_eq!(GenericDownloadType::from_str("hifigan").unwrap(), GenericDownloadType::HifiGan); + assert_eq!(GenericDownloadType::from_str("hifigan_rocket_vc").unwrap(), GenericDownloadType::HifiGanRocketVc); + assert_eq!(GenericDownloadType::from_str("hifigan_so_vits_svc").unwrap(), GenericDownloadType::HifiGanSoVitsSvc); + assert_eq!(GenericDownloadType::from_str("rocket_vc").unwrap(), GenericDownloadType::RocketVc); + assert_eq!(GenericDownloadType::from_str("rvc_v2").unwrap(), GenericDownloadType::RvcV2); + assert_eq!(GenericDownloadType::from_str("so_vits_svc").unwrap(), GenericDownloadType::SoVitsSvc); + assert_eq!(GenericDownloadType::from_str("tacotron2").unwrap(), GenericDownloadType::Tacotron2); + assert_eq!(GenericDownloadType::from_str("vits").unwrap(), GenericDownloadType::Vits); + } + + #[test] + fn all_variants() { + // Static check + let mut variants = GenericDownloadType::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::HifiGan)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::HifiGanRocketVc)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::HifiGanSoVitsSvc)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::RocketVc)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::RvcV2)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::SoVitsSvc)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::Tacotron2)); + assert_eq!(variants.pop_first(), Some(GenericDownloadType::Vits)); + assert_eq!(variants.pop_first(), None); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(GenericDownloadType::all_variants().len(), GenericDownloadType::iter().len()); + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_download_jobs/mod.rs b/crates/schema/public/enums/src/by_table/generic_download_jobs/mod.rs new file mode 100644 index 0000000000..40d1e08d44 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_download_jobs/mod.rs @@ -0,0 +1 @@ +pub mod generic_download_type; diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs new file mode 100644 index 0000000000..64a2ce90ce --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs @@ -0,0 +1,343 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `frontend_failure_category`. +/// +/// When jobs fail (permanently or transiently), we need to inform the frontend of the reason, +/// because perhaps there's something the user can do to change their input. +/// +/// The previous "VARCHAR(32) failure_reason" column was a text-based message that could not be +/// localized or made user friendly. This `frontend_failure_category` exists to provide well-defined +/// failure categories to the frontend that can easily be localized and indicated consistently in +/// the UI. +/// +/// Another benefit is that we'll surface all of the various types of failure and perhaps eventually +/// come to handle some in a cross-cutting way. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum FrontendFailureCategory { + /// When a face is not detected in the image used for animation. + /// For SadTalker (and possibly Wav2Lip) + #[serde(rename = "face_not_detected")] + FaceNotDetected, + + /// No foreground subject could be detected in the input image after + /// background removal (fal TripoSplat `input_value_error`). The user should + /// provide an image with a clear subject against a plain or distinct + /// background. + #[serde(rename = "no_foreground_subject_detected")] + NoForegroundSubjectDetected, + + /// The input file's format isn't supported by the model (e.g. Hunyuan 3D + /// Part only accepts FBX input). The user should convert or pick a + /// supported file. + #[serde(rename = "format_not_supported")] + FormatNotSupported, + + /// The user stepped away from their device and expected the workload to finish. + /// Some workloads require that the user keep their browser open. + #[serde(rename = "keep_alive_elapsed")] + KeepAliveElapsed, + + /// This is mostly for developers -- a feature isn't complete somewhere in the code. + /// Big oops if errors of this class make it to production. + #[serde(rename = "not_yet_implemented")] + NotYetImplemented, + + /// Tell the user that some kind of transient error happened. They don't need to know + /// exactly what happened. We'll retry their workload in any case. + #[serde(rename = "retryable_worker_error")] + RetryableWorkerError, + + /// Model content rules were violated + /// Eg. Seedance 2 will report: "your input text violates platform rules. please modify and try again" + #[serde(rename = "model_rules_violation")] + ModelRulesViolation, + + /// Model content violation + /// e.g. "Your uploaded image violates platform rules. Please modify and try again." (seedance2pro) + /// Model content rules prohibit the uploaded image. + #[serde(rename = "rule_bans_user_image")] + RuleBansUserImage, + + /// Model content violation + /// Model content rules prohibit user uploaded images containing faces (Seedance 2.0) + /// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro) + #[serde(rename = "rule_bans_user_image_with_faces")] + RuleBansUserImageWithFaces, + + /// Model content violation + /// Model content rules prohibit the user's given text prompt (this fails early). + /// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro) + #[serde(rename = "rule_bans_user_text_prompt")] + RuleBansUserTextPrompt, + + /// Model content violation + /// Model content rules prohibit user content. (I think this check happens early.) + /// e.g. "Content violates platform rules. Please modify and try again." (seedance2pro) + /// We also map fal WebhookErrorType::ContentPolicyViolation to this. + #[serde(rename = "rule_bans_user_content")] + RuleBansUserContent, + + /// Model content violation + /// The video didn't pass checks after it finished generation (this fails at the very end of the generation). + /// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro) + #[serde(rename = "rule_bans_generated_video")] + RuleBansGeneratedVideo, + + /// Model content violation + /// The audio (even in video!) didn't pass checks after it finished generation (this fails at the very end of the generation). + /// e.g. "The generated audio violates platform rules. Please adjust your prompt or images and try again." (seedance2pro) + #[serde(rename = "rule_bans_generated_audio")] + RuleBansGeneratedAudio, + + /// Model content violation + /// The content didn't pass checks after it finished generation (this fails at the very end of the generation). + /// e.g. "The generated content violates platform rules. Please adjust your prompt or images and try again." (seedance2pro) + #[serde(rename = "rule_bans_generated_content")] + RuleBansGeneratedContent, + + /// The uploaded file exceeds the maximum allowed size for this model. + /// We map fal WebhookErrorType::FileTooLarge to this. + #[serde(rename = "filesize_too_large")] + FilesizeTooLarge, + + /// The uploaded image dimensions are below the minimum required by the model. + /// We map fal WebhookErrorType::ImageTooSmall to this. + #[serde(rename = "image_dimensions_too_small")] + ImageDimensionsTooSmall, + + /// The uploaded image dimensions exceed the maximum allowed by the model. + /// We map fal WebhookErrorType::ImageTooLarge to this. + #[serde(rename = "image_dimensions_too_large")] + ImageDimensionsTooLarge, + + /// Generation failed (no reason) + /// Unspecified failure reason + /// Various example failures: + /// - "The generated video did not pass review. Credits will not be deducted." (seedance2pro) + /// - "Server error. Please try again later." (seedance2pro) + #[serde(rename = "generation_failed")] + GenerationFailed, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(FrontendFailureCategory); +impl_mysql_enum_coders!(FrontendFailureCategory); + +/// NB: Legacy API for older code. +impl FrontendFailureCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::FaceNotDetected => "face_not_detected", + Self::NoForegroundSubjectDetected => "no_foreground_subject_detected", + Self::FormatNotSupported => "format_not_supported", + Self::KeepAliveElapsed => "keep_alive_elapsed", + Self::NotYetImplemented => "not_yet_implemented", + Self::RetryableWorkerError => "retryable_worker_error", + Self::ModelRulesViolation => "model_rules_violation", + Self::RuleBansUserImage => "rule_bans_user_image", + Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces", + Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt", + Self::RuleBansUserContent => "rule_bans_user_content", + Self::RuleBansGeneratedVideo => "rule_bans_generated_video", + Self::RuleBansGeneratedAudio => "rule_bans_generated_audio", + Self::RuleBansGeneratedContent => "rule_bans_generated_content", + Self::FilesizeTooLarge => "filesize_too_large", + Self::ImageDimensionsTooSmall => "image_dimensions_too_small", + Self::ImageDimensionsTooLarge => "image_dimensions_too_large", + Self::GenerationFailed => "generation_failed", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "face_not_detected" => Ok(Self::FaceNotDetected), + "no_foreground_subject_detected" => Ok(Self::NoForegroundSubjectDetected), + "format_not_supported" => Ok(Self::FormatNotSupported), + "keep_alive_elapsed" => Ok(Self::KeepAliveElapsed), + "not_yet_implemented" => Ok(Self::NotYetImplemented), + "retryable_worker_error" => Ok(Self::RetryableWorkerError), + "model_rules_violation" => Ok(Self::ModelRulesViolation), + "rule_bans_user_image" => Ok(Self::RuleBansUserImage), + "rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces), + "rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt), + "rule_bans_user_content" => Ok(Self::RuleBansUserContent), + "rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo), + "rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio), + "rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent), + "filesize_too_large" => Ok(Self::FilesizeTooLarge), + "image_dimensions_too_small" => Ok(Self::ImageDimensionsTooSmall), + "image_dimensions_too_large" => Ok(Self::ImageDimensionsTooLarge), + "generation_failed" => Ok(Self::GenerationFailed), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::FaceNotDetected, + Self::NoForegroundSubjectDetected, + Self::FormatNotSupported, + Self::KeepAliveElapsed, + Self::NotYetImplemented, + Self::RetryableWorkerError, + Self::ModelRulesViolation, + Self::RuleBansUserImage, + Self::RuleBansUserImageWithFaces, + Self::RuleBansUserTextPrompt, + Self::RuleBansUserContent, + Self::RuleBansGeneratedVideo, + Self::RuleBansGeneratedAudio, + Self::RuleBansGeneratedContent, + Self::FilesizeTooLarge, + Self::ImageDimensionsTooSmall, + Self::ImageDimensionsTooLarge, + Self::GenerationFailed, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(FrontendFailureCategory::FaceNotDetected, "face_not_detected"); + assert_serialization(FrontendFailureCategory::NoForegroundSubjectDetected, "no_foreground_subject_detected"); + assert_serialization(FrontendFailureCategory::FormatNotSupported, "format_not_supported"); + assert_serialization(FrontendFailureCategory::KeepAliveElapsed, "keep_alive_elapsed"); + assert_serialization(FrontendFailureCategory::NotYetImplemented, "not_yet_implemented"); + assert_serialization(FrontendFailureCategory::RetryableWorkerError, "retryable_worker_error"); + assert_serialization(FrontendFailureCategory::ModelRulesViolation, "model_rules_violation"); + assert_serialization(FrontendFailureCategory::RuleBansUserImage, "rule_bans_user_image"); + assert_serialization(FrontendFailureCategory::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces"); + assert_serialization(FrontendFailureCategory::RuleBansUserTextPrompt, "rule_bans_user_text_prompt"); + assert_serialization(FrontendFailureCategory::RuleBansUserContent, "rule_bans_user_content"); + assert_serialization(FrontendFailureCategory::RuleBansGeneratedVideo, "rule_bans_generated_video"); + assert_serialization(FrontendFailureCategory::RuleBansGeneratedAudio, "rule_bans_generated_audio"); + assert_serialization(FrontendFailureCategory::RuleBansGeneratedContent, "rule_bans_generated_content"); + assert_serialization(FrontendFailureCategory::FilesizeTooLarge, "filesize_too_large"); + assert_serialization(FrontendFailureCategory::ImageDimensionsTooSmall, "image_dimensions_too_small"); + assert_serialization(FrontendFailureCategory::ImageDimensionsTooLarge, "image_dimensions_too_large"); + assert_serialization(FrontendFailureCategory::GenerationFailed, "generation_failed"); + } + + #[test] + fn to_str() { + assert_eq!(FrontendFailureCategory::FaceNotDetected.to_str(), "face_not_detected"); + assert_eq!(FrontendFailureCategory::NoForegroundSubjectDetected.to_str(), "no_foreground_subject_detected"); + assert_eq!(FrontendFailureCategory::FormatNotSupported.to_str(), "format_not_supported"); + assert_eq!(FrontendFailureCategory::KeepAliveElapsed.to_str(), "keep_alive_elapsed"); + assert_eq!(FrontendFailureCategory::NotYetImplemented.to_str(), "not_yet_implemented"); + assert_eq!(FrontendFailureCategory::RetryableWorkerError.to_str(), "retryable_worker_error"); + assert_eq!(FrontendFailureCategory::ModelRulesViolation.to_str(), "model_rules_violation"); + assert_eq!(FrontendFailureCategory::RuleBansUserImage.to_str(), "rule_bans_user_image"); + assert_eq!(FrontendFailureCategory::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces"); + assert_eq!(FrontendFailureCategory::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt"); + assert_eq!(FrontendFailureCategory::RuleBansUserContent.to_str(), "rule_bans_user_content"); + assert_eq!(FrontendFailureCategory::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video"); + assert_eq!(FrontendFailureCategory::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio"); + assert_eq!(FrontendFailureCategory::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content"); + assert_eq!(FrontendFailureCategory::FilesizeTooLarge.to_str(), "filesize_too_large"); + assert_eq!(FrontendFailureCategory::ImageDimensionsTooSmall.to_str(), "image_dimensions_too_small"); + assert_eq!(FrontendFailureCategory::ImageDimensionsTooLarge.to_str(), "image_dimensions_too_large"); + assert_eq!(FrontendFailureCategory::GenerationFailed.to_str(), "generation_failed"); + } + + #[test] + fn from_str() { + assert_eq!(FrontendFailureCategory::from_str("face_not_detected").unwrap(), FrontendFailureCategory::FaceNotDetected); + assert_eq!(FrontendFailureCategory::from_str("no_foreground_subject_detected").unwrap(), FrontendFailureCategory::NoForegroundSubjectDetected); + assert_eq!(FrontendFailureCategory::from_str("format_not_supported").unwrap(), FrontendFailureCategory::FormatNotSupported); + assert_eq!(FrontendFailureCategory::from_str("keep_alive_elapsed").unwrap(), FrontendFailureCategory::KeepAliveElapsed); + assert_eq!(FrontendFailureCategory::from_str("not_yet_implemented").unwrap(), FrontendFailureCategory::NotYetImplemented); + assert_eq!(FrontendFailureCategory::from_str("retryable_worker_error").unwrap(), FrontendFailureCategory::RetryableWorkerError); + assert_eq!(FrontendFailureCategory::from_str("model_rules_violation").unwrap(), FrontendFailureCategory::ModelRulesViolation); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image").unwrap(), FrontendFailureCategory::RuleBansUserImage); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image_with_faces").unwrap(), FrontendFailureCategory::RuleBansUserImageWithFaces); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_text_prompt").unwrap(), FrontendFailureCategory::RuleBansUserTextPrompt); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_content").unwrap(), FrontendFailureCategory::RuleBansUserContent); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_video").unwrap(), FrontendFailureCategory::RuleBansGeneratedVideo); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_audio").unwrap(), FrontendFailureCategory::RuleBansGeneratedAudio); + assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_content").unwrap(), FrontendFailureCategory::RuleBansGeneratedContent); + assert_eq!(FrontendFailureCategory::from_str("filesize_too_large").unwrap(), FrontendFailureCategory::FilesizeTooLarge); + assert_eq!(FrontendFailureCategory::from_str("image_dimensions_too_small").unwrap(), FrontendFailureCategory::ImageDimensionsTooSmall); + assert_eq!(FrontendFailureCategory::from_str("image_dimensions_too_large").unwrap(), FrontendFailureCategory::ImageDimensionsTooLarge); + assert_eq!(FrontendFailureCategory::from_str("generation_failed").unwrap(), FrontendFailureCategory::GenerationFailed); + assert_eq!(FrontendFailureCategory::from_str("invalid_value").is_err(), true); + } + + #[test] + fn all_variants() { + let mut variants = FrontendFailureCategory::all_variants(); + assert_eq!(variants.len(), 18); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FaceNotDetected)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NoForegroundSubjectDetected)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FormatNotSupported)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::KeepAliveElapsed)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NotYetImplemented)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RetryableWorkerError)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::ModelRulesViolation)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImage)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImageWithFaces)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserTextPrompt)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserContent)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedVideo)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedAudio)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedContent)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FilesizeTooLarge)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::ImageDimensionsTooSmall)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::ImageDimensionsTooLarge)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::GenerationFailed)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(FrontendFailureCategory::all_variants().len(), FrontendFailureCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in FrontendFailureCategory::all_variants() { + assert_eq!(variant, FrontendFailureCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, FrontendFailureCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, FrontendFailureCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + /// The database column is VARCHAR(32). Every serialized variant must fit. + #[test] + fn max_serialization_length() { + const MAX_LENGTH: usize = 32; + for variant in FrontendFailureCategory::all_variants() { + let serialized = variant.to_str(); + assert!( + serialized.len() <= MAX_LENGTH, + "{:?} serializes to {:?} ({} chars), exceeds VARCHAR({}) limit", + variant, serialized, serialized.len(), MAX_LENGTH, + ); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_category.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_category.rs new file mode 100644 index 0000000000..715e562d8b --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_category.rs @@ -0,0 +1,310 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `inference_category`. +/// +/// Our "generic inference" pipeline supports a wide variety of ML models and other media. +/// Each "category" of inference is identified by the following enum variants. +/// These types are present in the HTTP API and database columns as serialized here. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default, ToSchema)] +pub enum InferenceCategory { + /// Deprecate this field !!! + /// We should drain all jobs from using this database field, then remove it. + #[deprecated(note = "NB(bt,2024-09-05): The frontend still needs this")] + #[serde(rename = "deprecated_field")] + DeprecatedField, + + /// Facial lipsync animation (eg. SadTalker, Wav2Lip, FaceFusion) + #[serde(rename = "lipsync_animation")] + #[default] + LipsyncAnimation, + + /// FakeYou's text to speech (eg. Tacotron2) + #[serde(rename = "text_to_speech")] + TextToSpeech, + + /// FakeYou's voice conversion (eg. svc, rvc) + #[serde(rename = "voice_conversion")] + VoiceConversion, + + /// Image generation (eg. Stable Diffusion 1.5), FAL-powered image generation, etc. + #[serde(rename = "image_generation")] + ImageGeneration, + + /// FAL-powered video generation + /// (Also Seedance2-Pro.com) + #[serde(rename = "video_generation")] + VideoGeneration, + + /// Audio generation (eg. Suno music, Seed Audio) + #[serde(rename = "audio_generation")] + AudioGeneration, + + /// FAL-powered 3D object generation + #[serde(rename = "object_generation")] + ObjectGeneration, + + /// Gaussian Splat generation (eg. World Labs Marble) + #[serde(rename = "splat_generation")] + SplatGeneration, + + /// Character generation (eg. Kinovi/Seedance2Pro characters) + #[serde(rename = "character_generation")] + CharacterGeneration, + + /// FAL-powered image background removal + #[serde(rename = "background_removal")] + BackgroundRemoval, + + /// Turn video into animation data with mocap processing (eg. Mocapnet). + #[serde(rename = "mocap")] + Mocap, + + /// ComfyUI workflows + /// This is what powers Storyteller Studio! + #[serde(rename = "workflow")] + Workflow, + + /// FBX to GLTF/GLB. + /// Still supported, but few people will use it. + #[serde(rename = "format_conversion")] + FormatConversion, + + /// Live portrait + #[serde(rename = "live_portrait")] + LivePortrait, + + #[serde(rename="seed_vc")] + SeedVc, + + /// DEPRECATED. Do not use. + /// This was for ReRenderAVideo, which we never productionized. + #[deprecated(note = "This was for ReRenderAVideo, which we never productionized.")] + #[serde(rename = "video_filter")] + VideoFilter, + + /// DEPRECATED. Bevy engine serverside rendering. + #[deprecated(note = "This was for Bevy engine's server side rendering.")] + #[serde(rename = "convert_bvh_to_workflow")] + ConvertBvhToWorkflow, + + #[serde(rename = "f5_tts")] + F5TTS, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceCategory); +impl_mysql_enum_coders!(InferenceCategory); +impl_mysql_from_row!(InferenceCategory); + +/// NB: Legacy API for older code. +impl InferenceCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::DeprecatedField => "deprecated_field", + Self::LipsyncAnimation => "lipsync_animation", + Self::TextToSpeech => "text_to_speech", + Self::VoiceConversion => "voice_conversion", + Self::ImageGeneration => "image_generation", + Self::VideoGeneration => "video_generation", + Self::AudioGeneration => "audio_generation", + Self::ObjectGeneration => "object_generation", + Self::SplatGeneration => "splat_generation", + Self::CharacterGeneration => "character_generation", + Self::BackgroundRemoval => "background_removal", + Self::Mocap => "mocap", + Self::Workflow => "workflow", + Self::F5TTS => "f5_tts", + Self::FormatConversion => "format_conversion", + Self::LivePortrait => "live_portrait", + Self::SeedVc => "seed_vc", + Self::VideoFilter => "video_filter", + Self::ConvertBvhToWorkflow => "convert_bvh_to_workflow", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "deprecated_field" => Ok(Self::DeprecatedField), + "lipsync_animation" => Ok(Self::LipsyncAnimation), + "text_to_speech" => Ok(Self::TextToSpeech), + "voice_conversion" => Ok(Self::VoiceConversion), + "image_generation" => Ok(Self::ImageGeneration), + "video_generation" => Ok(Self::VideoGeneration), + "audio_generation" => Ok(Self::AudioGeneration), + "object_generation" => Ok(Self::ObjectGeneration), + "splat_generation" => Ok(Self::SplatGeneration), + "character_generation" => Ok(Self::CharacterGeneration), + "background_removal" => Ok(Self::BackgroundRemoval), + "f5_tts" => Ok(Self::F5TTS), + "mocap" => Ok(Self::Mocap), + "workflow" => Ok(Self::Workflow), + "format_conversion" => Ok(Self::FormatConversion), + "live_portrait" => Ok(Self::LivePortrait), + "seed_vc" => Ok(Self::SeedVc), + "video_filter" => Ok(Self::VideoFilter), + "convert_bvh_to_workflow" => Ok(Self::ConvertBvhToWorkflow), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::DeprecatedField, + Self::LipsyncAnimation, + Self::TextToSpeech, + Self::VoiceConversion, + Self::ImageGeneration, + Self::ObjectGeneration, + Self::SplatGeneration, + Self::CharacterGeneration, + Self::VideoGeneration, + Self::AudioGeneration, + Self::BackgroundRemoval, + Self::Mocap, + Self::F5TTS, + Self::SeedVc, + Self::Workflow, + Self::FormatConversion, + Self::LivePortrait, + Self::VideoFilter, + Self::ConvertBvhToWorkflow, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_category::InferenceCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(InferenceCategory::DeprecatedField, "deprecated_field"); + assert_serialization(InferenceCategory::LipsyncAnimation, "lipsync_animation"); + assert_serialization(InferenceCategory::TextToSpeech, "text_to_speech"); + assert_serialization(InferenceCategory::VoiceConversion, "voice_conversion"); + assert_serialization(InferenceCategory::ImageGeneration, "image_generation"); + assert_serialization(InferenceCategory::VideoGeneration, "video_generation"); + assert_serialization(InferenceCategory::AudioGeneration, "audio_generation"); + assert_serialization(InferenceCategory::ObjectGeneration, "object_generation"); + assert_serialization(InferenceCategory::SplatGeneration, "splat_generation"); + assert_serialization(InferenceCategory::CharacterGeneration, "character_generation"); + assert_serialization(InferenceCategory::BackgroundRemoval, "background_removal"); + assert_serialization(InferenceCategory::Mocap, "mocap"); + assert_serialization(InferenceCategory::F5TTS, "f5_tts"); + assert_serialization(InferenceCategory::SeedVc, "seed_vc"); + assert_serialization(InferenceCategory::Workflow, "workflow"); + assert_serialization(InferenceCategory::FormatConversion, "format_conversion"); + assert_serialization(InferenceCategory::LivePortrait, "live_portrait"); + assert_serialization(InferenceCategory::VideoFilter, "video_filter"); + assert_serialization(InferenceCategory::ConvertBvhToWorkflow, "convert_bvh_to_workflow"); + } + + #[test] + fn to_str() { + assert_eq!(InferenceCategory::DeprecatedField.to_str(), "deprecated_field"); + assert_eq!(InferenceCategory::LipsyncAnimation.to_str(), "lipsync_animation"); + assert_eq!(InferenceCategory::TextToSpeech.to_str(), "text_to_speech"); + assert_eq!(InferenceCategory::VoiceConversion.to_str(), "voice_conversion"); + assert_eq!(InferenceCategory::ImageGeneration.to_str(), "image_generation"); + assert_eq!(InferenceCategory::VideoGeneration.to_str(), "video_generation"); + assert_eq!(InferenceCategory::AudioGeneration.to_str(), "audio_generation"); + assert_eq!(InferenceCategory::ObjectGeneration.to_str(), "object_generation"); + assert_eq!(InferenceCategory::SplatGeneration.to_str(), "splat_generation"); + assert_eq!(InferenceCategory::CharacterGeneration.to_str(), "character_generation"); + assert_eq!(InferenceCategory::BackgroundRemoval.to_str(), "background_removal"); + assert_eq!(InferenceCategory::F5TTS.to_str(), "f5_tts"); + assert_eq!(InferenceCategory::SeedVc.to_str(), "seed_vc"); + assert_eq!(InferenceCategory::Mocap.to_str(), "mocap"); + assert_eq!(InferenceCategory::Workflow.to_str(), "workflow"); + assert_eq!(InferenceCategory::FormatConversion.to_str(), "format_conversion"); + assert_eq!(InferenceCategory::LivePortrait.to_str(), "live_portrait"); + assert_eq!(InferenceCategory::VideoFilter.to_str(), "video_filter"); + assert_eq!(InferenceCategory::ConvertBvhToWorkflow.to_str(), "convert_bvh_to_workflow"); + } + + #[test] + fn from_str() { + assert_eq!(InferenceCategory::from_str("deprecated_field").unwrap(), InferenceCategory::DeprecatedField); + assert_eq!(InferenceCategory::from_str("lipsync_animation").unwrap(), InferenceCategory::LipsyncAnimation); + assert_eq!(InferenceCategory::from_str("text_to_speech").unwrap(), InferenceCategory::TextToSpeech); + assert_eq!(InferenceCategory::from_str("voice_conversion").unwrap(), InferenceCategory::VoiceConversion); + assert_eq!(InferenceCategory::from_str("image_generation").unwrap(), InferenceCategory::ImageGeneration); + assert_eq!(InferenceCategory::from_str("video_generation").unwrap(), InferenceCategory::VideoGeneration); + assert_eq!(InferenceCategory::from_str("audio_generation").unwrap(), InferenceCategory::AudioGeneration); + assert_eq!(InferenceCategory::from_str("object_generation").unwrap(), InferenceCategory::ObjectGeneration); + assert_eq!(InferenceCategory::from_str("splat_generation").unwrap(), InferenceCategory::SplatGeneration); + assert_eq!(InferenceCategory::from_str("character_generation").unwrap(), InferenceCategory::CharacterGeneration); + assert_eq!(InferenceCategory::from_str("background_removal").unwrap(), InferenceCategory::BackgroundRemoval); + assert_eq!(InferenceCategory::from_str("f5_tts").unwrap(), InferenceCategory::F5TTS); + assert_eq!(InferenceCategory::from_str("seed_vc").unwrap(), InferenceCategory::SeedVc); + assert_eq!(InferenceCategory::from_str("mocap").unwrap(), InferenceCategory::Mocap); + assert_eq!(InferenceCategory::from_str("workflow").unwrap(), InferenceCategory::Workflow); + assert_eq!(InferenceCategory::from_str("format_conversion").unwrap(), InferenceCategory::FormatConversion); + assert_eq!(InferenceCategory::from_str("live_portrait").unwrap(), InferenceCategory::LivePortrait); + assert_eq!(InferenceCategory::from_str("video_filter").unwrap(), InferenceCategory::VideoFilter); + assert_eq!(InferenceCategory::from_str("convert_bvh_to_workflow").unwrap(), InferenceCategory::ConvertBvhToWorkflow); + } + + #[test] + fn all_variants() { + // Static check + const EXPECTED_COUNT : usize = 19; + + assert_eq!(InferenceCategory::all_variants().len(), EXPECTED_COUNT); + assert_eq!(InferenceCategory::iter().len(), EXPECTED_COUNT); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(InferenceCategory::all_variants().len(), InferenceCategory::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(InferenceCategory::all_variants().len(), InferenceCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in InferenceCategory::all_variants() { + assert_eq!(variant, InferenceCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, InferenceCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, InferenceCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in InferenceCategory::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_input_source_token_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_input_source_token_type.rs new file mode 100644 index 0000000000..76d98627fb --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_input_source_token_type.rs @@ -0,0 +1,87 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `maybe_input_source_token`. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum InferenceInputSourceTokenType { + #[serde(rename = "media_file")] + MediaFile, + #[serde(rename = "media_upload")] + MediaUpload, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceInputSourceTokenType); +impl_mysql_enum_coders!(InferenceInputSourceTokenType); + +/// NB: Legacy API for older code. +impl InferenceInputSourceTokenType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + Self::MediaUpload => "media_upload", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + "media_upload" => Ok(Self::MediaUpload), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + InferenceInputSourceTokenType::MediaFile, + InferenceInputSourceTokenType::MediaUpload, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_input_source_token_type::InferenceInputSourceTokenType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(InferenceInputSourceTokenType::MediaFile, "media_file"); + assert_serialization(InferenceInputSourceTokenType::MediaUpload, "media_upload"); + } + + #[test] + fn to_str() { + assert_eq!(InferenceInputSourceTokenType::MediaFile.to_str(), "media_file"); + assert_eq!(InferenceInputSourceTokenType::MediaUpload.to_str(), "media_upload"); + } + + #[test] + fn from_str() { + assert_eq!(InferenceInputSourceTokenType::from_str("media_file").unwrap(), InferenceInputSourceTokenType::MediaFile); + assert_eq!(InferenceInputSourceTokenType::from_str("media_upload").unwrap(), InferenceInputSourceTokenType::MediaUpload); + } + + #[test] + fn all_variants() { + // Static check + let mut variants = InferenceInputSourceTokenType::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(InferenceInputSourceTokenType::MediaFile)); + assert_eq!(variants.pop_first(), Some(InferenceInputSourceTokenType::MediaUpload)); + assert_eq!(variants.pop_first(), None); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(InferenceInputSourceTokenType::all_variants().len(), InferenceInputSourceTokenType::iter().len()); + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_external_third_party.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_external_third_party.rs new file mode 100644 index 0000000000..2aac8e1617 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_external_third_party.rs @@ -0,0 +1,184 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(16)` field `maybe_external_third_party`. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum InferenceJobExternalThirdParty { + /// Beeble jobs + #[serde(rename = "beeble")] + Beeble, + + /// Fal jobs + #[serde(rename = "fal")] + #[default] + Fal, + + /// GmiCloud jobs + #[serde(rename = "gmicloud")] + GmiCloud, + + /// Grok (xAI) API jobs + #[serde(rename = "grok_api")] + GrokApi, + + /// Seedance 2 Pro jobs + #[serde(rename = "seedance2pro")] + Seedance2Pro, + + /// Seedance 2 Pro Alt jobs + #[serde(rename = "seedance2pro_alt")] + Seedance2ProAlt, + + /// Seedance 2 Pro BytePlus Ultra jobs + #[serde(rename = "seedance2pro_bpu")] + Seedance2ProBytePlusUltra, + + /// World Labs jobs + #[serde(rename = "worldlabs")] + Worldlabs, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceJobExternalThirdParty); +impl_mysql_enum_coders!(InferenceJobExternalThirdParty); + +/// NB: Legacy API for older code. +impl InferenceJobExternalThirdParty { + pub fn to_str(&self) -> &'static str { + match self { + Self::Beeble => "beeble", + Self::Fal => "fal", + Self::GmiCloud => "gmicloud", + Self::GrokApi => "grok_api", + Self::Seedance2Pro => "seedance2pro", + Self::Seedance2ProAlt => "seedance2pro_alt", + Self::Seedance2ProBytePlusUltra => "seedance2pro_bpu", + Self::Worldlabs => "worldlabs", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "beeble" => Ok(Self::Beeble), + "fal" => Ok(Self::Fal), + "gmicloud" => Ok(Self::GmiCloud), + "grok_api" => Ok(Self::GrokApi), + "seedance2pro" => Ok(Self::Seedance2Pro), + "seedance2pro_alt" => Ok(Self::Seedance2ProAlt), + "seedance2pro_bpu" => Ok(Self::Seedance2ProBytePlusUltra), + "worldlabs" => Ok(Self::Worldlabs), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Beeble, + Self::Fal, + Self::GmiCloud, + Self::GrokApi, + Self::Seedance2Pro, + Self::Seedance2ProAlt, + Self::Seedance2ProBytePlusUltra, + Self::Worldlabs, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(InferenceJobExternalThirdParty::Beeble, "beeble"); + assert_serialization(InferenceJobExternalThirdParty::Fal, "fal"); + assert_serialization(InferenceJobExternalThirdParty::GmiCloud, "gmicloud"); + assert_serialization(InferenceJobExternalThirdParty::GrokApi, "grok_api"); + assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro"); + assert_serialization(InferenceJobExternalThirdParty::Seedance2ProAlt, "seedance2pro_alt"); + assert_serialization(InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra, "seedance2pro_bpu"); + assert_serialization(InferenceJobExternalThirdParty::Worldlabs, "worldlabs"); + } + + #[test] + fn to_str() { + assert_eq!(InferenceJobExternalThirdParty::Beeble.to_str(), "beeble"); + assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal"); + assert_eq!(InferenceJobExternalThirdParty::GmiCloud.to_str(), "gmicloud"); + assert_eq!(InferenceJobExternalThirdParty::GrokApi.to_str(), "grok_api"); + assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro"); + assert_eq!(InferenceJobExternalThirdParty::Seedance2ProAlt.to_str(), "seedance2pro_alt"); + assert_eq!(InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra.to_str(), "seedance2pro_bpu"); + assert_eq!(InferenceJobExternalThirdParty::Worldlabs.to_str(), "worldlabs"); + } + + #[test] + fn from_str() { + assert_eq!(InferenceJobExternalThirdParty::from_str("beeble").unwrap(), InferenceJobExternalThirdParty::Beeble); + assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal); + assert_eq!(InferenceJobExternalThirdParty::from_str("gmicloud").unwrap(), InferenceJobExternalThirdParty::GmiCloud); + assert_eq!(InferenceJobExternalThirdParty::from_str("grok_api").unwrap(), InferenceJobExternalThirdParty::GrokApi); + assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro); + assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro_alt").unwrap(), InferenceJobExternalThirdParty::Seedance2ProAlt); + assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro_bpu").unwrap(), InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra); + assert_eq!(InferenceJobExternalThirdParty::from_str("worldlabs").unwrap(), InferenceJobExternalThirdParty::Worldlabs); + } + + #[test] + fn all_variants() { + // Static check + const EXPECTED_COUNT : usize = 8; + + assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT); + assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), InferenceJobExternalThirdParty::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), InferenceJobExternalThirdParty::iter().len()); + } + + #[test] + fn round_trip() { + for variant in InferenceJobExternalThirdParty::all_variants() { + assert_eq!(variant, InferenceJobExternalThirdParty::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, InferenceJobExternalThirdParty::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, InferenceJobExternalThirdParty::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in InferenceJobExternalThirdParty::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs new file mode 100644 index 0000000000..c54c6abd58 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs @@ -0,0 +1,505 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `product_category`. +/// +/// This is a user-facing and analytics-facing column that describes what product area the job +/// is attributed to. For example, this will help us separate "video style transfer" from +/// "storyteller studio" and also separate "live portrait" from "webcam live portrait". +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum InferenceJobProductCategory { + // =============== DOWNLOAD =============== + + /// Download: GptSoVits + #[default] + DownloadGptSoVits, + + // =============== ARTCRAFT FIRST PARTY =============== + + /// First-party (our own GPU inference) Minimax H3 video generation. + /// Covers both the Turbo and Ultra variants; the `job_type` column + /// distinguishes them. + #[serde(rename = "artcraft_minimax_h3")] + ArtcraftMinimaxH3, + + // =============== FAL =============== + + FalImage, + FalVideo, + FalAudio, + /// Fal: 3D Object Generation + FalObject, + FalBgRemoval, + + // =============== GMICLOUD =============== + + #[serde(rename = "gmicloud_video")] + GmiCloudVideo, + + // =============== GROK (xAI) =============== + + #[serde(rename = "grok_api_video")] + GrokApiVideo, + + // =============== BEEBLE =============== + + #[serde(rename = "beeble_video")] + BeebleVideo, + + // =============== SEEDANCE 2 PRO =============== + + #[serde(rename = "seedance2pro_video")] + Seedance2ProVideo, + + #[serde(rename = "seedance2pro_character")] + Seedance2ProCharacter, + + /// Seedance 2 Pro / Kinovi: Midjourney image generation. + #[serde(rename = "seedance2pro_image")] + Seedance2ProImage, + + /// Seedance 2 Pro / Kinovi: Suno audio generation. + #[serde(rename = "seedance2pro_audio")] + Seedance2ProAudio, + + // =============== SEEDANCE 2 PRO ALT =============== + + #[serde(rename = "seedance2pro_video_alt")] + Seedance2ProVideoAlt, + + // =============== SEEDANCE 2 PRO BYTEPLUS ULTRA =============== + + #[serde(rename = "seedance2pro_video_bpu")] + Seedance2ProVideoBytePlusUltra, + + /// Seedance 2 Pro / Kinovi (BytePlus Ultra account): image generation. + #[serde(rename = "seedance2pro_image_bpu")] + Seedance2ProImageBytePlusUltra, + + // =============== WORLD LABS =============== + + /// World Labs: Gaussian Splat Generation + #[serde(rename = "worldlabs_splat")] + WorldlabsSplat, + + // =============== TEXT TO SPEECH =============== + + /// TTS: GptSoVits + TtsGptSoVits, + + /// TTS: F5Tts (Zero Shot) + TtsF5, + + /// TTS: StyleTts2 (Zero Shot) + TtsStyleTts2, + + /// TTS: Tacotron2 + TtsTacotron2, + + // =============== VOICE CONVERSION =============== + + /// Voice Conversion: RVC v2 + VcRvc2, + + /// Voice Conversion: SoVitsSvc + VcSvc, + + VcSeedVc, // Ugh + + // =============== VIDEO =============== + + /// Video: Face Fusion (Lipsync) + VidLipsyncFaceFusion, + + /// Video: Sad Talker (Lipsync) + VidLipsyncSadTalker, + + /// Live Portrait (normal interface) + VidLivePortrait, + + /// Live Portrait (webcam interface) + VidLivePortraitWebcam, + + /// Video: Studio + VidStudio, + + /// Video: Studio Gen 2 + VidStudioGen2, + + /// Video: Style Transfer + VidStyleTransfer, + + // =============== DEPRECATED =============== + + /// Lipsync: Face Fusion + #[deprecated(note = "Use `VidLipsyncFaceFusion` instead")] + LipsyncFaceFusion, + + /// Lipsync: SadTalker + #[deprecated(note = "Use `VidLipsyncSadTalker` instead")] + LipsyncSadTalker, + + /// Live Portrait (normal interface) + #[deprecated(note = "Use `VidLivePortrait` instead")] + LivePortrait, + + /// Live Portrait (webcam interface) + #[deprecated(note = "Use `VidLivePortraitWebcam` instead")] + LivePortraitWebcam, + + /// Stable Diffusion (deprecated) + #[deprecated(note = "unused")] + StableDiffusion, + + /// Storyteller Studio + #[deprecated(note = "Use `VidStudio` instead")] + Studio, + + /// Lipsync: Face Fusion + #[deprecated(note = "Use `VidLipsyncFaceFusion` instead")] + VidFaceFusion, + + /// Video Style Transfer + #[deprecated(note = "Use `VidStyleTransfer` instead")] + Vst, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceJobProductCategory); +impl_mysql_enum_coders!(InferenceJobProductCategory); + +/// NB: Legacy API for older code. +impl InferenceJobProductCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::DownloadGptSoVits => "download_gpt_so_vits", + Self::ArtcraftMinimaxH3 => "artcraft_minimax_h3", + Self::FalImage => "fal_image", + Self::FalVideo => "fal_video", + Self::FalAudio => "fal_audio", + Self::FalObject => "fal_object", + Self::FalBgRemoval => "fal_bg_removal", + Self::GmiCloudVideo => "gmicloud_video", + Self::GrokApiVideo => "grok_api_video", + Self::BeebleVideo => "beeble_video", + Self::Seedance2ProVideo => "seedance2pro_video", + Self::Seedance2ProCharacter => "seedance2pro_character", + Self::Seedance2ProImage => "seedance2pro_image", + Self::Seedance2ProAudio => "seedance2pro_audio", + Self::Seedance2ProVideoAlt => "seedance2pro_video_alt", + Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu", + Self::Seedance2ProImageBytePlusUltra => "seedance2pro_image_bpu", + Self::WorldlabsSplat => "worldlabs_splat", + Self::TtsGptSoVits => "tts_gpt_so_vits", + Self::TtsStyleTts2 => "tts_style_tts2", + Self::TtsTacotron2 => "tts_tacotron2", + Self::TtsF5 => "tts_f5", + Self::VcSvc => "vc_svc", + Self::VcRvc2 => "vc_rvc2", + Self::VcSeedVc => "vc_seed_vc", + Self::VidLipsyncFaceFusion => "vid_lipsync_face_fusion", + Self::VidLipsyncSadTalker => "vid_lipsync_sad_talker", + Self::VidLivePortrait => "vid_live_portrait", + Self::VidLivePortraitWebcam => "vid_live_portrait_webcam", + Self::VidStudio => "vid_studio", + Self::VidStudioGen2 => "vid_studio_gen2", + Self::VidStyleTransfer => "vid_style_transfer", + Self::LipsyncFaceFusion => "lipsync_face_fusion", + Self::LipsyncSadTalker => "lipsync_sad_talker", + Self::LivePortrait => "live_portrait", + Self::LivePortraitWebcam => "live_portrait_webcam", + Self::StableDiffusion => "stable_diffusion", + Self::Studio => "studio", + Self::VidFaceFusion => "vid_face_fusion", + Self::Vst => "vst", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "download_gpt_so_vits" => Ok(Self::DownloadGptSoVits), + "artcraft_minimax_h3" => Ok(Self::ArtcraftMinimaxH3), + "fal_image" => Ok(Self::FalImage), + "fal_video" => Ok(Self::FalVideo), + "fal_audio" => Ok(Self::FalAudio), + "fal_object" => Ok(Self::FalObject), + "fal_bg_removal" => Ok(Self::FalBgRemoval), + "gmicloud_video" => Ok(Self::GmiCloudVideo), + "grok_api_video" => Ok(Self::GrokApiVideo), + "beeble_video" => Ok(Self::BeebleVideo), + "seedance2pro_video" => Ok(Self::Seedance2ProVideo), + "seedance2pro_character" => Ok(Self::Seedance2ProCharacter), + "seedance2pro_image" => Ok(Self::Seedance2ProImage), + "seedance2pro_audio" => Ok(Self::Seedance2ProAudio), + "seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt), + "seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra), + "seedance2pro_image_bpu" => Ok(Self::Seedance2ProImageBytePlusUltra), + "worldlabs_splat" => Ok(Self::WorldlabsSplat), + "tts_gpt_so_vits" => Ok(Self::TtsGptSoVits), + "tts_style_tts2" => Ok(Self::TtsStyleTts2), + "tts_tacotron2" => Ok(Self::TtsTacotron2), + "tts_f5" => Ok(Self::TtsF5), + "vc_svc" => Ok(Self::VcSvc), + "vc_rvc2" => Ok(Self::VcRvc2), + "vc_seed_vc" => Ok(Self::VcSeedVc), + "vid_lipsync_face_fusion" => Ok(Self::VidLipsyncFaceFusion), + "vid_lipsync_sad_talker" => Ok(Self::VidLipsyncSadTalker), + "vid_live_portrait" => Ok(Self::VidLivePortrait), + "vid_live_portrait_webcam" => Ok(Self::VidLivePortraitWebcam), + "vid_studio" => Ok(Self::VidStudio), + "vid_studio_gen2" => Ok(Self::VidStudioGen2), + "vid_style_transfer" => Ok(Self::VidStyleTransfer), + "lipsync_face_fusion" => Ok(Self::LipsyncFaceFusion), + "lipsync_sad_talker" => Ok(Self::LipsyncSadTalker), + "live_portrait" => Ok(Self::LivePortrait), + "live_portrait_webcam" => Ok(Self::LivePortraitWebcam), + "stable_diffusion" => Ok(Self::StableDiffusion), + "studio" => Ok(Self::Studio), + "vid_face_fusion" => Ok(Self::VidFaceFusion), + "vst" => Ok(Self::Vst), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::DownloadGptSoVits, + Self::ArtcraftMinimaxH3, + Self::FalImage, + Self::FalVideo, + Self::FalAudio, + Self::FalObject, + Self::FalBgRemoval, + Self::GmiCloudVideo, + Self::GrokApiVideo, + Self::BeebleVideo, + Self::Seedance2ProVideo, + Self::Seedance2ProCharacter, + Self::Seedance2ProImage, + Self::Seedance2ProAudio, + Self::Seedance2ProVideoAlt, + Self::Seedance2ProVideoBytePlusUltra, + Self::Seedance2ProImageBytePlusUltra, + Self::WorldlabsSplat, + Self::TtsGptSoVits, + Self::TtsStyleTts2, + Self::TtsTacotron2, + Self::TtsF5, + Self::VcSeedVc, + Self::VcSvc, + Self::VcRvc2, + Self::VidLipsyncFaceFusion, + Self::VidLipsyncSadTalker, + Self::VidLivePortrait, + Self::VidLivePortraitWebcam, + Self::VidStudio, + Self::VidStudioGen2, + Self::VidStyleTransfer, + Self::LipsyncFaceFusion, + Self::LipsyncSadTalker, + Self::LivePortrait, + Self::LivePortraitWebcam, + Self::StableDiffusion, + Self::Studio, + Self::VidFaceFusion, + Self::Vst, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(InferenceJobProductCategory::DownloadGptSoVits, "download_gpt_so_vits"); + assert_serialization(InferenceJobProductCategory::ArtcraftMinimaxH3, "artcraft_minimax_h3"); + assert_serialization(InferenceJobProductCategory::FalImage, "fal_image"); + assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video"); + assert_serialization(InferenceJobProductCategory::FalAudio, "fal_audio"); + assert_serialization(InferenceJobProductCategory::FalObject, "fal_object"); + assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal"); + assert_serialization(InferenceJobProductCategory::GmiCloudVideo, "gmicloud_video"); + assert_serialization(InferenceJobProductCategory::GrokApiVideo, "grok_api_video"); + assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video"); + assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video"); + assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character"); + assert_serialization(InferenceJobProductCategory::Seedance2ProImage, "seedance2pro_image"); + assert_serialization(InferenceJobProductCategory::Seedance2ProAudio, "seedance2pro_audio"); + assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt"); + assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu"); + assert_serialization(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra, "seedance2pro_image_bpu"); + assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat"); + assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits"); + assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2"); + assert_serialization(InferenceJobProductCategory::TtsTacotron2, "tts_tacotron2"); + assert_serialization(InferenceJobProductCategory::TtsF5, "tts_f5"); + assert_serialization(InferenceJobProductCategory::VcRvc2, "vc_rvc2"); + assert_serialization(InferenceJobProductCategory::VcSvc, "vc_svc"); + assert_serialization(InferenceJobProductCategory::VcSvc, "vc_svc"); + assert_serialization(InferenceJobProductCategory::VidLipsyncFaceFusion, "vid_lipsync_face_fusion"); + assert_serialization(InferenceJobProductCategory::VidLipsyncSadTalker, "vid_lipsync_sad_talker"); + assert_serialization(InferenceJobProductCategory::VidLivePortrait, "vid_live_portrait"); + assert_serialization(InferenceJobProductCategory::VidLivePortraitWebcam, "vid_live_portrait_webcam"); + assert_serialization(InferenceJobProductCategory::VidStudio, "vid_studio"); + assert_serialization(InferenceJobProductCategory::VidStudioGen2, "vid_studio_gen2"); + assert_serialization(InferenceJobProductCategory::VidStyleTransfer, "vid_style_transfer"); + assert_serialization(InferenceJobProductCategory::LipsyncFaceFusion, "lipsync_face_fusion"); + assert_serialization(InferenceJobProductCategory::LipsyncSadTalker, "lipsync_sad_talker"); + assert_serialization(InferenceJobProductCategory::LivePortrait, "live_portrait"); + assert_serialization(InferenceJobProductCategory::LivePortraitWebcam, "live_portrait_webcam"); + assert_serialization(InferenceJobProductCategory::StableDiffusion, "stable_diffusion"); + assert_serialization(InferenceJobProductCategory::Studio, "studio"); + assert_serialization(InferenceJobProductCategory::VidFaceFusion, "vid_face_fusion"); + assert_serialization(InferenceJobProductCategory::Vst, "vst"); + } + + #[test] + fn to_str() { + assert_eq!(InferenceJobProductCategory::DownloadGptSoVits.to_str(), "download_gpt_so_vits"); + assert_eq!(InferenceJobProductCategory::ArtcraftMinimaxH3.to_str(), "artcraft_minimax_h3"); + assert_eq!(InferenceJobProductCategory::FalImage.to_str(), "fal_image"); + assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video"); + assert_eq!(InferenceJobProductCategory::FalAudio.to_str(), "fal_audio"); + assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object"); + assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal"); + assert_eq!(InferenceJobProductCategory::GmiCloudVideo.to_str(), "gmicloud_video"); + assert_eq!(InferenceJobProductCategory::GrokApiVideo.to_str(), "grok_api_video"); + assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video"); + assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video"); + assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character"); + assert_eq!(InferenceJobProductCategory::Seedance2ProImage.to_str(), "seedance2pro_image"); + assert_eq!(InferenceJobProductCategory::Seedance2ProAudio.to_str(), "seedance2pro_audio"); + assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt"); + assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu"); + assert_eq!(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra.to_str(), "seedance2pro_image_bpu"); + assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat"); + assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits"); + assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2"); + assert_eq!(InferenceJobProductCategory::TtsTacotron2.to_str(), "tts_tacotron2"); + assert_eq!(InferenceJobProductCategory::VcRvc2.to_str(), "vc_rvc2"); + assert_eq!(InferenceJobProductCategory::VcSvc.to_str(), "vc_svc"); + assert_eq!(InferenceJobProductCategory::VcSvc.to_str(), "vc_svc"); + assert_eq!(InferenceJobProductCategory::VidLipsyncFaceFusion.to_str(), "vid_lipsync_face_fusion"); + assert_eq!(InferenceJobProductCategory::VidLipsyncSadTalker.to_str(), "vid_lipsync_sad_talker"); + assert_eq!(InferenceJobProductCategory::VidLivePortrait.to_str(), "vid_live_portrait"); + assert_eq!(InferenceJobProductCategory::VidLivePortraitWebcam.to_str(), "vid_live_portrait_webcam"); + assert_eq!(InferenceJobProductCategory::VidStudio.to_str(), "vid_studio"); + assert_eq!(InferenceJobProductCategory::VidStudioGen2.to_str(), "vid_studio_gen2"); + assert_eq!(InferenceJobProductCategory::VidStyleTransfer.to_str(), "vid_style_transfer"); + assert_eq!(InferenceJobProductCategory::LipsyncFaceFusion.to_str(), "lipsync_face_fusion"); + assert_eq!(InferenceJobProductCategory::LipsyncSadTalker.to_str(), "lipsync_sad_talker"); + assert_eq!(InferenceJobProductCategory::LivePortrait.to_str(), "live_portrait"); + assert_eq!(InferenceJobProductCategory::LivePortraitWebcam.to_str(), "live_portrait_webcam"); + assert_eq!(InferenceJobProductCategory::StableDiffusion.to_str(), "stable_diffusion"); + assert_eq!(InferenceJobProductCategory::Studio.to_str(), "studio"); + assert_eq!(InferenceJobProductCategory::VidFaceFusion.to_str(), "vid_face_fusion"); + assert_eq!(InferenceJobProductCategory::Vst.to_str(), "vst"); + } + + #[test] + fn from_str() { + assert_eq!(InferenceJobProductCategory::from_str("download_gpt_so_vits").unwrap(), InferenceJobProductCategory::DownloadGptSoVits); + assert_eq!(InferenceJobProductCategory::from_str("artcraft_minimax_h3").unwrap(), InferenceJobProductCategory::ArtcraftMinimaxH3); + assert_eq!(InferenceJobProductCategory::from_str("fal_image").unwrap(), InferenceJobProductCategory::FalImage); + assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo); + assert_eq!(InferenceJobProductCategory::from_str("fal_audio").unwrap(), InferenceJobProductCategory::FalAudio); + assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject); + assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval); + assert_eq!(InferenceJobProductCategory::from_str("gmicloud_video").unwrap(), InferenceJobProductCategory::GmiCloudVideo); + assert_eq!(InferenceJobProductCategory::from_str("grok_api_video").unwrap(), InferenceJobProductCategory::GrokApiVideo); + assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image").unwrap(), InferenceJobProductCategory::Seedance2ProImage); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_audio").unwrap(), InferenceJobProductCategory::Seedance2ProAudio); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProImageBytePlusUltra); + assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat); + assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits); + assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2); + assert_eq!(InferenceJobProductCategory::from_str("tts_tacotron2").unwrap(), InferenceJobProductCategory::TtsTacotron2); + assert_eq!(InferenceJobProductCategory::from_str("vc_rvc2").unwrap(), InferenceJobProductCategory::VcRvc2); + assert_eq!(InferenceJobProductCategory::from_str("vc_svc").unwrap(), InferenceJobProductCategory::VcSvc); + assert_eq!(InferenceJobProductCategory::from_str("vc_svc").unwrap(), InferenceJobProductCategory::VcSvc); + assert_eq!(InferenceJobProductCategory::from_str("vid_lipsync_face_fusion").unwrap(), InferenceJobProductCategory::VidLipsyncFaceFusion); + assert_eq!(InferenceJobProductCategory::from_str("vid_lipsync_sad_talker").unwrap(), InferenceJobProductCategory::VidLipsyncSadTalker); + assert_eq!(InferenceJobProductCategory::from_str("vid_live_portrait").unwrap(), InferenceJobProductCategory::VidLivePortrait); + assert_eq!(InferenceJobProductCategory::from_str("vid_live_portrait_webcam").unwrap(), InferenceJobProductCategory::VidLivePortraitWebcam); + assert_eq!(InferenceJobProductCategory::from_str("vid_studio").unwrap(), InferenceJobProductCategory::VidStudio); + assert_eq!(InferenceJobProductCategory::from_str("vid_studio_gen2").unwrap(), InferenceJobProductCategory::VidStudioGen2); + assert_eq!(InferenceJobProductCategory::from_str("vid_style_transfer").unwrap(), InferenceJobProductCategory::VidStyleTransfer); + assert_eq!(InferenceJobProductCategory::from_str("lipsync_face_fusion").unwrap(), InferenceJobProductCategory::LipsyncFaceFusion); + assert_eq!(InferenceJobProductCategory::from_str("lipsync_sad_talker").unwrap(), InferenceJobProductCategory::LipsyncSadTalker); + assert_eq!(InferenceJobProductCategory::from_str("live_portrait").unwrap(), InferenceJobProductCategory::LivePortrait); + assert_eq!(InferenceJobProductCategory::from_str("live_portrait_webcam").unwrap(), InferenceJobProductCategory::LivePortraitWebcam); + assert_eq!(InferenceJobProductCategory::from_str("stable_diffusion").unwrap(), InferenceJobProductCategory::StableDiffusion); + assert_eq!(InferenceJobProductCategory::from_str("studio").unwrap(), InferenceJobProductCategory::Studio); + assert_eq!(InferenceJobProductCategory::from_str("vid_face_fusion").unwrap(), InferenceJobProductCategory::VidFaceFusion); + assert_eq!(InferenceJobProductCategory::from_str("vst").unwrap(), InferenceJobProductCategory::Vst); + } + + #[test] + fn all_variants() { + // Static check + const EXPECTED_COUNT : usize = 40; + + assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT); + assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(InferenceJobProductCategory::all_variants().len(), InferenceJobProductCategory::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(InferenceJobProductCategory::all_variants().len(), InferenceJobProductCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in InferenceJobProductCategory::all_variants() { + assert_eq!(variant, InferenceJobProductCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, InferenceJobProductCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, InferenceJobProductCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in InferenceJobProductCategory::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_type.rs new file mode 100644 index 0000000000..7a42a9e56f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_type.rs @@ -0,0 +1,427 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `job_type`. +/// +/// TODO(bt,2024-02-01): This will replace "inference_category" and "maybe_model_type" for job control and dispatch, +/// since those mechanisms are overloaded and inconsistent. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum InferenceJobType { + /// Fal jobs do not run on our job runners. + /// Instead, we receive web hook updates from Fal. + FalQueue, + + /// GmiCloud jobs. We poll for results. + #[serde(rename = "gmicloud_queue")] + GmiCloudQueue, + + /// Grok (xAI) API jobs. We poll for results. + #[serde(rename = "grok_api_queue")] + GrokApiQueue, + + /// Seedance 2 Pro jobs. We poll for results. + #[serde(rename = "seedance2pro_queue")] + Seedance2ProQueue, + + /// Seedance 2 Pro character creation. We poll for results. + #[serde(rename = "seedance2pro_character")] + Seedance2ProCharacter, + + /// Seedance 2 Pro Alt jobs. We poll for results. + #[serde(rename = "seedance2pro_alt_queue")] + Seedance2ProAltQueue, + + /// Seedance 2 Pro BytePlus Ultra jobs. We poll for results. + #[serde(rename = "seedance2pro_bpu_queue")] + Seedance2ProBytePlusUltraQueue, + + /// First-party Minimax H3 Turbo jobs. These run on our own GPU inference. + #[serde(rename = "artcraft_minimax_h3_turbo")] + ArtcraftMinimaxH3Turbo, + + /// First-party Minimax H3 Ultra jobs. These run on our own GPU inference. + #[serde(rename = "artcraft_minimax_h3_ultra")] + ArtcraftMinimaxH3Ultra, + + /// Beeble jobs. We poll for results. + #[serde(rename = "beeble_queue")] + BeebleQueue, + + /// World Labs jobs. We poll for results. + #[serde(rename = "worldlabs_queue")] + WorldlabsQueue, + + /// Storyteller Studio and Video Style Transfer Jobs (which we may want to split). + /// These run in Comfy. + /// TODO(bt,2024-07-15): We may segregate these two job types in the future + VideoRender, + + /// Live Portrait Jobs. + /// These run in Comfy. + LivePortrait, + + /// Face Fusion Jobs. + /// These run in Comfy. + FaceFusion, + + /// Voice jobs that use GPT-Sovits + GptSovits, + + /// Jobs that run ComfyUI workflows + /// This is actually just for Video Style Transfer and Storyteller Studio. + #[deprecated(note = "Use VideoRender instead.")] + ComfyUi, + + /// Second gen studio + #[serde(rename = "studio_gen2")] + StudioGen2, + + /// Sora GPT 4o image gen + #[serde(rename = "image_gen_api")] + ImageGenApi, + + /// A job that turns "FBX" game engine files into "GLTF" files (Bevy-compatible). + #[serde(rename = "convert_fbx_gltf")] + ConvertFbxToGltf, + + /// Process a video into BVH mocap animation data for game engines + MocapNet, + + /// Jobs that run F5TTS + #[serde(rename = "f5_tts")] + F5TTS, + + /// RVC is a voice conversion model. RVCv2 is the most popular such model currently. + #[serde(rename = "rvc_v2")] + RvcV2, + + /// SadTalker does image-to-video lip-syncing when given an audio file and image. + SadTalker, + + /// so-vits-svc voice conversion. This predates RVCv2. + SoVitsSvc, + + #[serde(rename = "seed_vc")] + SeedVc, + + /// Stable diffusion image generation + StableDiffusion, + + /// StyleTTS2 is a zero shot multi-speaker TTS model. + /// This job type should handle both speaker vector encoding and inference. + #[serde(rename = "styletts2")] + StyleTTS2, + + /// TT2 Text to speech + Tacotron2, + + /// A value we may use in the future for historical jobs + /// (i.e. when we backfill the database column and make it non-nullable) + #[default] + Unknown, + + /// DEPRECATED. DO NOT USE. + /// Job that converts bevy to workflow files + #[deprecated(note = "This was for Bevy engine's server side rendering.")] + #[serde(rename = "bevy_to_workflow")] + BevyToWorkflow, + + #[deprecated(note = "This was for ReRenderAVideo, which we never productionized.")] + /// DEPRECATED. DO NOT USE. + /// Re-render a video is a video style transfer algorithm. We developed code + /// around it, but chose to develop AnimateDiff / ComfyUI support instead. + RerenderAVideo, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceJobType); +impl_mysql_enum_coders!(InferenceJobType); + +/// NB: Legacy API for older code. +impl InferenceJobType { + pub fn to_str(&self) -> &'static str { + match self { + Self::FalQueue => "fal_queue", + Self::GmiCloudQueue => "gmicloud_queue", + Self::GrokApiQueue => "grok_api_queue", + Self::ArtcraftMinimaxH3Turbo => "artcraft_minimax_h3_turbo", + Self::ArtcraftMinimaxH3Ultra => "artcraft_minimax_h3_ultra", + Self::BeebleQueue => "beeble_queue", + Self::Seedance2ProQueue => "seedance2pro_queue", + Self::Seedance2ProAltQueue => "seedance2pro_alt_queue", + Self::Seedance2ProBytePlusUltraQueue => "seedance2pro_bpu_queue", + Self::Seedance2ProCharacter => "seedance2pro_character", + Self::WorldlabsQueue => "worldlabs_queue", + Self::VideoRender => "video_render", + Self::LivePortrait => "live_portrait", + Self::FaceFusion => "face_fusion", + Self::F5TTS => "f5_tts", + Self::GptSovits => "gpt_sovits", + Self::ComfyUi => "comfy_ui", + Self::StudioGen2 => "studio_gen2", + Self::ImageGenApi => "image_gen_api", + Self::ConvertFbxToGltf => "convert_fbx_gltf", + Self::MocapNet => "mocap_net", + Self::RvcV2 => "rvc_v2", + Self::SadTalker => "sad_talker", + Self::SeedVc => "seed_vc", + Self::SoVitsSvc => "so_vits_svc", + Self::StableDiffusion => "stable_diffusion", + Self::StyleTTS2 => "styletts2", + Self::Tacotron2 => "tacotron2", + Self::Unknown => "unknown", + Self::BevyToWorkflow => "bevy_to_workflow", + Self::RerenderAVideo => "rerender_a_video", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "fal_queue" => Ok(Self::FalQueue), + "gmicloud_queue" => Ok(Self::GmiCloudQueue), + "grok_api_queue" => Ok(Self::GrokApiQueue), + "artcraft_minimax_h3_turbo" => Ok(Self::ArtcraftMinimaxH3Turbo), + "artcraft_minimax_h3_ultra" => Ok(Self::ArtcraftMinimaxH3Ultra), + "beeble_queue" => Ok(Self::BeebleQueue), + "seedance2pro_queue" => Ok(Self::Seedance2ProQueue), + "seedance2pro_character" => Ok(Self::Seedance2ProCharacter), + "seedance2pro_alt_queue" => Ok(Self::Seedance2ProAltQueue), + "seedance2pro_bpu_queue" => Ok(Self::Seedance2ProBytePlusUltraQueue), + "worldlabs_queue" => Ok(Self::WorldlabsQueue), + "video_render" => Ok(Self::VideoRender), + "live_portrait" => Ok(Self::LivePortrait), + "face_fusion" => Ok(Self::FaceFusion), + "f5_tts" => Ok(Self::F5TTS), + "gpt_sovits" => Ok(Self::GptSovits), + "comfy_ui" => Ok(Self::ComfyUi), + "studio_gen2" => Ok(Self::StudioGen2), + "image_gen_api" => Ok(Self::ImageGenApi), + "convert_fbx_gltf" => Ok(Self::ConvertFbxToGltf), + "mocap_net" => Ok(Self::MocapNet), + "rvc_v2" => Ok(Self::RvcV2), + "sad_talker" => Ok(Self::SadTalker), + "seed_vc" => Ok(Self::SeedVc), + "so_vits_svc" => Ok(Self::SoVitsSvc), + "stable_diffusion" => Ok(Self::StableDiffusion), + "styletts2" => Ok(Self::StyleTTS2), + "tacotron2" => Ok(Self::Tacotron2), + "unknown" => Ok(Self::Unknown), + "bevy_to_workflow" => Ok(Self::BevyToWorkflow), + "rerender_a_video" => Ok(Self::RerenderAVideo), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::FalQueue, + Self::GmiCloudQueue, + Self::GrokApiQueue, + Self::ArtcraftMinimaxH3Turbo, + Self::ArtcraftMinimaxH3Ultra, + Self::BeebleQueue, + Self::Seedance2ProQueue, + Self::Seedance2ProCharacter, + Self::Seedance2ProAltQueue, + Self::Seedance2ProBytePlusUltraQueue, + Self::WorldlabsQueue, + Self::VideoRender, + Self::LivePortrait, + Self::FaceFusion, + Self::F5TTS, + Self::GptSovits, + Self::ComfyUi, + Self::StudioGen2, + Self::ImageGenApi, + Self::ConvertFbxToGltf, + Self::MocapNet, + Self::RvcV2, + Self::SadTalker, + Self::SeedVc, + Self::SoVitsSvc, + Self::StableDiffusion, + Self::StyleTTS2, + Self::Tacotron2, + Self::Unknown, + Self::BevyToWorkflow, + Self::RerenderAVideo, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_job_type::InferenceJobType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn default() { + assert_eq!(InferenceJobType::default(), InferenceJobType::Unknown); + } + + #[test] + fn test_serialization() { + assert_serialization(InferenceJobType::FalQueue, "fal_queue"); + assert_serialization(InferenceJobType::GmiCloudQueue, "gmicloud_queue"); + assert_serialization(InferenceJobType::GrokApiQueue, "grok_api_queue"); + assert_serialization(InferenceJobType::ArtcraftMinimaxH3Turbo, "artcraft_minimax_h3_turbo"); + assert_serialization(InferenceJobType::ArtcraftMinimaxH3Ultra, "artcraft_minimax_h3_ultra"); + assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue"); + assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue"); + assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character"); + assert_serialization(InferenceJobType::Seedance2ProAltQueue, "seedance2pro_alt_queue"); + assert_serialization(InferenceJobType::Seedance2ProBytePlusUltraQueue, "seedance2pro_bpu_queue"); + assert_serialization(InferenceJobType::WorldlabsQueue, "worldlabs_queue"); + assert_serialization(InferenceJobType::VideoRender, "video_render"); + assert_serialization(InferenceJobType::LivePortrait, "live_portrait"); + assert_serialization(InferenceJobType::FaceFusion, "face_fusion"); + assert_serialization(InferenceJobType::F5TTS, "f5_tts"); + assert_serialization(InferenceJobType::GptSovits, "gpt_sovits"); + assert_serialization(InferenceJobType::ComfyUi, "comfy_ui"); + assert_serialization(InferenceJobType::StudioGen2, "studio_gen2"); + assert_serialization(InferenceJobType::ImageGenApi, "image_gen_api"); + assert_serialization(InferenceJobType::ConvertFbxToGltf, "convert_fbx_gltf"); + assert_serialization(InferenceJobType::MocapNet, "mocap_net"); + assert_serialization(InferenceJobType::RvcV2, "rvc_v2"); + assert_serialization(InferenceJobType::SadTalker, "sad_talker"); + assert_serialization(InferenceJobType::SeedVc, "seed_vc"); + assert_serialization(InferenceJobType::SoVitsSvc, "so_vits_svc"); + assert_serialization(InferenceJobType::StableDiffusion, "stable_diffusion"); + assert_serialization(InferenceJobType::StyleTTS2, "styletts2"); + assert_serialization(InferenceJobType::Tacotron2, "tacotron2"); + assert_serialization(InferenceJobType::Unknown, "unknown"); + assert_serialization(InferenceJobType::BevyToWorkflow, "bevy_to_workflow"); + assert_serialization(InferenceJobType::RerenderAVideo, "rerender_a_video"); + } + + #[test] + fn to_str() { + assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue"); + assert_eq!(InferenceJobType::GmiCloudQueue.to_str(), "gmicloud_queue"); + assert_eq!(InferenceJobType::GrokApiQueue.to_str(), "grok_api_queue"); + assert_eq!(InferenceJobType::ArtcraftMinimaxH3Turbo.to_str(), "artcraft_minimax_h3_turbo"); + assert_eq!(InferenceJobType::ArtcraftMinimaxH3Ultra.to_str(), "artcraft_minimax_h3_ultra"); + assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue"); + assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue"); + assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character"); + assert_eq!(InferenceJobType::Seedance2ProAltQueue.to_str(), "seedance2pro_alt_queue"); + assert_eq!(InferenceJobType::Seedance2ProBytePlusUltraQueue.to_str(), "seedance2pro_bpu_queue"); + assert_eq!(InferenceJobType::WorldlabsQueue.to_str(), "worldlabs_queue"); + assert_eq!(InferenceJobType::VideoRender.to_str(), "video_render"); + assert_eq!(InferenceJobType::LivePortrait.to_str(), "live_portrait"); + assert_eq!(InferenceJobType::FaceFusion.to_str(), "face_fusion"); + assert_eq!(InferenceJobType::F5TTS.to_str(), "f5_tts"); + assert_eq!(InferenceJobType::GptSovits.to_str(), "gpt_sovits"); + assert_eq!(InferenceJobType::ComfyUi.to_str(), "comfy_ui"); + assert_eq!(InferenceJobType::StudioGen2.to_str(), "studio_gen2"); + assert_eq!(InferenceJobType::ImageGenApi.to_str(), "image_gen_api"); + assert_eq!(InferenceJobType::ConvertFbxToGltf.to_str(), "convert_fbx_gltf"); + assert_eq!(InferenceJobType::MocapNet.to_str(), "mocap_net"); + assert_eq!(InferenceJobType::RvcV2.to_str(), "rvc_v2"); + assert_eq!(InferenceJobType::SadTalker.to_str(), "sad_talker"); + assert_eq!(InferenceJobType::SeedVc.to_str(), "seed_vc"); + assert_eq!(InferenceJobType::SoVitsSvc.to_str(), "so_vits_svc"); + assert_eq!(InferenceJobType::StableDiffusion.to_str(), "stable_diffusion"); + assert_eq!(InferenceJobType::StyleTTS2.to_str(), "styletts2"); + assert_eq!(InferenceJobType::Tacotron2.to_str(), "tacotron2"); + assert_eq!(InferenceJobType::Unknown.to_str(), "unknown"); + assert_eq!(InferenceJobType::BevyToWorkflow.to_str(), "bevy_to_workflow"); + assert_eq!(InferenceJobType::RerenderAVideo.to_str(), "rerender_a_video"); + } + + #[test] + fn from_str() { + assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue); + assert_eq!(InferenceJobType::from_str("gmicloud_queue").unwrap(), InferenceJobType::GmiCloudQueue); + assert_eq!(InferenceJobType::from_str("grok_api_queue").unwrap(), InferenceJobType::GrokApiQueue); + assert_eq!(InferenceJobType::from_str("artcraft_minimax_h3_turbo").unwrap(), InferenceJobType::ArtcraftMinimaxH3Turbo); + assert_eq!(InferenceJobType::from_str("artcraft_minimax_h3_ultra").unwrap(), InferenceJobType::ArtcraftMinimaxH3Ultra); + assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue); + assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue); + assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter); + assert_eq!(InferenceJobType::from_str("seedance2pro_alt_queue").unwrap(), InferenceJobType::Seedance2ProAltQueue); + assert_eq!(InferenceJobType::from_str("seedance2pro_bpu_queue").unwrap(), InferenceJobType::Seedance2ProBytePlusUltraQueue); + assert_eq!(InferenceJobType::from_str("worldlabs_queue").unwrap(), InferenceJobType::WorldlabsQueue); + assert_eq!(InferenceJobType::from_str("video_render").unwrap(), InferenceJobType::VideoRender); + assert_eq!(InferenceJobType::from_str("live_portrait").unwrap(), InferenceJobType::LivePortrait); + assert_eq!(InferenceJobType::from_str("face_fusion").unwrap(), InferenceJobType::FaceFusion); + assert_eq!(InferenceJobType::from_str("f5_tts").unwrap(), InferenceJobType::F5TTS); + assert_eq!(InferenceJobType::from_str("gpt_sovits").unwrap(), InferenceJobType::GptSovits); + assert_eq!(InferenceJobType::from_str("comfy_ui").unwrap(), InferenceJobType::ComfyUi); + assert_eq!(InferenceJobType::from_str("studio_gen2").unwrap(), InferenceJobType::StudioGen2); + assert_eq!(InferenceJobType::from_str("image_gen_api").unwrap(), InferenceJobType::ImageGenApi); + assert_eq!(InferenceJobType::from_str("convert_fbx_gltf").unwrap(), InferenceJobType::ConvertFbxToGltf); + assert_eq!(InferenceJobType::from_str("mocap_net").unwrap(), InferenceJobType::MocapNet); + assert_eq!(InferenceJobType::from_str("rvc_v2").unwrap(), InferenceJobType::RvcV2); + assert_eq!(InferenceJobType::from_str("sad_talker").unwrap(), InferenceJobType::SadTalker); + assert_eq!(InferenceJobType::from_str("seed_vc").unwrap(), InferenceJobType::SeedVc); + assert_eq!(InferenceJobType::from_str("so_vits_svc").unwrap(), InferenceJobType::SoVitsSvc); + assert_eq!(InferenceJobType::from_str("stable_diffusion").unwrap(), InferenceJobType::StableDiffusion); + assert_eq!(InferenceJobType::from_str("styletts2").unwrap(), InferenceJobType::StyleTTS2); + assert_eq!(InferenceJobType::from_str("tacotron2").unwrap(), InferenceJobType::Tacotron2); + assert_eq!(InferenceJobType::from_str("unknown").unwrap(), InferenceJobType::Unknown); + assert_eq!(InferenceJobType::from_str("bevy_to_workflow").unwrap(), InferenceJobType::BevyToWorkflow); + assert_eq!(InferenceJobType::from_str("rerender_a_video").unwrap(), InferenceJobType::RerenderAVideo); + } + + #[test] + fn all_variants() { + // Static check + const EXPECTED_COUNT : usize = 31; + + assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT); + assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(InferenceJobType::all_variants().len(), InferenceJobType::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(InferenceJobType::all_variants().len(), InferenceJobType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in InferenceJobType::all_variants() { + assert_eq!(variant, InferenceJobType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, InferenceJobType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, InferenceJobType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in InferenceJobType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs new file mode 100644 index 0000000000..f6f55c73fb --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs @@ -0,0 +1,1393 @@ +// The deprecated Seedance 2.0 Ultra variants must stay referenced by this +// enum's own coders, variant lists, and tests; suppress the lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +use crate::common::generation::common_model_type::CommonModelType; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `maybe_model_type`. +/// +/// Our "generic inference" pipeline supports a wide variety of ML models and other media. +/// Each inference "model type" identified by the following enum variants, though some pipelines +/// may use multiple models or no model (and may report NULL). +/// +/// These types are present in the HTTP API and database columns as serialized here. +/// +/// `InferenceModelType` is a **superset** of [`CommonModelType`]: every variant in +/// `CommonModelType` has a 1:1 mapping here (see [`from_common_model_type`]). The legacy +/// variants at the top of the enum (`ComfyUi`, `RvcV2`, …) only exist on this side. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum InferenceModelType { + // --------------------------------------------------------------------------- + // Legacy / inference-pipeline-only variants. These predate CommonModelType + // and have no counterpart there. + // --------------------------------------------------------------------------- + + // TODO(bt,2024-07-15): This is too generic. We probably need "StorytellerStudio", "LivePortrait", etc. + #[serde(rename = "comfy_ui")] + ComfyUi, + + #[serde(rename = "rvc_v2")] + RvcV2, + // NB: sad_talker does use user-supplied models, so there is no "model token" + #[serde(rename = "sad_talker")] + SadTalker, + #[serde(rename = "so_vits_svc")] + SoVitsSvc, + // TODO: Does this need to be "legacy_tacotron2" ? + + #[serde(rename = "seed_vc")] + SeedVc, + + /// NB: This is for Sora GPT 4o image gen + #[serde(rename = "image_gen_api")] + ImageGenApi, + + #[serde(rename = "tacotron2")] + Tacotron2, + #[serde(rename = "vits")] + Vits, + #[serde(rename = "vall_e_x")] + VallEX, + #[serde(rename = "rerender_a_video")] + RerenderAVideo, + #[serde(rename = "stable_diffusion")] + StableDiffusion, + #[serde(rename = "mocap_net")] + MocapNet, + #[serde(rename = "styletts2")] + StyleTTS2, + /// A job that turns "FBX" game engine files into "GLTF" files (Bevy-compatible). + #[serde(rename = "convert_fbx_gltf")] + ConvertFbxToGltf, + #[serde(rename = "bvh_to_workflow")] + BvhToWorkflow, + + // --------------------------------------------------------------------------- + // Mirror of `CommonModelType`. Names and serialized strings must stay in + // lock-step with that enum so `from_common_model_type` is trivially 1:1. + // When you add a new variant to `CommonModelType`, the match in + // `from_common_model_type` below will fail to compile until you mirror it + // here. + // --------------------------------------------------------------------------- + + // Image models + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_dev_juggernaut")] + FluxDevJuggernaut, + #[serde(rename = "flux_pro_1")] + FluxPro1, + #[serde(rename = "flux_pro_1p1")] + FluxPro11, + #[serde(rename = "flux_pro_1p1_ultra")] + FluxPro11Ultra, + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + #[serde(rename = "grok_image")] + GrokImage, + #[serde(rename = "grok_imagine_image")] + GrokImagineImage, + #[serde(rename = "grok_imagine_image_q")] + GrokImagineImageQuality, + #[serde(rename = "recraft_3")] + Recraft3, + #[serde(rename = "seededit_3")] + SeedEdit3, + #[serde(rename = "qwen")] + Qwen, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + #[serde(rename = "seedream_5p0_pro")] + Seedream5p0Pro, + #[serde(rename = "seedream_5p0_pro_u")] + Seedream5p0ProUltra, + #[serde(rename = "midjourney")] + Midjourney, + #[serde(rename = "midjourney_v6")] + MidjourneyV6, + #[serde(rename = "midjourney_v6p1")] + MidjourneyV6p1, + #[serde(rename = "midjourney_v6p1_raw")] + MidjourneyV6p1Raw, + #[serde(rename = "midjourney_v7")] + MidjourneyV7, + #[serde(rename = "midjourney_v7_draft")] + MidjourneyV7Draft, + #[serde(rename = "midjourney_v7_draft_raw")] + MidjourneyV7DraftRaw, + #[serde(rename = "midjourney_v7_raw")] + MidjourneyV7Raw, + /// Midjourney v7 as routed via kinovi + #[serde(rename = "midjourney_7")] + Midjourney7, + /// Midjourney v7 niji as routed via kinovi + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + /// Midjourney v8 as routed via kinovi + #[serde(rename = "midjourney_8")] + Midjourney8, + + // Video models + #[serde(rename = "grok_video")] + GrokVideo, + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "flux_3")] + Flux3, + #[serde(rename = "flux_3_draft")] + Flux3Draft, + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "minimax_h3")] + MinimaxH3, + #[serde(rename = "minimax_h3_turbo")] + MinimaxH3Turbo, + #[serde(rename = "minimax_h3_ultra")] + MinimaxH3Ultra, + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p0_pro")] + Seedance10Pro, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + #[serde(rename = "seedance_2p0")] + Seedance2p0, + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + #[serde(rename = "seedance_2p0_mini")] + Seedance2p0Mini, + #[serde(rename = "seedance_2p0_bp_mini")] + Seedance2p0BytePlusMini, + #[serde(rename = "seedance_2p0_bpu_mini")] + Seedance2p0BytePlusUltraMini, + #[serde(rename = "seedance_2p5_preview")] + Seedance2p5Preview, + #[serde(rename = "seedance_2p5")] + Seedance2p5, + #[serde(rename = "seedance_2p5_u")] + Seedance2p5Ultra, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, + #[serde(rename = "veo_3p1_lite")] + Veo3p1Lite, + #[serde(rename = "vidu_q3")] + ViduQ3, + #[serde(rename = "vidu_q3_turbo")] + ViduQ3Turbo, + + // Audio models + #[serde(rename = "suno_music")] + SunoMusic, + #[serde(rename = "suno_remix")] + SunoRemix, + #[serde(rename = "suno_sounds")] + SunoSounds, + #[serde(rename = "suno_sample")] + SunoSample, + #[serde(rename = "seed_audio_1p0")] + SeedAudio1p0, + + #[serde(rename = "preview_model")] + PreviewModel, + #[serde(rename = "preview_model_fast")] + PreviewModelFast, + #[serde(rename = "switch_x")] + SwitchX, + + // 3D Object generation models + #[serde(rename = "hunyuan_3d_2p0")] + Hunyuan3d2_0, + #[serde(rename = "hunyuan_3d_2p1")] + Hunyuan3d2_1, + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + #[serde(rename = "hunyuan_3d_3p1_pro")] + Hunyuan3d3_1Pro, + #[serde(rename = "hunyuan_3d_3p1_rapid")] + Hunyuan3d3_1Rapid, + #[serde(rename = "hunyuan_3d_3p1_part")] + Hunyuan3d3_1Part, + #[serde(rename = "hunyuan_3d_3p1_topology")] + Hunyuan3d3_1SmartTopology, + #[serde(rename = "tripo3d_h3p1")] + Tripo3dH3_1, + #[serde(rename = "meshy_v6")] + MeshyV6, + #[serde(rename = "rodin_2p5_fast")] + Rodin2_5Fast, + + // Splat generation models (World Labs) + #[serde(rename = "marble_0p1_mini")] + Marble0p1Mini, + #[serde(rename = "marble_0p1_plus")] + Marble0p1Plus, + #[serde(rename = "marble_1p0")] + Marble1p0, + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + #[serde(rename = "marble_1p1")] + Marble1p1, + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, + #[serde(rename = "triposplat")] + TripoSplat, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceModelType); +impl_mysql_enum_coders!(InferenceModelType); + +/// NB: Legacy API for older code. +impl InferenceModelType { + pub fn to_str(&self) -> &'static str { + match self { + // Legacy variants + Self::RvcV2 => "rvc_v2", + Self::SadTalker => "sad_talker", + Self::SoVitsSvc => "so_vits_svc", + Self::Tacotron2 => "tacotron2", + Self::Vits => "vits", + Self::VallEX => "vall_e_x", + Self::RerenderAVideo => "rerender_a_video", + Self::StableDiffusion => "stable_diffusion", + Self::ImageGenApi => "image_gen_api", + Self::SeedVc => "seed_vc", + Self::MocapNet => "mocap_net", + Self::StyleTTS2 => "styletts2", + Self::ComfyUi => "comfy_ui", + Self::ConvertFbxToGltf => "convert_fbx_gltf", + Self::BvhToWorkflow => "bvh_to_workflow", + + // Image models (mirror of CommonModelType) + Self::Flux1Dev => "flux_1_dev", + Self::Flux1Schnell => "flux_1_schnell", + Self::FluxDevJuggernaut => "flux_dev_juggernaut", + Self::FluxPro1 => "flux_pro_1", + Self::FluxPro11 => "flux_pro_1p1", + Self::FluxPro11Ultra => "flux_pro_1p1_ultra", + Self::FluxProKontextMax => "flux_pro_kontext_max", + Self::Flux2LoraAngles => "flux_2_lora_angles", + Self::GptImage1 => "gpt_image_1", + Self::GptImage1p5 => "gpt_image_1p5", + Self::GptImage2 => "gpt_image_2", + Self::GptImage2p5Flare => "gpt_image_2p5_flare", + Self::GptImage2p5Sunburst => "gpt_image_2p5_sunburst", + Self::GrokImage => "grok_image", + Self::GrokImagineImage => "grok_imagine_image", + Self::GrokImagineImageQuality => "grok_imagine_image_q", + Self::Recraft3 => "recraft_3", + Self::SeedEdit3 => "seededit_3", + Self::Qwen => "qwen", + Self::QwenEdit2511Angles => "qwen_edit_2511_angles", + Self::Gemini25Flash => "gemini_25_flash", + Self::NanoBanana => "nano_banana", + Self::NanoBanana2 => "nano_banana_2", + Self::NanoBananaPro => "nano_banana_pro", + Self::Seedream4 => "seedream_4", + Self::Seedream4p5 => "seedream_4p5", + Self::Seedream5Lite => "seedream_5_lite", + Self::Seedream5p0Pro => "seedream_5p0_pro", + Self::Seedream5p0ProUltra => "seedream_5p0_pro_u", + Self::Midjourney => "midjourney", + Self::MidjourneyV6 => "midjourney_v6", + Self::MidjourneyV6p1 => "midjourney_v6p1", + Self::MidjourneyV6p1Raw => "midjourney_v6p1_raw", + Self::MidjourneyV7 => "midjourney_v7", + Self::MidjourneyV7Draft => "midjourney_v7_draft", + Self::MidjourneyV7DraftRaw => "midjourney_v7_draft_raw", + Self::MidjourneyV7Raw => "midjourney_v7_raw", + Self::Midjourney7 => "midjourney_7", + Self::Midjourney7Niji => "midjourney_7_niji", + Self::Midjourney8 => "midjourney_8", + + // Video models + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Flux3 => "flux_3", + Self::Flux3Draft => "flux_3_draft", + Self::Kling16Pro => "kling_1p6_pro", + Self::Kling21Pro => "kling_2p1_pro", + Self::Kling21Master => "kling_2p1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::MinimaxH3 => "minimax_h3", + Self::MinimaxH3Turbo => "minimax_h3_turbo", + Self::MinimaxH3Ultra => "minimax_h3_ultra", + Self::Seedance10Lite => "seedance_1p0_lite", + Self::Seedance10Pro => "seedance_1p0_pro", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Seedance2p0BytePlus => "seedance_2p0_bp", + Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast", + Self::Seedance2p0Ultra => "seedance_2p0_u", + Self::Seedance2p0UltraFast => "seedance_2p0_u_fast", + Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu", + Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast", + Self::Seedance2p0Mini => "seedance_2p0_mini", + Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini", + Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini", + Self::Seedance2p5Preview => "seedance_2p5_preview", + Self::Seedance2p5 => "seedance_2p5", + Self::Seedance2p5Ultra => "seedance_2p5_u", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + Self::Veo3p1Lite => "veo_3p1_lite", + Self::ViduQ3 => "vidu_q3", + Self::ViduQ3Turbo => "vidu_q3_turbo", + + // Audio models + Self::SunoMusic => "suno_music", + Self::SunoRemix => "suno_remix", + Self::SunoSounds => "suno_sounds", + Self::SunoSample => "suno_sample", + Self::SeedAudio1p0 => "seed_audio_1p0", + + Self::PreviewModel => "preview_model", + Self::PreviewModelFast => "preview_model_fast", + Self::SwitchX => "switch_x", + + // 3D Object generation models + Self::Hunyuan3d2_0 => "hunyuan_3d_2p0", + Self::Hunyuan3d2_1 => "hunyuan_3d_2p1", + Self::Hunyuan3d3 => "hunyuan_3d_3", + Self::Hunyuan3d3_1Pro => "hunyuan_3d_3p1_pro", + Self::Hunyuan3d3_1Rapid => "hunyuan_3d_3p1_rapid", + Self::Hunyuan3d3_1Part => "hunyuan_3d_3p1_part", + Self::Hunyuan3d3_1SmartTopology => "hunyuan_3d_3p1_topology", + Self::Tripo3dH3_1 => "tripo3d_h3p1", + Self::MeshyV6 => "meshy_v6", + Self::Rodin2_5Fast => "rodin_2p5_fast", + + // Splat generation models (World Labs) + Self::Marble0p1Mini => "marble_0p1_mini", + Self::Marble0p1Plus => "marble_0p1_plus", + Self::Marble1p0 => "marble_1p0", + Self::Marble1p0Draft => "marble_1p0_draft", + Self::Marble1p1 => "marble_1p1", + Self::Marble1p1Plus => "marble_1p1_plus", + Self::TripoSplat => "triposplat", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + // Legacy variants + "rvc_v2" => Ok(Self::RvcV2), + "sad_talker" => Ok(Self::SadTalker), + "so_vits_svc" => Ok(Self::SoVitsSvc), + "seed_vc" => Ok(Self::SeedVc), + "tacotron2" => Ok(Self::Tacotron2), + "vits" => Ok(Self::Vits), + "vall_e_x" => Ok(Self::VallEX), + "rerender_a_video" => Ok(Self::RerenderAVideo), + "stable_diffusion" => Ok(Self::StableDiffusion), + "image_gen_api" => Ok(Self::ImageGenApi), + "mocap_net" => Ok(Self::MocapNet), + "styletts2" => Ok(Self::StyleTTS2), + "comfy_ui" => Ok(Self::ComfyUi), + "convert_fbx_gltf" => Ok(Self::ConvertFbxToGltf), + "bvh_to_workflow" => Ok(Self::BvhToWorkflow), + + // Image models (mirror of CommonModelType) + "flux_1_dev" => Ok(Self::Flux1Dev), + "flux_1_schnell" => Ok(Self::Flux1Schnell), + "flux_dev_juggernaut" => Ok(Self::FluxDevJuggernaut), + "flux_pro_1" => Ok(Self::FluxPro1), + "flux_pro_1p1" => Ok(Self::FluxPro11), + "flux_pro_1p1_ultra" => Ok(Self::FluxPro11Ultra), + "flux_pro_kontext_max" => Ok(Self::FluxProKontextMax), + "flux_2_lora_angles" => Ok(Self::Flux2LoraAngles), + "gpt_image_1" => Ok(Self::GptImage1), + "gpt_image_1p5" => Ok(Self::GptImage1p5), + "gpt_image_2" => Ok(Self::GptImage2), + "gpt_image_2p5_flare" => Ok(Self::GptImage2p5Flare), + "gpt_image_2p5_sunburst" => Ok(Self::GptImage2p5Sunburst), + "grok_image" => Ok(Self::GrokImage), + "grok_imagine_image" => Ok(Self::GrokImagineImage), + "grok_imagine_image_q" => Ok(Self::GrokImagineImageQuality), + "recraft_3" => Ok(Self::Recraft3), + "seededit_3" => Ok(Self::SeedEdit3), + "qwen" => Ok(Self::Qwen), + "qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles), + "gemini_25_flash" => Ok(Self::Gemini25Flash), + "nano_banana" => Ok(Self::NanoBanana), + "nano_banana_2" => Ok(Self::NanoBanana2), + "nano_banana_pro" => Ok(Self::NanoBananaPro), + "seedream_4" => Ok(Self::Seedream4), + "seedream_4p5" => Ok(Self::Seedream4p5), + "seedream_5_lite" => Ok(Self::Seedream5Lite), + "seedream_5p0_pro" => Ok(Self::Seedream5p0Pro), + "seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra), + "midjourney" => Ok(Self::Midjourney), + "midjourney_v6" => Ok(Self::MidjourneyV6), + "midjourney_v6p1" => Ok(Self::MidjourneyV6p1), + "midjourney_v6p1_raw" => Ok(Self::MidjourneyV6p1Raw), + "midjourney_v7" => Ok(Self::MidjourneyV7), + "midjourney_v7_draft" => Ok(Self::MidjourneyV7Draft), + "midjourney_v7_draft_raw" => Ok(Self::MidjourneyV7DraftRaw), + "midjourney_v7_raw" => Ok(Self::MidjourneyV7Raw), + "midjourney_7" => Ok(Self::Midjourney7), + "midjourney_7_niji" => Ok(Self::Midjourney7Niji), + "midjourney_8" => Ok(Self::Midjourney8), + + // Video models + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "flux_3" => Ok(Self::Flux3), + "flux_3_draft" => Ok(Self::Flux3Draft), + "kling_1p6_pro" => Ok(Self::Kling16Pro), + "kling_2p1_pro" => Ok(Self::Kling21Pro), + "kling_2p1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "minimax_h3" => Ok(Self::MinimaxH3), + "minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo), + "minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra), + "seedance_1p0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p0_pro" => Ok(Self::Seedance10Pro), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus), + "seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast), + "seedance_2p0_u" => Ok(Self::Seedance2p0Ultra), + "seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast), + "seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra), + "seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast), + "seedance_2p0_mini" => Ok(Self::Seedance2p0Mini), + "seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini), + "seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini), + "seedance_2p5_preview" => Ok(Self::Seedance2p5Preview), + "seedance_2p5" => Ok(Self::Seedance2p5), + "seedance_2p5_u" => Ok(Self::Seedance2p5Ultra), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + "veo_3p1_lite" => Ok(Self::Veo3p1Lite), + "vidu_q3" => Ok(Self::ViduQ3), + "vidu_q3_turbo" => Ok(Self::ViduQ3Turbo), + + // Audio models + "suno_music" => Ok(Self::SunoMusic), + "suno_remix" => Ok(Self::SunoRemix), + "suno_sounds" => Ok(Self::SunoSounds), + "suno_sample" => Ok(Self::SunoSample), + "seed_audio_1p0" => Ok(Self::SeedAudio1p0), + + "preview_model" => Ok(Self::PreviewModel), + "preview_model_fast" => Ok(Self::PreviewModelFast), + "switch_x" => Ok(Self::SwitchX), + + // 3D Object generation models + "hunyuan_3d_2p0" => Ok(Self::Hunyuan3d2_0), + "hunyuan_3d_2p1" => Ok(Self::Hunyuan3d2_1), + "hunyuan_3d_3" => Ok(Self::Hunyuan3d3), + "hunyuan_3d_3p1_pro" => Ok(Self::Hunyuan3d3_1Pro), + "hunyuan_3d_3p1_rapid" => Ok(Self::Hunyuan3d3_1Rapid), + "hunyuan_3d_3p1_part" => Ok(Self::Hunyuan3d3_1Part), + "hunyuan_3d_3p1_topology" => Ok(Self::Hunyuan3d3_1SmartTopology), + "tripo3d_h3p1" => Ok(Self::Tripo3dH3_1), + "meshy_v6" => Ok(Self::MeshyV6), + "rodin_2p5_fast" => Ok(Self::Rodin2_5Fast), + + // Splat generation models (World Labs) + "marble_0p1_mini" => Ok(Self::Marble0p1Mini), + "marble_0p1_plus" => Ok(Self::Marble0p1Plus), + "marble_1p0" => Ok(Self::Marble1p0), + "marble_1p0_draft" => Ok(Self::Marble1p0Draft), + "marble_1p1" => Ok(Self::Marble1p1), + "marble_1p1_plus" => Ok(Self::Marble1p1Plus), + "triposplat" => Ok(Self::TripoSplat), + + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + // Legacy variants + Self::ComfyUi, + Self::RvcV2, + Self::SadTalker, + Self::SoVitsSvc, + Self::SeedVc, + Self::Tacotron2, + Self::Vits, + Self::VallEX, + Self::RerenderAVideo, + Self::StableDiffusion, + Self::ImageGenApi, + Self::MocapNet, + Self::StyleTTS2, + Self::ConvertFbxToGltf, + Self::BvhToWorkflow, + + // Image models (mirror of CommonModelType) + Self::Flux1Dev, + Self::Flux1Schnell, + Self::FluxDevJuggernaut, + Self::FluxPro1, + Self::FluxPro11, + Self::FluxPro11Ultra, + Self::FluxProKontextMax, + Self::Flux2LoraAngles, + Self::GptImage1, + Self::GptImage1p5, + Self::GptImage2, + Self::GptImage2p5Flare, + Self::GptImage2p5Sunburst, + Self::GrokImage, + Self::GrokImagineImage, + Self::GrokImagineImageQuality, + Self::Recraft3, + Self::SeedEdit3, + Self::Qwen, + Self::QwenEdit2511Angles, + Self::Gemini25Flash, + Self::NanoBanana, + Self::NanoBanana2, + Self::NanoBananaPro, + Self::Seedream4, + Self::Seedream4p5, + Self::Seedream5Lite, + Self::Seedream5p0Pro, + Self::Seedream5p0ProUltra, + Self::Midjourney, + Self::MidjourneyV6, + Self::MidjourneyV6p1, + Self::MidjourneyV6p1Raw, + Self::MidjourneyV7, + Self::MidjourneyV7Draft, + Self::MidjourneyV7DraftRaw, + Self::MidjourneyV7Raw, + Self::Midjourney7, + Self::Midjourney7Niji, + Self::Midjourney8, + + // Video models + Self::GrokVideo, + Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, + Self::Flux3, + Self::Flux3Draft, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::MinimaxH3, + Self::MinimaxH3Turbo, + Self::MinimaxH3Ultra, + Self::Seedance10Lite, + Self::Seedance10Pro, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra, + Self::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast, + Self::Seedance2p0Mini, + Self::Seedance2p0BytePlusMini, + Self::Seedance2p0BytePlusUltraMini, + Self::Seedance2p5Preview, + Self::Seedance2p5, + Self::Seedance2p5Ultra, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + Self::Veo3p1Lite, + Self::ViduQ3, + Self::ViduQ3Turbo, + + // Audio models + Self::SunoMusic, + Self::SunoRemix, + Self::SunoSounds, + Self::SunoSample, + Self::SeedAudio1p0, + + Self::PreviewModel, + Self::PreviewModelFast, + Self::SwitchX, + + // 3D Object generation models + Self::Hunyuan3d2_0, + Self::Hunyuan3d2_1, + Self::Hunyuan3d3, + Self::Hunyuan3d3_1Pro, + Self::Hunyuan3d3_1Rapid, + Self::Hunyuan3d3_1Part, + Self::Hunyuan3d3_1SmartTopology, + Self::Tripo3dH3_1, + Self::MeshyV6, + Self::Rodin2_5Fast, + + // Splat generation models (World Labs) + Self::Marble0p1Mini, + Self::Marble0p1Plus, + Self::Marble1p0, + Self::Marble1p0Draft, + Self::Marble1p1, + Self::Marble1p1Plus, + Self::TripoSplat, + ]) + } + + /// Infallibly map a [`CommonModelType`] to its [`InferenceModelType`] + /// counterpart. The mapping is exhaustively 1:1 by variant name; when a + /// new variant is added to `CommonModelType`, this match will fail to + /// compile until you mirror the variant here too. + pub fn from_common_model_type(common: CommonModelType) -> Self { + match common { + // Image models + CommonModelType::Flux1Dev => Self::Flux1Dev, + CommonModelType::Flux1Schnell => Self::Flux1Schnell, + CommonModelType::FluxDevJuggernaut => Self::FluxDevJuggernaut, + CommonModelType::FluxPro1 => Self::FluxPro1, + CommonModelType::FluxPro11 => Self::FluxPro11, + CommonModelType::FluxPro11Ultra => Self::FluxPro11Ultra, + CommonModelType::FluxProKontextMax => Self::FluxProKontextMax, + CommonModelType::Flux2LoraAngles => Self::Flux2LoraAngles, + CommonModelType::GptImage1 => Self::GptImage1, + CommonModelType::GptImage1p5 => Self::GptImage1p5, + CommonModelType::GptImage2 => Self::GptImage2, + CommonModelType::GptImage2p5Flare => Self::GptImage2p5Flare, + CommonModelType::GptImage2p5Sunburst => Self::GptImage2p5Sunburst, + CommonModelType::GrokImage => Self::GrokImage, + CommonModelType::GrokImagineImage => Self::GrokImagineImage, + CommonModelType::GrokImagineImageQuality => Self::GrokImagineImageQuality, + CommonModelType::Recraft3 => Self::Recraft3, + CommonModelType::SeedEdit3 => Self::SeedEdit3, + CommonModelType::Qwen => Self::Qwen, + CommonModelType::QwenEdit2511Angles => Self::QwenEdit2511Angles, + CommonModelType::Gemini25Flash => Self::Gemini25Flash, + CommonModelType::NanoBanana => Self::NanoBanana, + CommonModelType::NanoBanana2 => Self::NanoBanana2, + CommonModelType::NanoBananaPro => Self::NanoBananaPro, + CommonModelType::Seedream4 => Self::Seedream4, + CommonModelType::Seedream4p5 => Self::Seedream4p5, + CommonModelType::Seedream5Lite => Self::Seedream5Lite, + CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro, + CommonModelType::Seedream5p0ProUltra => Self::Seedream5p0ProUltra, + CommonModelType::Midjourney => Self::Midjourney, + CommonModelType::MidjourneyV6 => Self::MidjourneyV6, + CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1, + CommonModelType::MidjourneyV6p1Raw => Self::MidjourneyV6p1Raw, + CommonModelType::MidjourneyV7 => Self::MidjourneyV7, + CommonModelType::MidjourneyV7Draft => Self::MidjourneyV7Draft, + CommonModelType::MidjourneyV7DraftRaw => Self::MidjourneyV7DraftRaw, + CommonModelType::MidjourneyV7Raw => Self::MidjourneyV7Raw, + CommonModelType::Midjourney7 => Self::Midjourney7, + CommonModelType::Midjourney7Niji => Self::Midjourney7Niji, + CommonModelType::Midjourney8 => Self::Midjourney8, + + // Video models + CommonModelType::GrokVideo => Self::GrokVideo, + CommonModelType::GrokImagineVideo => Self::GrokImagineVideo, + CommonModelType::GrokImagineVideo1p5 => Self::GrokImagineVideo1p5, + CommonModelType::Flux3 => Self::Flux3, + CommonModelType::Flux3Draft => Self::Flux3Draft, + CommonModelType::Kling16Pro => Self::Kling16Pro, + CommonModelType::Kling21Pro => Self::Kling21Pro, + CommonModelType::Kling21Master => Self::Kling21Master, + CommonModelType::Kling2p5TurboPro => Self::Kling2p5TurboPro, + CommonModelType::Kling2p6Pro => Self::Kling2p6Pro, + CommonModelType::Kling3p0Standard => Self::Kling3p0Standard, + CommonModelType::Kling3p0Pro => Self::Kling3p0Pro, + CommonModelType::HappyHorse1p0 => Self::HappyHorse1p0, + CommonModelType::MinimaxH3 => Self::MinimaxH3, + CommonModelType::MinimaxH3Turbo => Self::MinimaxH3Turbo, + CommonModelType::MinimaxH3Ultra => Self::MinimaxH3Ultra, + CommonModelType::Seedance10Lite => Self::Seedance10Lite, + CommonModelType::Seedance10Pro => Self::Seedance10Pro, + CommonModelType::Seedance1p5Pro => Self::Seedance1p5Pro, + CommonModelType::Seedance2p0 => Self::Seedance2p0, + CommonModelType::Seedance2p0Fast => Self::Seedance2p0Fast, + CommonModelType::Seedance2p0BytePlus => Self::Seedance2p0BytePlus, + CommonModelType::Seedance2p0BytePlusFast => Self::Seedance2p0BytePlusFast, + CommonModelType::Seedance2p0Ultra => Self::Seedance2p0Ultra, + CommonModelType::Seedance2p0UltraFast => Self::Seedance2p0UltraFast, + CommonModelType::Seedance2p0BytePlusUltra => Self::Seedance2p0BytePlusUltra, + CommonModelType::Seedance2p0BytePlusUltraFast => Self::Seedance2p0BytePlusUltraFast, + CommonModelType::Seedance2p0Mini => Self::Seedance2p0Mini, + CommonModelType::Seedance2p0BytePlusMini => Self::Seedance2p0BytePlusMini, + CommonModelType::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini, + CommonModelType::Seedance2p5Preview => Self::Seedance2p5Preview, + CommonModelType::Seedance2p5 => Self::Seedance2p5, + CommonModelType::Seedance2p5Ultra => Self::Seedance2p5Ultra, + CommonModelType::Sora2 => Self::Sora2, + CommonModelType::Sora2Pro => Self::Sora2Pro, + CommonModelType::Veo2 => Self::Veo2, + CommonModelType::Veo3 => Self::Veo3, + CommonModelType::Veo3Fast => Self::Veo3Fast, + CommonModelType::Veo3p1 => Self::Veo3p1, + CommonModelType::Veo3p1Fast => Self::Veo3p1Fast, + CommonModelType::Veo3p1Lite => Self::Veo3p1Lite, + CommonModelType::ViduQ3 => Self::ViduQ3, + CommonModelType::ViduQ3Turbo => Self::ViduQ3Turbo, + + // Audio models + CommonModelType::SunoMusic => Self::SunoMusic, + CommonModelType::SunoRemix => Self::SunoRemix, + CommonModelType::SunoSounds => Self::SunoSounds, + CommonModelType::SunoSample => Self::SunoSample, + CommonModelType::SeedAudio1p0 => Self::SeedAudio1p0, + + CommonModelType::PreviewModel => Self::PreviewModel, + CommonModelType::PreviewModelFast => Self::PreviewModelFast, + CommonModelType::SwitchX => Self::SwitchX, + + // 3D Object generation models + CommonModelType::Hunyuan3d2_0 => Self::Hunyuan3d2_0, + CommonModelType::Hunyuan3d2_1 => Self::Hunyuan3d2_1, + CommonModelType::Hunyuan3d3 => Self::Hunyuan3d3, + CommonModelType::Hunyuan3d3_1Pro => Self::Hunyuan3d3_1Pro, + CommonModelType::Hunyuan3d3_1Rapid => Self::Hunyuan3d3_1Rapid, + CommonModelType::Hunyuan3d3_1Part => Self::Hunyuan3d3_1Part, + CommonModelType::Hunyuan3d3_1SmartTopology => Self::Hunyuan3d3_1SmartTopology, + CommonModelType::Tripo3dH3_1 => Self::Tripo3dH3_1, + CommonModelType::MeshyV6 => Self::MeshyV6, + CommonModelType::Rodin2_5Fast => Self::Rodin2_5Fast, + + // Splat generation models (World Labs) + CommonModelType::Marble0p1Mini => Self::Marble0p1Mini, + CommonModelType::Marble0p1Plus => Self::Marble0p1Plus, + CommonModelType::Marble1p0 => Self::Marble1p0, + CommonModelType::Marble1p0Draft => Self::Marble1p0Draft, + CommonModelType::Marble1p1 => Self::Marble1p1, + CommonModelType::Marble1p1Plus => Self::Marble1p1Plus, + CommonModelType::TripoSplat => Self::TripoSplat, + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_model_type::InferenceModelType; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + // Legacy variants + assert_serialization(InferenceModelType::RvcV2, "rvc_v2"); + assert_serialization(InferenceModelType::SadTalker, "sad_talker"); + assert_serialization(InferenceModelType::SeedVc, "seed_vc"); + assert_serialization(InferenceModelType::SoVitsSvc, "so_vits_svc"); + assert_serialization(InferenceModelType::Tacotron2, "tacotron2"); + assert_serialization(InferenceModelType::Vits, "vits"); + assert_serialization(InferenceModelType::VallEX, "vall_e_x"); + assert_serialization(InferenceModelType::RerenderAVideo, "rerender_a_video"); + assert_serialization(InferenceModelType::StableDiffusion, "stable_diffusion"); + assert_serialization(InferenceModelType::ImageGenApi, "image_gen_api"); + assert_serialization(InferenceModelType::MocapNet, "mocap_net"); + assert_serialization(InferenceModelType::ComfyUi, "comfy_ui"); + assert_serialization(InferenceModelType::StyleTTS2, "styletts2"); + assert_serialization(InferenceModelType::ConvertFbxToGltf, "convert_fbx_gltf"); + assert_serialization(InferenceModelType::BvhToWorkflow, "bvh_to_workflow"); + + // Image models (mirror of CommonModelType) + assert_serialization(InferenceModelType::Flux1Dev, "flux_1_dev"); + assert_serialization(InferenceModelType::Flux1Schnell, "flux_1_schnell"); + assert_serialization(InferenceModelType::FluxDevJuggernaut, "flux_dev_juggernaut"); + assert_serialization(InferenceModelType::FluxPro1, "flux_pro_1"); + assert_serialization(InferenceModelType::FluxPro11, "flux_pro_1p1"); + assert_serialization(InferenceModelType::FluxPro11Ultra, "flux_pro_1p1_ultra"); + assert_serialization(InferenceModelType::FluxProKontextMax, "flux_pro_kontext_max"); + assert_serialization(InferenceModelType::Flux2LoraAngles, "flux_2_lora_angles"); + assert_serialization(InferenceModelType::GptImage1, "gpt_image_1"); + assert_serialization(InferenceModelType::GptImage1p5, "gpt_image_1p5"); + assert_serialization(InferenceModelType::GptImage2, "gpt_image_2"); + assert_serialization(InferenceModelType::GptImage2p5Flare, "gpt_image_2p5_flare"); + assert_serialization(InferenceModelType::GptImage2p5Sunburst, "gpt_image_2p5_sunburst"); + assert_serialization(InferenceModelType::GrokImage, "grok_image"); + assert_serialization(InferenceModelType::GrokImagineImage, "grok_imagine_image"); + assert_serialization(InferenceModelType::GrokImagineImageQuality, "grok_imagine_image_q"); + assert_serialization(InferenceModelType::Recraft3, "recraft_3"); + assert_serialization(InferenceModelType::SeedEdit3, "seededit_3"); + assert_serialization(InferenceModelType::Qwen, "qwen"); + assert_serialization(InferenceModelType::QwenEdit2511Angles, "qwen_edit_2511_angles"); + assert_serialization(InferenceModelType::Gemini25Flash, "gemini_25_flash"); + assert_serialization(InferenceModelType::NanoBanana, "nano_banana"); + assert_serialization(InferenceModelType::NanoBanana2, "nano_banana_2"); + assert_serialization(InferenceModelType::NanoBananaPro, "nano_banana_pro"); + assert_serialization(InferenceModelType::Seedream4, "seedream_4"); + assert_serialization(InferenceModelType::Seedream4p5, "seedream_4p5"); + assert_serialization(InferenceModelType::Seedream5Lite, "seedream_5_lite"); + assert_serialization(InferenceModelType::Seedream5p0Pro, "seedream_5p0_pro"); + assert_serialization(InferenceModelType::Midjourney, "midjourney"); + assert_serialization(InferenceModelType::MidjourneyV6, "midjourney_v6"); + assert_serialization(InferenceModelType::MidjourneyV6p1, "midjourney_v6p1"); + assert_serialization(InferenceModelType::MidjourneyV6p1Raw, "midjourney_v6p1_raw"); + assert_serialization(InferenceModelType::MidjourneyV7, "midjourney_v7"); + assert_serialization(InferenceModelType::MidjourneyV7Draft, "midjourney_v7_draft"); + assert_serialization(InferenceModelType::MidjourneyV7DraftRaw, "midjourney_v7_draft_raw"); + assert_serialization(InferenceModelType::MidjourneyV7Raw, "midjourney_v7_raw"); + assert_serialization(InferenceModelType::Midjourney7, "midjourney_7"); + assert_serialization(InferenceModelType::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(InferenceModelType::Midjourney8, "midjourney_8"); + + // Video models + assert_serialization(InferenceModelType::GrokVideo, "grok_video"); + assert_serialization(InferenceModelType::GrokImagineVideo, "grok_imagine_video"); + assert_serialization(InferenceModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5"); + assert_serialization(InferenceModelType::Flux3, "flux_3"); + assert_serialization(InferenceModelType::Flux3Draft, "flux_3_draft"); + assert_serialization(InferenceModelType::Kling16Pro, "kling_1p6_pro"); + assert_serialization(InferenceModelType::Kling21Pro, "kling_2p1_pro"); + assert_serialization(InferenceModelType::Kling21Master, "kling_2p1_master"); + assert_serialization(InferenceModelType::Kling2p5TurboPro, "kling_2p5_turbo_pro"); + assert_serialization(InferenceModelType::Kling2p6Pro, "kling_2p6_pro"); + assert_serialization(InferenceModelType::Kling3p0Standard, "kling_3p0_standard"); + assert_serialization(InferenceModelType::Kling3p0Pro, "kling_3p0_pro"); + assert_serialization(InferenceModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(InferenceModelType::MinimaxH3, "minimax_h3"); + assert_serialization(InferenceModelType::MinimaxH3Turbo, "minimax_h3_turbo"); + assert_serialization(InferenceModelType::MinimaxH3Ultra, "minimax_h3_ultra"); + assert_serialization(InferenceModelType::Seedance10Lite, "seedance_1p0_lite"); + assert_serialization(InferenceModelType::Seedance10Pro, "seedance_1p0_pro"); + assert_serialization(InferenceModelType::Seedance1p5Pro, "seedance_1p5_pro"); + assert_serialization(InferenceModelType::Seedance2p0, "seedance_2p0"); + assert_serialization(InferenceModelType::Seedance2p0Fast, "seedance_2p0_fast"); + assert_serialization(InferenceModelType::Seedance2p0BytePlus, "seedance_2p0_bp"); + assert_serialization(InferenceModelType::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"); + assert_serialization(InferenceModelType::Seedance2p0Ultra, "seedance_2p0_u"); + assert_serialization(InferenceModelType::Seedance2p0UltraFast, "seedance_2p0_u_fast"); + assert_serialization(InferenceModelType::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"); + assert_serialization(InferenceModelType::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"); + assert_serialization(InferenceModelType::Seedance2p0Mini, "seedance_2p0_mini"); + assert_serialization(InferenceModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"); + assert_serialization(InferenceModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"); + assert_serialization(InferenceModelType::Seedance2p5Preview, "seedance_2p5_preview"); + assert_serialization(InferenceModelType::Seedance2p5, "seedance_2p5"); + assert_serialization(InferenceModelType::Seedance2p5Ultra, "seedance_2p5_u"); + assert_serialization(InferenceModelType::Sora2, "sora_2"); + assert_serialization(InferenceModelType::Sora2Pro, "sora_2_pro"); + assert_serialization(InferenceModelType::Veo2, "veo_2"); + assert_serialization(InferenceModelType::Veo3, "veo_3"); + assert_serialization(InferenceModelType::Veo3Fast, "veo_3_fast"); + assert_serialization(InferenceModelType::Veo3p1, "veo_3p1"); + assert_serialization(InferenceModelType::Veo3p1Fast, "veo_3p1_fast"); + assert_serialization(InferenceModelType::Veo3p1Lite, "veo_3p1_lite"); + assert_serialization(InferenceModelType::ViduQ3, "vidu_q3"); + assert_serialization(InferenceModelType::ViduQ3Turbo, "vidu_q3_turbo"); + // Audio models + assert_serialization(InferenceModelType::SunoMusic, "suno_music"); + assert_serialization(InferenceModelType::SunoRemix, "suno_remix"); + assert_serialization(InferenceModelType::SunoSounds, "suno_sounds"); + assert_serialization(InferenceModelType::SunoSample, "suno_sample"); + assert_serialization(InferenceModelType::SeedAudio1p0, "seed_audio_1p0"); + assert_serialization(InferenceModelType::PreviewModel, "preview_model"); + assert_serialization(InferenceModelType::PreviewModelFast, "preview_model_fast"); + assert_serialization(InferenceModelType::SwitchX, "switch_x"); + + // 3D Object generation models + assert_serialization(InferenceModelType::Hunyuan3d2_0, "hunyuan_3d_2p0"); + assert_serialization(InferenceModelType::Hunyuan3d2_1, "hunyuan_3d_2p1"); + assert_serialization(InferenceModelType::Hunyuan3d3, "hunyuan_3d_3"); + assert_serialization(InferenceModelType::Hunyuan3d3_1Pro, "hunyuan_3d_3p1_pro"); + assert_serialization(InferenceModelType::Hunyuan3d3_1Rapid, "hunyuan_3d_3p1_rapid"); + assert_serialization(InferenceModelType::Hunyuan3d3_1Part, "hunyuan_3d_3p1_part"); + assert_serialization(InferenceModelType::Hunyuan3d3_1SmartTopology, "hunyuan_3d_3p1_topology"); + assert_serialization(InferenceModelType::Tripo3dH3_1, "tripo3d_h3p1"); + assert_serialization(InferenceModelType::MeshyV6, "meshy_v6"); + assert_serialization(InferenceModelType::Rodin2_5Fast, "rodin_2p5_fast"); + + // Splat generation models (World Labs) + assert_serialization(InferenceModelType::Marble0p1Mini, "marble_0p1_mini"); + assert_serialization(InferenceModelType::Marble0p1Plus, "marble_0p1_plus"); + assert_serialization(InferenceModelType::Marble1p0, "marble_1p0"); + assert_serialization(InferenceModelType::Marble1p0Draft, "marble_1p0_draft"); + assert_serialization(InferenceModelType::Marble1p1, "marble_1p1"); + assert_serialization(InferenceModelType::Marble1p1Plus, "marble_1p1_plus"); + assert_serialization(InferenceModelType::TripoSplat, "triposplat"); + } + + #[test] + fn to_str() { + // Legacy variants + assert_eq!(InferenceModelType::ComfyUi.to_str(), "comfy_ui"); + assert_eq!(InferenceModelType::RvcV2.to_str(), "rvc_v2"); + assert_eq!(InferenceModelType::SadTalker.to_str(), "sad_talker"); + assert_eq!(InferenceModelType::SoVitsSvc.to_str(), "so_vits_svc"); + assert_eq!(InferenceModelType::SeedVc.to_str(), "seed_vc"); + assert_eq!(InferenceModelType::ImageGenApi.to_str(), "image_gen_api"); + assert_eq!(InferenceModelType::Tacotron2.to_str(), "tacotron2"); + assert_eq!(InferenceModelType::Vits.to_str(), "vits"); + assert_eq!(InferenceModelType::VallEX.to_str(), "vall_e_x"); + assert_eq!(InferenceModelType::RerenderAVideo.to_str(), "rerender_a_video"); + assert_eq!(InferenceModelType::StableDiffusion.to_str(), "stable_diffusion"); + assert_eq!(InferenceModelType::MocapNet.to_str(), "mocap_net"); + assert_eq!(InferenceModelType::StyleTTS2.to_str(), "styletts2"); + assert_eq!(InferenceModelType::ConvertFbxToGltf.to_str(), "convert_fbx_gltf"); + assert_eq!(InferenceModelType::BvhToWorkflow.to_str(), "bvh_to_workflow"); + + // Image models (mirror of CommonModelType) + assert_eq!(InferenceModelType::Flux1Dev.to_str(), "flux_1_dev"); + assert_eq!(InferenceModelType::Flux1Schnell.to_str(), "flux_1_schnell"); + assert_eq!(InferenceModelType::FluxDevJuggernaut.to_str(), "flux_dev_juggernaut"); + assert_eq!(InferenceModelType::FluxPro1.to_str(), "flux_pro_1"); + assert_eq!(InferenceModelType::FluxPro11.to_str(), "flux_pro_1p1"); + assert_eq!(InferenceModelType::FluxPro11Ultra.to_str(), "flux_pro_1p1_ultra"); + assert_eq!(InferenceModelType::FluxProKontextMax.to_str(), "flux_pro_kontext_max"); + assert_eq!(InferenceModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles"); + assert_eq!(InferenceModelType::GptImage1.to_str(), "gpt_image_1"); + assert_eq!(InferenceModelType::GptImage1p5.to_str(), "gpt_image_1p5"); + assert_eq!(InferenceModelType::GptImage2.to_str(), "gpt_image_2"); + assert_eq!(InferenceModelType::GptImage2p5Flare.to_str(), "gpt_image_2p5_flare"); + assert_eq!(InferenceModelType::GptImage2p5Sunburst.to_str(), "gpt_image_2p5_sunburst"); + assert_eq!(InferenceModelType::GrokImage.to_str(), "grok_image"); + assert_eq!(InferenceModelType::GrokImagineImage.to_str(), "grok_imagine_image"); + assert_eq!(InferenceModelType::GrokImagineImageQuality.to_str(), "grok_imagine_image_q"); + assert_eq!(InferenceModelType::Recraft3.to_str(), "recraft_3"); + assert_eq!(InferenceModelType::SeedEdit3.to_str(), "seededit_3"); + assert_eq!(InferenceModelType::Qwen.to_str(), "qwen"); + assert_eq!(InferenceModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles"); + assert_eq!(InferenceModelType::Gemini25Flash.to_str(), "gemini_25_flash"); + assert_eq!(InferenceModelType::NanoBanana.to_str(), "nano_banana"); + assert_eq!(InferenceModelType::NanoBanana2.to_str(), "nano_banana_2"); + assert_eq!(InferenceModelType::NanoBananaPro.to_str(), "nano_banana_pro"); + assert_eq!(InferenceModelType::Seedream4.to_str(), "seedream_4"); + assert_eq!(InferenceModelType::Seedream4p5.to_str(), "seedream_4p5"); + assert_eq!(InferenceModelType::Seedream5Lite.to_str(), "seedream_5_lite"); + assert_eq!(InferenceModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro"); + assert_eq!(InferenceModelType::Midjourney.to_str(), "midjourney"); + assert_eq!(InferenceModelType::MidjourneyV6.to_str(), "midjourney_v6"); + assert_eq!(InferenceModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1"); + assert_eq!(InferenceModelType::MidjourneyV6p1Raw.to_str(), "midjourney_v6p1_raw"); + assert_eq!(InferenceModelType::MidjourneyV7.to_str(), "midjourney_v7"); + assert_eq!(InferenceModelType::MidjourneyV7Draft.to_str(), "midjourney_v7_draft"); + assert_eq!(InferenceModelType::MidjourneyV7DraftRaw.to_str(), "midjourney_v7_draft_raw"); + assert_eq!(InferenceModelType::MidjourneyV7Raw.to_str(), "midjourney_v7_raw"); + assert_eq!(InferenceModelType::Midjourney7.to_str(), "midjourney_7"); + assert_eq!(InferenceModelType::Midjourney7Niji.to_str(), "midjourney_7_niji"); + assert_eq!(InferenceModelType::Midjourney8.to_str(), "midjourney_8"); + + // Video models + assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video"); + assert_eq!(InferenceModelType::GrokImagineVideo.to_str(), "grok_imagine_video"); + assert_eq!(InferenceModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5"); + assert_eq!(InferenceModelType::Flux3.to_str(), "flux_3"); + assert_eq!(InferenceModelType::Flux3Draft.to_str(), "flux_3_draft"); + assert_eq!(InferenceModelType::Kling16Pro.to_str(), "kling_1p6_pro"); + assert_eq!(InferenceModelType::Kling21Pro.to_str(), "kling_2p1_pro"); + assert_eq!(InferenceModelType::Kling21Master.to_str(), "kling_2p1_master"); + assert_eq!(InferenceModelType::Kling2p5TurboPro.to_str(), "kling_2p5_turbo_pro"); + assert_eq!(InferenceModelType::Kling2p6Pro.to_str(), "kling_2p6_pro"); + assert_eq!(InferenceModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); + assert_eq!(InferenceModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); + assert_eq!(InferenceModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(InferenceModelType::MinimaxH3.to_str(), "minimax_h3"); + assert_eq!(InferenceModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo"); + assert_eq!(InferenceModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra"); + assert_eq!(InferenceModelType::Seedance10Lite.to_str(), "seedance_1p0_lite"); + assert_eq!(InferenceModelType::Seedance10Pro.to_str(), "seedance_1p0_pro"); + assert_eq!(InferenceModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro"); + assert_eq!(InferenceModelType::Seedance2p0.to_str(), "seedance_2p0"); + assert_eq!(InferenceModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast"); + assert_eq!(InferenceModelType::Seedance2p0BytePlus.to_str(), "seedance_2p0_bp"); + assert_eq!(InferenceModelType::Seedance2p0BytePlusFast.to_str(), "seedance_2p0_bp_fast"); + assert_eq!(InferenceModelType::Seedance2p0Ultra.to_str(), "seedance_2p0_u"); + assert_eq!(InferenceModelType::Seedance2p0UltraFast.to_str(), "seedance_2p0_u_fast"); + assert_eq!(InferenceModelType::Seedance2p0BytePlusUltra.to_str(), "seedance_2p0_bpu"); + assert_eq!(InferenceModelType::Seedance2p0BytePlusUltraFast.to_str(), "seedance_2p0_bpu_fast"); + assert_eq!(InferenceModelType::Seedance2p0Mini.to_str(), "seedance_2p0_mini"); + assert_eq!(InferenceModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini"); + assert_eq!(InferenceModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini"); + assert_eq!(InferenceModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview"); + assert_eq!(InferenceModelType::Seedance2p5.to_str(), "seedance_2p5"); + assert_eq!(InferenceModelType::Seedance2p5Ultra.to_str(), "seedance_2p5_u"); + assert_eq!(InferenceModelType::Sora2.to_str(), "sora_2"); + assert_eq!(InferenceModelType::Sora2Pro.to_str(), "sora_2_pro"); + assert_eq!(InferenceModelType::Veo2.to_str(), "veo_2"); + assert_eq!(InferenceModelType::Veo3.to_str(), "veo_3"); + assert_eq!(InferenceModelType::Veo3Fast.to_str(), "veo_3_fast"); + assert_eq!(InferenceModelType::Veo3p1.to_str(), "veo_3p1"); + assert_eq!(InferenceModelType::Veo3p1Fast.to_str(), "veo_3p1_fast"); + assert_eq!(InferenceModelType::Veo3p1Lite.to_str(), "veo_3p1_lite"); + assert_eq!(InferenceModelType::ViduQ3.to_str(), "vidu_q3"); + assert_eq!(InferenceModelType::ViduQ3Turbo.to_str(), "vidu_q3_turbo"); + + // Audio models + assert_eq!(InferenceModelType::SunoMusic.to_str(), "suno_music"); + assert_eq!(InferenceModelType::SunoRemix.to_str(), "suno_remix"); + assert_eq!(InferenceModelType::SunoSounds.to_str(), "suno_sounds"); + assert_eq!(InferenceModelType::SunoSample.to_str(), "suno_sample"); + assert_eq!(InferenceModelType::SeedAudio1p0.to_str(), "seed_audio_1p0"); + + assert_eq!(InferenceModelType::PreviewModel.to_str(), "preview_model"); + assert_eq!(InferenceModelType::PreviewModelFast.to_str(), "preview_model_fast"); + assert_eq!(InferenceModelType::SwitchX.to_str(), "switch_x"); + + // 3D Object generation models + assert_eq!(InferenceModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2p0"); + assert_eq!(InferenceModelType::Hunyuan3d2_1.to_str(), "hunyuan_3d_2p1"); + assert_eq!(InferenceModelType::Hunyuan3d3.to_str(), "hunyuan_3d_3"); + assert_eq!(InferenceModelType::Hunyuan3d3_1Pro.to_str(), "hunyuan_3d_3p1_pro"); + assert_eq!(InferenceModelType::Hunyuan3d3_1Rapid.to_str(), "hunyuan_3d_3p1_rapid"); + assert_eq!(InferenceModelType::Hunyuan3d3_1Part.to_str(), "hunyuan_3d_3p1_part"); + assert_eq!(InferenceModelType::Hunyuan3d3_1SmartTopology.to_str(), "hunyuan_3d_3p1_topology"); + assert_eq!(InferenceModelType::Tripo3dH3_1.to_str(), "tripo3d_h3p1"); + assert_eq!(InferenceModelType::MeshyV6.to_str(), "meshy_v6"); + assert_eq!(InferenceModelType::Rodin2_5Fast.to_str(), "rodin_2p5_fast"); + + // Splat generation models (World Labs) + assert_eq!(InferenceModelType::Marble0p1Mini.to_str(), "marble_0p1_mini"); + assert_eq!(InferenceModelType::Marble0p1Plus.to_str(), "marble_0p1_plus"); + assert_eq!(InferenceModelType::Marble1p0.to_str(), "marble_1p0"); + assert_eq!(InferenceModelType::Marble1p0Draft.to_str(), "marble_1p0_draft"); + assert_eq!(InferenceModelType::Marble1p1.to_str(), "marble_1p1"); + assert_eq!(InferenceModelType::Marble1p1Plus.to_str(), "marble_1p1_plus"); + assert_eq!(InferenceModelType::TripoSplat.to_str(), "triposplat"); + } + + #[test] + fn from_str() { + // Legacy variants + assert_eq!(InferenceModelType::from_str("comfy_ui").unwrap(), InferenceModelType::ComfyUi); + assert_eq!(InferenceModelType::from_str("rvc_v2").unwrap(), InferenceModelType::RvcV2); + assert_eq!(InferenceModelType::from_str("sad_talker").unwrap(), InferenceModelType::SadTalker); + assert_eq!(InferenceModelType::from_str("so_vits_svc").unwrap(), InferenceModelType::SoVitsSvc); + assert_eq!(InferenceModelType::from_str("seed_vc").unwrap(), InferenceModelType::SeedVc); + assert_eq!(InferenceModelType::from_str("image_gen_api").unwrap(), InferenceModelType::ImageGenApi); + assert_eq!(InferenceModelType::from_str("tacotron2").unwrap(), InferenceModelType::Tacotron2); + assert_eq!(InferenceModelType::from_str("vits").unwrap(), InferenceModelType::Vits); + assert_eq!(InferenceModelType::from_str("vall_e_x").unwrap(), InferenceModelType::VallEX); + assert_eq!(InferenceModelType::from_str("rerender_a_video").unwrap(), InferenceModelType::RerenderAVideo); + assert_eq!(InferenceModelType::from_str("stable_diffusion").unwrap(), InferenceModelType::StableDiffusion); + assert_eq!(InferenceModelType::from_str("mocap_net").unwrap(), InferenceModelType::MocapNet); + assert_eq!(InferenceModelType::from_str("styletts2").unwrap(), InferenceModelType::StyleTTS2); + assert_eq!(InferenceModelType::from_str("convert_fbx_gltf").unwrap(), InferenceModelType::ConvertFbxToGltf); + assert_eq!(InferenceModelType::from_str("bvh_to_workflow").unwrap(), InferenceModelType::BvhToWorkflow); + + // Image models (mirror of CommonModelType) + assert_eq!(InferenceModelType::from_str("flux_1_dev").unwrap(), InferenceModelType::Flux1Dev); + assert_eq!(InferenceModelType::from_str("flux_1_schnell").unwrap(), InferenceModelType::Flux1Schnell); + assert_eq!(InferenceModelType::from_str("flux_dev_juggernaut").unwrap(), InferenceModelType::FluxDevJuggernaut); + assert_eq!(InferenceModelType::from_str("flux_pro_1").unwrap(), InferenceModelType::FluxPro1); + assert_eq!(InferenceModelType::from_str("flux_pro_1p1").unwrap(), InferenceModelType::FluxPro11); + assert_eq!(InferenceModelType::from_str("flux_pro_1p1_ultra").unwrap(), InferenceModelType::FluxPro11Ultra); + assert_eq!(InferenceModelType::from_str("flux_pro_kontext_max").unwrap(), InferenceModelType::FluxProKontextMax); + assert_eq!(InferenceModelType::from_str("flux_2_lora_angles").unwrap(), InferenceModelType::Flux2LoraAngles); + assert_eq!(InferenceModelType::from_str("gpt_image_1").unwrap(), InferenceModelType::GptImage1); + assert_eq!(InferenceModelType::from_str("gpt_image_1p5").unwrap(), InferenceModelType::GptImage1p5); + assert_eq!(InferenceModelType::from_str("gpt_image_2").unwrap(), InferenceModelType::GptImage2); + assert_eq!(InferenceModelType::from_str("gpt_image_2p5_flare").unwrap(), InferenceModelType::GptImage2p5Flare); + assert_eq!(InferenceModelType::from_str("gpt_image_2p5_sunburst").unwrap(), InferenceModelType::GptImage2p5Sunburst); + assert_eq!(InferenceModelType::from_str("grok_image").unwrap(), InferenceModelType::GrokImage); + assert_eq!(InferenceModelType::from_str("grok_imagine_image").unwrap(), InferenceModelType::GrokImagineImage); + assert_eq!(InferenceModelType::from_str("grok_imagine_image_q").unwrap(), InferenceModelType::GrokImagineImageQuality); + assert_eq!(InferenceModelType::from_str("recraft_3").unwrap(), InferenceModelType::Recraft3); + assert_eq!(InferenceModelType::from_str("seededit_3").unwrap(), InferenceModelType::SeedEdit3); + assert_eq!(InferenceModelType::from_str("qwen").unwrap(), InferenceModelType::Qwen); + assert_eq!(InferenceModelType::from_str("qwen_edit_2511_angles").unwrap(), InferenceModelType::QwenEdit2511Angles); + assert_eq!(InferenceModelType::from_str("gemini_25_flash").unwrap(), InferenceModelType::Gemini25Flash); + assert_eq!(InferenceModelType::from_str("nano_banana").unwrap(), InferenceModelType::NanoBanana); + assert_eq!(InferenceModelType::from_str("nano_banana_2").unwrap(), InferenceModelType::NanoBanana2); + assert_eq!(InferenceModelType::from_str("nano_banana_pro").unwrap(), InferenceModelType::NanoBananaPro); + assert_eq!(InferenceModelType::from_str("seedream_4").unwrap(), InferenceModelType::Seedream4); + assert_eq!(InferenceModelType::from_str("seedream_4p5").unwrap(), InferenceModelType::Seedream4p5); + assert_eq!(InferenceModelType::from_str("seedream_5_lite").unwrap(), InferenceModelType::Seedream5Lite); + assert_eq!(InferenceModelType::from_str("seedream_5p0_pro").unwrap(), InferenceModelType::Seedream5p0Pro); + assert_eq!(InferenceModelType::from_str("midjourney").unwrap(), InferenceModelType::Midjourney); + assert_eq!(InferenceModelType::from_str("midjourney_v6").unwrap(), InferenceModelType::MidjourneyV6); + assert_eq!(InferenceModelType::from_str("midjourney_v6p1").unwrap(), InferenceModelType::MidjourneyV6p1); + assert_eq!(InferenceModelType::from_str("midjourney_v6p1_raw").unwrap(), InferenceModelType::MidjourneyV6p1Raw); + assert_eq!(InferenceModelType::from_str("midjourney_v7").unwrap(), InferenceModelType::MidjourneyV7); + assert_eq!(InferenceModelType::from_str("midjourney_v7_draft").unwrap(), InferenceModelType::MidjourneyV7Draft); + assert_eq!(InferenceModelType::from_str("midjourney_v7_draft_raw").unwrap(), InferenceModelType::MidjourneyV7DraftRaw); + assert_eq!(InferenceModelType::from_str("midjourney_v7_raw").unwrap(), InferenceModelType::MidjourneyV7Raw); + assert_eq!(InferenceModelType::from_str("midjourney_7").unwrap(), InferenceModelType::Midjourney7); + assert_eq!(InferenceModelType::from_str("midjourney_7_niji").unwrap(), InferenceModelType::Midjourney7Niji); + assert_eq!(InferenceModelType::from_str("midjourney_8").unwrap(), InferenceModelType::Midjourney8); + + // Video models + assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo); + assert_eq!(InferenceModelType::from_str("grok_imagine_video").unwrap(), InferenceModelType::GrokImagineVideo); + assert_eq!(InferenceModelType::from_str("grok_imagine_video_1p5").unwrap(), InferenceModelType::GrokImagineVideo1p5); + assert_eq!(InferenceModelType::from_str("flux_3").unwrap(), InferenceModelType::Flux3); + assert_eq!(InferenceModelType::from_str("flux_3_draft").unwrap(), InferenceModelType::Flux3Draft); + assert_eq!(InferenceModelType::from_str("kling_1p6_pro").unwrap(), InferenceModelType::Kling16Pro); + assert_eq!(InferenceModelType::from_str("kling_2p1_pro").unwrap(), InferenceModelType::Kling21Pro); + assert_eq!(InferenceModelType::from_str("kling_2p1_master").unwrap(), InferenceModelType::Kling21Master); + assert_eq!(InferenceModelType::from_str("kling_2p5_turbo_pro").unwrap(), InferenceModelType::Kling2p5TurboPro); + assert_eq!(InferenceModelType::from_str("kling_2p6_pro").unwrap(), InferenceModelType::Kling2p6Pro); + assert_eq!(InferenceModelType::from_str("kling_3p0_standard").unwrap(), InferenceModelType::Kling3p0Standard); + assert_eq!(InferenceModelType::from_str("kling_3p0_pro").unwrap(), InferenceModelType::Kling3p0Pro); + assert_eq!(InferenceModelType::from_str("happy_horse_1p0").unwrap(), InferenceModelType::HappyHorse1p0); + assert_eq!(InferenceModelType::from_str("minimax_h3").unwrap(), InferenceModelType::MinimaxH3); + assert_eq!(InferenceModelType::from_str("minimax_h3_turbo").unwrap(), InferenceModelType::MinimaxH3Turbo); + assert_eq!(InferenceModelType::from_str("minimax_h3_ultra").unwrap(), InferenceModelType::MinimaxH3Ultra); + assert_eq!(InferenceModelType::from_str("seedance_1p0_lite").unwrap(), InferenceModelType::Seedance10Lite); + assert_eq!(InferenceModelType::from_str("seedance_1p0_pro").unwrap(), InferenceModelType::Seedance10Pro); + assert_eq!(InferenceModelType::from_str("seedance_1p5_pro").unwrap(), InferenceModelType::Seedance1p5Pro); + assert_eq!(InferenceModelType::from_str("seedance_2p0").unwrap(), InferenceModelType::Seedance2p0); + assert_eq!(InferenceModelType::from_str("seedance_2p0_fast").unwrap(), InferenceModelType::Seedance2p0Fast); + assert_eq!(InferenceModelType::from_str("seedance_2p0_bp").unwrap(), InferenceModelType::Seedance2p0BytePlus); + assert_eq!(InferenceModelType::from_str("seedance_2p0_bp_fast").unwrap(), InferenceModelType::Seedance2p0BytePlusFast); + assert_eq!(InferenceModelType::from_str("seedance_2p0_u").unwrap(), InferenceModelType::Seedance2p0Ultra); + assert_eq!(InferenceModelType::from_str("seedance_2p0_u_fast").unwrap(), InferenceModelType::Seedance2p0UltraFast); + assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu").unwrap(), InferenceModelType::Seedance2p0BytePlusUltra); + assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu_fast").unwrap(), InferenceModelType::Seedance2p0BytePlusUltraFast); + assert_eq!(InferenceModelType::from_str("seedance_2p0_mini").unwrap(), InferenceModelType::Seedance2p0Mini); + assert_eq!(InferenceModelType::from_str("seedance_2p0_bp_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusMini); + assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusUltraMini); + assert_eq!(InferenceModelType::from_str("seedance_2p5_preview").unwrap(), InferenceModelType::Seedance2p5Preview); + assert_eq!(InferenceModelType::from_str("seedance_2p5").unwrap(), InferenceModelType::Seedance2p5); + assert_eq!(InferenceModelType::from_str("seedance_2p5_u").unwrap(), InferenceModelType::Seedance2p5Ultra); + assert_eq!(InferenceModelType::from_str("sora_2").unwrap(), InferenceModelType::Sora2); + assert_eq!(InferenceModelType::from_str("sora_2_pro").unwrap(), InferenceModelType::Sora2Pro); + assert_eq!(InferenceModelType::from_str("veo_2").unwrap(), InferenceModelType::Veo2); + assert_eq!(InferenceModelType::from_str("veo_3").unwrap(), InferenceModelType::Veo3); + assert_eq!(InferenceModelType::from_str("veo_3_fast").unwrap(), InferenceModelType::Veo3Fast); + assert_eq!(InferenceModelType::from_str("veo_3p1").unwrap(), InferenceModelType::Veo3p1); + assert_eq!(InferenceModelType::from_str("veo_3p1_fast").unwrap(), InferenceModelType::Veo3p1Fast); + assert_eq!(InferenceModelType::from_str("veo_3p1_lite").unwrap(), InferenceModelType::Veo3p1Lite); + assert_eq!(InferenceModelType::from_str("vidu_q3").unwrap(), InferenceModelType::ViduQ3); + assert_eq!(InferenceModelType::from_str("vidu_q3_turbo").unwrap(), InferenceModelType::ViduQ3Turbo); + // Audio models + assert_eq!(InferenceModelType::from_str("suno_music").unwrap(), InferenceModelType::SunoMusic); + assert_eq!(InferenceModelType::from_str("suno_remix").unwrap(), InferenceModelType::SunoRemix); + assert_eq!(InferenceModelType::from_str("suno_sounds").unwrap(), InferenceModelType::SunoSounds); + assert_eq!(InferenceModelType::from_str("suno_sample").unwrap(), InferenceModelType::SunoSample); + assert_eq!(InferenceModelType::from_str("seed_audio_1p0").unwrap(), InferenceModelType::SeedAudio1p0); + assert_eq!(InferenceModelType::from_str("preview_model").unwrap(), InferenceModelType::PreviewModel); + assert_eq!(InferenceModelType::from_str("preview_model_fast").unwrap(), InferenceModelType::PreviewModelFast); + assert_eq!(InferenceModelType::from_str("switch_x").unwrap(), InferenceModelType::SwitchX); + + // 3D Object generation models + assert_eq!(InferenceModelType::from_str("hunyuan_3d_2p0").unwrap(), InferenceModelType::Hunyuan3d2_0); + assert_eq!(InferenceModelType::from_str("hunyuan_3d_2p1").unwrap(), InferenceModelType::Hunyuan3d2_1); + assert_eq!(InferenceModelType::from_str("hunyuan_3d_3").unwrap(), InferenceModelType::Hunyuan3d3); + assert_eq!(InferenceModelType::from_str("hunyuan_3d_3p1_pro").unwrap(), InferenceModelType::Hunyuan3d3_1Pro); + assert_eq!(InferenceModelType::from_str("hunyuan_3d_3p1_rapid").unwrap(), InferenceModelType::Hunyuan3d3_1Rapid); + assert_eq!(InferenceModelType::from_str("hunyuan_3d_3p1_part").unwrap(), InferenceModelType::Hunyuan3d3_1Part); + assert_eq!(InferenceModelType::from_str("hunyuan_3d_3p1_topology").unwrap(), InferenceModelType::Hunyuan3d3_1SmartTopology); + assert_eq!(InferenceModelType::from_str("tripo3d_h3p1").unwrap(), InferenceModelType::Tripo3dH3_1); + assert_eq!(InferenceModelType::from_str("meshy_v6").unwrap(), InferenceModelType::MeshyV6); + assert_eq!(InferenceModelType::from_str("rodin_2p5_fast").unwrap(), InferenceModelType::Rodin2_5Fast); + + // Splat generation models (World Labs) + assert_eq!(InferenceModelType::from_str("marble_0p1_mini").unwrap(), InferenceModelType::Marble0p1Mini); + assert_eq!(InferenceModelType::from_str("marble_0p1_plus").unwrap(), InferenceModelType::Marble0p1Plus); + assert_eq!(InferenceModelType::from_str("marble_1p0").unwrap(), InferenceModelType::Marble1p0); + assert_eq!(InferenceModelType::from_str("marble_1p0_draft").unwrap(), InferenceModelType::Marble1p0Draft); + assert_eq!(InferenceModelType::from_str("marble_1p1").unwrap(), InferenceModelType::Marble1p1); + assert_eq!(InferenceModelType::from_str("marble_1p1_plus").unwrap(), InferenceModelType::Marble1p1Plus); + assert_eq!(InferenceModelType::from_str("triposplat").unwrap(), InferenceModelType::TripoSplat); + } + + #[test] + fn from_str_unknown_value_errors() { + assert!(InferenceModelType::from_str("not_a_real_model").is_err()); + } + + #[test] + fn all_variants() { + // 15 legacy variants + every CommonModelType variant we mirror. + let variants = InferenceModelType::all_variants(); + assert_eq!(variants.len(), 15 + CommonModelType::all_variants().len()); + + // BTreeSet sort order is enum Ord = declaration order. The legacy + // block is declared first, so ComfyUi is always pop_first, and the + // legacy block is exhausted before the mirrored block begins. + assert_eq!(variants.iter().next(), Some(&InferenceModelType::ComfyUi)); + + // Every variant must be reachable via from_str(to_str(.)) — round-trip + // is the structural guarantee we actually care about here. + for variant in &variants { + assert_eq!(*variant, InferenceModelType::from_str(variant.to_str()).unwrap()); + } + } + } + + mod from_common_model_type_tests { + use super::*; + + #[test] + fn round_trip_via_serialized_string() { + // Every CommonModelType maps to an InferenceModelType whose + // serialization matches exactly. This proves the 1:1 mapping + // is consistent (no swapped variants). + for common in CommonModelType::all_variants() { + let inference = InferenceModelType::from_common_model_type(common); + assert_eq!( + common.to_str(), + inference.to_str(), + "mismatch: CommonModelType::{:?} -> InferenceModelType::{:?}", + common, inference, + ); + } + } + + #[test] + fn every_common_variant_is_covered() { + // If a CommonModelType variant is missing from the match in + // from_common_model_type, this would fail to compile. The runtime + // assertion is a belt-and-suspenders sanity check. + let count = CommonModelType::all_variants().len(); + assert!(count > 0); + for common in CommonModelType::all_variants() { + let _ = InferenceModelType::from_common_model_type(common); + } + } + + #[test] + fn spot_check_specific_mappings() { + assert_eq!( + InferenceModelType::from_common_model_type(CommonModelType::Flux1Dev), + InferenceModelType::Flux1Dev, + ); + assert_eq!( + InferenceModelType::from_common_model_type(CommonModelType::Sora2Pro), + InferenceModelType::Sora2Pro, + ); + assert_eq!( + InferenceModelType::from_common_model_type(CommonModelType::Marble0p1Plus), + InferenceModelType::Marble0p1Plus, + ); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(InferenceModelType::all_variants().len(), InferenceModelType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in InferenceModelType::all_variants() { + assert_eq!(variant, InferenceModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, InferenceModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, InferenceModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in InferenceModelType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long ({} chars)", variant, serialized.len()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_result_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_result_type.rs new file mode 100644 index 0000000000..8bd3fda32c --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_result_type.rs @@ -0,0 +1,137 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `generic_inference_jobs` table in `VARCHAR(32)` field `on_success_result_entity_type`. +/// +/// Our "generic inference" pipeline supports a wide variety of output types. +/// Each "result type" is identified by the following enum variants. +/// +/// These types are present in the HTTP API and database columns as serialized here. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum InferenceResultType { + /// Result is stored in the "media_files" table. + /// (The upstream model could have produced any type of media - image, video, audio. That is irrelevant.) + #[serde(rename = "media_file")] + MediaFile, + + #[serde(rename = "text_to_speech")] + TextToSpeech, + + #[serde(rename = "voice_conversion")] + VoiceConversion, + + #[serde(rename = "zs_voice_embedding")] + ZeroShotVoiceEmbedding, + + #[serde(rename = "upload_model")] + UploadModel, + + /// Result is a character record. + #[serde(rename = "character")] + Character, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(InferenceResultType); +impl_mysql_enum_coders!(InferenceResultType); + +/// NB: Legacy API for older code. +impl InferenceResultType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + Self::TextToSpeech => "text_to_speech", + Self::VoiceConversion => "voice_conversion", + Self::ZeroShotVoiceEmbedding => "zs_voice_embedding", + Self::UploadModel => "upload_model", + Self::Character => "character", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + "text_to_speech" => Ok(Self::TextToSpeech), + "voice_conversion" => Ok(Self::VoiceConversion), + "zs_voice_embedding" => Ok(Self::ZeroShotVoiceEmbedding), + "upload_model" => Ok(Self::UploadModel), + "character" => Ok(Self::Character), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + InferenceResultType::MediaFile, + InferenceResultType::TextToSpeech, + InferenceResultType::VoiceConversion, + InferenceResultType::ZeroShotVoiceEmbedding, + InferenceResultType::UploadModel, + InferenceResultType::Character, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(InferenceResultType::MediaFile, "media_file"); + assert_serialization(InferenceResultType::TextToSpeech, "text_to_speech"); + assert_serialization(InferenceResultType::VoiceConversion, "voice_conversion"); + assert_serialization(InferenceResultType::ZeroShotVoiceEmbedding, "zs_voice_embedding"); + assert_serialization(InferenceResultType::UploadModel, "upload_model"); + assert_serialization(InferenceResultType::Character, "character"); + } + + #[test] + fn to_str() { + assert_eq!(InferenceResultType::MediaFile.to_str(), "media_file"); + assert_eq!(InferenceResultType::TextToSpeech.to_str(), "text_to_speech"); + assert_eq!(InferenceResultType::VoiceConversion.to_str(), "voice_conversion"); + assert_eq!(InferenceResultType::ZeroShotVoiceEmbedding.to_str(), "zs_voice_embedding"); + assert_eq!(InferenceResultType::UploadModel.to_str(), "upload_model"); + assert_eq!(InferenceResultType::Character.to_str(), "character"); + } + + #[test] + fn from_str() { + assert_eq!(InferenceResultType::from_str("media_file").unwrap(), InferenceResultType::MediaFile); + assert_eq!(InferenceResultType::from_str("text_to_speech").unwrap(), InferenceResultType::TextToSpeech); + assert_eq!(InferenceResultType::from_str("voice_conversion").unwrap(), InferenceResultType::VoiceConversion); + assert_eq!(InferenceResultType::from_str("zs_voice_embedding").unwrap(), InferenceResultType::ZeroShotVoiceEmbedding); + assert_eq!(InferenceResultType::from_str("upload_model").unwrap(), InferenceResultType::UploadModel); + assert_eq!(InferenceResultType::from_str("character").unwrap(), InferenceResultType::Character); + assert_eq!(InferenceResultType::from_str("invalid").is_err(), true); + } + + #[test] + fn all_variants() { + // Static check + let mut variants = InferenceResultType::all_variants(); + assert_eq!(variants.len(), 6); + assert_eq!(variants.pop_first(), Some(InferenceResultType::MediaFile)); + assert_eq!(variants.pop_first(), Some(InferenceResultType::TextToSpeech)); + assert_eq!(variants.pop_first(), Some(InferenceResultType::VoiceConversion)); + assert_eq!(variants.pop_first(), Some(InferenceResultType::ZeroShotVoiceEmbedding)); + assert_eq!(variants.pop_first(), Some(InferenceResultType::UploadModel)); + assert_eq!(variants.pop_first(), Some(InferenceResultType::Character)); + assert_eq!(variants.pop_first(), None); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(InferenceResultType::all_variants().len(), InferenceResultType::iter().len()); + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/mod.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/mod.rs new file mode 100644 index 0000000000..ab12b4e367 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/mod.rs @@ -0,0 +1,8 @@ +pub mod frontend_failure_category; +pub mod inference_category; +pub mod inference_input_source_token_type; +pub mod inference_job_external_third_party; +pub mod inference_job_product_category; +pub mod inference_job_type; +pub mod inference_model_type; +pub mod inference_result_type; diff --git a/crates/schema/public/enums/src/by_table/generic_synthetic_ids/id_category.rs b/crates/schema/public/enums/src/by_table/generic_synthetic_ids/id_category.rs new file mode 100644 index 0000000000..ce2c5b1592 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_synthetic_ids/id_category.rs @@ -0,0 +1,259 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `generic_synthetic_ids` table in `VARCHAR(32)` field `id_category`. +/// +/// This lets us create synthetic increment IDs on a per-user, per-category basis. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum IdCategory { + /// media_files table + #[serde(rename = "media_file")] + MediaFile, + + /// Results from lipsync animations (which may live in the media_files table) + #[serde(rename = "lipsync_animation")] + LipsyncAnimationResult, + + /// Results from face fusion + #[serde(rename = "face_fusion")] + FaceFusionResult, + + /// Results from video filters + #[serde(rename = "video_filter")] + VideoFilterResult, + + /// Results from Live Portrait + #[serde(rename = "live_portrait")] + LivePortraitResult, + + /// Studio Renders + #[serde(rename = "studio_render")] + StudioRender, + + /// Results from mocap + #[serde(rename = "mocap")] + MocapResult, + + /// Results from workflows + #[serde(rename = "workflow")] + WorkflowResult, + + /// Results from tacotron2 + /// Applies for RVC and SVC + #[serde(rename = "tts_result")] + TtsResult, + + /// Results from voice conversion (which may live in the media_files table) + /// Applies for RVC and SVC + #[serde(rename = "voice_conversion")] + VoiceConversionResult, + + /// Results from the zero shot tts (which may live in the media_files table) + #[serde(rename = "zs_tts_result")] + ZeroShotTtsResult, + + /// Zs dataset which lives in the zs_voice_datasets table + #[serde(rename = "zs_dataset")] + ZeroShotVoiceDataset, + + /// Zs voice which lives in the zs_voices table + #[serde(rename = "zs_voice")] + ZeroShotVoiceEmbedding, + + #[serde(rename = "model_weights")] + ModelWeights, + + /// Files that are uploaded with no general product area they belong to. (Eg. local dev testing) + #[serde(rename = "file_upload")] + FileUpload, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(IdCategory); +impl_mysql_enum_coders!(IdCategory); + +/// NB: Legacy API for older code. +impl IdCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + Self::LipsyncAnimationResult => "lipsync_animation", + Self::FaceFusionResult => "face_fusion", + Self::TtsResult => "tts_result", + Self::VoiceConversionResult => "voice_conversion", + Self::ZeroShotVoiceDataset => "zs_dataset", + Self::ZeroShotVoiceEmbedding => "zs_voice", + Self::ZeroShotTtsResult => "zs_tts_result", + Self::VideoFilterResult => "video_filter", + Self::LivePortraitResult => "live_portrait", + Self::StudioRender => "studio_render", + Self::ModelWeights => "model_weights", + Self::FileUpload => "file_upload", + Self::MocapResult => "mocap", + Self::WorkflowResult => "workflow", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + "lipsync_animation" => Ok(Self::LipsyncAnimationResult), + "face_fusion" => Ok(Self::FaceFusionResult), + "tts_result" => Ok(Self::TtsResult), + "voice_conversion" => Ok(Self::VoiceConversionResult), + "zs_dataset" => Ok(Self::ZeroShotVoiceDataset), + "zs_voice" => Ok(Self::ZeroShotVoiceEmbedding), + "zs_tts_result" => Ok(Self::ZeroShotTtsResult), + "video_filter" => Ok(Self::VideoFilterResult), + "live_portrait" => Ok(Self::LivePortraitResult), + "studio_render" => Ok(Self::StudioRender), + "model_weights" => Ok(Self::ModelWeights), + "file_upload" => Ok(Self::FileUpload), + "mocap" => Ok(Self::MocapResult), + "workflow" => Ok(Self::WorkflowResult), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::MediaFile, + Self::LipsyncAnimationResult, + Self::FaceFusionResult, + Self::VideoFilterResult, + Self::LivePortraitResult, + Self::StudioRender, + Self::TtsResult, + Self::VoiceConversionResult, + Self::ZeroShotTtsResult, + Self::ZeroShotVoiceDataset, + Self::ZeroShotVoiceEmbedding, + Self::ModelWeights, + Self::FileUpload, + Self::MocapResult, + Self::WorkflowResult, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::generic_synthetic_ids::id_category::IdCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(IdCategory::MediaFile, "media_file"); + assert_serialization(IdCategory::LipsyncAnimationResult, "lipsync_animation"); + assert_serialization(IdCategory::FaceFusionResult, "face_fusion"); + assert_serialization(IdCategory::VideoFilterResult, "video_filter"); + assert_serialization(IdCategory::LivePortraitResult, "live_portrait"); + assert_serialization(IdCategory::StudioRender, "studio_render"); + assert_serialization(IdCategory::TtsResult, "tts_result"); + assert_serialization(IdCategory::VoiceConversionResult, "voice_conversion"); + assert_serialization(IdCategory::ZeroShotVoiceDataset, "zs_dataset"); + assert_serialization(IdCategory::ZeroShotVoiceEmbedding, "zs_voice"); + assert_serialization(IdCategory::ZeroShotTtsResult, "zs_tts_result"); + assert_serialization(IdCategory::ModelWeights, "model_weights"); + assert_serialization(IdCategory::FileUpload, "file_upload"); + assert_serialization(IdCategory::MocapResult, "mocap"); + assert_serialization(IdCategory::WorkflowResult, "workflow"); + } + + #[test] + fn to_str() { + assert_eq!(IdCategory::MediaFile.to_str(), "media_file"); + assert_eq!(IdCategory::LipsyncAnimationResult.to_str(), "lipsync_animation"); + assert_eq!(IdCategory::FaceFusionResult.to_str(), "face_fusion"); + assert_eq!(IdCategory::VideoFilterResult.to_str(), "video_filter"); + assert_eq!(IdCategory::LivePortraitResult.to_str(), "live_portrait"); + assert_eq!(IdCategory::StudioRender.to_str(), "studio_render"); + assert_eq!(IdCategory::TtsResult.to_str(), "tts_result"); + assert_eq!(IdCategory::VoiceConversionResult.to_str(), "voice_conversion"); + assert_eq!(IdCategory::ZeroShotVoiceDataset.to_str(), "zs_dataset"); + assert_eq!(IdCategory::ZeroShotVoiceEmbedding.to_str(), "zs_voice"); + assert_eq!(IdCategory::ZeroShotTtsResult.to_str(), "zs_tts_result"); + assert_eq!(IdCategory::ModelWeights.to_str(), "model_weights"); + assert_eq!(IdCategory::FileUpload.to_str(), "file_upload"); + assert_eq!(IdCategory::MocapResult.to_str(), "mocap"); + assert_eq!(IdCategory::WorkflowResult.to_str(), "workflow"); + } + + #[test] + fn from_str() { + assert_eq!(IdCategory::from_str("media_file").unwrap(), IdCategory::MediaFile); + assert_eq!(IdCategory::from_str("lipsync_animation").unwrap(), IdCategory::LipsyncAnimationResult); + assert_eq!(IdCategory::from_str("face_fusion").unwrap(), IdCategory::FaceFusionResult); + assert_eq!(IdCategory::from_str("video_filter").unwrap(), IdCategory::VideoFilterResult); + assert_eq!(IdCategory::from_str("live_portrait").unwrap(), IdCategory::LivePortraitResult); + assert_eq!(IdCategory::from_str("studio_render").unwrap(), IdCategory::StudioRender); + assert_eq!(IdCategory::from_str("tts_result").unwrap(), IdCategory::TtsResult); + assert_eq!(IdCategory::from_str("voice_conversion").unwrap(), IdCategory::VoiceConversionResult); + assert_eq!(IdCategory::from_str("zs_dataset").unwrap(), IdCategory::ZeroShotVoiceDataset); + assert_eq!(IdCategory::from_str("zs_voice").unwrap(), IdCategory::ZeroShotVoiceEmbedding); + assert_eq!(IdCategory::from_str("zs_tts_result").unwrap(), IdCategory::ZeroShotTtsResult); + assert_eq!(IdCategory::from_str("model_weights").unwrap(), IdCategory::ModelWeights); + assert_eq!(IdCategory::from_str("file_upload").unwrap(), IdCategory::FileUpload); + assert_eq!(IdCategory::from_str("mocap").unwrap(), IdCategory::MocapResult); + assert_eq!(IdCategory::from_str("workflow").unwrap(), IdCategory::WorkflowResult); + } + + #[test] + fn all_variants() { + // Static check + let mut variants = IdCategory::all_variants(); + assert_eq!(variants.len(), 15); + assert_eq!(variants.pop_first(), Some(IdCategory::MediaFile)); + assert_eq!(variants.pop_first(), Some(IdCategory::LipsyncAnimationResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::FaceFusionResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::VideoFilterResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::LivePortraitResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::StudioRender)); + assert_eq!(variants.pop_first(), Some(IdCategory::MocapResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::WorkflowResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::TtsResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::VoiceConversionResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::ZeroShotTtsResult)); + assert_eq!(variants.pop_first(), Some(IdCategory::ZeroShotVoiceDataset)); + assert_eq!(variants.pop_first(), Some(IdCategory::ZeroShotVoiceEmbedding)); + assert_eq!(variants.pop_first(), Some(IdCategory::ModelWeights)); + assert_eq!(variants.pop_first(), Some(IdCategory::FileUpload)); + assert_eq!(variants.pop_first(), None); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(IdCategory::all_variants().len(), IdCategory::iter().len()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(IdCategory::all_variants().len(), IdCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in IdCategory::all_variants() { + assert_eq!(variant, IdCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, IdCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, IdCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/generic_synthetic_ids/mod.rs b/crates/schema/public/enums/src/by_table/generic_synthetic_ids/mod.rs new file mode 100644 index 0000000000..17a815f89c --- /dev/null +++ b/crates/schema/public/enums/src/by_table/generic_synthetic_ids/mod.rs @@ -0,0 +1 @@ +pub mod id_category; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_animation_type.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_animation_type.rs new file mode 100644 index 0000000000..6f211b6125 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_animation_type.rs @@ -0,0 +1,245 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileAnimationType { + /// Body: No body animation or rig. + /// Face: Rig supporting ARKit face blendshapes, or ARKit data + ArKit, + + /// Body: Animation or rig for MikuMikuDance (MMD). + /// Face: No face animation. + MikuMikuDance, + + /// Body: Animation or rig for MikuMikuDance (MMD). + /// Face: Animation blendshapes for ARKit. + MikuMikuDanceArKit, + + /// Body: Animation or rig for Mixamo + /// Face: No face animation. + Mixamo, + + /// Body: Animation or rig for Mixamo + /// Face: Animation blendshapes for ARKit. + MixamoArKit, + + /// Body: Animation or rig by MocapNet + /// Face: No face animation. + MocapNet, + + /// Body: Animation or rig for MocapNet + /// Face: Animation blendshapes for ARKit. + MocapNetArKit, + + /// Body: Animation or rig by Move.ai + /// Face: No face animation. + MoveAi, + + /// Body: Animation or rig for Move.ai + /// Face: Animation blendshapes for ARKit. + MoveAiArKit, + + /// Body: Animation or rig for Rigify. + /// Face: No face animation. + Rigify, + + /// Body: Animation or rig for Rigify. + /// Face: Animation blendshapes for ARKit. + RigifyArKit, + + /// Body: Animation or rig for Rokoko. + /// Face: No face animation. + Rokoko, + + /// Body: Animation or rig for Rokoko. + /// Face: Animation blendshapes for ARKit. + RokokoArKit, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileAnimationType); +impl_mysql_enum_coders!(MediaFileAnimationType); +impl_mysql_from_row!(MediaFileAnimationType); + +/// NB: Legacy API for older code. +impl MediaFileAnimationType { + pub fn to_str(&self) -> &'static str { + match self { + Self::ArKit => "ar_kit", + Self::MikuMikuDance => "miku_miku_dance", + Self::MikuMikuDanceArKit => "miku_miku_dance_ar_kit", + Self::Mixamo => "mixamo", + Self::MixamoArKit => "mixamo_ar_kit", + Self::MocapNet => "mocap_net", + Self::MocapNetArKit => "mocap_net_ar_kit", + Self::MoveAi => "move_ai", + Self::MoveAiArKit => "move_ai_ar_kit", + Self::Rigify => "rigify", + Self::RigifyArKit => "rigify_ar_kit", + Self::Rokoko => "rokoko", + Self::RokokoArKit => "rokoko_ar_kit", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "ar_kit" => Ok(Self::ArKit), + "miku_miku_dance" => Ok(Self::MikuMikuDance), + "miku_miku_dance_ar_kit" => Ok(Self::MikuMikuDanceArKit), + "mixamo" => Ok(Self::Mixamo), + "mixamo_ar_kit" => Ok(Self::MixamoArKit), + "mocap_net" => Ok(Self::MocapNet), + "mocap_net_ar_kit" => Ok(Self::MocapNetArKit), + "move_ai" => Ok(Self::MoveAi), + "move_ai_ar_kit" => Ok(Self::MoveAiArKit), + "rigify" => Ok(Self::Rigify), + "rigify_ar_kit" => Ok(Self::RigifyArKit), + "rokoko" => Ok(Self::Rokoko), + "rokoko_ar_kit" => Ok(Self::RokokoArKit), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ArKit, + Self::MikuMikuDance, + Self::MikuMikuDanceArKit, + Self::Mixamo, + Self::MixamoArKit, + Self::MocapNet, + Self::MocapNetArKit, + Self::MoveAi, + Self::MoveAiArKit, + Self::Rigify, + Self::RigifyArKit, + Self::Rokoko, + Self::RokokoArKit, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_animation_type::MediaFileAnimationType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileAnimationType::ArKit, "ar_kit"); + assert_serialization(MediaFileAnimationType::MikuMikuDance, "miku_miku_dance"); + assert_serialization(MediaFileAnimationType::MikuMikuDanceArKit, "miku_miku_dance_ar_kit"); + assert_serialization(MediaFileAnimationType::Mixamo, "mixamo"); + assert_serialization(MediaFileAnimationType::MixamoArKit, "mixamo_ar_kit"); + assert_serialization(MediaFileAnimationType::MocapNet, "mocap_net"); + assert_serialization(MediaFileAnimationType::MocapNetArKit, "mocap_net_ar_kit"); + assert_serialization(MediaFileAnimationType::MoveAi, "move_ai"); + assert_serialization(MediaFileAnimationType::MoveAiArKit, "move_ai_ar_kit"); + assert_serialization(MediaFileAnimationType::Rigify, "rigify"); + assert_serialization(MediaFileAnimationType::RigifyArKit, "rigify_ar_kit"); + assert_serialization(MediaFileAnimationType::Rokoko, "rokoko"); + assert_serialization(MediaFileAnimationType::RokokoArKit, "rokoko_ar_kit"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaFileAnimationType::ArKit.to_str(), "ar_kit"); + assert_eq!(MediaFileAnimationType::MikuMikuDance.to_str(), "miku_miku_dance"); + assert_eq!(MediaFileAnimationType::MikuMikuDanceArKit.to_str(), "miku_miku_dance_ar_kit"); + assert_eq!(MediaFileAnimationType::Mixamo.to_str(), "mixamo"); + assert_eq!(MediaFileAnimationType::MixamoArKit.to_str(), "mixamo_ar_kit"); + assert_eq!(MediaFileAnimationType::MocapNet.to_str(), "mocap_net"); + assert_eq!(MediaFileAnimationType::MocapNetArKit.to_str(), "mocap_net_ar_kit"); + assert_eq!(MediaFileAnimationType::MoveAi.to_str(), "move_ai"); + assert_eq!(MediaFileAnimationType::MoveAiArKit.to_str(), "move_ai_ar_kit"); + assert_eq!(MediaFileAnimationType::Rigify.to_str(), "rigify"); + assert_eq!(MediaFileAnimationType::RigifyArKit.to_str(), "rigify_ar_kit"); + assert_eq!(MediaFileAnimationType::Rokoko.to_str(), "rokoko"); + assert_eq!(MediaFileAnimationType::RokokoArKit.to_str(), "rokoko_ar_kit"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaFileAnimationType::from_str("ar_kit").unwrap(), MediaFileAnimationType::ArKit); + assert_eq!(MediaFileAnimationType::from_str("miku_miku_dance").unwrap(), MediaFileAnimationType::MikuMikuDance); + assert_eq!(MediaFileAnimationType::from_str("miku_miku_dance_ar_kit").unwrap(), MediaFileAnimationType::MikuMikuDanceArKit); + assert_eq!(MediaFileAnimationType::from_str("mixamo").unwrap(), MediaFileAnimationType::Mixamo); + assert_eq!(MediaFileAnimationType::from_str("mixamo_ar_kit").unwrap(), MediaFileAnimationType::MixamoArKit); + assert_eq!(MediaFileAnimationType::from_str("mocap_net").unwrap(), MediaFileAnimationType::MocapNet); + assert_eq!(MediaFileAnimationType::from_str("mocap_net_ar_kit").unwrap(), MediaFileAnimationType::MocapNetArKit); + assert_eq!(MediaFileAnimationType::from_str("move_ai").unwrap(), MediaFileAnimationType::MoveAi); + assert_eq!(MediaFileAnimationType::from_str("move_ai_ar_kit").unwrap(), MediaFileAnimationType::MoveAiArKit); + assert_eq!(MediaFileAnimationType::from_str("rigify").unwrap(), MediaFileAnimationType::Rigify); + assert_eq!(MediaFileAnimationType::from_str("rigify_ar_kit").unwrap(), MediaFileAnimationType::RigifyArKit); + assert_eq!(MediaFileAnimationType::from_str("rokoko").unwrap(), MediaFileAnimationType::Rokoko); + assert_eq!(MediaFileAnimationType::from_str("rokoko_ar_kit").unwrap(), MediaFileAnimationType::RokokoArKit); + assert!(MediaFileAnimationType::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = MediaFileAnimationType::all_variants(); + assert_eq!(variants.len(), 13); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::ArKit)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MikuMikuDance)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MikuMikuDanceArKit)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::Mixamo)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MixamoArKit)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MocapNet)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MocapNetArKit)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MoveAi)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::MoveAiArKit)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::Rigify)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::RigifyArKit)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::Rokoko)); + assert_eq!(variants.pop_first(), Some(MediaFileAnimationType::RokokoArKit)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileAnimationType::all_variants().len(), MediaFileAnimationType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileAnimationType::all_variants() { + assert_eq!(variant, MediaFileAnimationType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileAnimationType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileAnimationType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in MediaFileAnimationType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_class.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_class.rs new file mode 100644 index 0000000000..7875b711d5 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_class.rs @@ -0,0 +1,199 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in a `VARCHAR(16)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileClass { + /// Unknown (default value) + /// This will be present until we migrate all old files. + Unknown, + + /// Audio files: wav, mp3, etc. + Audio, + + /// Image files: png, jpeg, etc. + Image, + + /// Video files: mp4, etc. + Video, + + /// 3D engine data: glb, gltf, etc. + /// Being split into `Mesh`, `Splat`, and `Project`. Do not write new records + /// with this value; it remains readable until existing rows are backfilled. + #[deprecated(note = "Split into Mesh, Splat, and Project. Read-only until backfilled.")] + Dimensional, + + /// 3D mesh assets: glb, gltf, fbx, obj, pmx, pmd, etc. + Mesh, + + /// Gaussian splats: spz, etc. + Splat, + + /// Internal Artcraft project documents (JSON): 3D editor scenes, mood boards, + /// graph workflows, video editor timelines, etc. + /// The specific kind is stored in `maybe_project_type`. + Project, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileClass); +impl_mysql_enum_coders!(MediaFileClass); +impl_mysql_from_row!(MediaFileClass); + +/// NB: Legacy API for older code. +impl MediaFileClass { + pub const fn to_str(&self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Audio => "audio", + Self::Image => "image", + Self::Video => "video", + Self::Dimensional => "dimensional", + Self::Mesh => "mesh", + Self::Splat => "splat", + Self::Project => "project", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "unknown" => Ok(Self::Unknown), + "audio" => Ok(Self::Audio), + "image" => Ok(Self::Image), + "video" => Ok(Self::Video), + "dimensional" => Ok(Self::Dimensional), + "mesh" => Ok(Self::Mesh), + "splat" => Ok(Self::Splat), + "project" => Ok(Self::Project), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Unknown, + Self::Audio, + Self::Image, + Self::Video, + Self::Dimensional, + Self::Mesh, + Self::Splat, + Self::Project, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_class::MediaFileClass; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileClass::Unknown, "unknown"); + assert_serialization(MediaFileClass::Audio, "audio"); + assert_serialization(MediaFileClass::Image, "image"); + assert_serialization(MediaFileClass::Video, "video"); + assert_serialization(MediaFileClass::Dimensional, "dimensional"); + assert_serialization(MediaFileClass::Mesh, "mesh"); + assert_serialization(MediaFileClass::Splat, "splat"); + assert_serialization(MediaFileClass::Project, "project"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(MediaFileClass::Unknown.to_str(), "unknown"); + assert_eq!(MediaFileClass::Audio.to_str(), "audio"); + assert_eq!(MediaFileClass::Image.to_str(), "image"); + assert_eq!(MediaFileClass::Video.to_str(), "video"); + assert_eq!(MediaFileClass::Dimensional.to_str(), "dimensional"); + assert_eq!(MediaFileClass::Mesh.to_str(), "mesh"); + assert_eq!(MediaFileClass::Splat.to_str(), "splat"); + assert_eq!(MediaFileClass::Project.to_str(), "project"); + } + + #[test] + fn from_str() { + assert_eq!(MediaFileClass::from_str("unknown").unwrap(), MediaFileClass::Unknown); + assert_eq!(MediaFileClass::from_str("audio").unwrap(), MediaFileClass::Audio); + assert_eq!(MediaFileClass::from_str("image").unwrap(), MediaFileClass::Image); + assert_eq!(MediaFileClass::from_str("video").unwrap(), MediaFileClass::Video); + assert_eq!(MediaFileClass::from_str("dimensional").unwrap(), MediaFileClass::Dimensional); + assert_eq!(MediaFileClass::from_str("mesh").unwrap(), MediaFileClass::Mesh); + assert_eq!(MediaFileClass::from_str("splat").unwrap(), MediaFileClass::Splat); + assert_eq!(MediaFileClass::from_str("project").unwrap(), MediaFileClass::Project); + assert!(MediaFileClass::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = MediaFileClass::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Unknown)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Audio)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Image)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Video)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Dimensional)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Mesh)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Splat)); + assert_eq!(variants.pop_first(), Some(MediaFileClass::Project)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileClass::all_variants().len(), MediaFileClass::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileClass::all_variants() { + assert_eq!(variant, MediaFileClass::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileClass::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileClass::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in MediaFileClass::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_engine_category.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_engine_category.rs new file mode 100644 index 0000000000..6b1bbf660b --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_engine_category.rs @@ -0,0 +1,219 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileEngineCategory { + /// A 3D scene full of objects, characters, animations, etc. + Scene, + + /// A 3D character model. + Character, + + /// A 3D non-humanoid model, e.g. dragon, wolf, slime creature, etc. + /// These may or may not have a skeleton that can be animated with IK/FK. + Creature, + + /// A 3D skeletal animation sequence that can be applied to a character, + /// creature, etc. Typically, these use Mixamo or Move.ai. + Animation, + + /// A facial blend shape / shape key animation. Currently, these + /// are 100% ARKit. + Expression, + + /// A large 3D object that can be used as a backdrop for a scene, + /// a.k.a. "set", "stage", etc. These are typically self-contained + /// locations, buildings, or environments. + Location, + + /// All items used to add atmosphere to a scene, e.g. furniture, plants, + /// vehicles, radios, and certain props. (We'll reserve the word props for + /// "hand props" later.) + SetDressing, + + /// A 3D object that doesn't fit with the other types. + Object, + + /// A 3D skybox. + Skybox, + + /// An Image Plane (texture that spawns as an object) + ImagePlane, + + /// A Video Plane (texture that spawns as an object) + VideoPlane, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileEngineCategory); +impl_mysql_enum_coders!(MediaFileEngineCategory); +impl_mysql_from_row!(MediaFileEngineCategory); + +/// NB: Legacy API for older code. +impl MediaFileEngineCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::Scene => "scene", + Self::Character => "character", + Self::Creature => "creature", + Self::Animation => "animation", + Self::Expression => "expression", + Self::Location => "location", + Self::SetDressing => "set_dressing", + Self::Object => "object", + Self::Skybox => "skybox", + Self::ImagePlane => "image_plane", + Self::VideoPlane => "video_plane", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "scene" => Ok(Self::Scene), + "character" => Ok(Self::Character), + "creature" => Ok(Self::Creature), + "animation" => Ok(Self::Animation), + "expression" => Ok(Self::Expression), + "location" => Ok(Self::Location), + "set_dressing" => Ok(Self::SetDressing), + "object" => Ok(Self::Object), + "skybox" => Ok(Self::Skybox), + "image_plane" => Ok(Self::ImagePlane), + "video_plane" => Ok(Self::VideoPlane), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Scene, + Self::Character, + Self::Creature, + Self::Animation, + Self::Expression, + Self::Location, + Self::SetDressing, + Self::Object, + Self::Skybox, + Self::ImagePlane, + Self::VideoPlane, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_engine_category::MediaFileEngineCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileEngineCategory::Scene, "scene"); + assert_serialization(MediaFileEngineCategory::Character, "character"); + assert_serialization(MediaFileEngineCategory::Creature, "creature"); + assert_serialization(MediaFileEngineCategory::Animation, "animation"); + assert_serialization(MediaFileEngineCategory::Expression, "expression"); + assert_serialization(MediaFileEngineCategory::Location, "location"); + assert_serialization(MediaFileEngineCategory::SetDressing, "set_dressing"); + assert_serialization(MediaFileEngineCategory::Object, "object"); + assert_serialization(MediaFileEngineCategory::Skybox, "skybox"); + assert_serialization(MediaFileEngineCategory::ImagePlane, "image_plane"); + assert_serialization(MediaFileEngineCategory::VideoPlane, "video_plane"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaFileEngineCategory::Scene.to_str(), "scene"); + assert_eq!(MediaFileEngineCategory::Character.to_str(), "character"); + assert_eq!(MediaFileEngineCategory::Creature.to_str(), "creature"); + assert_eq!(MediaFileEngineCategory::Animation.to_str(), "animation"); + assert_eq!(MediaFileEngineCategory::Expression.to_str(), "expression"); + assert_eq!(MediaFileEngineCategory::Location.to_str(), "location"); + assert_eq!(MediaFileEngineCategory::SetDressing.to_str(), "set_dressing"); + assert_eq!(MediaFileEngineCategory::Object.to_str(), "object"); + assert_eq!(MediaFileEngineCategory::Skybox.to_str(), "skybox"); + assert_eq!(MediaFileEngineCategory::ImagePlane.to_str(), "image_plane"); + assert_eq!(MediaFileEngineCategory::VideoPlane.to_str(), "video_plane"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaFileEngineCategory::from_str("scene").unwrap(), MediaFileEngineCategory::Scene); + assert_eq!(MediaFileEngineCategory::from_str("character").unwrap(), MediaFileEngineCategory::Character); + assert_eq!(MediaFileEngineCategory::from_str("creature").unwrap(), MediaFileEngineCategory::Creature); + assert_eq!(MediaFileEngineCategory::from_str("animation").unwrap(), MediaFileEngineCategory::Animation); + assert_eq!(MediaFileEngineCategory::from_str("expression").unwrap(), MediaFileEngineCategory::Expression); + assert_eq!(MediaFileEngineCategory::from_str("location").unwrap(), MediaFileEngineCategory::Location); + assert_eq!(MediaFileEngineCategory::from_str("set_dressing").unwrap(), MediaFileEngineCategory::SetDressing); + assert_eq!(MediaFileEngineCategory::from_str("object").unwrap(), MediaFileEngineCategory::Object); + assert_eq!(MediaFileEngineCategory::from_str("skybox").unwrap(), MediaFileEngineCategory::Skybox); + assert_eq!(MediaFileEngineCategory::from_str("image_plane").unwrap(), MediaFileEngineCategory::ImagePlane); + assert_eq!(MediaFileEngineCategory::from_str("video_plane").unwrap(), MediaFileEngineCategory::VideoPlane); + assert!(MediaFileEngineCategory::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = MediaFileEngineCategory::all_variants(); + assert_eq!(variants.len(), 11); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Scene)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Character)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Creature)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Animation)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Expression)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Location)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::SetDressing)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Object)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::Skybox)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::ImagePlane)); + assert_eq!(variants.pop_first(), Some(MediaFileEngineCategory::VideoPlane)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileEngineCategory::all_variants().len(), MediaFileEngineCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileEngineCategory::all_variants() { + assert_eq!(variant, MediaFileEngineCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileEngineCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileEngineCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in MediaFileEngineCategory::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_origin_category.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_origin_category.rs new file mode 100644 index 0000000000..dc5a55c6f3 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_origin_category.rs @@ -0,0 +1,180 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileOriginCategory { + /// ML model inference output - uploaded models or zero shot. + Inference, + + /// Third-party inference output (e.g. FAL, Replicate). + /// The file was generated by a third-party AI service and uploaded by the client. + #[serde(rename = "inference3rd")] + ThirdPartyInference, + + /// Processed results - (we don't have these systems yet, but eg. trim, transcode, etc). + Processed, + + /// User uploaded files (from their filesystem) + Upload, + + /// User uploaded files recorded directly from their device (browser, mobile), typically using device APIs. + DeviceApi, + + /// From Storyteller Studio Engine + #[deprecated(note="This db field should only denote file provenance, not the product.")] + #[serde(rename = "studio")] + StorytellerStudio, + + /// From Storyteller Studio Engine + #[deprecated(note = "DO NOT USE. Originally deprecated in favor of `StorytellerStudio`.")] + StoryEngine, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileOriginCategory); +impl_mysql_enum_coders!(MediaFileOriginCategory); +impl_mysql_from_row!(MediaFileOriginCategory); + +/// NB: Legacy API for older code. +impl MediaFileOriginCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::Inference => "inference", + Self::ThirdPartyInference => "inference3rd", + Self::Processed => "processed", + Self::Upload => "upload", + Self::DeviceApi => "device_api", + Self::StorytellerStudio => "studio", + Self::StoryEngine => "story_engine", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "inference" => Ok(Self::Inference), + "inference3rd" => Ok(Self::ThirdPartyInference), + "processed" => Ok(Self::Processed), + "upload" => Ok(Self::Upload), + "device_api" => Ok(Self::DeviceApi), + "studio" => Ok(Self::StorytellerStudio), + "story_engine" => Ok(Self::StoryEngine), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Inference, + Self::ThirdPartyInference, + Self::Processed, + Self::Upload, + Self::DeviceApi, + Self::StorytellerStudio, + Self::StoryEngine, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileOriginCategory::Inference, "inference"); + assert_serialization(MediaFileOriginCategory::ThirdPartyInference, "inference3rd"); + assert_serialization(MediaFileOriginCategory::Processed, "processed"); + assert_serialization(MediaFileOriginCategory::Upload, "upload"); + assert_serialization(MediaFileOriginCategory::DeviceApi, "device_api"); + assert_serialization(MediaFileOriginCategory::StorytellerStudio, "studio"); + assert_serialization(MediaFileOriginCategory::StoryEngine, "story_engine"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaFileOriginCategory::Inference.to_str(), "inference"); + assert_eq!(MediaFileOriginCategory::ThirdPartyInference.to_str(), "inference3rd"); + assert_eq!(MediaFileOriginCategory::Processed.to_str(), "processed"); + assert_eq!(MediaFileOriginCategory::Upload.to_str(), "upload"); + assert_eq!(MediaFileOriginCategory::DeviceApi.to_str(), "device_api"); + assert_eq!(MediaFileOriginCategory::StorytellerStudio.to_str(), "studio"); + assert_eq!(MediaFileOriginCategory::StoryEngine.to_str(), "story_engine"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaFileOriginCategory::from_str("inference").unwrap(), MediaFileOriginCategory::Inference); + assert_eq!(MediaFileOriginCategory::from_str("inference3rd").unwrap(), MediaFileOriginCategory::ThirdPartyInference); + assert_eq!(MediaFileOriginCategory::from_str("processed").unwrap(), MediaFileOriginCategory::Processed); + assert_eq!(MediaFileOriginCategory::from_str("upload").unwrap(), MediaFileOriginCategory::Upload); + assert_eq!(MediaFileOriginCategory::from_str("device_api").unwrap(), MediaFileOriginCategory::DeviceApi); + assert_eq!(MediaFileOriginCategory::from_str("studio").unwrap(), MediaFileOriginCategory::StorytellerStudio); + assert_eq!(MediaFileOriginCategory::from_str("story_engine").unwrap(), MediaFileOriginCategory::StoryEngine); + assert!(MediaFileOriginCategory::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = MediaFileOriginCategory::all_variants(); + assert_eq!(variants.len(), 7); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::Inference)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::ThirdPartyInference)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::Processed)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::Upload)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::DeviceApi)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::StorytellerStudio)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::StoryEngine)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileOriginCategory::all_variants().len(), MediaFileOriginCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileOriginCategory::all_variants() { + assert_eq!(variant, MediaFileOriginCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileOriginCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileOriginCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in MediaFileOriginCategory::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_origin_model_type.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_origin_model_type.rs new file mode 100644 index 0000000000..e982157a93 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_origin_model_type.rs @@ -0,0 +1,277 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileOriginModelType { + #[serde(rename = "face_fusion")] + FaceFusion, + + #[serde(rename = "f5_tts")] + F5TTS, + + #[serde(rename = "live_portrait")] + LivePortrait, + + /// RVC (v2) voice conversion models + #[serde(rename = "rvc_v2")] + RvcV2, + + /// SadTalker -- v1, we may add another enum value for future versions + #[serde(rename = "sad_talker")] + SadTalker, + + /// so-vits-svc voice conversion models + #[serde(rename = "so_vits_svc")] + SoVitsSvc, + + #[serde(rename = "tacotron2")] + Tacotron2, + + #[serde(rename = "mocap_net")] + MocapNet, + + #[serde(rename = "seed_vc")] + SeedVc, + + #[serde(rename = "styletts2")] + StyleTTS2, + + #[serde(rename = "stable_diffusion_1_5")] + StableDiffusion15, + + #[serde(rename = "gpt_sovits")] + GptSovits, + + #[serde(rename = "studio")] + StorytellerStudio, + + #[serde(rename = "studio_ig")] + StorytellerStudioImageGen, + + #[serde(rename = "vst")] + VideoStyleTransfer, + + #[deprecated(note = "This is not a model type!")] + #[serde(rename = "comfy_ui")] + ComfyUi, + + #[deprecated(note = "We don't use this anymore")] + #[serde(rename = "vall_e_x")] + VallEX, + + #[deprecated(note = "We don't use this anymore")] + #[serde(rename = "rerender")] + Rerender, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileOriginModelType); +impl_mysql_enum_coders!(MediaFileOriginModelType); +impl_mysql_from_row!(MediaFileOriginModelType); + +/// NB: Legacy API for older code. +impl MediaFileOriginModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::FaceFusion => "face_fusion", + Self::F5TTS => "f5_tts", + Self::LivePortrait => "live_portrait", + Self::RvcV2 => "rvc_v2", + Self::SadTalker => "sad_talker", + Self::SeedVc => "seed_vc", + Self::SoVitsSvc => "so_vits_svc", + Self::Tacotron2 => "tacotron2", + Self::MocapNet => "mocap_net", + Self::StyleTTS2 => "styletts2", + Self::StableDiffusion15 => "stable_diffusion_1_5", + Self::StorytellerStudioImageGen => "studio_ig", + Self::GptSovits => "gpt_sovits", + Self::StorytellerStudio => "studio", + Self::VideoStyleTransfer => "vst", + Self::ComfyUi => "comfy_ui", + Self::VallEX => "vall_e_x", + Self::Rerender => "rerender", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "face_fusion" => Ok(Self::FaceFusion), + "f5_tts" => Ok(Self::F5TTS), + "live_portrait" => Ok(Self::LivePortrait), + "rvc_v2" => Ok(Self::RvcV2), + "sad_talker" => Ok(Self::SadTalker), + "seed_vc" => Ok(Self::SeedVc), + "so_vits_svc" => Ok(Self::SoVitsSvc), + "tacotron2" => Ok(Self::Tacotron2), + "mocap_net" => Ok(Self::MocapNet), + "styletts2" => Ok(Self::StyleTTS2), + "stable_diffusion_1_5" => Ok(Self::StableDiffusion15), + "studio_ig" => Ok(Self::StorytellerStudioImageGen), + "gpt_sovits" => Ok(Self::GptSovits), + "studio" => Ok(Self::StorytellerStudio), + "vst" => Ok(Self::VideoStyleTransfer), + "comfy_ui" => Ok(Self::ComfyUi), + "vall_e_x" => Ok(Self::VallEX), + "rerender" => Ok(Self::Rerender), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::FaceFusion, + Self::F5TTS, + Self::LivePortrait, + Self::RvcV2, + Self::SadTalker, + Self::SeedVc, + Self::SoVitsSvc, + Self::Tacotron2, + Self::MocapNet, + Self::StyleTTS2, + Self::StableDiffusion15, + Self::GptSovits, + Self::StorytellerStudio, + Self::StorytellerStudioImageGen, + Self::VideoStyleTransfer, + Self::ComfyUi, + Self::VallEX, + Self::Rerender, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileOriginModelType::FaceFusion, "face_fusion"); + assert_serialization(MediaFileOriginModelType::SadTalker, "sad_talker"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaFileOriginModelType::FaceFusion.to_str(), "face_fusion"); + assert_eq!(MediaFileOriginModelType::F5TTS.to_str(), "f5_tts"); + assert_eq!(MediaFileOriginModelType::LivePortrait.to_str(), "live_portrait"); + assert_eq!(MediaFileOriginModelType::RvcV2.to_str(), "rvc_v2"); + assert_eq!(MediaFileOriginModelType::SadTalker.to_str(), "sad_talker"); + assert_eq!(MediaFileOriginModelType::SeedVc.to_str(), "seed_vc"); + assert_eq!(MediaFileOriginModelType::SoVitsSvc.to_str(), "so_vits_svc"); + assert_eq!(MediaFileOriginModelType::Tacotron2.to_str(), "tacotron2"); + assert_eq!(MediaFileOriginModelType::MocapNet.to_str(), "mocap_net"); + assert_eq!(MediaFileOriginModelType::StyleTTS2.to_str(), "styletts2"); + assert_eq!(MediaFileOriginModelType::StableDiffusion15.to_str(), "stable_diffusion_1_5"); + assert_eq!(MediaFileOriginModelType::GptSovits.to_str(), "gpt_sovits"); + assert_eq!(MediaFileOriginModelType::StorytellerStudio.to_str(), "studio"); + assert_eq!(MediaFileOriginModelType::VideoStyleTransfer.to_str(), "vst"); + assert_eq!(MediaFileOriginModelType::ComfyUi.to_str(), "comfy_ui"); + assert_eq!(MediaFileOriginModelType::VallEX.to_str(), "vall_e_x"); + assert_eq!(MediaFileOriginModelType::Rerender.to_str(), "rerender"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaFileOriginModelType::from_str("face_fusion").unwrap(), MediaFileOriginModelType::FaceFusion); + assert_eq!(MediaFileOriginModelType::from_str("f5_tts").unwrap(), MediaFileOriginModelType::F5TTS); + assert_eq!(MediaFileOriginModelType::from_str("live_portrait").unwrap(), MediaFileOriginModelType::LivePortrait); + assert_eq!(MediaFileOriginModelType::from_str("rvc_v2").unwrap(), MediaFileOriginModelType::RvcV2); + assert_eq!(MediaFileOriginModelType::from_str("sad_talker").unwrap(), MediaFileOriginModelType::SadTalker); + assert_eq!(MediaFileOriginModelType::from_str("seed_vc").unwrap(), MediaFileOriginModelType::SeedVc); + assert_eq!(MediaFileOriginModelType::from_str("so_vits_svc").unwrap(), MediaFileOriginModelType::SoVitsSvc); + assert_eq!(MediaFileOriginModelType::from_str("tacotron2").unwrap(), MediaFileOriginModelType::Tacotron2); + assert_eq!(MediaFileOriginModelType::from_str("mocap_net").unwrap(), MediaFileOriginModelType::MocapNet); + assert_eq!(MediaFileOriginModelType::from_str("styletts2").unwrap(), MediaFileOriginModelType::StyleTTS2); + assert_eq!(MediaFileOriginModelType::from_str("stable_diffusion_1_5").unwrap(), MediaFileOriginModelType::StableDiffusion15); + assert_eq!(MediaFileOriginModelType::from_str("gpt_sovits").unwrap(), MediaFileOriginModelType::GptSovits); + assert_eq!(MediaFileOriginModelType::from_str("studio").unwrap(), MediaFileOriginModelType::StorytellerStudio); + assert_eq!(MediaFileOriginModelType::from_str("vst").unwrap(), MediaFileOriginModelType::VideoStyleTransfer); + assert_eq!(MediaFileOriginModelType::from_str("comfy_ui").unwrap(), MediaFileOriginModelType::ComfyUi); + assert_eq!(MediaFileOriginModelType::from_str("vall_e_x").unwrap(), MediaFileOriginModelType::VallEX); + assert_eq!(MediaFileOriginModelType::from_str("rerender").unwrap(), MediaFileOriginModelType::Rerender); + assert!(MediaFileOriginModelType::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = MediaFileOriginModelType::all_variants(); + assert_eq!(variants.len(), 18); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::FaceFusion)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::F5TTS)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::LivePortrait)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::RvcV2)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SadTalker)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SoVitsSvc)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::Tacotron2)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::MocapNet)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::SeedVc)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StyleTTS2)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StableDiffusion15)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::GptSovits)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StorytellerStudio)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::StorytellerStudioImageGen)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::VideoStyleTransfer)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::ComfyUi)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::VallEX)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::Rerender)); + assert_eq!(variants.pop_first(), None); + } + } + + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileOriginModelType::all_variants().len(), MediaFileOriginModelType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileOriginModelType::all_variants() { + assert_eq!(variant, MediaFileOriginModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileOriginModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileOriginModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + // NB: The media_files table has allocated width for VARCHAR(32), but let's slim it down to 24. + const MAX_LENGTH : usize = 24; + for variant in MediaFileOriginModelType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_origin_product_category.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_origin_product_category.rs new file mode 100644 index 0000000000..fea8a6254f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_origin_product_category.rs @@ -0,0 +1,295 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in `VARCHAR(16)` field `origin_product_category`. +/// +/// This value indicates what product originally created the media file. (Not the ML model or +/// user upload process.) This will let us scope media files to the product that generated them +/// and filter them out of unrelated products if necessary (eg. a user probably doesn't want +/// "Voice Designer" dataset samples in a video generation flow.) +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum MediaFileOriginProductCategory { + /// Unknown which product is attached to the file (generated the file, the file was + /// uploaded on behalf of, etc.) + #[serde(rename = "unknown")] + Unknown, + + /// Media files created by (or uploaded for) the Face Animator product. + /// The underlying model could be SadTalker, Wav2Lip, or some future model + #[serde(rename = "face_animator")] + FaceAnimator, + + /// Media files created by Face Fusion (lipsync) + #[serde(rename = "face_fusion")] + FaceFusion, + + /// Media files created by Face Mirror (currently powered by Live Portrait) + #[serde(rename = "face_mirror")] + FaceMirror, + + /// Video style transfer + #[serde(rename = "vst")] + VideoStyleTransfer, + + /// Image Studio + #[serde(rename = "image_studio")] + ImageStudio, + + /// Storyteller Studio + #[serde(rename = "studio")] + StorytellerStudio, + + /// Text to speech (Tacotron2, not voice designer / VallE-X) + #[serde(rename = "tts")] + TextToSpeech, + + /// Voice conversion (either RVC or SVC) + #[serde(rename = "voice_conversion")] + VoiceConversion, + + /// Media files created by (or uploaded for) the Zero Shot voice product. + #[serde(rename = "zs_voice")] + ZeroShotVoice, + + // Mocap + #[serde(rename = "mocap")] + Mocap, + + #[serde(rename = "image_gen")] + ImageGeneration, + + #[serde(rename = "video_gen")] + VideoGeneration, + + #[serde(rename = "video_edit")] + VideoEdit, + + #[serde(rename = "world_gen")] + WorldGeneration, + + // Media files for video filters + #[deprecated(note = "This isn't relevant product surface area anymore")] + #[serde(rename = "video_filter")] + VideoFilter, + + // Workflow + #[deprecated(note = "Use studio and VST instead.")] + #[serde(rename = "workflow")] + Workflow, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileOriginProductCategory); +impl_mysql_enum_coders!(MediaFileOriginProductCategory); +impl_mysql_from_row!(MediaFileOriginProductCategory); + +/// NB: Legacy API for older code. +impl MediaFileOriginProductCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::FaceAnimator => "face_animator", + Self::FaceFusion => "face_fusion", + Self::FaceMirror => "face_mirror", + Self::VideoStyleTransfer => "vst", + Self::ImageStudio => "image_studio", + Self::StorytellerStudio => "studio", + Self::TextToSpeech => "tts", + Self::VoiceConversion => "voice_conversion", + Self::ZeroShotVoice => "zs_voice", + Self::Mocap => "mocap", + Self::ImageGeneration => "image_gen", + Self::VideoGeneration => "video_gen", + Self::VideoEdit => "video_edit", + Self::WorldGeneration => "world_gen", + Self::VideoFilter => "video_filter", + Self::Workflow => "workflow", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "unknown" => Ok(Self::Unknown), + "face_animator" => Ok(Self::FaceAnimator), + "face_fusion" => Ok(Self::FaceFusion), + "face_mirror" => Ok(Self::FaceMirror), + "vst" => Ok(Self::VideoStyleTransfer), + "image_studio" => Ok(Self::ImageStudio), + "studio" => Ok(Self::StorytellerStudio), + "tts" => Ok(Self::TextToSpeech), + "voice_conversion" => Ok(Self::VoiceConversion), + "zs_voice" => Ok(Self::ZeroShotVoice), + "mocap" => Ok(Self::Mocap), + "image_gen" => Ok(Self::ImageGeneration), + "video_gen" => Ok(Self::VideoGeneration), + "video_edit" => Ok(Self::VideoEdit), + "world_gen" => Ok(Self::WorldGeneration), + "video_filter" => Ok(Self::VideoFilter), + "workflow" => Ok(Self::Workflow), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Unknown, + Self::FaceAnimator, + Self::FaceFusion, + Self::FaceMirror, + Self::VideoStyleTransfer, + Self::ImageStudio, + Self::StorytellerStudio, + Self::TextToSpeech, + Self::VoiceConversion, + Self::ZeroShotVoice, + Self::Mocap, + Self::ImageGeneration, + Self::VideoGeneration, + Self::VideoEdit, + Self::WorldGeneration, + Self::VideoFilter, + Self::Workflow, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileOriginProductCategory::Unknown, "unknown"); + assert_serialization(MediaFileOriginProductCategory::FaceAnimator, "face_animator"); + assert_serialization(MediaFileOriginProductCategory::FaceFusion, "face_fusion"); + assert_serialization(MediaFileOriginProductCategory::FaceMirror, "face_mirror"); + assert_serialization(MediaFileOriginProductCategory::VideoStyleTransfer, "vst"); + assert_serialization(MediaFileOriginProductCategory::ImageStudio, "image_studio"); + assert_serialization(MediaFileOriginProductCategory::StorytellerStudio, "studio"); + assert_serialization(MediaFileOriginProductCategory::TextToSpeech, "tts"); + assert_serialization(MediaFileOriginProductCategory::VoiceConversion, "voice_conversion"); + assert_serialization(MediaFileOriginProductCategory::ZeroShotVoice, "zs_voice"); + assert_serialization(MediaFileOriginProductCategory::Mocap, "mocap"); + assert_serialization(MediaFileOriginProductCategory::ImageGeneration, "image_gen"); + assert_serialization(MediaFileOriginProductCategory::VideoGeneration, "video_gen"); + assert_serialization(MediaFileOriginProductCategory::VideoEdit, "video_edit"); + assert_serialization(MediaFileOriginProductCategory::WorldGeneration, "world_gen"); + assert_serialization(MediaFileOriginProductCategory::VideoFilter, "video_filter"); + assert_serialization(MediaFileOriginProductCategory::Workflow, "workflow"); + } + + #[test] + fn to_str() { + assert_eq!(MediaFileOriginProductCategory::Unknown.to_str(), "unknown"); + assert_eq!(MediaFileOriginProductCategory::FaceAnimator.to_str(), "face_animator"); + assert_eq!(MediaFileOriginProductCategory::FaceFusion.to_str(), "face_fusion"); + assert_eq!(MediaFileOriginProductCategory::FaceMirror.to_str(), "face_mirror"); + assert_eq!(MediaFileOriginProductCategory::VideoStyleTransfer.to_str(), "vst"); + assert_eq!(MediaFileOriginProductCategory::ImageStudio.to_str(), "image_studio"); + assert_eq!(MediaFileOriginProductCategory::StorytellerStudio.to_str(), "studio"); + assert_eq!(MediaFileOriginProductCategory::TextToSpeech.to_str(), "tts"); + assert_eq!(MediaFileOriginProductCategory::VoiceConversion.to_str(), "voice_conversion"); + assert_eq!(MediaFileOriginProductCategory::ZeroShotVoice.to_str(), "zs_voice"); + assert_eq!(MediaFileOriginProductCategory::Mocap.to_str(), "mocap"); + assert_eq!(MediaFileOriginProductCategory::ImageGeneration.to_str(), "image_gen"); + assert_eq!(MediaFileOriginProductCategory::VideoGeneration.to_str(), "video_gen"); + assert_eq!(MediaFileOriginProductCategory::VideoEdit.to_str(), "video_edit"); + assert_eq!(MediaFileOriginProductCategory::WorldGeneration.to_str(), "world_gen"); + assert_eq!(MediaFileOriginProductCategory::VideoFilter.to_str(), "video_filter"); + assert_eq!(MediaFileOriginProductCategory::Workflow.to_str(), "workflow"); + } + + #[test] + fn from_str() { + assert_eq!(MediaFileOriginProductCategory::from_str("unknown").unwrap(), MediaFileOriginProductCategory::Unknown); + assert_eq!(MediaFileOriginProductCategory::from_str("face_animator").unwrap(), MediaFileOriginProductCategory::FaceAnimator); + assert_eq!(MediaFileOriginProductCategory::from_str("face_fusion").unwrap(), MediaFileOriginProductCategory::FaceFusion); + assert_eq!(MediaFileOriginProductCategory::from_str("face_mirror").unwrap(), MediaFileOriginProductCategory::FaceMirror); + assert_eq!(MediaFileOriginProductCategory::from_str("vst").unwrap(), MediaFileOriginProductCategory::VideoStyleTransfer); + assert_eq!(MediaFileOriginProductCategory::from_str("image_studio").unwrap(), MediaFileOriginProductCategory::ImageStudio); + assert_eq!(MediaFileOriginProductCategory::from_str("studio").unwrap(), MediaFileOriginProductCategory::StorytellerStudio); + assert_eq!(MediaFileOriginProductCategory::from_str("tts").unwrap(), MediaFileOriginProductCategory::TextToSpeech); + assert_eq!(MediaFileOriginProductCategory::from_str("voice_conversion").unwrap(), MediaFileOriginProductCategory::VoiceConversion); + assert_eq!(MediaFileOriginProductCategory::from_str("zs_voice").unwrap(), MediaFileOriginProductCategory::ZeroShotVoice); + assert_eq!(MediaFileOriginProductCategory::from_str("mocap").unwrap(), MediaFileOriginProductCategory::Mocap); + assert_eq!(MediaFileOriginProductCategory::from_str("image_gen").unwrap(),MediaFileOriginProductCategory::ImageGeneration); + assert_eq!(MediaFileOriginProductCategory::from_str("video_gen").unwrap(), MediaFileOriginProductCategory::VideoGeneration); + assert_eq!(MediaFileOriginProductCategory::from_str("video_edit").unwrap(), MediaFileOriginProductCategory::VideoEdit); + assert_eq!(MediaFileOriginProductCategory::from_str("world_gen").unwrap(), MediaFileOriginProductCategory::WorldGeneration); + assert_eq!(MediaFileOriginProductCategory::from_str("video_filter").unwrap(), MediaFileOriginProductCategory::VideoFilter); + assert_eq!(MediaFileOriginProductCategory::from_str("workflow").unwrap(), MediaFileOriginProductCategory::Workflow); + } + + #[test] + fn all_variants() { + let mut variants = MediaFileOriginProductCategory::all_variants(); + assert_eq!(variants.len(), 17); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceMirror)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoStyleTransfer)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageStudio)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::StorytellerStudio)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::TextToSpeech)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VoiceConversion)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ZeroShotVoice)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Mocap)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageGeneration)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoGeneration)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoEdit)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::WorldGeneration)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoFilter)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Workflow)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileOriginProductCategory::all_variants().len(), MediaFileOriginProductCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileOriginProductCategory::all_variants() { + assert_eq!(variant, MediaFileOriginProductCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileOriginProductCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileOriginProductCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in MediaFileOriginProductCategory::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_project_type.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_project_type.rs new file mode 100644 index 0000000000..f020c50a44 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_project_type.rs @@ -0,0 +1,168 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in the nullable `maybe_project_type` +/// VARCHAR(32) field. +/// +/// Identifies which kind of internal Artcraft project document a media file is. +/// Only set when `media_class = 'project'`. The file format lives in +/// `media_type` (`json` / legacy `scene_json`); the JSON payload carries its +/// own schema version. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileProjectType { + /// 3D editor scene descriptor: objects with locations, skyboxes, animations, + /// etc. — a full editable 3D scene. + /// NB: serde's snake_case renames this to "scene3d" (no underscore before + /// the digit), so the rename must be explicit. + #[serde(rename = "scene_3d")] + Scene3d, + + /// Mood board: a collection of images on a lightweight graph. + MoodBoard, + + /// Graph workflow: a node-graph pipeline of image and video generation steps. + Workflow, + + /// Video editor project: a nonlinear video editing timeline. + VideoTimeline, + + /// 2D editor project: a 2D image editing document. + /// NB: serde's snake_case renames this to "editor2d" (no underscore before + /// the digit), so the rename must be explicit. + #[serde(rename = "editor_2d")] + Editor2d, +} + +impl_enum_display_and_debug_using_to_str!(MediaFileProjectType); +impl_mysql_enum_coders!(MediaFileProjectType); +impl_mysql_from_row!(MediaFileProjectType); + +impl MediaFileProjectType { + pub const fn to_str(&self) -> &'static str { + match self { + Self::Scene3d => "scene_3d", + Self::MoodBoard => "mood_board", + Self::Workflow => "workflow", + Self::VideoTimeline => "video_timeline", + Self::Editor2d => "editor_2d", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "scene_3d" => Ok(Self::Scene3d), + "mood_board" => Ok(Self::MoodBoard), + "workflow" => Ok(Self::Workflow), + "video_timeline" => Ok(Self::VideoTimeline), + "editor_2d" => Ok(Self::Editor2d), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + BTreeSet::from([ + Self::Scene3d, + Self::MoodBoard, + Self::Workflow, + Self::VideoTimeline, + Self::Editor2d, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_project_type::MediaFileProjectType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileProjectType::Scene3d, "scene_3d"); + assert_serialization(MediaFileProjectType::MoodBoard, "mood_board"); + assert_serialization(MediaFileProjectType::Workflow, "workflow"); + assert_serialization(MediaFileProjectType::VideoTimeline, "video_timeline"); + assert_serialization(MediaFileProjectType::Editor2d, "editor_2d"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(MediaFileProjectType::Scene3d.to_str(), "scene_3d"); + assert_eq!(MediaFileProjectType::MoodBoard.to_str(), "mood_board"); + assert_eq!(MediaFileProjectType::Workflow.to_str(), "workflow"); + assert_eq!(MediaFileProjectType::VideoTimeline.to_str(), "video_timeline"); + assert_eq!(MediaFileProjectType::Editor2d.to_str(), "editor_2d"); + } + + #[test] + fn from_str() { + assert_eq!(MediaFileProjectType::from_str("scene_3d").unwrap(), MediaFileProjectType::Scene3d); + assert_eq!(MediaFileProjectType::from_str("mood_board").unwrap(), MediaFileProjectType::MoodBoard); + assert_eq!(MediaFileProjectType::from_str("workflow").unwrap(), MediaFileProjectType::Workflow); + assert_eq!(MediaFileProjectType::from_str("video_timeline").unwrap(), MediaFileProjectType::VideoTimeline); + assert_eq!(MediaFileProjectType::from_str("editor_2d").unwrap(), MediaFileProjectType::Editor2d); + assert!(MediaFileProjectType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = MediaFileProjectType::all_variants(); + assert_eq!(variants.len(), 5); + assert_eq!(variants.pop_first(), Some(MediaFileProjectType::Scene3d)); + assert_eq!(variants.pop_first(), Some(MediaFileProjectType::MoodBoard)); + assert_eq!(variants.pop_first(), Some(MediaFileProjectType::Workflow)); + assert_eq!(variants.pop_first(), Some(MediaFileProjectType::VideoTimeline)); + assert_eq!(variants.pop_first(), Some(MediaFileProjectType::Editor2d)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileProjectType::all_variants().len(), MediaFileProjectType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileProjectType::all_variants() { + assert_eq!(variant, MediaFileProjectType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileProjectType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileProjectType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in MediaFileProjectType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_subtype.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_subtype.rs new file mode 100644 index 0000000000..6665c22a67 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_subtype.rs @@ -0,0 +1,229 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `media_files` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +#[deprecated(note = "This was primarily for Bevy")] +pub enum MediaFileSubtype { + /// NB: MediaFileSubtype is deprecated. + /// This should signal that the field / enum is fully deprecated. + Deprecated, + + // TODO(bt,2024-04-22): Deprecated (migrate) + /// Animation file from Mixamo + /// Primarily used for FBX and GLB. + Mixamo, + + // TODO(bt,2024-04-22): Deprecated (migrate) + /// Animation file from MocapNet + /// Primarily used for BVH. + MocapNet, + + // TODO(bt,2024-04-22): Deprecated + /// Generic animation case + /// Used for BVH files, but can also pertain to animation-only files of other types. + AnimationOnly, + + // TODO(bt,2024-04-22): Deprecated + /// Generic 3D scene file. + /// Can pertain to glTF, glB, FBX, etc. + SceneImport, + + // TODO(bt,2024-04-22): Deprecated + /// Native Storyteller scene format. + /// Typically stored in a `.scn.ron` file. + StorytellerScene, + + /// A 3D scene full of objects, characters, animations, etc. + Scene, + + /// A 3D character model. + Character, + + /// A 3D animation. + Animation, + + /// A 3D object that doesn't fit with the other types. + Object, + + /// A 3D skybox. + Skybox, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileSubtype); +impl_mysql_enum_coders!(MediaFileSubtype); +impl_mysql_from_row!(MediaFileSubtype); + +/// NB: Legacy API for older code. +impl MediaFileSubtype { + pub fn to_str(&self) -> &'static str { + match self { + Self::Deprecated => "deprecated", + Self::Mixamo => "mixamo", + Self::MocapNet => "mocap_net", + Self::AnimationOnly => "animation_only", + Self::SceneImport => "scene_import", + Self::StorytellerScene => "storyteller_scene", + Self::Scene => "scene", + Self::Character => "character", + Self::Animation => "animation", + Self::Object => "object", + Self::Skybox => "skybox", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "deprecated" => Ok(Self::Deprecated), + "mixamo" => Ok(Self::Mixamo), + "mocap_net" => Ok(Self::MocapNet), + "animation_only" => Ok(Self::AnimationOnly), + "scene_import" => Ok(Self::SceneImport), + "storyteller_scene" => Ok(Self::StorytellerScene), + "scene" => Ok(Self::Scene), + "character" => Ok(Self::Character), + "animation" => Ok(Self::Animation), + "object" => Ok(Self::Object), + "skybox" => Ok(Self::Skybox), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Deprecated, + Self::Mixamo, + Self::MocapNet, + Self::AnimationOnly, + Self::SceneImport, + Self::StorytellerScene, + Self::Scene, + Self::Character, + Self::Animation, + Self::Object, + Self::Skybox, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_subtype::MediaFileSubtype; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileSubtype::Deprecated, "deprecated"); + assert_serialization(MediaFileSubtype::Mixamo, "mixamo"); + assert_serialization(MediaFileSubtype::MocapNet, "mocap_net"); + assert_serialization(MediaFileSubtype::AnimationOnly, "animation_only"); + assert_serialization(MediaFileSubtype::SceneImport, "scene_import"); + assert_serialization(MediaFileSubtype::StorytellerScene, "storyteller_scene"); + assert_serialization(MediaFileSubtype::Scene, "scene"); + assert_serialization(MediaFileSubtype::Character, "character"); + assert_serialization(MediaFileSubtype::Animation, "animation"); + assert_serialization(MediaFileSubtype::Object, "object"); + assert_serialization(MediaFileSubtype::Skybox, "skybox"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaFileSubtype::Deprecated.to_str(), "deprecated"); + assert_eq!(MediaFileSubtype::Mixamo.to_str(), "mixamo"); + assert_eq!(MediaFileSubtype::MocapNet.to_str(), "mocap_net"); + assert_eq!(MediaFileSubtype::AnimationOnly.to_str(), "animation_only"); + assert_eq!(MediaFileSubtype::SceneImport.to_str(), "scene_import"); + assert_eq!(MediaFileSubtype::StorytellerScene.to_str(), "storyteller_scene"); + assert_eq!(MediaFileSubtype::Scene.to_str(), "scene"); + assert_eq!(MediaFileSubtype::Character.to_str(), "character"); + assert_eq!(MediaFileSubtype::Animation.to_str(), "animation"); + assert_eq!(MediaFileSubtype::Object.to_str(), "object"); + assert_eq!(MediaFileSubtype::Skybox.to_str(), "skybox"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaFileSubtype::from_str("deprecated").unwrap(), MediaFileSubtype::Deprecated); + assert_eq!(MediaFileSubtype::from_str("mixamo").unwrap(), MediaFileSubtype::Mixamo); + assert_eq!(MediaFileSubtype::from_str("mocap_net").unwrap(), MediaFileSubtype::MocapNet); + assert_eq!(MediaFileSubtype::from_str("animation_only").unwrap(), MediaFileSubtype::AnimationOnly); + assert_eq!(MediaFileSubtype::from_str("scene_import").unwrap(), MediaFileSubtype::SceneImport); + assert_eq!(MediaFileSubtype::from_str("storyteller_scene").unwrap(), MediaFileSubtype::StorytellerScene); + assert_eq!(MediaFileSubtype::from_str("scene").unwrap(), MediaFileSubtype::Scene); + assert_eq!(MediaFileSubtype::from_str("character").unwrap(), MediaFileSubtype::Character); + assert_eq!(MediaFileSubtype::from_str("animation").unwrap(), MediaFileSubtype::Animation); + assert_eq!(MediaFileSubtype::from_str("object").unwrap(), MediaFileSubtype::Object); + assert_eq!(MediaFileSubtype::from_str("skybox").unwrap(), MediaFileSubtype::Skybox); + assert!(MediaFileSubtype::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = MediaFileSubtype::all_variants(); + assert_eq!(variants.len(), 11); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Deprecated)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Mixamo)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::MocapNet)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::AnimationOnly)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::SceneImport)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::StorytellerScene)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Scene)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Character)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Animation)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Object)); + assert_eq!(variants.pop_first(), Some(MediaFileSubtype::Skybox)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileSubtype::all_variants().len(), MediaFileSubtype::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileSubtype::all_variants() { + assert_eq!(variant, MediaFileSubtype::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileSubtype::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileSubtype::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in MediaFileSubtype::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/media_file_type.rs b/crates/schema/public/enums/src/by_table/media_files/media_file_type.rs new file mode 100644 index 0000000000..2fdd21a745 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/media_file_type.rs @@ -0,0 +1,813 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::by_table::media_files::media_file_class::MediaFileClass; + +/// Used in the `media_files` table in a `VARCHAR(16)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MediaFileType { + // TODO(bt): Deprecate and split into audio mime types; use media_class to represent broadly + /// Audio files: wav, mp3, etc. + #[deprecated] + Audio, + + // TODO(bt): Deprecate and split into image mime types; use media_class to represent broadly + /// Image files: png, jpeg, etc. + #[deprecated] + Image, + + // TODO(bt): Deprecate and split into video mime types; use media_class to represent broadly + /// Video files: mp4, etc. + #[deprecated] + Video, + + /// BVH files (for Bevy) + /// NB: This is the new type to migrate to. + Bvh, + + /// FBX files (for Bevy) + Fbx, + + /// Wavefront OBJ mesh files + Obj, + + /// PLY (polygon file format) files; also used for Gaussian splats. + Ply, + + /// glTF binary files (for Bevy) + Glb, + + /// glTF files (for Bevy) + Gltf, + + /// Spz Gaussian splats + /// This is a standard file format for Gaussian Splats, used by World Labs. + /// The mime type is application/gzip. + Spz, + + /// Bevy's scene files (in RON; Rusty Object Notation) + /// This will be replaced with another format in future versions of Bevy + SceneRon, + + /// Alternate scene files. + SceneJson, + + /// Mood board project documents (JSON). + MoodJson, + + /// Video editor timeline project documents (JSON). + TimelineJson, + + /// 2D editor project documents (JSON). + EditorJson, + + /// "Polygon Model Data", character data for MikuMikuDance + /// See: https://mikumikudance.fandom.com/wiki/MMD:Polygon_Model_Data + Pmd, + + /// "Vocaloid Motion Data", animation data for MikuMikuDance + /// See: https://mikumikudance.fandom.com/wiki/VMD_file_format + Vmd, + + /// "Polygon Model eXtend", character data from MikuMikuDance + /// NB: this is often associated with external files for textures, which + /// we'll store in the same bucket path. + /// See: https://mikumikudance.fandom.com/wiki/MMD:Polygon_Model_eXtend + Pmx, + + /// CSV format. (We use these for ArKit) + Csv, + + /// Jpeg images + Jpg, + + /// Png images + Png, + + /// Gif images + Gif, + + /// Mp4 videos + Mp4, + + /// Wav audio + Wav, + + /// Mp3 audio + Mp3, + + // ========================= LESS SUPPORT FOR THE FILES BELOW THIS LINE ========================= // + + /// Webp images + Webp, + + /// Webm videos + Webm, + + /// QuickTime videos + Mov, + + /// Opus audio + Opus, + + /// Ogg audio + Ogg, + + /// AAC audio + Aac, + + /// M4a audio + M4a, + + /// FLAC audio + Flac, + + /// Generic JSON documents (eg. internal project files). + /// NB: 3D scene files predate this and use `scene_json` instead. + Json, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaFileType); +impl_mysql_enum_coders!(MediaFileType); +impl_mysql_from_row!(MediaFileType); + +/// NB: Legacy API for older code. +impl MediaFileType { + + /// Jpeg and Png are the most widely supported static image formats for AI inference. + /// Webp, Webm, and Gif aren't as widely supported, so we don't include them here. + pub fn is_jpg_or_png(&self) -> bool { + matches!(self, Self::Jpg | Self::Png) + } + + /// A little bit less strict than `is_jpg_or_png`, this includes the legacy `Image` type. + pub fn is_jpg_or_png_or_legacy_image(&self) -> bool { + matches!(self, Self::Jpg | Self::Png | Self::Image) + } + + /// Any image format, including the legacy coarse `Image` type. + pub fn is_image(&self) -> bool { + matches!(self, Self::Image | Self::Jpg | Self::Png | Self::Gif | Self::Webp) + } + + /// The broad `media_class` this file format belongs to. + pub fn to_media_class(&self) -> MediaFileClass { + match self { + Self::Audio + | Self::Wav + | Self::Mp3 + | Self::Opus + | Self::Ogg + | Self::Aac + | Self::M4a + | Self::Flac => MediaFileClass::Audio, + + Self::Image + | Self::Jpg + | Self::Png + | Self::Gif + | Self::Webp => MediaFileClass::Image, + + Self::Video + | Self::Mp4 + | Self::Webm + | Self::Mov => MediaFileClass::Video, + + Self::Fbx + | Self::Obj + | Self::Glb + | Self::Gltf + | Self::Pmd + | Self::Pmx => MediaFileClass::Mesh, + + Self::Spz + | Self::Ply => MediaFileClass::Splat, + + // Animation and auxiliary data formats with no dedicated class yet. + #[allow(deprecated)] + Self::Bvh + | Self::Vmd + | Self::Csv => MediaFileClass::Dimensional, + + Self::SceneRon + | Self::SceneJson + | Self::MoodJson + | Self::TimelineJson + | Self::EditorJson + | Self::Json => MediaFileClass::Project, + } + } + + /// Returns the `MediaFileType` if the mime type matches one of the known types. + /// This is not exhaustive. + /// (NB: In general we shouldn't keep much application logic in the `enums` crate, + /// but this is a good case for it.) + pub fn try_from_mime_type(mime_type: &str) -> Option { + match mime_type { + "image/jpeg" => Some(Self::Jpg), + "image/png" => Some(Self::Png), + "image/gif" => Some(Self::Gif), + "image/webp" => Some(Self::Webp), + "video/mp4" => Some(Self::Mp4), + "video/webm" => Some(Self::Webm), + "video/quicktime" => Some(Self::Mov), + "audio/wav" => Some(Self::Wav), + "audio/x-wav" => Some(Self::Wav), + "audio/wave" => Some(Self::Wav), + "audio/mpeg" => Some(Self::Mp3), + "audio/mp3" => Some(Self::Mp3), + "audio/opus" => Some(Self::Opus), + "audio/ogg" => Some(Self::Ogg), + "audio/aac" => Some(Self::Aac), + "audio/m4a" => Some(Self::M4a), + "audio/x-m4a" => Some(Self::M4a), + "audio/mp4" => Some(Self::M4a), + "audio/flac" => Some(Self::Flac), + "audio/x-flac" => Some(Self::Flac), + "model/gltf-binary" => Some(Self::Glb), + "model/gltf+json" => Some(Self::Gltf), + "model/fbx" => Some(Self::Fbx), + "application/fbx" => Some(Self::Fbx), + "model/obj" => Some(Self::Obj), + "model/ply" => Some(Self::Ply), + _ => None, + } + } + + /// Tries the input as a filename first, then as a bare extension. + /// Useful as a fallback when `try_from_mime_type` fails. + pub fn try_from_filename_or_extension(filename_or_extension: &str) -> Option { + Self::try_from_filename(filename_or_extension) + .or_else(|| Self::try_from_extension(filename_or_extension)) + } + + /// Returns the `MediaFileType` for a filename by examining its extension. + pub fn try_from_filename(filename: &str) -> Option { + let (_, extension) = filename.trim().rsplit_once('.')?; + Self::try_from_extension(extension) + } + + /// Returns the `MediaFileType` for a known file extension. + /// Accepts extensions with or without a leading dot, in any letter case. + pub fn try_from_extension(extension: &str) -> Option { + let extension = extension.trim().trim_start_matches('.').to_ascii_lowercase(); + match extension.as_str() { + "jpg" | "jpeg" => Some(Self::Jpg), + "png" => Some(Self::Png), + "gif" => Some(Self::Gif), + "webp" => Some(Self::Webp), + "mp4" => Some(Self::Mp4), + "webm" => Some(Self::Webm), + "mov" => Some(Self::Mov), + "wav" => Some(Self::Wav), + "mp3" => Some(Self::Mp3), + "opus" => Some(Self::Opus), + "ogg" => Some(Self::Ogg), + "aac" => Some(Self::Aac), + "m4a" => Some(Self::M4a), + "flac" => Some(Self::Flac), + "bvh" => Some(Self::Bvh), + "fbx" => Some(Self::Fbx), + "obj" => Some(Self::Obj), + "glb" => Some(Self::Glb), + "gltf" => Some(Self::Gltf), + "spz" => Some(Self::Spz), + "pmd" => Some(Self::Pmd), + "vmd" => Some(Self::Vmd), + "pmx" => Some(Self::Pmx), + "csv" => Some(Self::Csv), + "json" => Some(Self::Json), + _ => None, + } + } + + pub fn to_str(&self) -> &'static str { + match self { + Self::Audio => "audio", + Self::Image => "image", + Self::Video => "video", + Self::Bvh => "bvh", + Self::Fbx => "fbx", + Self::Obj => "obj", + Self::Ply => "ply", + Self::Glb => "glb", + Self::Gltf => "gltf", + Self::Spz => "spz", + Self::SceneRon => "scene_ron", + Self::SceneJson => "scene_json", + Self::MoodJson => "mood_json", + Self::TimelineJson => "timeline_json", + Self::EditorJson => "editor_json", + Self::Pmd => "pmd", + Self::Vmd => "vmd", + Self::Pmx => "pmx", + Self::Csv => "csv", + Self::Jpg => "jpg", + Self::Png => "png", + Self::Gif => "gif", + Self::Mp4 => "mp4", + Self::Wav => "wav", + Self::Mp3 => "mp3", + Self::Webp => "webp", + Self::Webm => "webm", + Self::Mov => "mov", + Self::Opus => "opus", + Self::Ogg => "ogg", + Self::Aac => "aac", + Self::M4a => "m4a", + Self::Flac => "flac", + Self::Json => "json", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "audio" => Ok(Self::Audio), + "image" => Ok(Self::Image), + "video" => Ok(Self::Video), + "bvh" => Ok(Self::Bvh), + "fbx" => Ok(Self::Fbx), + "obj" => Ok(Self::Obj), + "ply" => Ok(Self::Ply), + "glb" => Ok(Self::Glb), + "gltf" => Ok(Self::Gltf), + "spz" => Ok(Self::Spz), + "scene_ron" => Ok(Self::SceneRon), + "scene_json" => Ok(Self::SceneJson), + "mood_json" => Ok(Self::MoodJson), + "timeline_json" => Ok(Self::TimelineJson), + "editor_json" => Ok(Self::EditorJson), + "pmd" => Ok(Self::Pmd), + "vmd" => Ok(Self::Vmd), + "pmx" => Ok(Self::Pmx), + "csv" => Ok(Self::Csv), + "jpg" => Ok(Self::Jpg), + "png" => Ok(Self::Png), + "gif" => Ok(Self::Gif), + "mp4" => Ok(Self::Mp4), + "wav" => Ok(Self::Wav), + "mp3" => Ok(Self::Mp3), + "webp" => Ok(Self::Webp), + "webm" => Ok(Self::Webm), + "mov" => Ok(Self::Mov), + "opus" => Ok(Self::Opus), + "ogg" => Ok(Self::Ogg), + "aac" => Ok(Self::Aac), + "m4a" => Ok(Self::M4a), + "flac" => Ok(Self::Flac), + "json" => Ok(Self::Json), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Audio, + Self::Image, + Self::Video, + Self::Bvh, + Self::Fbx, + Self::Obj, + Self::Ply, + Self::Glb, + Self::Gltf, + Self::Spz, + Self::SceneRon, + Self::SceneJson, + Self::MoodJson, + Self::TimelineJson, + Self::EditorJson, + Self::Pmd, + Self::Vmd, + Self::Pmx, + Self::Csv, + Self::Jpg, + Self::Png, + Self::Gif, + Self::Mp4, + Self::Wav, + Self::Mp3, + Self::Webp, + Self::Webm, + Self::Mov, + Self::Opus, + Self::Ogg, + Self::Aac, + Self::M4a, + Self::Flac, + Self::Json, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_files::media_file_class::MediaFileClass; + use crate::by_table::media_files::media_file_type::MediaFileType; + use crate::test_helpers::assert_serialization; + + mod utility { + use super::*; + + #[test] + fn test_jpg_or_png() { + // True + assert!(MediaFileType::Jpg.is_jpg_or_png()); + assert!(MediaFileType::Png.is_jpg_or_png()); + + // Assert these image types are false + assert!(!MediaFileType::Gif.is_jpg_or_png()); + assert!(!MediaFileType::Image.is_jpg_or_png()); + + // Everything else is false + for variant in MediaFileType::all_variants() { + if matches!(variant, MediaFileType::Jpg | MediaFileType::Png) { + continue; + } + assert!(!variant.is_jpg_or_png(), "Expected {:?} to not be jpg or png", variant); + } + } + } + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(MediaFileType::Audio, "audio"); + assert_serialization(MediaFileType::Image, "image"); + assert_serialization(MediaFileType::Video, "video"); + assert_serialization(MediaFileType::Bvh, "bvh"); + assert_serialization(MediaFileType::Fbx, "fbx"); + assert_serialization(MediaFileType::Obj, "obj"); + assert_serialization(MediaFileType::Ply, "ply"); + assert_serialization(MediaFileType::Glb, "glb"); + assert_serialization(MediaFileType::Gltf, "gltf"); + assert_serialization(MediaFileType::Spz, "spz"); + assert_serialization(MediaFileType::SceneRon, "scene_ron"); + assert_serialization(MediaFileType::SceneJson, "scene_json"); + assert_serialization(MediaFileType::MoodJson, "mood_json"); + assert_serialization(MediaFileType::TimelineJson, "timeline_json"); + assert_serialization(MediaFileType::EditorJson, "editor_json"); + assert_serialization(MediaFileType::Pmd, "pmd"); + assert_serialization(MediaFileType::Vmd, "vmd"); + assert_serialization(MediaFileType::Pmx, "pmx"); + assert_serialization(MediaFileType::Csv, "csv"); + assert_serialization(MediaFileType::Jpg, "jpg"); + assert_serialization(MediaFileType::Png, "png"); + assert_serialization(MediaFileType::Gif, "gif"); + assert_serialization(MediaFileType::Mp4, "mp4"); + assert_serialization(MediaFileType::Wav, "wav"); + assert_serialization(MediaFileType::Mp3, "mp3"); + assert_serialization(MediaFileType::Webp, "webp"); + assert_serialization(MediaFileType::Webm, "webm"); + assert_serialization(MediaFileType::Mov, "mov"); + assert_serialization(MediaFileType::Opus, "opus"); + assert_serialization(MediaFileType::Ogg, "ogg"); + assert_serialization(MediaFileType::Aac, "aac"); + assert_serialization(MediaFileType::M4a, "m4a"); + assert_serialization(MediaFileType::Flac, "flac"); + assert_serialization(MediaFileType::Json, "json"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(MediaFileType::Audio.to_str(), "audio"); + assert_eq!(MediaFileType::Image.to_str(), "image"); + assert_eq!(MediaFileType::Video.to_str(), "video"); + assert_eq!(MediaFileType::Bvh.to_str(), "bvh"); + assert_eq!(MediaFileType::Fbx.to_str(), "fbx"); + assert_eq!(MediaFileType::Glb.to_str(), "glb"); + assert_eq!(MediaFileType::Gltf.to_str(), "gltf"); + assert_eq!(MediaFileType::Spz.to_str(), "spz"); + assert_eq!(MediaFileType::SceneRon.to_str(), "scene_ron"); + assert_eq!(MediaFileType::SceneJson.to_str(), "scene_json"); + assert_eq!(MediaFileType::MoodJson.to_str(), "mood_json"); + assert_eq!(MediaFileType::TimelineJson.to_str(), "timeline_json"); + assert_eq!(MediaFileType::EditorJson.to_str(), "editor_json"); + assert_eq!(MediaFileType::Pmd.to_str(), "pmd"); + assert_eq!(MediaFileType::Vmd.to_str(), "vmd"); + assert_eq!(MediaFileType::Pmx.to_str(), "pmx"); + assert_eq!(MediaFileType::Csv.to_str(), "csv"); + assert_eq!(MediaFileType::Jpg.to_str(), "jpg"); + assert_eq!(MediaFileType::Png.to_str(), "png"); + assert_eq!(MediaFileType::Gif.to_str(), "gif"); + assert_eq!(MediaFileType::Mp4.to_str(), "mp4"); + assert_eq!(MediaFileType::Wav.to_str(), "wav"); + assert_eq!(MediaFileType::Mp3.to_str(), "mp3"); + assert_eq!(MediaFileType::Webp.to_str(), "webp"); + assert_eq!(MediaFileType::Webm.to_str(), "webm"); + assert_eq!(MediaFileType::Mov.to_str(), "mov"); + assert_eq!(MediaFileType::Opus.to_str(), "opus"); + assert_eq!(MediaFileType::Ogg.to_str(), "ogg"); + assert_eq!(MediaFileType::Aac.to_str(), "aac"); + assert_eq!(MediaFileType::M4a.to_str(), "m4a"); + assert_eq!(MediaFileType::Flac.to_str(), "flac"); + assert_eq!(MediaFileType::Json.to_str(), "json"); + } + + #[test] + fn from_str() { + assert_eq!(MediaFileType::from_str("audio").unwrap(), MediaFileType::Audio); + assert_eq!(MediaFileType::from_str("image").unwrap(), MediaFileType::Image); + assert_eq!(MediaFileType::from_str("video").unwrap(), MediaFileType::Video); + assert_eq!(MediaFileType::from_str("bvh").unwrap(), MediaFileType::Bvh); + assert_eq!(MediaFileType::from_str("fbx").unwrap(), MediaFileType::Fbx); + assert_eq!(MediaFileType::from_str("glb").unwrap(), MediaFileType::Glb); + assert_eq!(MediaFileType::from_str("gltf").unwrap(), MediaFileType::Gltf); + assert_eq!(MediaFileType::from_str("spz").unwrap(), MediaFileType::Spz); + assert_eq!(MediaFileType::from_str("scene_ron").unwrap(), MediaFileType::SceneRon); + assert_eq!(MediaFileType::from_str("scene_json").unwrap(), MediaFileType::SceneJson); + assert_eq!(MediaFileType::from_str("mood_json").unwrap(), MediaFileType::MoodJson); + assert_eq!(MediaFileType::from_str("timeline_json").unwrap(), MediaFileType::TimelineJson); + assert_eq!(MediaFileType::from_str("editor_json").unwrap(), MediaFileType::EditorJson); + assert_eq!(MediaFileType::from_str("pmd").unwrap(), MediaFileType::Pmd); + assert_eq!(MediaFileType::from_str("vmd").unwrap(), MediaFileType::Vmd); + assert_eq!(MediaFileType::from_str("pmx").unwrap(), MediaFileType::Pmx); + assert_eq!(MediaFileType::from_str("csv").unwrap(), MediaFileType::Csv); + assert_eq!(MediaFileType::from_str("jpg").unwrap(), MediaFileType::Jpg); + assert_eq!(MediaFileType::from_str("png").unwrap(), MediaFileType::Png); + assert_eq!(MediaFileType::from_str("gif").unwrap(), MediaFileType::Gif); + assert_eq!(MediaFileType::from_str("mp4").unwrap(), MediaFileType::Mp4); + assert_eq!(MediaFileType::from_str("wav").unwrap(), MediaFileType::Wav); + assert_eq!(MediaFileType::from_str("mp3").unwrap(), MediaFileType::Mp3); + assert_eq!(MediaFileType::from_str("webp").unwrap(), MediaFileType::Webp); + assert_eq!(MediaFileType::from_str("webm").unwrap(), MediaFileType::Webm); + assert_eq!(MediaFileType::from_str("mov").unwrap(), MediaFileType::Mov); + assert_eq!(MediaFileType::from_str("opus").unwrap(), MediaFileType::Opus); + assert_eq!(MediaFileType::from_str("ogg").unwrap(), MediaFileType::Ogg); + assert_eq!(MediaFileType::from_str("aac").unwrap(), MediaFileType::Aac); + assert_eq!(MediaFileType::from_str("m4a").unwrap(), MediaFileType::M4a); + assert_eq!(MediaFileType::from_str("flac").unwrap(), MediaFileType::Flac); + assert_eq!(MediaFileType::from_str("json").unwrap(), MediaFileType::Json); + assert!(MediaFileType::from_str("foo").is_err()); + } + + #[test] + fn to_media_class() { + assert_eq!(MediaFileType::Audio.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Image.to_media_class(), MediaFileClass::Image); + assert_eq!(MediaFileType::Video.to_media_class(), MediaFileClass::Video); + assert_eq!(MediaFileType::Bvh.to_media_class(), MediaFileClass::Dimensional); + assert_eq!(MediaFileType::Fbx.to_media_class(), MediaFileClass::Mesh); + assert_eq!(MediaFileType::Obj.to_media_class(), MediaFileClass::Mesh); + assert_eq!(MediaFileType::Glb.to_media_class(), MediaFileClass::Mesh); + assert_eq!(MediaFileType::Gltf.to_media_class(), MediaFileClass::Mesh); + assert_eq!(MediaFileType::Spz.to_media_class(), MediaFileClass::Splat); + assert_eq!(MediaFileType::Ply.to_media_class(), MediaFileClass::Splat); + assert_eq!(MediaFileType::SceneRon.to_media_class(), MediaFileClass::Project); + assert_eq!(MediaFileType::SceneJson.to_media_class(), MediaFileClass::Project); + assert_eq!(MediaFileType::MoodJson.to_media_class(), MediaFileClass::Project); + assert_eq!(MediaFileType::TimelineJson.to_media_class(), MediaFileClass::Project); + assert_eq!(MediaFileType::EditorJson.to_media_class(), MediaFileClass::Project); + assert_eq!(MediaFileType::Pmd.to_media_class(), MediaFileClass::Mesh); + assert_eq!(MediaFileType::Vmd.to_media_class(), MediaFileClass::Dimensional); + assert_eq!(MediaFileType::Pmx.to_media_class(), MediaFileClass::Mesh); + assert_eq!(MediaFileType::Csv.to_media_class(), MediaFileClass::Dimensional); + assert_eq!(MediaFileType::Jpg.to_media_class(), MediaFileClass::Image); + assert_eq!(MediaFileType::Png.to_media_class(), MediaFileClass::Image); + assert_eq!(MediaFileType::Gif.to_media_class(), MediaFileClass::Image); + assert_eq!(MediaFileType::Webp.to_media_class(), MediaFileClass::Image); + assert_eq!(MediaFileType::Mp4.to_media_class(), MediaFileClass::Video); + assert_eq!(MediaFileType::Webm.to_media_class(), MediaFileClass::Video); + assert_eq!(MediaFileType::Mov.to_media_class(), MediaFileClass::Video); + assert_eq!(MediaFileType::Wav.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Mp3.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Opus.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Ogg.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Aac.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::M4a.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Flac.to_media_class(), MediaFileClass::Audio); + assert_eq!(MediaFileType::Json.to_media_class(), MediaFileClass::Project); + } + } + + mod format_detection { + use super::*; + + #[test] + fn try_from_mime_type() { + assert_eq!(MediaFileType::try_from_mime_type("image/jpeg"), Some(MediaFileType::Jpg)); + assert_eq!(MediaFileType::try_from_mime_type("image/png"), Some(MediaFileType::Png)); + assert_eq!(MediaFileType::try_from_mime_type("image/gif"), Some(MediaFileType::Gif)); + assert_eq!(MediaFileType::try_from_mime_type("image/webp"), Some(MediaFileType::Webp)); + assert_eq!(MediaFileType::try_from_mime_type("video/mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_mime_type("video/webm"), Some(MediaFileType::Webm)); + assert_eq!(MediaFileType::try_from_mime_type("video/quicktime"), Some(MediaFileType::Mov)); + assert_eq!(MediaFileType::try_from_mime_type("audio/wav"), Some(MediaFileType::Wav)); + assert_eq!(MediaFileType::try_from_mime_type("audio/x-wav"), Some(MediaFileType::Wav)); + assert_eq!(MediaFileType::try_from_mime_type("audio/wave"), Some(MediaFileType::Wav)); + assert_eq!(MediaFileType::try_from_mime_type("audio/mpeg"), Some(MediaFileType::Mp3)); + assert_eq!(MediaFileType::try_from_mime_type("audio/mp3"), Some(MediaFileType::Mp3)); + assert_eq!(MediaFileType::try_from_mime_type("audio/opus"), Some(MediaFileType::Opus)); + assert_eq!(MediaFileType::try_from_mime_type("audio/ogg"), Some(MediaFileType::Ogg)); + assert_eq!(MediaFileType::try_from_mime_type("audio/aac"), Some(MediaFileType::Aac)); + assert_eq!(MediaFileType::try_from_mime_type("audio/m4a"), Some(MediaFileType::M4a)); + assert_eq!(MediaFileType::try_from_mime_type("audio/x-m4a"), Some(MediaFileType::M4a)); + assert_eq!(MediaFileType::try_from_mime_type("audio/mp4"), Some(MediaFileType::M4a)); + assert_eq!(MediaFileType::try_from_mime_type("audio/flac"), Some(MediaFileType::Flac)); + assert_eq!(MediaFileType::try_from_mime_type("audio/x-flac"), Some(MediaFileType::Flac)); + assert_eq!(MediaFileType::try_from_mime_type("model/gltf-binary"), Some(MediaFileType::Glb)); + assert_eq!(MediaFileType::try_from_mime_type("model/gltf+json"), Some(MediaFileType::Gltf)); + assert_eq!(MediaFileType::try_from_mime_type("model/fbx"), Some(MediaFileType::Fbx)); + assert_eq!(MediaFileType::try_from_mime_type("application/fbx"), Some(MediaFileType::Fbx)); + assert_eq!(MediaFileType::try_from_mime_type("model/obj"), Some(MediaFileType::Obj)); + } + + #[test] + fn try_from_mime_type_unknown_mimes() { + // NB: application/json is deliberately unmapped — scene JSON, mood boards, + // and other project files can't be told apart by mime alone. + assert_eq!(MediaFileType::try_from_mime_type("application/json"), None); + // NB: application/gzip is the mime for spz splats, but gzip isn't + // necessarily a splat, so it stays unmapped. + assert_eq!(MediaFileType::try_from_mime_type("application/gzip"), None); + assert_eq!(MediaFileType::try_from_mime_type("application/octet-stream"), None); + assert_eq!(MediaFileType::try_from_mime_type("text/plain"), None); + assert_eq!(MediaFileType::try_from_mime_type(""), None); + assert_eq!(MediaFileType::try_from_mime_type("IMAGE/PNG"), None); + } + + #[test] + fn try_from_extension() { + assert_eq!(MediaFileType::try_from_extension("jpg"), Some(MediaFileType::Jpg)); + assert_eq!(MediaFileType::try_from_extension("jpeg"), Some(MediaFileType::Jpg)); + assert_eq!(MediaFileType::try_from_extension("png"), Some(MediaFileType::Png)); + assert_eq!(MediaFileType::try_from_extension("gif"), Some(MediaFileType::Gif)); + assert_eq!(MediaFileType::try_from_extension("webp"), Some(MediaFileType::Webp)); + assert_eq!(MediaFileType::try_from_extension("mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_extension("webm"), Some(MediaFileType::Webm)); + assert_eq!(MediaFileType::try_from_extension("mov"), Some(MediaFileType::Mov)); + assert_eq!(MediaFileType::try_from_extension("wav"), Some(MediaFileType::Wav)); + assert_eq!(MediaFileType::try_from_extension("mp3"), Some(MediaFileType::Mp3)); + assert_eq!(MediaFileType::try_from_extension("opus"), Some(MediaFileType::Opus)); + assert_eq!(MediaFileType::try_from_extension("ogg"), Some(MediaFileType::Ogg)); + assert_eq!(MediaFileType::try_from_extension("aac"), Some(MediaFileType::Aac)); + assert_eq!(MediaFileType::try_from_extension("m4a"), Some(MediaFileType::M4a)); + assert_eq!(MediaFileType::try_from_extension("flac"), Some(MediaFileType::Flac)); + assert_eq!(MediaFileType::try_from_extension("bvh"), Some(MediaFileType::Bvh)); + assert_eq!(MediaFileType::try_from_extension("fbx"), Some(MediaFileType::Fbx)); + assert_eq!(MediaFileType::try_from_extension("obj"), Some(MediaFileType::Obj)); + assert_eq!(MediaFileType::try_from_extension("glb"), Some(MediaFileType::Glb)); + assert_eq!(MediaFileType::try_from_extension("gltf"), Some(MediaFileType::Gltf)); + assert_eq!(MediaFileType::try_from_extension("spz"), Some(MediaFileType::Spz)); + assert_eq!(MediaFileType::try_from_extension("pmd"), Some(MediaFileType::Pmd)); + assert_eq!(MediaFileType::try_from_extension("vmd"), Some(MediaFileType::Vmd)); + assert_eq!(MediaFileType::try_from_extension("pmx"), Some(MediaFileType::Pmx)); + assert_eq!(MediaFileType::try_from_extension("csv"), Some(MediaFileType::Csv)); + assert_eq!(MediaFileType::try_from_extension("json"), Some(MediaFileType::Json)); + } + + #[test] + fn try_from_extension_normalization() { + // Leading dot + assert_eq!(MediaFileType::try_from_extension(".mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_extension(".spz"), Some(MediaFileType::Spz)); + // Case insensitivity + assert_eq!(MediaFileType::try_from_extension("JPG"), Some(MediaFileType::Jpg)); + assert_eq!(MediaFileType::try_from_extension(".WebP"), Some(MediaFileType::Webp)); + // Whitespace + assert_eq!(MediaFileType::try_from_extension(" png "), Some(MediaFileType::Png)); + assert_eq!(MediaFileType::try_from_extension(" .glb "), Some(MediaFileType::Glb)); + } + + #[test] + fn try_from_extension_unknown_extensions() { + assert_eq!(MediaFileType::try_from_extension("ron"), None); + assert_eq!(MediaFileType::try_from_extension("exe"), None); + assert_eq!(MediaFileType::try_from_extension("scene_json"), None); + assert_eq!(MediaFileType::try_from_extension("audio"), None); + assert_eq!(MediaFileType::try_from_extension(""), None); + assert_eq!(MediaFileType::try_from_extension("."), None); + } + + #[test] + fn try_from_filename() { + assert_eq!(MediaFileType::try_from_filename("photo.jpg"), Some(MediaFileType::Jpg)); + assert_eq!(MediaFileType::try_from_filename("PHOTO.JPEG"), Some(MediaFileType::Jpg)); + assert_eq!(MediaFileType::try_from_filename("clip.final.mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_filename("model.GLB"), Some(MediaFileType::Glb)); + assert_eq!(MediaFileType::try_from_filename("scene.spz"), Some(MediaFileType::Spz)); + assert_eq!(MediaFileType::try_from_filename("data.json"), Some(MediaFileType::Json)); + assert_eq!(MediaFileType::try_from_filename(" padded.png "), Some(MediaFileType::Png)); + // Failures + assert_eq!(MediaFileType::try_from_filename("archive.tar.gz"), None); + assert_eq!(MediaFileType::try_from_filename("noextension"), None); + assert_eq!(MediaFileType::try_from_filename("file."), None); + assert_eq!(MediaFileType::try_from_filename(".gitignore"), None); + assert_eq!(MediaFileType::try_from_filename(""), None); + // NB: a bare extension is not a filename (no dot separator) + assert_eq!(MediaFileType::try_from_filename("mp4"), None); + } + + #[test] + fn try_from_filename_or_extension() { + // Filenames + assert_eq!(MediaFileType::try_from_filename_or_extension("movie.mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_filename_or_extension("track.FLAC"), Some(MediaFileType::Flac)); + // Bare extensions, with and without a leading dot + assert_eq!(MediaFileType::try_from_filename_or_extension("mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_filename_or_extension(".mp4"), Some(MediaFileType::Mp4)); + assert_eq!(MediaFileType::try_from_filename_or_extension("webp"), Some(MediaFileType::Webp)); + // Failures + assert_eq!(MediaFileType::try_from_filename_or_extension("movie.xyz"), None); + assert_eq!(MediaFileType::try_from_filename_or_extension("unknown"), None); + assert_eq!(MediaFileType::try_from_filename_or_extension(""), None); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = MediaFileType::all_variants(); + assert_eq!(variants.len(), 34); + assert_eq!(variants.pop_first(), Some(MediaFileType::Audio)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Image)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Video)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Bvh)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Fbx)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Obj)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Ply)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Glb)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Gltf)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Spz)); + assert_eq!(variants.pop_first(), Some(MediaFileType::SceneRon)); + assert_eq!(variants.pop_first(), Some(MediaFileType::SceneJson)); + assert_eq!(variants.pop_first(), Some(MediaFileType::MoodJson)); + assert_eq!(variants.pop_first(), Some(MediaFileType::TimelineJson)); + assert_eq!(variants.pop_first(), Some(MediaFileType::EditorJson)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Pmd)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Vmd)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Pmx)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Csv)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Jpg)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Png)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Gif)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Mp4)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Wav)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Mp3)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Webp)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Webm)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Mov)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Opus)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Ogg)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Aac)); + assert_eq!(variants.pop_first(), Some(MediaFileType::M4a)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Flac)); + assert_eq!(variants.pop_first(), Some(MediaFileType::Json)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(MediaFileType::all_variants().len(), MediaFileType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in MediaFileType::all_variants() { + assert_eq!(variant, MediaFileType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, MediaFileType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, MediaFileType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in MediaFileType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/media_files/mod.rs b/crates/schema/public/enums/src/by_table/media_files/mod.rs new file mode 100644 index 0000000000..ceded741c9 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_files/mod.rs @@ -0,0 +1,9 @@ +pub mod media_file_animation_type; +pub mod media_file_class; +pub mod media_file_engine_category; +pub mod media_file_origin_category; +pub mod media_file_origin_model_type; +pub mod media_file_origin_product_category; +pub mod media_file_project_type; +pub mod media_file_subtype; +pub mod media_file_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/media_uploads/media_upload_source.rs b/crates/schema/public/enums/src/by_table/media_uploads/media_upload_source.rs new file mode 100644 index 0000000000..f8a94e54ab --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_uploads/media_upload_source.rs @@ -0,0 +1,77 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `media_uploads` table in a `VARCHAR` field `media_source`. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +pub enum MediaUploadSource { + /// Eg. browser javascript APIs to access the microphone, webcam, etc. + #[serde(rename = "device_api")] + DeviceApi, + + /// Uploaded files from the filesystem + #[serde(rename = "file")] + File, + + /// Unknown sources + #[serde(rename = "unknown")] + Unknown, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaUploadSource); +impl_mysql_enum_coders!(MediaUploadSource); + +/// NB: Legacy API for older code. +impl MediaUploadSource { + pub fn to_str(&self) -> &'static str { + match self { + Self::DeviceApi => "device_api", + Self::File => "file", + Self::Unknown => "unknown", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "device_api" => Ok(Self::DeviceApi), + "file" => Ok(Self::File), + "unknown" => Ok(Self::Unknown), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_uploads::media_upload_source::MediaUploadSource; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(MediaUploadSource::DeviceApi, "device_api"); + assert_serialization(MediaUploadSource::File, "file"); + assert_serialization(MediaUploadSource::Unknown, "unknown"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaUploadSource::DeviceApi.to_str(), "device_api"); + assert_eq!(MediaUploadSource::File.to_str(), "file"); + assert_eq!(MediaUploadSource::Unknown.to_str(), "unknown"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaUploadSource::from_str("device_api").unwrap(), MediaUploadSource::DeviceApi); + assert_eq!(MediaUploadSource::from_str("file").unwrap(), MediaUploadSource::File); + assert_eq!(MediaUploadSource::from_str("unknown").unwrap(), MediaUploadSource::Unknown); + assert!(MediaUploadSource::from_str("foo").is_err()); + } +} diff --git a/crates/schema/public/enums/src/by_table/media_uploads/media_upload_type.rs b/crates/schema/public/enums/src/by_table/media_uploads/media_upload_type.rs new file mode 100644 index 0000000000..42fd07ce41 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_uploads/media_upload_type.rs @@ -0,0 +1,83 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `media_uploads` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaUploadType { + /// Audio files: wav, mp3, etc. + Audio, + + /// Image files: png, jpeg, etc. + Image, + + /// Video files: mp4, etc. + Video, + + // Binary files: safetensors ... weights, etc. + Binary, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(MediaUploadType); +impl_mysql_enum_coders!(MediaUploadType); + +/// NB: Legacy API for older code. +impl MediaUploadType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Audio => "audio", + Self::Image => "image", + Self::Video => "video", + Self::Binary => "binary", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "audio" => Ok(Self::Audio), + "image" => Ok(Self::Image), + "video" => Ok(Self::Video), + "binary" => Ok(Self::Binary), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::media_uploads::media_upload_type::MediaUploadType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(MediaUploadType::Audio, "audio"); + assert_serialization(MediaUploadType::Image, "image"); + assert_serialization(MediaUploadType::Video, "video"); + assert_serialization(MediaUploadType::Binary, "binary"); + } + + #[test] + fn test_to_str() { + assert_eq!(MediaUploadType::Audio.to_str(), "audio"); + assert_eq!(MediaUploadType::Image.to_str(), "image"); + assert_eq!(MediaUploadType::Video.to_str(), "video"); + assert_eq!(MediaUploadType::Binary.to_str(), "binary"); + } + + #[test] + fn test_from_str() { + assert_eq!(MediaUploadType::from_str("audio").unwrap(), MediaUploadType::Audio); + assert_eq!(MediaUploadType::from_str("image").unwrap(), MediaUploadType::Image); + assert_eq!(MediaUploadType::from_str("video").unwrap(), MediaUploadType::Video); + assert_eq!(MediaUploadType::from_str("binary").unwrap(), MediaUploadType::Binary); + assert!(MediaUploadType::from_str("foo").is_err()); + } +} diff --git a/crates/schema/public/enums/src/by_table/media_uploads/mod.rs b/crates/schema/public/enums/src/by_table/media_uploads/mod.rs new file mode 100644 index 0000000000..d3698b600d --- /dev/null +++ b/crates/schema/public/enums/src/by_table/media_uploads/mod.rs @@ -0,0 +1,2 @@ +pub mod media_upload_type; +pub mod media_upload_source; diff --git a/crates/schema/public/enums/src/by_table/mod.rs b/crates/schema/public/enums/src/by_table/mod.rs new file mode 100644 index 0000000000..57321c921d --- /dev/null +++ b/crates/schema/public/enums/src/by_table/mod.rs @@ -0,0 +1,38 @@ +// ===== MySql ===== +pub mod audit_logs; +pub mod batch_generations; +pub mod characters; +pub mod beta_keys; +pub mod comments; +pub mod debug_logs; +pub mod email_sender_jobs; +pub mod entity_stats; +pub mod featured_items; +pub mod generic_download_jobs; +pub mod generic_inference_jobs; +pub mod generic_synthetic_ids; +pub mod media_files; +pub mod media_uploads; +pub mod model_categories; +pub mod model_weights; +pub mod prompt_context_items; +pub mod prompts; +pub mod staff_audit_logs; +pub mod trending_model_analytics; +pub mod tts_models; +pub mod uploaded_video_notes; +pub mod uploaded_videos; +pub mod usages; +pub mod user_bookmarks; +pub mod user_ratings; +pub mod user_spend_events; +pub mod users; +pub mod voice_conversion_models; +pub mod voice_conversion_results; +pub mod wallet_ledger_entries; +pub mod zs_voices; + +// ===== Sqlite ===== +pub mod tts_render_tasks; +pub mod web_rendition_targets; +pub mod web_scraping_targets; diff --git a/crates/schema/public/enums/src/by_table/model_categories/mod.rs b/crates/schema/public/enums/src/by_table/model_categories/mod.rs new file mode 100644 index 0000000000..5a53f2e0cf --- /dev/null +++ b/crates/schema/public/enums/src/by_table/model_categories/mod.rs @@ -0,0 +1 @@ +pub mod model_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/model_categories/model_type.rs b/crates/schema/public/enums/src/by_table/model_categories/model_type.rs new file mode 100644 index 0000000000..608064bb50 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/model_categories/model_type.rs @@ -0,0 +1,65 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `model_categories` table in an `ENUM` field. +/// (*WE WANT TO STOP USING ENUM FIELDS DUE TO MIGRATION ISSUES*) +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ModelType { + Tts, + W2l, +} + +// TODO(bt, 2023-01-16): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(ModelType); +impl_mysql_enum_coders!(ModelType); + +/// NB: Legacy API for older code. +impl ModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Tts => "tts", + Self::W2l => "w2l", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "tts" => Ok(Self::Tts), + "w2l" => Ok(Self::W2l), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::model_categories::model_type::ModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(ModelType::Tts, "tts"); + assert_serialization(ModelType::W2l, "w2l"); + } + + #[test] + fn test_to_str() { + assert_eq!(ModelType::Tts.to_str(), "tts"); + assert_eq!(ModelType::W2l.to_str(), "w2l"); + } + + #[test] + fn test_from_str() { + assert_eq!(ModelType::from_str("tts").unwrap(), ModelType::Tts); + assert_eq!(ModelType::from_str("w2l").unwrap(), ModelType::W2l); + assert!(ModelType::from_str("foo").is_err()); + } +} diff --git a/crates/schema/public/enums/src/by_table/model_weights/mod.rs b/crates/schema/public/enums/src/by_table/model_weights/mod.rs new file mode 100644 index 0000000000..0755131487 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/model_weights/mod.rs @@ -0,0 +1,3 @@ +pub mod weights_types; +pub mod weights_category; +pub mod weights_file_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/model_weights/weights_category.rs b/crates/schema/public/enums/src/by_table/model_weights/weights_category.rs new file mode 100644 index 0000000000..73b279f368 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/model_weights/weights_category.rs @@ -0,0 +1,93 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum WeightsCategory { + #[serde(rename = "image_generation")] + ImageGeneration, + #[serde(rename = "text_to_speech")] + TextToSpeech, + #[serde(rename = "vocoder")] + Vocoder, + #[serde(rename = "voice_conversion")] + VoiceConversion, + #[serde(rename = "workflow_config")] + WorkflowConfig, +} + +impl WeightsCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::ImageGeneration => "image_generation", + Self::TextToSpeech => "text_to_speech", + Self::Vocoder => "vocoder", + Self::VoiceConversion => "voice_conversion", + Self::WorkflowConfig => "workflow_config", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "image_generation" => Ok(Self::ImageGeneration), + "text_to_speech" => Ok(Self::TextToSpeech), + "vocoder" => Ok(Self::Vocoder), + "voice_conversion" => Ok(Self::VoiceConversion), + "workflow_config" => Ok(Self::WorkflowConfig), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::ImageGeneration, + Self::TextToSpeech, + Self::Vocoder, + Self::VoiceConversion, + Self::WorkflowConfig, + ]) + } +} +impl_enum_display_and_debug_using_to_str!(WeightsCategory); +impl_mysql_enum_coders!(WeightsCategory); +impl_mysql_from_row!(WeightsCategory); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(WeightsCategory::ImageGeneration.to_str(), "image_generation"); + assert_eq!(WeightsCategory::TextToSpeech.to_str(), "text_to_speech"); + assert_eq!(WeightsCategory::Vocoder.to_str(), "vocoder"); + assert_eq!(WeightsCategory::VoiceConversion.to_str(), "voice_conversion"); + assert_eq!(WeightsCategory::WorkflowConfig.to_str(), "workflow_config"); + } + + #[test] + fn test_from_str() { + assert_eq!(WeightsCategory::from_str("image_generation").unwrap(), WeightsCategory::ImageGeneration); + assert_eq!(WeightsCategory::from_str("text_to_speech").unwrap(), WeightsCategory::TextToSpeech); + assert_eq!(WeightsCategory::from_str("vocoder").unwrap(), WeightsCategory::Vocoder); + assert_eq!(WeightsCategory::from_str("voice_conversion").unwrap(), WeightsCategory::VoiceConversion); + assert_eq!(WeightsCategory::from_str("workflow_config").unwrap(), WeightsCategory::WorkflowConfig); + assert!(WeightsCategory::from_str("invalid").is_err()); + } + + #[test] + fn test_all_variants() { + let variants = WeightsCategory::all_variants(); + assert_eq!(variants.len(), 5); + assert!(variants.contains(&WeightsCategory::ImageGeneration)); + assert!(variants.contains(&WeightsCategory::TextToSpeech)); + assert!(variants.contains(&WeightsCategory::Vocoder)); + assert!(variants.contains(&WeightsCategory::VoiceConversion)); + assert!(variants.contains(&WeightsCategory::WorkflowConfig)); + } +} \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/model_weights/weights_file_type.rs b/crates/schema/public/enums/src/by_table/model_weights/weights_file_type.rs new file mode 100644 index 0000000000..caba87c7f1 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/model_weights/weights_file_type.rs @@ -0,0 +1,69 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +// TODO we will need to scan the checkpoints for malicious code. We can't just trust the file extension. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum WeightsFileType { + #[serde(rename = "checkpoint")] + Checkpoint, + #[serde(rename = "safetensors")] + SafeTensors, +} + +impl WeightsFileType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Checkpoint => "checkpoint", + Self::SafeTensors => "safetensors", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "checkpoint" => Ok(Self::Checkpoint), + "safetensors" => Ok(Self::SafeTensors), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Checkpoint, + Self::SafeTensors, + ]) + } +} + +impl_enum_display_and_debug_using_to_str!(WeightsFileType); +impl_mysql_enum_coders!(WeightsFileType); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(WeightsFileType::Checkpoint.to_str(), "checkpoint"); + assert_eq!(WeightsFileType::SafeTensors.to_str(), "safetensors"); + } + + #[test] + fn test_from_str() { + assert_eq!(WeightsFileType::from_str("checkpoint").unwrap(), WeightsFileType::Checkpoint); + assert_eq!(WeightsFileType::from_str("safetensors").unwrap(), WeightsFileType::SafeTensors); + assert!(WeightsFileType::from_str("invalid").is_err()); + } + + #[test] + fn test_all_variants() { + let variants = WeightsFileType::all_variants(); + assert_eq!(variants.len(), 2); + assert!(variants.contains(&WeightsFileType::Checkpoint)); + assert!(variants.contains(&WeightsFileType::SafeTensors)); + } +} \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/model_weights/weights_types.rs b/crates/schema/public/enums/src/by_table/model_weights/weights_types.rs new file mode 100644 index 0000000000..b58bc56b39 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/model_weights/weights_types.rs @@ -0,0 +1,165 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum WeightsType { + #[serde(rename = "hifigan_tt2")] + HifiganTacotron2, + #[serde(rename = "rvc_v2")] + RvcV2, + #[serde(rename = "sd_1.5")] + StableDiffusion15, + #[serde(rename = "sdxl")] + StableDiffusionXL, + #[serde(rename = "so_vits_svc")] + SoVitsSvc, + #[serde(rename = "tt2")] + Tacotron2, + #[serde(rename = "loRA")] + LoRA, + #[serde(rename = "vall_e")] + VallE, + #[serde(rename = "comfy_ui")] + ComfyUi, + + #[serde(rename = "gpt_so_vits")] + GptSoVits, +} + + +impl WeightsType { + pub fn to_str(&self) -> &'static str { + match self { + Self::HifiganTacotron2 => "hifigan_tt2", + Self::RvcV2 => "rvc_v2", + Self::StableDiffusion15 => "sd_1.5", + Self::StableDiffusionXL => "sdxl", + Self::SoVitsSvc => "so_vits_svc", + Self::Tacotron2 => "tt2", + Self::LoRA => "loRA", + Self::VallE => "vall_e", + Self::ComfyUi => "comfy_ui", + Self::GptSoVits => "gpt_so_vits", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "hifigan_tt2" => Ok(Self::HifiganTacotron2), + "rvc_v2" => Ok(Self::RvcV2), + "sd_1.5" => Ok(Self::StableDiffusion15), + "sdxl" => Ok(Self::StableDiffusionXL), + "so_vits_svc" => Ok(Self::SoVitsSvc), + "tt2" => Ok(Self::Tacotron2), + "loRA" => Ok(Self::LoRA), + "vall_e" => Ok(Self::VallE), + "comfy_ui" => Ok(Self::ComfyUi), + "gpt_so_vits" => Ok(Self::GptSoVits), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::HifiganTacotron2, + Self::RvcV2, + Self::StableDiffusion15, + Self::StableDiffusionXL, + Self::SoVitsSvc, + Self::Tacotron2, + Self::LoRA, + Self::VallE, + Self::ComfyUi, + Self::GptSoVits, + ]) + } +} + +impl_enum_display_and_debug_using_to_str!(WeightsType); +impl_mysql_enum_coders!(WeightsType); +impl_mysql_from_row!(WeightsType); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(WeightsType::HifiganTacotron2.to_str(), "hifigan_tt2"); + assert_eq!(WeightsType::RvcV2.to_str(), "rvc_v2"); + assert_eq!(WeightsType::StableDiffusion15.to_str(), "sd_1.5"); + assert_eq!(WeightsType::StableDiffusionXL.to_str(), "sdxl"); + assert_eq!(WeightsType::SoVitsSvc.to_str(), "so_vits_svc"); + assert_eq!(WeightsType::Tacotron2.to_str(), "tt2"); + assert_eq!(WeightsType::LoRA.to_str(), "loRA"); + assert_eq!(WeightsType::VallE.to_str(), "vall_e"); + assert_eq!(WeightsType::ComfyUi.to_str(), "comfy_ui"); + assert_eq!(WeightsType::GptSoVits.to_str(), "gpt_so_vits"); + } + + #[test] + fn test_from_str() { + assert_eq!(WeightsType::from_str("hifigan_tt2").unwrap(), WeightsType::HifiganTacotron2); + assert_eq!(WeightsType::from_str("rvc_v2").unwrap(), WeightsType::RvcV2); + assert_eq!(WeightsType::from_str("sd_1.5").unwrap(), WeightsType::StableDiffusion15); + assert_eq!(WeightsType::from_str("sdxl").unwrap(), WeightsType::StableDiffusionXL); + assert_eq!(WeightsType::from_str("so_vits_svc").unwrap(), WeightsType::SoVitsSvc); + assert_eq!(WeightsType::from_str("tt2").unwrap(), WeightsType::Tacotron2); + assert_eq!(WeightsType::from_str("loRA").unwrap(), WeightsType::LoRA); + assert_eq!(WeightsType::from_str("vall_e").unwrap(), WeightsType::VallE); + assert_eq!(WeightsType::from_str("comfy_ui").unwrap(), WeightsType::ComfyUi); + assert_eq!(WeightsType::from_str("gpt_so_vits").unwrap(), WeightsType::GptSoVits); + assert!(WeightsType::from_str("invalid").is_err()); + } + + #[test] + fn test_all_variants() { + let variants = WeightsType::all_variants(); + assert_eq!(variants.len(), 10); + assert!(variants.contains(&WeightsType::HifiganTacotron2)); + assert!(variants.contains(&WeightsType::RvcV2)); + assert!(variants.contains(&WeightsType::StableDiffusion15)); + assert!(variants.contains(&WeightsType::StableDiffusionXL)); + assert!(variants.contains(&WeightsType::SoVitsSvc)); + assert!(variants.contains(&WeightsType::Tacotron2)); + assert!(variants.contains(&WeightsType::LoRA)); + assert!(variants.contains(&WeightsType::VallE)); + assert!(variants.contains(&WeightsType::ComfyUi)); + assert!(variants.contains(&WeightsType::GptSoVits)); + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(WeightsType::all_variants().len(), WeightsType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in WeightsType::all_variants() { + assert_eq!(variant, WeightsType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, WeightsType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, WeightsType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in WeightsType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/prompt_context_items/mod.rs b/crates/schema/public/enums/src/by_table/prompt_context_items/mod.rs new file mode 100644 index 0000000000..d460910ccd --- /dev/null +++ b/crates/schema/public/enums/src/by_table/prompt_context_items/mod.rs @@ -0,0 +1 @@ +pub mod prompt_context_semantic_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/prompt_context_items/prompt_context_semantic_type.rs b/crates/schema/public/enums/src/by_table/prompt_context_items/prompt_context_semantic_type.rs new file mode 100644 index 0000000000..5a0852da8c --- /dev/null +++ b/crates/schema/public/enums/src/by_table/prompt_context_items/prompt_context_semantic_type.rs @@ -0,0 +1,215 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/* +Current DB values - this is in conflict with API serializations. Need to fix! +imgref +imgsrc +imgmask +vid_start_frame +vid_end_frame +vidref +*/ + +/// Used in the `prompt_context_items` table in a `VARCHAR(16)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PromptContextSemanticType { + /// Image-to-Video starting frame + VidStartFrame, + + /// Image-to-Video ending frame + VidEndFrame, + + /// Reference image for video generation (e.g. Seedance "vidref" mode) + VidRef, + + /// Source image, eg. for inpainting. + Imgsrc, + + /// Image mask, eg. for inpainting. + Imgmask, + + /// Standard image reference without a semantic type (e.g. Sora/ChatGPT 4o/gpt-image-1) + Imgref, + + ImgrefCharacter, + ImgrefStyle, + ImgrefBg, + + /// Audio reference (e.g. for audio-to-video generation) + Audioref, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(PromptContextSemanticType); +impl_mysql_enum_coders!(PromptContextSemanticType); +impl_mysql_from_row!(PromptContextSemanticType); + +/// NB: Legacy API for older code. +impl PromptContextSemanticType { + pub fn to_str(&self) -> &'static str { + match self { + Self::VidStartFrame => "vid_start_frame", + Self::VidEndFrame => "vid_end_frame", + Self::VidRef => "vidref", + Self::Imgsrc => "imgsrc", + Self::Imgmask => "imgmask", + Self::Imgref => "imgref", + Self::ImgrefCharacter => "imgref_character", + Self::ImgrefStyle => "imgref_style", + Self::ImgrefBg => "imgref_bg", + Self::Audioref => "audioref", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "vid_start_frame" => Ok(Self::VidStartFrame), + "vid_end_frame" => Ok(Self::VidEndFrame), + "vidref" => Ok(Self::VidRef), + "imgsrc" => Ok(Self::Imgsrc), + "imgmask" => Ok(Self::Imgmask), + "imgref" => Ok(Self::Imgref), + "imgref_character" => Ok(Self::ImgrefCharacter), + "imgref_style" => Ok(Self::ImgrefStyle), + "imgref_bg" => Ok(Self::ImgrefBg), + "audioref" => Ok(Self::Audioref), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::VidStartFrame, + Self::VidEndFrame, + Self::VidRef, + Self::Imgsrc, + Self::Imgmask, + Self::Imgref, + Self::ImgrefCharacter, + Self::ImgrefStyle, + Self::ImgrefBg, + Self::Audioref, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PromptContextSemanticType::VidStartFrame, "vid_start_frame"); + assert_serialization(PromptContextSemanticType::VidEndFrame, "vid_end_frame"); + // NB: serde serialization ("vid_ref") intentionally differs from to_str()/the DB value + // ("vidref"); see the "Current DB values" comment at the top of this file. + assert_serialization(PromptContextSemanticType::VidRef, "vid_ref"); + assert_serialization(PromptContextSemanticType::Imgsrc, "imgsrc"); + assert_serialization(PromptContextSemanticType::Imgmask, "imgmask"); + assert_serialization(PromptContextSemanticType::Imgref, "imgref"); + assert_serialization(PromptContextSemanticType::ImgrefCharacter, "imgref_character"); + assert_serialization(PromptContextSemanticType::ImgrefStyle, "imgref_style"); + assert_serialization(PromptContextSemanticType::ImgrefBg, "imgref_bg"); + assert_serialization(PromptContextSemanticType::Audioref, "audioref"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(PromptContextSemanticType::VidStartFrame.to_str(), "vid_start_frame"); + assert_eq!(PromptContextSemanticType::VidEndFrame.to_str(), "vid_end_frame"); + assert_eq!(PromptContextSemanticType::VidRef.to_str(), "vidref"); + assert_eq!(PromptContextSemanticType::Imgsrc.to_str(), "imgsrc"); + assert_eq!(PromptContextSemanticType::Imgmask.to_str(), "imgmask"); + assert_eq!(PromptContextSemanticType::Imgref.to_str(), "imgref"); + assert_eq!(PromptContextSemanticType::ImgrefCharacter.to_str(), "imgref_character"); + assert_eq!(PromptContextSemanticType::ImgrefStyle.to_str(), "imgref_style"); + assert_eq!(PromptContextSemanticType::ImgrefBg.to_str(), "imgref_bg"); + assert_eq!(PromptContextSemanticType::Audioref.to_str(), "audioref"); + } + + #[test] + fn from_str() { + assert_eq!(PromptContextSemanticType::from_str("vid_start_frame").unwrap(), PromptContextSemanticType::VidStartFrame); + assert_eq!(PromptContextSemanticType::from_str("vid_end_frame").unwrap(), PromptContextSemanticType::VidEndFrame); + assert_eq!(PromptContextSemanticType::from_str("vidref").unwrap(), PromptContextSemanticType::VidRef); + assert_eq!(PromptContextSemanticType::from_str("imgsrc").unwrap(), PromptContextSemanticType::Imgsrc); + assert_eq!(PromptContextSemanticType::from_str("imgmask").unwrap(), PromptContextSemanticType::Imgmask); + assert_eq!(PromptContextSemanticType::from_str("imgref").unwrap(), PromptContextSemanticType::Imgref); + assert_eq!(PromptContextSemanticType::from_str("imgref_character").unwrap(), PromptContextSemanticType::ImgrefCharacter); + assert_eq!(PromptContextSemanticType::from_str("imgref_style").unwrap(), PromptContextSemanticType::ImgrefStyle); + assert_eq!(PromptContextSemanticType::from_str("imgref_bg").unwrap(), PromptContextSemanticType::ImgrefBg); + assert_eq!(PromptContextSemanticType::from_str("audioref").unwrap(), PromptContextSemanticType::Audioref); + assert!(PromptContextSemanticType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = PromptContextSemanticType::all_variants(); + assert_eq!(variants.len(), 10); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidStartFrame)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidEndFrame)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidRef)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::Imgsrc)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::Imgmask)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::Imgref)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefCharacter)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefStyle)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefBg)); + assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::Audioref)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PromptContextSemanticType::all_variants().len(), PromptContextSemanticType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PromptContextSemanticType::all_variants() { + assert_eq!(variant, PromptContextSemanticType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PromptContextSemanticType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PromptContextSemanticType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in PromptContextSemanticType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/prompts/mod.rs b/crates/schema/public/enums/src/by_table/prompts/mod.rs new file mode 100644 index 0000000000..6f7977d6a7 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/prompts/mod.rs @@ -0,0 +1 @@ +pub mod prompt_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/prompts/prompt_type.rs b/crates/schema/public/enums/src/by_table/prompts/prompt_type.rs new file mode 100644 index 0000000000..0f8c747637 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/prompts/prompt_type.rs @@ -0,0 +1,145 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `prompts` table in a `VARCHAR(16)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PromptType { + /// Artcraft (App) + ArtcraftApp, + + /// Stable diffusion + #[deprecated] + StableDiffusion, + + /// Comfy UI + #[deprecated] + ComfyUi, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(PromptType); +impl_mysql_enum_coders!(PromptType); +impl_mysql_from_row!(PromptType); + +/// NB: Legacy API for older code. +impl PromptType { + pub fn to_str(&self) -> &'static str { + match self { + Self::ArtcraftApp => "artcraft_app", + Self::StableDiffusion => "stable_diffusion", + Self::ComfyUi => "comfy_ui", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "artcraft_app" => Ok(Self::ArtcraftApp), + "stable_diffusion" => Ok(Self::StableDiffusion), + "comfy_ui" => Ok(Self::ComfyUi), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ArtcraftApp, + Self::StableDiffusion, + Self::ComfyUi, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::prompts::prompt_type::PromptType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PromptType::ArtcraftApp, "artcraft_app"); + assert_serialization(PromptType::StableDiffusion, "stable_diffusion"); + assert_serialization(PromptType::ComfyUi, "comfy_ui"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(PromptType::ArtcraftApp.to_str(), "artcraft_app"); + assert_eq!(PromptType::StableDiffusion.to_str(), "stable_diffusion"); + assert_eq!(PromptType::ComfyUi.to_str(), "comfy_ui"); + } + + #[test] + fn from_str() { + assert_eq!(PromptType::from_str("artcraft_app").unwrap(), PromptType::ArtcraftApp); + assert_eq!(PromptType::from_str("stable_diffusion").unwrap(), PromptType::StableDiffusion); + assert_eq!(PromptType::from_str("comfy_ui").unwrap(), PromptType::ComfyUi); + assert!(PromptType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = PromptType::all_variants(); + assert_eq!(variants.len(), 3); + assert_eq!(variants.pop_first(), Some(PromptType::ArtcraftApp)); + assert_eq!(variants.pop_first(), Some(PromptType::StableDiffusion)); + assert_eq!(variants.pop_first(), Some(PromptType::ComfyUi)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PromptType::all_variants().len(), PromptType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PromptType::all_variants() { + assert_eq!(variant, PromptType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PromptType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PromptType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in PromptType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/mod.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/mod.rs new file mode 100644 index 0000000000..b7eaa8b495 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/mod.rs @@ -0,0 +1,2 @@ +pub mod staff_audit_action; +pub mod staff_audit_entity_type; diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs new file mode 100644 index 0000000000..7b801697d3 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs @@ -0,0 +1,175 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `staff_audit_logs` table in `VARCHAR(32)` field `entity_action`. +/// +/// The type of staff action that was performed. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum StaffAuditAction { + /// Staff initiated an impersonation request for a user. + #[serde(rename = "impersonate_user_request")] + ImpersonateUserRequest, + + /// Staff redeemed an impersonation request, creating a session. + #[serde(rename = "impersonate_user_redeem")] + ImpersonateUserRedeem, + + /// Staff banned a user. + #[serde(rename = "ban_user")] + BanUser, + + /// Staff unbanned a user. + #[serde(rename = "unban_user")] + UnbanUser, + + /// Staff added banked balance to a wallet. + #[serde(rename = "add_wallet_banked_balance")] + AddWalletBankedBalance, + + /// Staff sent a manual alert via the pager system. + #[serde(rename = "send_alert")] + SendAlert, + + /// Staff edited a user's feature flags. + #[serde(rename = "edit_user_feature_flags")] + EditUserFeatureFlags, + + /// Staff changed a user's email address. + #[serde(rename = "change_user_email")] + ChangeUserEmail, +} + +impl_enum_display_and_debug_using_to_str!(StaffAuditAction); +impl_mysql_enum_coders!(StaffAuditAction); +impl_mysql_from_row!(StaffAuditAction); + +impl StaffAuditAction { + pub fn to_str(&self) -> &'static str { + match self { + Self::ImpersonateUserRequest => "impersonate_user_request", + Self::ImpersonateUserRedeem => "impersonate_user_redeem", + Self::BanUser => "ban_user", + Self::UnbanUser => "unban_user", + Self::AddWalletBankedBalance => "add_wallet_banked_balance", + Self::SendAlert => "send_alert", + Self::EditUserFeatureFlags => "edit_user_feature_flags", + Self::ChangeUserEmail => "change_user_email", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "impersonate_user_request" => Ok(Self::ImpersonateUserRequest), + "impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem), + "ban_user" => Ok(Self::BanUser), + "unban_user" => Ok(Self::UnbanUser), + "add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance), + "send_alert" => Ok(Self::SendAlert), + "edit_user_feature_flags" => Ok(Self::EditUserFeatureFlags), + "change_user_email" => Ok(Self::ChangeUserEmail), + _ => Err(format!("invalid StaffAuditAction value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::ImpersonateUserRequest, + Self::ImpersonateUserRedeem, + Self::BanUser, + Self::UnbanUser, + Self::AddWalletBankedBalance, + Self::SendAlert, + Self::EditUserFeatureFlags, + Self::ChangeUserEmail, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(StaffAuditAction::ImpersonateUserRequest, "impersonate_user_request"); + assert_serialization(StaffAuditAction::ImpersonateUserRedeem, "impersonate_user_redeem"); + assert_serialization(StaffAuditAction::BanUser, "ban_user"); + assert_serialization(StaffAuditAction::UnbanUser, "unban_user"); + assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance"); + assert_serialization(StaffAuditAction::SendAlert, "send_alert"); + assert_serialization(StaffAuditAction::EditUserFeatureFlags, "edit_user_feature_flags"); + assert_serialization(StaffAuditAction::ChangeUserEmail, "change_user_email"); + } + + #[test] + fn to_str() { + assert_eq!(StaffAuditAction::ImpersonateUserRequest.to_str(), "impersonate_user_request"); + assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem"); + assert_eq!(StaffAuditAction::BanUser.to_str(), "ban_user"); + assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user"); + assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance"); + assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert"); + assert_eq!(StaffAuditAction::EditUserFeatureFlags.to_str(), "edit_user_feature_flags"); + assert_eq!(StaffAuditAction::ChangeUserEmail.to_str(), "change_user_email"); + } + + #[test] + fn from_str() { + assert_eq!(StaffAuditAction::from_str("impersonate_user_request").unwrap(), StaffAuditAction::ImpersonateUserRequest); + assert_eq!(StaffAuditAction::from_str("impersonate_user_redeem").unwrap(), StaffAuditAction::ImpersonateUserRedeem); + assert_eq!(StaffAuditAction::from_str("ban_user").unwrap(), StaffAuditAction::BanUser); + assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser); + assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance); + assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert); + assert_eq!(StaffAuditAction::from_str("edit_user_feature_flags").unwrap(), StaffAuditAction::EditUserFeatureFlags); + assert_eq!(StaffAuditAction::from_str("change_user_email").unwrap(), StaffAuditAction::ChangeUserEmail); + assert!(StaffAuditAction::from_str("invalid").is_err()); + } + + #[test] + fn all_variants() { + const EXPECTED_COUNT: usize = 8; + assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StaffAuditAction::all_variants().len(), StaffAuditAction::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StaffAuditAction::all_variants() { + assert_eq!(variant, StaffAuditAction::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StaffAuditAction::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StaffAuditAction::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in StaffAuditAction::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_entity_type.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_entity_type.rs new file mode 100644 index 0000000000..9bbdd4e680 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_entity_type.rs @@ -0,0 +1,115 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `staff_audit_logs` table in `VARCHAR(32)` field `entity_type`. +/// +/// The type of entity that a staff action was performed on. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum StaffAuditEntityType { + /// A user account. + #[serde(rename = "user")] + User, + + /// A wallet. + #[serde(rename = "wallet")] + Wallet, +} + +impl_enum_display_and_debug_using_to_str!(StaffAuditEntityType); +impl_mysql_enum_coders!(StaffAuditEntityType); +impl_mysql_from_row!(StaffAuditEntityType); + +impl StaffAuditEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::Wallet => "wallet", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "user" => Ok(Self::User), + "wallet" => Ok(Self::Wallet), + _ => Err(format!("invalid StaffAuditEntityType value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::User, + Self::Wallet, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(StaffAuditEntityType::User, "user"); + assert_serialization(StaffAuditEntityType::Wallet, "wallet"); + } + + #[test] + fn to_str() { + assert_eq!(StaffAuditEntityType::User.to_str(), "user"); + assert_eq!(StaffAuditEntityType::Wallet.to_str(), "wallet"); + } + + #[test] + fn from_str() { + assert_eq!(StaffAuditEntityType::from_str("user").unwrap(), StaffAuditEntityType::User); + assert_eq!(StaffAuditEntityType::from_str("wallet").unwrap(), StaffAuditEntityType::Wallet); + assert!(StaffAuditEntityType::from_str("invalid").is_err()); + } + + #[test] + fn all_variants() { + const EXPECTED_COUNT: usize = 2; + assert_eq!(StaffAuditEntityType::all_variants().len(), EXPECTED_COUNT); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StaffAuditEntityType::all_variants().len(), StaffAuditEntityType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StaffAuditEntityType::all_variants() { + assert_eq!(variant, StaffAuditEntityType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StaffAuditEntityType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StaffAuditEntityType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in StaffAuditEntityType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/trending_model_analytics/mod.rs b/crates/schema/public/enums/src/by_table/trending_model_analytics/mod.rs new file mode 100644 index 0000000000..fcb0d46e1b --- /dev/null +++ b/crates/schema/public/enums/src/by_table/trending_model_analytics/mod.rs @@ -0,0 +1,2 @@ +pub mod model_type; +pub mod window_name; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/trending_model_analytics/model_type.rs b/crates/schema/public/enums/src/by_table/trending_model_analytics/model_type.rs new file mode 100644 index 0000000000..176bb34b2d --- /dev/null +++ b/crates/schema/public/enums/src/by_table/trending_model_analytics/model_type.rs @@ -0,0 +1,62 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `trending_model_analytics` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ModelType { + /// TTS models + Tts, + + // NB: It's assumed we'll use this same system to track + // trending VC models too, so the next type would be "VC". +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(ModelType); +impl_mysql_enum_coders!(ModelType); + +/// NB: Legacy API for older code. +impl ModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Tts => "tts", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "tts" => Ok(Self::Tts), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::trending_model_analytics::model_type::ModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(ModelType::Tts, "tts"); + } + + #[test] + fn test_to_str() { + assert_eq!(ModelType::Tts.to_str(), "tts"); + } + + #[test] + fn test_from_str() { + assert_eq!(ModelType::from_str("tts").unwrap(), ModelType::Tts); + assert!(ModelType::from_str("foo").is_err()); + } +} diff --git a/crates/schema/public/enums/src/by_table/trending_model_analytics/window_name.rs b/crates/schema/public/enums/src/by_table/trending_model_analytics/window_name.rs new file mode 100644 index 0000000000..798d3a4589 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/trending_model_analytics/window_name.rs @@ -0,0 +1,87 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `window_name` table in a `VARCHAR` field. +/// +/// Contrary to most of this crate and unlike most "enum"-types +/// that are inflexible, new window names can be added/removed +/// without breaking too much. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum WindowName { + /// Analytics over the last three hours + #[serde(rename = "last_3_hours")] + Last3Hours, + + /// Analytics over the last three hours + #[serde(rename = "last_3_days")] + Last3Days, + + /// Analytics over all historical records + #[serde(rename = "all_time")] + AllTime, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(WindowName); +impl_mysql_enum_coders!(WindowName); + +/// NB: Legacy API for older code. +impl WindowName { + pub fn to_str(&self) -> &'static str { + match self { + Self::Last3Hours => "last_3_hours", + Self::Last3Days => "last_3_days", + Self::AllTime => "all_time", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "last_3_hours" => Ok(Self::Last3Hours), + "last_3_days" => Ok(Self::Last3Days), + "all_time" => Ok(Self::AllTime), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::trending_model_analytics::window_name::WindowName; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(WindowName::Last3Hours, "last_3_hours"); + assert_serialization(WindowName::Last3Days, "last_3_days"); + assert_serialization(WindowName::AllTime, "all_time"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(WindowName::Last3Hours.to_str(), "last_3_hours"); + assert_eq!(WindowName::Last3Days.to_str(), "last_3_days"); + assert_eq!(WindowName::AllTime.to_str(), "all_time"); + } + + #[test] + fn test_from_str() { + assert_eq!(WindowName::from_str("last_3_hours").unwrap(), WindowName::Last3Hours); + assert_eq!(WindowName::from_str("last_3_days").unwrap(), WindowName::Last3Days); + assert_eq!(WindowName::from_str("all_time").unwrap(), WindowName::AllTime); + assert!(WindowName::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/tts_models/mod.rs b/crates/schema/public/enums/src/by_table/tts_models/mod.rs new file mode 100644 index 0000000000..49cb0e29bc --- /dev/null +++ b/crates/schema/public/enums/src/by_table/tts_models/mod.rs @@ -0,0 +1 @@ +pub mod tts_model_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/tts_models/tts_model_type.rs b/crates/schema/public/enums/src/by_table/tts_models/tts_model_type.rs new file mode 100644 index 0000000000..c1340dd04e --- /dev/null +++ b/crates/schema/public/enums/src/by_table/tts_models/tts_model_type.rs @@ -0,0 +1,67 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `tts_models` table in an `ENUM` field. +/// -- Furthermore -- not all enum values are represented !! +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +pub enum TtsModelType { + #[serde(rename = "tacotron2")] + Tacotron2, + + #[serde(rename = "vits")] + Vits, +} + +// TODO(bt, 2023-04-03): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(TtsModelType); +impl_mysql_enum_coders!(TtsModelType); + +/// NB: Legacy API for older code. +impl TtsModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Tacotron2 => "tacotron2", + Self::Vits => "vits", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "tacotron2" => Ok(Self::Tacotron2), + "vits" => Ok(Self::Vits), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::tts_models::tts_model_type::TtsModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(TtsModelType::Tacotron2, "tacotron2"); + assert_serialization(TtsModelType::Vits, "vits"); + } + + #[test] + fn test_to_str() { + assert_eq!(TtsModelType::Tacotron2.to_str(), "tacotron2"); + assert_eq!(TtsModelType::Vits.to_str(), "vits"); + } + + #[test] + fn test_from_str() { + assert_eq!(TtsModelType::from_str("tacotron2").unwrap(), TtsModelType::Tacotron2); + assert_eq!(TtsModelType::from_str("vits").unwrap(), TtsModelType::Vits); + assert!(TtsModelType::from_str("foo").is_err()); + } +} diff --git a/crates/schema/public/enums/src/by_table/tts_render_tasks/mod.rs b/crates/schema/public/enums/src/by_table/tts_render_tasks/mod.rs new file mode 100644 index 0000000000..1eb035720c --- /dev/null +++ b/crates/schema/public/enums/src/by_table/tts_render_tasks/mod.rs @@ -0,0 +1 @@ +pub mod tts_render_status; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/tts_render_tasks/tts_render_status.rs b/crates/schema/public/enums/src/by_table/tts_render_tasks/tts_render_status.rs new file mode 100644 index 0000000000..6e2da6a11a --- /dev/null +++ b/crates/schema/public/enums/src/by_table/tts_render_tasks/tts_render_status.rs @@ -0,0 +1,105 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the SqLite `tts_render_tasks` table in a `TEXT` field named `tts_render_status`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum TtsRenderStatus { + #[serde(rename = "new")] + New, + + // TODO: Added to fix a big. This whole enum should die. + #[serde(rename = "processing")] + Processing, + + #[serde(rename = "skipped")] + Skipped, + + #[serde(rename = "failed")] + Failed, + + #[serde(rename = "permanently_failed")] + PermanentlyFailed, + + #[serde(rename = "success")] + Success, +} + +// TODO(bt, 2023-01-17): This desperately needs Sqlite integration tests! +impl_enum_display_and_debug_using_to_str!(TtsRenderStatus); +impl_sqlite_enum_coders!(TtsRenderStatus); + +/// NB: Legacy API for older code. +impl TtsRenderStatus { + pub fn to_str(&self) -> &'static str { + match self { + Self::New => "new", + Self::Processing => "processing", + Self::Skipped => "skipped", + Self::Failed => "failed", + Self::PermanentlyFailed => "permanently_failed", + Self::Success => "success", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "new" => Ok(Self::New), + "processing" => Ok(Self::Processing), + "skipped" => Ok(Self::Skipped), + "failed" => Ok(Self::Failed), + "permanently_failed" => Ok(Self::PermanentlyFailed), + "success" => Ok(Self::Success), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::tts_render_tasks::tts_render_status::TtsRenderStatus; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(TtsRenderStatus::New, "new"); + assert_serialization(TtsRenderStatus::Processing, "processing"); + assert_serialization(TtsRenderStatus::Skipped, "skipped"); + assert_serialization(TtsRenderStatus::Failed, "failed"); + assert_serialization(TtsRenderStatus::PermanentlyFailed, "permanently_failed"); + assert_serialization(TtsRenderStatus::Success, "success"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(TtsRenderStatus::New.to_str(), "new"); + assert_eq!(TtsRenderStatus::Processing.to_str(), "processing"); + assert_eq!(TtsRenderStatus::Skipped.to_str(), "skipped"); + assert_eq!(TtsRenderStatus::Failed.to_str(), "failed"); + assert_eq!(TtsRenderStatus::PermanentlyFailed.to_str(), "permanently_failed"); + assert_eq!(TtsRenderStatus::Success.to_str(), "success"); + } + + #[test] + fn test_from_str() { + assert_eq!(TtsRenderStatus::from_str("new").unwrap(), TtsRenderStatus::New); + assert_eq!(TtsRenderStatus::from_str("processing").unwrap(), TtsRenderStatus::Processing); + assert_eq!(TtsRenderStatus::from_str("skipped").unwrap(), TtsRenderStatus::Skipped); + assert_eq!(TtsRenderStatus::from_str("failed").unwrap(), TtsRenderStatus::Failed); + assert_eq!(TtsRenderStatus::from_str("permanently_failed").unwrap(), TtsRenderStatus::PermanentlyFailed); + assert_eq!(TtsRenderStatus::from_str("success").unwrap(), TtsRenderStatus::Success); + assert!(TtsRenderStatus::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_video_notes/mod.rs b/crates/schema/public/enums/src/by_table/uploaded_video_notes/mod.rs new file mode 100644 index 0000000000..42912c9d79 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_video_notes/mod.rs @@ -0,0 +1,2 @@ +pub mod uploaded_video_note_reported_model_type; +pub mod uploaded_video_note_reported_website; diff --git a/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_model_type.rs b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_model_type.rs new file mode 100644 index 0000000000..8a8817b41f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_model_type.rs @@ -0,0 +1,259 @@ +// The deprecated Seedance 2.0 Ultra variants must stay referenced by this +// enum's own coders, variant lists, and tests; suppress the lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_video_notes` table in a `VARCHAR(32)` field. +/// +/// The model the note's submitter claims the video is. The variant set mirrors +/// [`crate::common::generation::common_video_model::CommonVideoModel`]. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum UploadedVideoNoteReportedModelType { + #[serde(rename = "grok_video")] + GrokVideo, + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + // `preview_model` is a retired alias that collapses onto Seedance 2.0. + #[serde(rename = "seedance_2p0", alias = "preview_model")] + Seedance2p0, + // `preview_model_fast` is a retired alias that collapses onto Seedance 2.0 Fast. + #[serde(rename = "seedance_2p0_fast", alias = "preview_model_fast")] + Seedance2p0Fast, + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoNoteReportedModelType); +impl_mysql_enum_coders!(UploadedVideoNoteReportedModelType); +impl_mysql_from_row!(UploadedVideoNoteReportedModelType); + +impl UploadedVideoNoteReportedModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Kling16Pro => "kling_1p6_pro", + Self::Kling21Pro => "kling_2p1_pro", + Self::Kling21Master => "kling_2p1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Seedance10Lite => "seedance_1p0_lite", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Seedance2p0BytePlus => "seedance_2p0_bp", + Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast", + Self::Seedance2p0Ultra => "seedance_2p0_u", + Self::Seedance2p0UltraFast => "seedance_2p0_u_fast", + Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu", + Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "kling_1p6_pro" => Ok(Self::Kling16Pro), + "kling_2p1_pro" => Ok(Self::Kling21Pro), + "kling_2p1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "seedance_1p0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus), + "seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast), + "seedance_2p0_u" => Ok(Self::Seedance2p0Ultra), + "seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast), + "seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra), + "seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + // Retired values: collapse onto the Seedance 2.0 equivalents. + "preview_model" => Ok(Self::Seedance2p0), + "preview_model_fast" => Ok(Self::Seedance2p0Fast), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::GrokVideo, + Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::Seedance10Lite, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra, + Self::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + for variant in UploadedVideoNoteReportedModelType::all_variants() { + assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoNoteReportedModelType::from_str("foo").is_err()); + } + + #[test] + fn serde_round_trip() { + for variant in UploadedVideoNoteReportedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: UploadedVideoNoteReportedModelType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized); + } + } + + /// The MySQL string (to_str) and the JSON string (serde) must stay aligned. + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoNoteReportedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoNoteReportedModelType::all_variants().len(), UploadedVideoNoteReportedModelType::iter().len()); + assert_eq!(UploadedVideoNoteReportedModelType::COUNT, 28); + } + + /// Retired `preview_model*` values must still decode, collapsing onto Seedance 2.0. + #[test] + fn legacy_preview_values_map_to_seedance() { + assert_eq!( + UploadedVideoNoteReportedModelType::from_str("preview_model").unwrap(), + UploadedVideoNoteReportedModelType::Seedance2p0 + ); + assert_eq!( + UploadedVideoNoteReportedModelType::from_str("preview_model_fast").unwrap(), + UploadedVideoNoteReportedModelType::Seedance2p0Fast + ); + let decoded: UploadedVideoNoteReportedModelType = + serde_json::from_str("\"preview_model\"").unwrap(); + assert_eq!(decoded, UploadedVideoNoteReportedModelType::Seedance2p0); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoNoteReportedModelType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_website.rs b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_website.rs new file mode 100644 index 0000000000..7e4f2a796f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_video_notes/uploaded_video_note_reported_website.rs @@ -0,0 +1,122 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_video_notes` table in a `VARCHAR(32)` field. +/// +/// The website / platform the note's submitter says the video came from. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UploadedVideoNoteReportedWebsite { + Runway, + Higgsfield, + Krea, + OpenArt, + Artcraft, + Magnific, + FreePik, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoNoteReportedWebsite); +impl_mysql_enum_coders!(UploadedVideoNoteReportedWebsite); +impl_mysql_from_row!(UploadedVideoNoteReportedWebsite); + +impl UploadedVideoNoteReportedWebsite { + pub fn to_str(&self) -> &'static str { + match self { + Self::Runway => "runway", + Self::Higgsfield => "higgsfield", + Self::Krea => "krea", + Self::OpenArt => "open_art", + Self::Artcraft => "artcraft", + Self::Magnific => "magnific", + Self::FreePik => "free_pik", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "runway" => Ok(Self::Runway), + "higgsfield" => Ok(Self::Higgsfield), + "krea" => Ok(Self::Krea), + "open_art" => Ok(Self::OpenArt), + "artcraft" => Ok(Self::Artcraft), + "magnific" => Ok(Self::Magnific), + "free_pik" => Ok(Self::FreePik), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Runway, + Self::Higgsfield, + Self::Krea, + Self::OpenArt, + Self::Artcraft, + Self::Magnific, + Self::FreePik, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(UploadedVideoNoteReportedWebsite::Runway, "runway"); + assert_serialization(UploadedVideoNoteReportedWebsite::Higgsfield, "higgsfield"); + assert_serialization(UploadedVideoNoteReportedWebsite::Krea, "krea"); + assert_serialization(UploadedVideoNoteReportedWebsite::OpenArt, "open_art"); + assert_serialization(UploadedVideoNoteReportedWebsite::Artcraft, "artcraft"); + assert_serialization(UploadedVideoNoteReportedWebsite::Magnific, "magnific"); + assert_serialization(UploadedVideoNoteReportedWebsite::FreePik, "free_pik"); + } + + #[test] + fn round_trip() { + for variant in UploadedVideoNoteReportedWebsite::all_variants() { + assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoNoteReportedWebsite::from_str("foo").is_err()); + } + + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoNoteReportedWebsite::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoNoteReportedWebsite::all_variants().len(), UploadedVideoNoteReportedWebsite::iter().len()); + assert_eq!(UploadedVideoNoteReportedWebsite::COUNT, 7); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoNoteReportedWebsite::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_videos/mod.rs b/crates/schema/public/enums/src/by_table/uploaded_videos/mod.rs new file mode 100644 index 0000000000..c6f2f0c5e3 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_videos/mod.rs @@ -0,0 +1,2 @@ +pub mod uploaded_video_detected_model_family; +pub mod uploaded_video_detected_model_type; diff --git a/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_family.rs b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_family.rs new file mode 100644 index 0000000000..61e4aa55e2 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_family.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_videos` table in a `VARCHAR(32)` field. +/// +/// The detected provenance family of an uploaded video. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UploadedVideoDetectedModelFamily { + Seedance, + Veo, + Sora, + Kling, + Grok, + HappyHorse, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoDetectedModelFamily); +impl_mysql_enum_coders!(UploadedVideoDetectedModelFamily); +impl_mysql_from_row!(UploadedVideoDetectedModelFamily); + +impl UploadedVideoDetectedModelFamily { + pub fn to_str(&self) -> &'static str { + match self { + Self::Seedance => "seedance", + Self::Veo => "veo", + Self::Sora => "sora", + Self::Kling => "kling", + Self::Grok => "grok", + Self::HappyHorse => "happy_horse", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "seedance" => Ok(Self::Seedance), + "veo" => Ok(Self::Veo), + "sora" => Ok(Self::Sora), + "kling" => Ok(Self::Kling), + "grok" => Ok(Self::Grok), + "happy_horse" => Ok(Self::HappyHorse), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Seedance, + Self::Veo, + Self::Sora, + Self::Kling, + Self::Grok, + Self::HappyHorse, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(UploadedVideoDetectedModelFamily::Seedance, "seedance"); + assert_serialization(UploadedVideoDetectedModelFamily::Veo, "veo"); + assert_serialization(UploadedVideoDetectedModelFamily::Sora, "sora"); + assert_serialization(UploadedVideoDetectedModelFamily::Kling, "kling"); + assert_serialization(UploadedVideoDetectedModelFamily::Grok, "grok"); + assert_serialization(UploadedVideoDetectedModelFamily::HappyHorse, "happy_horse"); + } + + #[test] + fn round_trip() { + for variant in UploadedVideoDetectedModelFamily::all_variants() { + assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoDetectedModelFamily::from_str("foo").is_err()); + } + + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoDetectedModelFamily::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoDetectedModelFamily::all_variants().len(), UploadedVideoDetectedModelFamily::iter().len()); + assert_eq!(UploadedVideoDetectedModelFamily::COUNT, 6); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoDetectedModelFamily::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_type.rs b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_type.rs new file mode 100644 index 0000000000..e00b543dd4 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/uploaded_videos/uploaded_video_detected_model_type.rs @@ -0,0 +1,259 @@ +// The deprecated Seedance 2.0 Ultra variants must stay referenced by this +// enum's own coders, variant lists, and tests; suppress the lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `uploaded_videos` table in a `VARCHAR(32)` field. +/// +/// The detected model / variant of an uploaded video. The variant set mirrors +/// [`crate::common::generation::common_video_model::CommonVideoModel`]. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum UploadedVideoDetectedModelType { + #[serde(rename = "grok_video")] + GrokVideo, + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + // `preview_model` is a retired alias that collapses onto Seedance 2.0. + #[serde(rename = "seedance_2p0", alias = "preview_model")] + Seedance2p0, + // `preview_model_fast` is a retired alias that collapses onto Seedance 2.0 Fast. + #[serde(rename = "seedance_2p0_fast", alias = "preview_model_fast")] + Seedance2p0Fast, + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, +} + +impl_enum_display_and_debug_using_to_str!(UploadedVideoDetectedModelType); +impl_mysql_enum_coders!(UploadedVideoDetectedModelType); +impl_mysql_from_row!(UploadedVideoDetectedModelType); + +impl UploadedVideoDetectedModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Kling16Pro => "kling_1p6_pro", + Self::Kling21Pro => "kling_2p1_pro", + Self::Kling21Master => "kling_2p1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Seedance10Lite => "seedance_1p0_lite", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Seedance2p0BytePlus => "seedance_2p0_bp", + Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast", + Self::Seedance2p0Ultra => "seedance_2p0_u", + Self::Seedance2p0UltraFast => "seedance_2p0_u_fast", + Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu", + Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "kling_1p6_pro" => Ok(Self::Kling16Pro), + "kling_2p1_pro" => Ok(Self::Kling21Pro), + "kling_2p1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "seedance_1p0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus), + "seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast), + "seedance_2p0_u" => Ok(Self::Seedance2p0Ultra), + "seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast), + "seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra), + "seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + // Retired values: collapse onto the Seedance 2.0 equivalents. + "preview_model" => Ok(Self::Seedance2p0), + "preview_model_fast" => Ok(Self::Seedance2p0Fast), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::GrokVideo, + Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::Seedance10Lite, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra, + Self::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + for variant in UploadedVideoDetectedModelType::all_variants() { + assert_eq!(variant, UploadedVideoDetectedModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UploadedVideoDetectedModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + assert!(UploadedVideoDetectedModelType::from_str("foo").is_err()); + } + + #[test] + fn serde_round_trip() { + for variant in UploadedVideoDetectedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: UploadedVideoDetectedModelType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized); + } + } + + /// The MySQL string (to_str) and the JSON string (serde) must stay aligned. + #[test] + fn to_str_matches_serde() { + for variant in UploadedVideoDetectedModelType::all_variants() { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{}\"", variant.to_str())); + } + } + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(UploadedVideoDetectedModelType::all_variants().len(), UploadedVideoDetectedModelType::iter().len()); + assert_eq!(UploadedVideoDetectedModelType::COUNT, 28); + } + + /// Retired `preview_model*` values must still decode, collapsing onto Seedance 2.0. + #[test] + fn legacy_preview_values_map_to_seedance() { + assert_eq!( + UploadedVideoDetectedModelType::from_str("preview_model").unwrap(), + UploadedVideoDetectedModelType::Seedance2p0 + ); + assert_eq!( + UploadedVideoDetectedModelType::from_str("preview_model_fast").unwrap(), + UploadedVideoDetectedModelType::Seedance2p0Fast + ); + let decoded: UploadedVideoDetectedModelType = + serde_json::from_str("\"preview_model\"").unwrap(); + assert_eq!(decoded, UploadedVideoDetectedModelType::Seedance2p0); + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in UploadedVideoDetectedModelType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/usages/mod.rs b/crates/schema/public/enums/src/by_table/usages/mod.rs new file mode 100644 index 0000000000..52bd98967b --- /dev/null +++ b/crates/schema/public/enums/src/by_table/usages/mod.rs @@ -0,0 +1 @@ +pub mod usages_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/usages/usages_type.rs b/crates/schema/public/enums/src/by_table/usages/usages_type.rs new file mode 100644 index 0000000000..16b1f56af1 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/usages/usages_type.rs @@ -0,0 +1,128 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `usages` table in a `VARCHAR(16)` field. (Two fields!) +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UsagesType { + /// Represents a foreign key link against a model_weights record + ModelWeight, + + /// Represents a foreign key link against a media_files record + MediaFile, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UsagesType); +impl_mysql_enum_coders!(UsagesType); +impl_mysql_from_row!(UsagesType); + +/// NB: Legacy API for older code. +impl UsagesType { + pub fn to_str(&self) -> &'static str { + match self { + Self::ModelWeight => "model_weight", + Self::MediaFile => "media_file", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "model_weight" => Ok(Self::ModelWeight), + "media_file" => Ok(Self::MediaFile), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ModelWeight, + Self::MediaFile, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::usages::usages_type::UsagesType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UsagesType::ModelWeight, "model_weight"); + assert_serialization(UsagesType::MediaFile, "media_file"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(UsagesType::ModelWeight.to_str(), "model_weight"); + assert_eq!(UsagesType::MediaFile.to_str(), "media_file"); + } + + #[test] + fn from_str() { + assert_eq!(UsagesType::from_str("model_weight").unwrap(), UsagesType::ModelWeight); + assert_eq!(UsagesType::from_str("media_file").unwrap(), UsagesType::MediaFile); + assert!(UsagesType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = UsagesType::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(UsagesType::ModelWeight)); + assert_eq!(variants.pop_first(), Some(UsagesType::MediaFile)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(UsagesType::all_variants().len(), UsagesType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in UsagesType::all_variants() { + assert_eq!(variant, UsagesType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UsagesType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UsagesType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in UsagesType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/user_bookmarks/mod.rs b/crates/schema/public/enums/src/by_table/user_bookmarks/mod.rs new file mode 100644 index 0000000000..c3c539aad3 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_bookmarks/mod.rs @@ -0,0 +1 @@ +pub mod user_bookmark_entity_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/user_bookmarks/user_bookmark_entity_type.rs b/crates/schema/public/enums/src/by_table/user_bookmarks/user_bookmark_entity_type.rs new file mode 100644 index 0000000000..da317e730a --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_bookmarks/user_bookmark_entity_type.rs @@ -0,0 +1,139 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_bookmarks` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize, ToSchema)] +pub enum UserBookmarkEntityType { + /// User + #[serde(rename = "user")] + User, + + /// TTS model (architecture does not matter) + #[serde(rename = "tts_model")] + TtsModel, + + /// TTS result (architecture does not matter) + #[serde(rename = "tts_result")] + TtsResult, + + /// W2L template + #[serde(rename = "w2l_template")] + W2lTemplate, + + /// W2L result + #[serde(rename = "w2l_result")] + W2lResult, + + /// MediaFile + #[serde(rename = "media_file")] + MediaFile, + + /// ModelWeight (the new way to store models) + #[serde(rename = "model_weight")] + ModelWeight, + + /// VoiceConversionModel + #[serde(rename = "voice_conversion_model")] + VoiceConversionModel, + + /// ZsVoice + #[serde(rename = "zs_voice")] + ZsVoice, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UserBookmarkEntityType); +impl_mysql_enum_coders!(UserBookmarkEntityType); +impl_mysql_from_row!(UserBookmarkEntityType); + +/// NB: Legacy API for older code. +impl UserBookmarkEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::TtsModel => "tts_model", + Self::TtsResult => "tts_result", + Self::W2lTemplate => "w2l_template", + Self::W2lResult => "w2l_result", + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + Self::VoiceConversionModel => "voice_conversion_model", + Self::ZsVoice => "zs_voice", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "user" => Ok(Self::User), + "tts_model" => Ok(Self::TtsModel), + "tts_result" => Ok(Self::TtsResult), + "w2l_template" => Ok(Self::W2lTemplate), + "w2l_result" => Ok(Self::W2lResult), + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + "voice_conversion_model" => Ok(Self::VoiceConversionModel), + "zs_voice" => Ok(Self::ZsVoice), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UserBookmarkEntityType::User, "user"); + assert_serialization(UserBookmarkEntityType::TtsModel, "tts_model"); + assert_serialization(UserBookmarkEntityType::TtsResult, "tts_result"); + assert_serialization(UserBookmarkEntityType::W2lTemplate, "w2l_template"); + assert_serialization(UserBookmarkEntityType::W2lResult, "w2l_result"); + assert_serialization(UserBookmarkEntityType::MediaFile, "media_file"); + assert_serialization(UserBookmarkEntityType::ModelWeight, "model_weight"); + assert_serialization(UserBookmarkEntityType::VoiceConversionModel, "voice_conversion_model"); + assert_serialization(UserBookmarkEntityType::ZsVoice, "zs_voice"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(UserBookmarkEntityType::User.to_str(), "user"); + assert_eq!(UserBookmarkEntityType::TtsModel.to_str(), "tts_model"); + assert_eq!(UserBookmarkEntityType::TtsResult.to_str(), "tts_result"); + assert_eq!(UserBookmarkEntityType::W2lTemplate.to_str(), "w2l_template"); + assert_eq!(UserBookmarkEntityType::W2lResult.to_str(), "w2l_result"); + assert_eq!(UserBookmarkEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(UserBookmarkEntityType::ModelWeight.to_str(), "model_weight"); + assert_eq!(UserBookmarkEntityType::VoiceConversionModel.to_str(), "voice_conversion_model"); + assert_eq!(UserBookmarkEntityType::ZsVoice.to_str(), "zs_voice"); + } + + #[test] + fn test_from_str() { + assert_eq!(UserBookmarkEntityType::from_str("user").unwrap(), UserBookmarkEntityType::User); + assert_eq!(UserBookmarkEntityType::from_str("tts_model").unwrap(), UserBookmarkEntityType::TtsModel); + assert_eq!(UserBookmarkEntityType::from_str("tts_result").unwrap(), UserBookmarkEntityType::TtsResult); + assert_eq!(UserBookmarkEntityType::from_str("w2l_template").unwrap(), UserBookmarkEntityType::W2lTemplate); + assert_eq!(UserBookmarkEntityType::from_str("w2l_result").unwrap(), UserBookmarkEntityType::W2lResult); + assert_eq!(UserBookmarkEntityType::from_str("media_file").unwrap(), UserBookmarkEntityType::MediaFile); + assert_eq!(UserBookmarkEntityType::from_str("model_weight").unwrap(), UserBookmarkEntityType::ModelWeight); + assert_eq!(UserBookmarkEntityType::from_str("voice_conversion_model").unwrap(), UserBookmarkEntityType::VoiceConversionModel); + assert_eq!(UserBookmarkEntityType::from_str("zs_voice").unwrap(), UserBookmarkEntityType::ZsVoice); + assert!(UserBookmarkEntityType::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/user_ratings/entity_type.rs b/crates/schema/public/enums/src/by_table/user_ratings/entity_type.rs new file mode 100644 index 0000000000..1e9907ee4a --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_ratings/entity_type.rs @@ -0,0 +1,111 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_ratings` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize, ToSchema)] +pub enum UserRatingEntityType { + /// Media files (inference results, uploads, etc.) + #[serde(rename = "media_file")] + MediaFile, + + /// Model weights (modern, polymorphic, type agnostic) + #[serde(rename = "model_weight")] + ModelWeight, + + /// TTS model (architecture does not matter) + #[serde(rename = "tts_model")] + TtsModel, + + /// TTS result (architecture does not matter) + #[serde(rename = "tts_result")] + TtsResult, + + /// W2L template + #[serde(rename = "w2l_template")] + W2lTemplate, + + /// W2L result + #[serde(rename = "w2l_result")] + W2lResult, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UserRatingEntityType); +impl_mysql_enum_coders!(UserRatingEntityType); + +/// NB: Legacy API for older code. +impl UserRatingEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + Self::TtsModel => "tts_model", + Self::TtsResult => "tts_result", + Self::W2lTemplate => "w2l_template", + Self::W2lResult => "w2l_result", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + "tts_model" => Ok(Self::TtsModel), + "tts_result" => Ok(Self::TtsResult), + "w2l_template" => Ok(Self::W2lTemplate), + "w2l_result" => Ok(Self::W2lResult), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_ratings::entity_type::UserRatingEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UserRatingEntityType::MediaFile, "media_file"); + assert_serialization(UserRatingEntityType::ModelWeight, "model_weight"); + assert_serialization(UserRatingEntityType::TtsModel, "tts_model"); + assert_serialization(UserRatingEntityType::TtsResult, "tts_result"); + assert_serialization(UserRatingEntityType::W2lTemplate, "w2l_template"); + assert_serialization(UserRatingEntityType::W2lResult, "w2l_result"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(UserRatingEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(UserRatingEntityType::ModelWeight.to_str(), "model_weight"); + assert_eq!(UserRatingEntityType::TtsModel.to_str(), "tts_model"); + assert_eq!(UserRatingEntityType::TtsResult.to_str(), "tts_result"); + assert_eq!(UserRatingEntityType::W2lTemplate.to_str(), "w2l_template"); + assert_eq!(UserRatingEntityType::W2lResult.to_str(), "w2l_result"); + } + + #[test] + fn test_from_str() { + assert_eq!(UserRatingEntityType::from_str("media_file").unwrap(), UserRatingEntityType::MediaFile); + assert_eq!(UserRatingEntityType::from_str("model_weight").unwrap(), UserRatingEntityType::ModelWeight); + assert_eq!(UserRatingEntityType::from_str("tts_model").unwrap(), UserRatingEntityType::TtsModel); + assert_eq!(UserRatingEntityType::from_str("tts_result").unwrap(), UserRatingEntityType::TtsResult); + assert_eq!(UserRatingEntityType::from_str("w2l_template").unwrap(), UserRatingEntityType::W2lTemplate); + assert_eq!(UserRatingEntityType::from_str("w2l_result").unwrap(), UserRatingEntityType::W2lResult); + assert!(UserRatingEntityType::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/user_ratings/mod.rs b/crates/schema/public/enums/src/by_table/user_ratings/mod.rs new file mode 100644 index 0000000000..5f3a6ed2e0 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_ratings/mod.rs @@ -0,0 +1,2 @@ +pub mod entity_type; +pub mod rating_value; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/user_ratings/rating_value.rs b/crates/schema/public/enums/src/by_table/user_ratings/rating_value.rs new file mode 100644 index 0000000000..cffae688fe --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_ratings/rating_value.rs @@ -0,0 +1,163 @@ +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// UserRatingValue +/// +/// - Used in the `user_ratings` table as an `ENUM` field named `rating_type`. +/// - Used in the HTTP API. +/// +/// To use this in a query, the query must have type annotations. +/// See: https://www.gitmemory.com/issue/launchbadge/sqlx/1241/847154375 +/// eg. preferred_tts_result_visibility as `rating_type: enums::by_table::user_ratings::rating_type::UserRatingValue` +/// +/// See also: https://docs.rs/sqlx/0.4.0-beta.1/sqlx/trait.Type.html +/// +/// *DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY!* +/// +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(rename_all = "lowercase"))] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[serde(rename_all = "lowercase")] +pub enum UserRatingValue { + /// This is considered a ratings "soft deletion" and does not count towards a total score. + /// This is the default rating. + Neutral, + /// This is a positive vote / upvote / like. + /// They are available to non-logged-in users as long as they have the URL. + Positive, + /// This is a negative vote / downvote / dislike. + Negative, +} + + +impl_enum_display_and_debug_using_to_str!(UserRatingValue); + +impl Default for UserRatingValue { + fn default() -> Self { Self::Neutral } +} + +/// NB: Legacy API for older code. +impl UserRatingValue { + pub fn to_str(&self) -> &'static str { + match self { + Self::Neutral => "neutral", + Self::Positive => "positive", + Self::Negative => "negative", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "neutral" => Ok(Self::Neutral), + "positive" => Ok(Self::Positive), + "negative" => Ok(Self::Negative), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_ratings::rating_value::UserRatingValue; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(UserRatingValue::Neutral, "neutral"); + assert_serialization(UserRatingValue::Positive, "positive"); + assert_serialization(UserRatingValue::Negative, "negative"); + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(UserRatingValue::Neutral.to_str(), "neutral"); + assert_eq!(UserRatingValue::Positive.to_str(), "positive"); + assert_eq!(UserRatingValue::Negative.to_str(), "negative"); + } + + #[test] + fn from_str() { + assert_eq!(UserRatingValue::from_str("neutral").unwrap(), UserRatingValue::Neutral); + assert_eq!(UserRatingValue::from_str("positive").unwrap(), UserRatingValue::Positive); + assert_eq!(UserRatingValue::from_str("negative").unwrap(), UserRatingValue::Negative); + assert!(UserRatingValue::from_str("foo").is_err()); + } + } + + mod traits { + use super::*; + + #[test] + fn default() { + assert_eq!(UserRatingValue::default(), UserRatingValue::Neutral); + } + + #[test] + fn display() { + let visibility = UserRatingValue::Positive; + assert_eq!(format!("{}", visibility), "positive".to_string()); + } + + #[test] + fn debug() { + let visibility = UserRatingValue::Negative; + assert_eq!(format!("{:?}", visibility), "negative".to_string()); + } + } + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct CompositeType { + visibility: UserRatingValue, + string: String, + } + + mod serde_serialization { + use super::*; + + #[test] + fn serialize() { + let expected = "\"positive\"".to_string(); // NB: Quoted + + assert_eq!(expected, serde_json::to_string(&UserRatingValue::Positive).unwrap()); + + // Just to show this serializes the same as a string + assert_eq!(expected, serde_json::to_string("positive").unwrap()); + } + + #[test] + fn nested_serialize() { + let value = CompositeType { visibility: UserRatingValue::Negative, string: "bar".to_string() }; + let expected = r#"{"visibility":"negative","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } + } + + mod serde_deserialization { + use super::*; + + #[test] + fn deserialize() { + let payload = "\"positive\""; // NB: Quoted + let value: UserRatingValue = serde_json::from_str(payload).unwrap(); + assert_eq!(value, UserRatingValue::Positive); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"visibility":"neutral","string":"bar"}"#.to_string(); + let expected = CompositeType { + visibility: UserRatingValue::Neutral, + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/user_spend_events/mod.rs b/crates/schema/public/enums/src/by_table/user_spend_events/mod.rs new file mode 100644 index 0000000000..d8ce5e919c --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_spend_events/mod.rs @@ -0,0 +1,2 @@ +pub mod payment_event_type; +pub mod payment_source; diff --git a/crates/schema/public/enums/src/by_table/user_spend_events/payment_event_type.rs b/crates/schema/public/enums/src/by_table/user_spend_events/payment_event_type.rs new file mode 100644 index 0000000000..e1e6712847 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_spend_events/payment_event_type.rs @@ -0,0 +1,211 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_spend_events` table in a `VARCHAR(32)` field. +/// +/// Splits subscription revenue from credit-pack revenue, plus the +/// money-out (refund/chargeback) and non-revenue (manual / monthly refill) cases. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PaymentEventType { + /// First paid invoice of a new subscription. + #[serde(rename = "subscription_initial")] + SubscriptionInitial, + + /// Recurring paid invoice (monthly, or annual once per year). + #[serde(rename = "subscription_renewal")] + SubscriptionRenewal, + + /// Proration charge from a mid-cycle upgrade (positive amount). + #[serde(rename = "subscription_proration_upgrade")] + SubscriptionProrationUpgrade, + + /// Proration credit from a mid-cycle downgrade (typically a balance credit; + /// rare as a standalone settled event). NB: "subscription_proration_downgrade" + /// is 32 chars -- exactly the event_type VARCHAR(32) limit (zero headroom). + #[serde(rename = "subscription_proration_downgrade")] + SubscriptionProrationDowngrade, + + /// One-time credit pack purchase (the ~10x-volume case). + #[serde(rename = "credit_pack_purchase")] + CreditPackPurchase, + + /// Refund (amount negative). + #[serde(rename = "refund")] + Refund, + + /// Dispute / chargeback (amount negative). + #[serde(rename = "chargeback")] + Chargeback, + + /// Staff / console correction (payment_source = 'manual'). + #[serde(rename = "manual_adjustment")] + ManualAdjustment, + + /// Non-revenue event for annual-plan monthly credit refills (amount 0). + #[serde(rename = "subscription_monthly_refill")] + SubscriptionMonthlyRefill, +} + +impl_enum_display_and_debug_using_to_str!(PaymentEventType); +impl_mysql_enum_coders!(PaymentEventType); +impl_mysql_from_row!(PaymentEventType); + +/// NB: Legacy API for older code. +impl PaymentEventType { + pub fn to_str(&self) -> &'static str { + match self { + Self::SubscriptionInitial => "subscription_initial", + Self::SubscriptionRenewal => "subscription_renewal", + Self::SubscriptionProrationUpgrade => "subscription_proration_upgrade", + Self::SubscriptionProrationDowngrade => "subscription_proration_downgrade", + Self::CreditPackPurchase => "credit_pack_purchase", + Self::Refund => "refund", + Self::Chargeback => "chargeback", + Self::ManualAdjustment => "manual_adjustment", + Self::SubscriptionMonthlyRefill => "subscription_monthly_refill", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "subscription_initial" => Ok(Self::SubscriptionInitial), + "subscription_renewal" => Ok(Self::SubscriptionRenewal), + "subscription_proration_upgrade" => Ok(Self::SubscriptionProrationUpgrade), + "subscription_proration_downgrade" => Ok(Self::SubscriptionProrationDowngrade), + "credit_pack_purchase" => Ok(Self::CreditPackPurchase), + "refund" => Ok(Self::Refund), + "chargeback" => Ok(Self::Chargeback), + "manual_adjustment" => Ok(Self::ManualAdjustment), + "subscription_monthly_refill" => Ok(Self::SubscriptionMonthlyRefill), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::SubscriptionInitial, + Self::SubscriptionRenewal, + Self::SubscriptionProrationUpgrade, + Self::SubscriptionProrationDowngrade, + Self::CreditPackPurchase, + Self::Refund, + Self::Chargeback, + Self::ManualAdjustment, + Self::SubscriptionMonthlyRefill, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_spend_events::payment_event_type::PaymentEventType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PaymentEventType::SubscriptionInitial, "subscription_initial"); + assert_serialization(PaymentEventType::SubscriptionRenewal, "subscription_renewal"); + assert_serialization(PaymentEventType::SubscriptionProrationUpgrade, "subscription_proration_upgrade"); + assert_serialization(PaymentEventType::SubscriptionProrationDowngrade, "subscription_proration_downgrade"); + assert_serialization(PaymentEventType::CreditPackPurchase, "credit_pack_purchase"); + assert_serialization(PaymentEventType::Refund, "refund"); + assert_serialization(PaymentEventType::Chargeback, "chargeback"); + assert_serialization(PaymentEventType::ManualAdjustment, "manual_adjustment"); + assert_serialization(PaymentEventType::SubscriptionMonthlyRefill, "subscription_monthly_refill"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(PaymentEventType::SubscriptionInitial.to_str(), "subscription_initial"); + assert_eq!(PaymentEventType::SubscriptionRenewal.to_str(), "subscription_renewal"); + assert_eq!(PaymentEventType::SubscriptionProrationUpgrade.to_str(), "subscription_proration_upgrade"); + assert_eq!(PaymentEventType::SubscriptionProrationDowngrade.to_str(), "subscription_proration_downgrade"); + assert_eq!(PaymentEventType::CreditPackPurchase.to_str(), "credit_pack_purchase"); + assert_eq!(PaymentEventType::Refund.to_str(), "refund"); + assert_eq!(PaymentEventType::Chargeback.to_str(), "chargeback"); + assert_eq!(PaymentEventType::ManualAdjustment.to_str(), "manual_adjustment"); + assert_eq!(PaymentEventType::SubscriptionMonthlyRefill.to_str(), "subscription_monthly_refill"); + } + + #[test] + fn from_str() { + assert_eq!(PaymentEventType::from_str("subscription_initial").unwrap(), PaymentEventType::SubscriptionInitial); + assert_eq!(PaymentEventType::from_str("subscription_renewal").unwrap(), PaymentEventType::SubscriptionRenewal); + assert_eq!(PaymentEventType::from_str("subscription_proration_upgrade").unwrap(), PaymentEventType::SubscriptionProrationUpgrade); + assert_eq!(PaymentEventType::from_str("subscription_proration_downgrade").unwrap(), PaymentEventType::SubscriptionProrationDowngrade); + assert_eq!(PaymentEventType::from_str("credit_pack_purchase").unwrap(), PaymentEventType::CreditPackPurchase); + assert_eq!(PaymentEventType::from_str("refund").unwrap(), PaymentEventType::Refund); + assert_eq!(PaymentEventType::from_str("chargeback").unwrap(), PaymentEventType::Chargeback); + assert_eq!(PaymentEventType::from_str("manual_adjustment").unwrap(), PaymentEventType::ManualAdjustment); + assert_eq!(PaymentEventType::from_str("subscription_monthly_refill").unwrap(), PaymentEventType::SubscriptionMonthlyRefill); + assert!(PaymentEventType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = PaymentEventType::all_variants(); + assert_eq!(variants.len(), 9); + assert_eq!(variants.pop_first(), Some(PaymentEventType::SubscriptionInitial)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::SubscriptionRenewal)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::SubscriptionProrationUpgrade)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::SubscriptionProrationDowngrade)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::CreditPackPurchase)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::Refund)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::Chargeback)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::ManualAdjustment)); + assert_eq!(variants.pop_first(), Some(PaymentEventType::SubscriptionMonthlyRefill)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PaymentEventType::all_variants().len(), PaymentEventType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PaymentEventType::all_variants() { + assert_eq!(variant, PaymentEventType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PaymentEventType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PaymentEventType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in PaymentEventType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/user_spend_events/payment_source.rs b/crates/schema/public/enums/src/by_table/user_spend_events/payment_source.rs new file mode 100644 index 0000000000..7e4436aea5 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/user_spend_events/payment_source.rs @@ -0,0 +1,132 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_spend_events` table in a `VARCHAR(16)` field. +/// +/// Where a spend event came from. USD/Stripe only for the foreseeable future; +/// `manual` covers staff/console adjustments (which carry a NULL source_object_id). +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PaymentSource { + /// Stripe (the source for essentially all rows). + #[serde(rename = "stripe")] + Stripe, + + /// Manual staff / console adjustment (no provider object). + #[serde(rename = "manual")] + Manual, +} + +impl_enum_display_and_debug_using_to_str!(PaymentSource); +impl_mysql_enum_coders!(PaymentSource); +impl_mysql_from_row!(PaymentSource); + +/// NB: Legacy API for older code. +impl PaymentSource { + pub fn to_str(&self) -> &'static str { + match self { + Self::Stripe => "stripe", + Self::Manual => "manual", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "stripe" => Ok(Self::Stripe), + "manual" => Ok(Self::Manual), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Stripe, + Self::Manual, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_spend_events::payment_source::PaymentSource; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PaymentSource::Stripe, "stripe"); + assert_serialization(PaymentSource::Manual, "manual"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(PaymentSource::Stripe.to_str(), "stripe"); + assert_eq!(PaymentSource::Manual.to_str(), "manual"); + } + + #[test] + fn from_str() { + assert_eq!(PaymentSource::from_str("stripe").unwrap(), PaymentSource::Stripe); + assert_eq!(PaymentSource::from_str("manual").unwrap(), PaymentSource::Manual); + assert!(PaymentSource::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = PaymentSource::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(PaymentSource::Stripe)); + assert_eq!(variants.pop_first(), Some(PaymentSource::Manual)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PaymentSource::all_variants().len(), PaymentSource::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PaymentSource::all_variants() { + assert_eq!(variant, PaymentSource::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PaymentSource::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PaymentSource::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 16; + for variant in PaymentSource::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/users/mod.rs b/crates/schema/public/enums/src/by_table/users/mod.rs new file mode 100644 index 0000000000..486ee10772 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/users/mod.rs @@ -0,0 +1,3 @@ +pub mod user_feature_flag; +pub mod user_signup_method; +pub mod user_signup_source; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs b/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs new file mode 100644 index 0000000000..c76e596b8a --- /dev/null +++ b/crates/schema/public/enums/src/by_table/users/user_feature_flag.rs @@ -0,0 +1,254 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `users` table in a `VARCHAR` field (stored as comma separated set). +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UserFeatureFlag { + /// Grants a user the ability to list media + /// (For now, it's hidden until we get an NSFW filter.) + ExploreMedia, + + + /// Access to studio features + Studio, + + /// Whether users are allowed to upload 3D models + #[serde(rename = "upload_3d")] + Upload3d, + + /// Access to video style transfer + VideoStyleTransfer, + + /// Access to the faster Seedance 2.0 whitelist + #[serde(rename = "sd_wl")] + SeedanceWhitelist, + + /// Access to Happy Horse + #[serde(rename = "hh")] + HappyHorse, + + /// Access to Happy Horse (with a rate limit) + #[serde(rename = "hh_rl")] + HappyHorseRateLimit, + + /// Access to the referrals program + #[serde(rename = "referrals")] + ReferralsProgram, + + /// Access to API key creation + #[serde(rename = "api_key")] + ApiKey, + + /// Access to Minimax + #[serde(rename = "minimax")] + Minimax, + + /// Access to Minimax with debugging priority (admins) + #[serde(rename = "minimax_p")] + MinimaxPriority, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UserFeatureFlag); +impl_mysql_enum_coders!(UserFeatureFlag); +impl_mysql_from_row!(UserFeatureFlag); + +/// NB: Legacy API for older code. +impl UserFeatureFlag { + pub fn to_str(&self) -> &'static str { + match self { + Self::ExploreMedia => "explore_media", + Self::Studio => "studio", + Self::Upload3d => "upload_3d", + Self::VideoStyleTransfer => "video_style_transfer", + Self::SeedanceWhitelist => "sd_wl", + Self::HappyHorse => "hh", + Self::HappyHorseRateLimit => "hh_rl", + Self::ReferralsProgram => "referrals", + Self::ApiKey => "api_key", + Self::Minimax => "minimax", + Self::MinimaxPriority => "minimax_p", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "explore_media" => Ok(Self::ExploreMedia), + "studio" => Ok(Self::Studio), + "upload_3d" => Ok(Self::Upload3d), + "video_style_transfer" => Ok(Self::VideoStyleTransfer), + "sd_wl" => Ok(Self::SeedanceWhitelist), + "hh" => Ok(Self::HappyHorse), + "hh_rl" => Ok(Self::HappyHorseRateLimit), + "referrals" => Ok(Self::ReferralsProgram), + "api_key" => Ok(Self::ApiKey), + "minimax" => Ok(Self::Minimax), + "minimax_p" => Ok(Self::MinimaxPriority), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::ExploreMedia => "Explore Media", + Self::Studio => "Storyteller Studio", + Self::Upload3d => "Upload 3D", + Self::VideoStyleTransfer => "Video Style Transfer", + Self::SeedanceWhitelist => "DO NOT USE: Seedance Whitelist (legacy)", + Self::HappyHorse => "Happy Horse (no Rate Limit)", + Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)", + Self::ReferralsProgram => "Referrals Program", + Self::ApiKey => "API Keys", + Self::Minimax => "Minimax", + Self::MinimaxPriority => "Minimax (Debugging Priority)", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::ExploreMedia => "Grants a user the ability to list media on FakeYou (admin permission)", + Self::Studio => "Access to Storyteller Studio product", + Self::Upload3d => "Whether users are allowed to upload 3D models", + Self::VideoStyleTransfer => "Access to video style transfer on Storyteller", + Self::SeedanceWhitelist => "DO NOT USE THIS. IT WILL BREAK ACCOUNTS.", + 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", + Self::Minimax => "Access to Minimax", + Self::MinimaxPriority => "Access to Minimax with debugging priority (admins)", + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ExploreMedia, + Self::Studio, + Self::Upload3d, + Self::VideoStyleTransfer, + Self::SeedanceWhitelist, + Self::HappyHorse, + Self::HappyHorseRateLimit, + Self::ReferralsProgram, + Self::ApiKey, + Self::Minimax, + Self::MinimaxPriority, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::users::user_feature_flag::UserFeatureFlag; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UserFeatureFlag::ExploreMedia, "explore_media"); + assert_serialization(UserFeatureFlag::Studio, "studio"); + assert_serialization(UserFeatureFlag::Upload3d, "upload_3d"); + assert_serialization(UserFeatureFlag::VideoStyleTransfer, "video_style_transfer"); + assert_serialization(UserFeatureFlag::SeedanceWhitelist, "sd_wl"); + assert_serialization(UserFeatureFlag::HappyHorse, "hh"); + assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl"); + assert_serialization(UserFeatureFlag::ReferralsProgram, "referrals"); + assert_serialization(UserFeatureFlag::ApiKey, "api_key"); + assert_serialization(UserFeatureFlag::Minimax, "minimax"); + assert_serialization(UserFeatureFlag::MinimaxPriority, "minimax_p"); + } + + #[test] + fn test_to_str() { + assert_eq!(UserFeatureFlag::ExploreMedia.to_str(), "explore_media"); + assert_eq!(UserFeatureFlag::Studio.to_str(), "studio"); + assert_eq!(UserFeatureFlag::Upload3d.to_str(), "upload_3d"); + assert_eq!(UserFeatureFlag::VideoStyleTransfer.to_str(), "video_style_transfer"); + assert_eq!(UserFeatureFlag::SeedanceWhitelist.to_str(), "sd_wl"); + 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"); + assert_eq!(UserFeatureFlag::Minimax.to_str(), "minimax"); + assert_eq!(UserFeatureFlag::MinimaxPriority.to_str(), "minimax_p"); + } + + #[test] + fn test_from_str() { + assert_eq!(UserFeatureFlag::from_str("explore_media").unwrap(), UserFeatureFlag::ExploreMedia); + assert_eq!(UserFeatureFlag::from_str("studio").unwrap(), UserFeatureFlag::Studio); + assert_eq!(UserFeatureFlag::from_str("upload_3d").unwrap(), UserFeatureFlag::Upload3d); + assert_eq!(UserFeatureFlag::from_str("video_style_transfer").unwrap(), UserFeatureFlag::VideoStyleTransfer); + assert_eq!(UserFeatureFlag::from_str("sd_wl").unwrap(), UserFeatureFlag::SeedanceWhitelist); + 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_eq!(UserFeatureFlag::from_str("minimax").unwrap(), UserFeatureFlag::Minimax); + assert_eq!(UserFeatureFlag::from_str("minimax_p").unwrap(), UserFeatureFlag::MinimaxPriority); + assert!(UserFeatureFlag::from_str("foo").is_err()); + } + + #[test] + fn all_variants() { + let mut variants = UserFeatureFlag::all_variants(); + assert_eq!(variants.len(), 11); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia)); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio)); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d)); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::VideoStyleTransfer)); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::SeedanceWhitelist)); + 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(), Some(UserFeatureFlag::Minimax)); + assert_eq!(variants.pop_first(), Some(UserFeatureFlag::MinimaxPriority)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(UserFeatureFlag::all_variants().len(), UserFeatureFlag::iter().len()); + } + + #[test] + fn round_trip() { + for variant in UserFeatureFlag::all_variants() { + assert_eq!(variant, UserFeatureFlag::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UserFeatureFlag::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UserFeatureFlag::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in UserFeatureFlag::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/users/user_signup_method.rs b/crates/schema/public/enums/src/by_table/users/user_signup_method.rs new file mode 100644 index 0000000000..57e62ed6b5 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/users/user_signup_method.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `users` table in a `VARCHAR(16)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UserSignupMethod { + /// Email + Password + EmailPassword, + + /// "Sign in With Google" SSO + GoogleSignIn, + + /// Stripe Checkout flow, where we provision user accounts for users with a + /// synthetic/fake email address and no password. After checkout completes, + /// the user gets a real email and password - or the user can set them. + StripeCheckout, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UserSignupMethod); +impl_mysql_enum_coders!(UserSignupMethod); +impl_mysql_from_row!(UserSignupMethod); + +/// NB: Legacy API for older code. +impl UserSignupMethod { + pub fn to_str(&self) -> &'static str { + match self { + Self::EmailPassword => "email_password", + Self::GoogleSignIn=> "google_sign_in", + Self::StripeCheckout => "stripe_checkout", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "email_password" => Ok(Self::EmailPassword), + "google_sign_in" => Ok(Self::GoogleSignIn), + "stripe_checkout" => Ok(Self::StripeCheckout), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::EmailPassword, + Self::GoogleSignIn, + Self::StripeCheckout, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::users::user_signup_method::UserSignupMethod; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UserSignupMethod::EmailPassword, "email_password"); + assert_serialization(UserSignupMethod::GoogleSignIn, "google_sign_in"); + assert_serialization(UserSignupMethod::StripeCheckout, "stripe_checkout"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(UserSignupMethod::EmailPassword.to_str(), "email_password"); + assert_eq!(UserSignupMethod::GoogleSignIn.to_str(), "google_sign_in"); + assert_eq!(UserSignupMethod::StripeCheckout.to_str(), "stripe_checkout"); + } + + #[test] + fn from_str() { + assert_eq!(UserSignupMethod::from_str("email_password").unwrap(), UserSignupMethod::EmailPassword); + assert_eq!(UserSignupMethod::from_str("google_sign_in").unwrap(), UserSignupMethod::GoogleSignIn); + assert_eq!(UserSignupMethod::from_str("stripe_checkout").unwrap(), UserSignupMethod::StripeCheckout); + assert!(UserSignupMethod::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = UserSignupMethod::all_variants(); + assert_eq!(variants.len(), 3); + assert_eq!(variants.pop_first(), Some(UserSignupMethod::EmailPassword)); + assert_eq!(variants.pop_first(), Some(UserSignupMethod::GoogleSignIn)); + assert_eq!(variants.pop_first(), Some(UserSignupMethod::StripeCheckout)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(UserSignupMethod::all_variants().len(), UserSignupMethod::iter().len()); + } + + #[test] + fn round_trip() { + for variant in UserSignupMethod::all_variants() { + assert_eq!(variant, UserSignupMethod::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UserSignupMethod::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UserSignupMethod::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in UserSignupMethod::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/users/user_signup_source.rs b/crates/schema/public/enums/src/by_table/users/user_signup_source.rs new file mode 100644 index 0000000000..2384bde4b9 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/users/user_signup_source.rs @@ -0,0 +1,198 @@ +// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep +// their classification arms here so historical rows still map; suppress the +// deprecation lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `users` table in a `VARCHAR(255)` (which should be a `VARCHAR(16)`) field, `maybe_source`. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UserSignupSource { + #[deprecated(since = "2026-01-30", note = "Use other Artcraft* enum variants instead")] + #[serde(rename = "artcraft")] + ArtCraft, + + /// ArtCraft Tauri App Onboard Flow + #[serde(rename = "artcraft_app")] + ArtCraftApp, + + /// artcraft.ai normal onboard flow + #[serde(rename = "artcraft_ai_web")] + ArtCraftAiWeb, + + /// artcraft.ai stripe checkout flow + #[serde(rename = "artcraft_ai_s")] + ArtCraftAiStripe, + + /// getartcraft.com normal onboard flow + #[serde(rename = "artcraft_get_web")] + ArtCraftGetWeb, + + /// getartcraft.com stripe checkout flow + #[serde(rename = "artcraft_get_s")] + ArtCraftGetStripe, + + #[serde(rename = "fakeyou")] + FakeYou, + + #[serde(rename = "storyteller")] + Storyteller, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UserSignupSource); +impl_mysql_enum_coders!(UserSignupSource); +impl_mysql_from_row!(UserSignupSource); + +/// NB: Legacy API for older code. +impl UserSignupSource { + pub fn to_str(&self) -> &'static str { + match self { + Self::ArtCraft => "artcraft", + Self::ArtCraftApp => "artcraft_app", + Self::ArtCraftAiWeb => "artcraft_ai_web", + Self::ArtCraftAiStripe => "artcraft_ai_s", + Self::ArtCraftGetWeb => "artcraft_get_web", + Self::ArtCraftGetStripe => "artcraft_get_s", + Self::FakeYou => "fakeyou", + Self::Storyteller => "storyteller", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "artcraft" => Ok(Self::ArtCraft), + "artcraft_app" => Ok(Self::ArtCraftApp), + "artcraft_ai_web" => Ok(Self::ArtCraftAiWeb), + "artcraft_ai_s" => Ok(Self::ArtCraftAiStripe), + "artcraft_get_web" => Ok(Self::ArtCraftGetWeb), + "artcraft_get_s" => Ok(Self::ArtCraftGetStripe), + "fakeyou" => Ok(Self::FakeYou), + "storyteller" => Ok(Self::Storyteller), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ArtCraft, + Self::ArtCraftApp, + Self::ArtCraftAiWeb, + Self::ArtCraftAiStripe, + Self::ArtCraftGetWeb, + Self::ArtCraftGetStripe, + Self::FakeYou, + Self::Storyteller, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::users::user_signup_source::UserSignupSource; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UserSignupSource::ArtCraft, "artcraft"); + assert_serialization(UserSignupSource::ArtCraftApp, "artcraft_app"); + assert_serialization(UserSignupSource::ArtCraftAiWeb, "artcraft_ai_web"); + assert_serialization(UserSignupSource::ArtCraftAiStripe, "artcraft_ai_s"); + assert_serialization(UserSignupSource::ArtCraftGetWeb, "artcraft_get_web"); + assert_serialization(UserSignupSource::ArtCraftGetStripe, "artcraft_get_s"); + assert_serialization(UserSignupSource::FakeYou, "fakeyou"); + assert_serialization(UserSignupSource::Storyteller, "storyteller"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(UserSignupSource::ArtCraft.to_str(), "artcraft"); + assert_eq!(UserSignupSource::ArtCraftApp.to_str(), "artcraft_app"); + assert_eq!(UserSignupSource::ArtCraftAiWeb.to_str(), "artcraft_ai_web"); + assert_eq!(UserSignupSource::ArtCraftAiStripe.to_str(), "artcraft_ai_s"); + assert_eq!(UserSignupSource::ArtCraftGetWeb.to_str(), "artcraft_get_web"); + assert_eq!(UserSignupSource::FakeYou.to_str(), "fakeyou"); + assert_eq!(UserSignupSource::Storyteller.to_str(), "storyteller"); + } + + #[test] + fn from_str() { + assert_eq!(UserSignupSource::from_str("artcraft").unwrap(), UserSignupSource::ArtCraft); + assert_eq!(UserSignupSource::from_str("artcraft_app").unwrap(), UserSignupSource::ArtCraftApp); + assert_eq!(UserSignupSource::from_str("artcraft_ai_web").unwrap(), UserSignupSource::ArtCraftAiWeb); + assert_eq!(UserSignupSource::from_str("artcraft_ai_s").unwrap(), UserSignupSource::ArtCraftAiStripe); + assert_eq!(UserSignupSource::from_str("artcraft_get_web").unwrap(), UserSignupSource::ArtCraftGetWeb); + assert_eq!(UserSignupSource::from_str("artcraft_get_s").unwrap(), UserSignupSource::ArtCraftGetStripe); + assert_eq!(UserSignupSource::from_str("fakeyou").unwrap(), UserSignupSource::FakeYou); + assert_eq!(UserSignupSource::from_str("storyteller").unwrap(), UserSignupSource::Storyteller); + assert!(UserSignupSource::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = UserSignupSource::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraft)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftApp)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftAiWeb)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftAiStripe)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftGetWeb)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftGetStripe)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::FakeYou)); + assert_eq!(variants.pop_first(), Some(UserSignupSource::Storyteller)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(UserSignupSource::all_variants().len(), UserSignupSource::iter().len()); + } + + #[test] + fn round_trip() { + for variant in UserSignupSource::all_variants() { + assert_eq!(variant, UserSignupSource::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, UserSignupSource::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, UserSignupSource::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in UserSignupSource::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/voice_conversion_models/mod.rs b/crates/schema/public/enums/src/by_table/voice_conversion_models/mod.rs new file mode 100644 index 0000000000..e29f215214 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/voice_conversion_models/mod.rs @@ -0,0 +1 @@ +pub mod voice_conversion_model_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/voice_conversion_models/voice_conversion_model_type.rs b/crates/schema/public/enums/src/by_table/voice_conversion_models/voice_conversion_model_type.rs new file mode 100644 index 0000000000..9e1ec7f6bf --- /dev/null +++ b/crates/schema/public/enums/src/by_table/voice_conversion_models/voice_conversion_model_type.rs @@ -0,0 +1,100 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `voice_conversion_models` table in `VARCHAR(32)` field `model_type`. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum VoiceConversionModelType { + // We're skipping RVC "v1" models as "v2" are much higher quality. + // Future incompatible upgrades may deserve a different enum variant. + #[serde(rename = "rvc_v2")] + RvcV2, + + #[serde(rename = "soft_vc")] + SoftVc, + + #[serde(rename = "so_vits_svc")] + SoVitsSvc, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(VoiceConversionModelType); +impl_mysql_enum_coders!(VoiceConversionModelType); + +/// NB: Legacy API for older code. +impl VoiceConversionModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::RvcV2 => "rvc_v2", + Self::SoftVc => "soft_vc", + Self::SoVitsSvc => "so_vits_svc", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "rvc_v2" => Ok(Self::RvcV2), + "soft_vc" => Ok(Self::SoftVc), + "so_vits_svc" => Ok(Self::SoVitsSvc), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::RvcV2, + Self::SoftVc, + Self::SoVitsSvc, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::voice_conversion_models::voice_conversion_model_type::VoiceConversionModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(VoiceConversionModelType::RvcV2, "rvc_v2"); + assert_serialization(VoiceConversionModelType::SoftVc, "soft_vc"); + assert_serialization(VoiceConversionModelType::SoVitsSvc, "so_vits_svc"); + } + + #[test] + fn to_str() { + assert_eq!(VoiceConversionModelType::RvcV2.to_str(), "rvc_v2"); + assert_eq!(VoiceConversionModelType::SoftVc.to_str(), "soft_vc"); + assert_eq!(VoiceConversionModelType::SoVitsSvc.to_str(), "so_vits_svc"); + } + + #[test] + fn from_str() { + assert_eq!(VoiceConversionModelType::from_str("rvc_v2").unwrap(), VoiceConversionModelType::RvcV2); + assert_eq!(VoiceConversionModelType::from_str("soft_vc").unwrap(), VoiceConversionModelType::SoftVc); + assert_eq!(VoiceConversionModelType::from_str("so_vits_svc").unwrap(), VoiceConversionModelType::SoVitsSvc); + } + + #[test] + fn all_variants() { + // Static check + let mut variants = VoiceConversionModelType::all_variants(); + assert_eq!(variants.len(), 3); + assert_eq!(variants.pop_first(), Some(VoiceConversionModelType::RvcV2)); + assert_eq!(variants.pop_first(), Some(VoiceConversionModelType::SoftVc)); + assert_eq!(variants.pop_first(), Some(VoiceConversionModelType::SoVitsSvc)); + assert_eq!(variants.pop_first(), None); + + // Generated check + use strum::IntoEnumIterator; + assert_eq!(VoiceConversionModelType::all_variants().len(), VoiceConversionModelType::iter().len()); + } +} diff --git a/crates/schema/public/enums/src/by_table/voice_conversion_results/mod.rs b/crates/schema/public/enums/src/by_table/voice_conversion_results/mod.rs new file mode 100644 index 0000000000..b1edddf9f7 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/voice_conversion_results/mod.rs @@ -0,0 +1 @@ +pub mod voice_conversion_media_token_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/voice_conversion_results/voice_conversion_media_token_type.rs b/crates/schema/public/enums/src/by_table/voice_conversion_results/voice_conversion_media_token_type.rs new file mode 100644 index 0000000000..4a4b03a5d4 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/voice_conversion_results/voice_conversion_media_token_type.rs @@ -0,0 +1,59 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `voice_conversion_results` table in a `VARCHAR` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VoiceConversionMediaTokenType { + /// Media token refers to record in `media_uploads` table. + MediaUpload, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(VoiceConversionMediaTokenType); +impl_mysql_enum_coders!(VoiceConversionMediaTokenType); + +/// NB: Legacy API for older code. +impl VoiceConversionMediaTokenType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaUpload => "media_upload", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_upload" => Ok(Self::MediaUpload), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::voice_conversion_results::voice_conversion_media_token_type::VoiceConversionMediaTokenType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(VoiceConversionMediaTokenType::MediaUpload, "media_upload"); + } + + #[test] + fn test_to_str() { + assert_eq!(VoiceConversionMediaTokenType::MediaUpload.to_str(), "media_upload"); + } + + #[test] + fn test_from_str() { + assert_eq!(VoiceConversionMediaTokenType::from_str("media_upload").unwrap(), VoiceConversionMediaTokenType::MediaUpload); + assert!(VoiceConversionMediaTokenType::from_str("foo").is_err()); + } +} diff --git a/crates/schema/public/enums/src/by_table/wallet_ledger_entries/mod.rs b/crates/schema/public/enums/src/by_table/wallet_ledger_entries/mod.rs new file mode 100644 index 0000000000..618223e147 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/wallet_ledger_entries/mod.rs @@ -0,0 +1 @@ +pub mod wallet_ledger_entry_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/wallet_ledger_entries/wallet_ledger_entry_type.rs b/crates/schema/public/enums/src/by_table/wallet_ledger_entries/wallet_ledger_entry_type.rs new file mode 100644 index 0000000000..110babf2bf --- /dev/null +++ b/crates/schema/public/enums/src/by_table/wallet_ledger_entries/wallet_ledger_entry_type.rs @@ -0,0 +1,204 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `wallet_ledger_entries` table in a `VARCHAR(16)` field. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum WalletLedgerEntryType { + /// Wallet created + #[serde(rename = "create")] + Create, + + /// Credit durable banked balance + #[serde(rename = "credit_banked")] + CreditBanked, + + /// Credit monthly refill + #[serde(rename = "credit_monthly")] + CreditMonthly, + + /// Deduct credits (mixed durable and monthly deduction) + #[serde(rename = "deduct_mixed")] + DeductMixed, + + /// Deduct durable banked credits + #[serde(rename = "deduct_banked")] + DeductBanked, + + /// Deduct monthly credits + #[serde(rename = "deduct_monthly")] + DeductMonthly, + + /// Refund banked credits + #[serde(rename = "refund_banked")] + RefundBanked, + + /// Support staff manually credit account + #[serde(rename = "staff_add_banked")] + StaffAddBanked, + + // TODO: No clean way to do "mixed" refunds yet, and if we + // refund close to the cutoff it might be unfair. Let's + // just not do monthly refunds yet and instead credit our + // users with banked credits. + // /// Refund monthly credits + // #[serde(rename = "refund_monthly")] + // RefundMonthly, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(WalletLedgerEntryType); +impl_mysql_enum_coders!(WalletLedgerEntryType); +impl_mysql_from_row!(WalletLedgerEntryType); + +/// NB: Legacy API for older code. +impl WalletLedgerEntryType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Create => "create", + Self::CreditBanked => "credit_banked", + Self::CreditMonthly => "credit_monthly", + Self::DeductMixed => "deduct_mixed", + Self::DeductBanked => "deduct_banked", + Self::DeductMonthly => "deduct_monthly", + Self::RefundBanked => "refund_banked", + Self::StaffAddBanked => "staff_add_banked", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "create" => Ok(Self::Create), + "credit_banked" => Ok(Self::CreditBanked), + "credit_monthly" => Ok(Self::CreditMonthly), + "deduct_mixed" => Ok(Self::DeductMixed), + "deduct_banked" => Ok(Self::DeductBanked), + "deduct_monthly" => Ok(Self::DeductMonthly), + "refund_banked" => Ok(Self::RefundBanked), + "staff_add_banked" => Ok(Self::StaffAddBanked), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Create, + Self::CreditBanked, + Self::CreditMonthly, + Self::DeductMixed, + Self::DeductBanked, + Self::DeductMonthly, + Self::RefundBanked, + Self::StaffAddBanked, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(WalletLedgerEntryType::Create, "create"); + assert_serialization(WalletLedgerEntryType::CreditBanked, "credit_banked"); + assert_serialization(WalletLedgerEntryType::CreditMonthly, "credit_monthly"); + assert_serialization(WalletLedgerEntryType::DeductMixed, "deduct_mixed"); + assert_serialization(WalletLedgerEntryType::DeductBanked, "deduct_banked"); + assert_serialization(WalletLedgerEntryType::DeductMonthly, "deduct_monthly"); + assert_serialization(WalletLedgerEntryType::RefundBanked, "refund_banked"); + assert_serialization(WalletLedgerEntryType::StaffAddBanked, "staff_add_banked"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(WalletLedgerEntryType::Create.to_str(), "create"); + assert_eq!(WalletLedgerEntryType::CreditBanked.to_str(), "credit_banked"); + assert_eq!(WalletLedgerEntryType::CreditMonthly.to_str(), "credit_monthly"); + assert_eq!(WalletLedgerEntryType::DeductMixed.to_str(), "deduct_mixed"); + assert_eq!(WalletLedgerEntryType::DeductBanked.to_str(), "deduct_banked"); + assert_eq!(WalletLedgerEntryType::DeductMonthly.to_str(), "deduct_monthly"); + assert_eq!(WalletLedgerEntryType::RefundBanked.to_str(), "refund_banked"); + assert_eq!(WalletLedgerEntryType::StaffAddBanked.to_str(), "staff_add_banked"); + } + + #[test] + fn from_str() { + assert_eq!(WalletLedgerEntryType::from_str("create").unwrap(), WalletLedgerEntryType::Create); + assert_eq!(WalletLedgerEntryType::from_str("credit_banked").unwrap(), WalletLedgerEntryType::CreditBanked); + assert_eq!(WalletLedgerEntryType::from_str("credit_monthly").unwrap(), WalletLedgerEntryType::CreditMonthly); + assert_eq!(WalletLedgerEntryType::from_str("deduct_mixed").unwrap(), WalletLedgerEntryType::DeductMixed); + assert_eq!(WalletLedgerEntryType::from_str("deduct_banked").unwrap(), WalletLedgerEntryType::DeductBanked); + assert_eq!(WalletLedgerEntryType::from_str("deduct_monthly").unwrap(), WalletLedgerEntryType::DeductMonthly); + assert_eq!(WalletLedgerEntryType::from_str("refund_banked").unwrap(), WalletLedgerEntryType::RefundBanked); + assert_eq!(WalletLedgerEntryType::from_str("staff_add_banked").unwrap(), WalletLedgerEntryType::StaffAddBanked); + assert!(WalletLedgerEntryType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = WalletLedgerEntryType::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::Create)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::CreditBanked)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::CreditMonthly)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::DeductMixed)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::DeductBanked)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::DeductMonthly)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::RefundBanked)); + assert_eq!(variants.pop_first(), Some(WalletLedgerEntryType::StaffAddBanked)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(WalletLedgerEntryType::all_variants().len(), WalletLedgerEntryType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in WalletLedgerEntryType::all_variants() { + assert_eq!(variant, WalletLedgerEntryType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, WalletLedgerEntryType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, WalletLedgerEntryType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in WalletLedgerEntryType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/web_rendition_targets/mod.rs b/crates/schema/public/enums/src/by_table/web_rendition_targets/mod.rs new file mode 100644 index 0000000000..371fe4d32f --- /dev/null +++ b/crates/schema/public/enums/src/by_table/web_rendition_targets/mod.rs @@ -0,0 +1 @@ +pub mod rendition_status; diff --git a/crates/schema/public/enums/src/by_table/web_rendition_targets/rendition_status.rs b/crates/schema/public/enums/src/by_table/web_rendition_targets/rendition_status.rs new file mode 100644 index 0000000000..b09b0be749 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/web_rendition_targets/rendition_status.rs @@ -0,0 +1,96 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the SqLite `web_scraping_targets` table in a `TEXT` field named `scraping_status`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum RenditionStatus { + #[serde(rename = "new")] + New, + + #[serde(rename = "skipped")] + Skipped, + + #[serde(rename = "failed")] + Failed, + + #[serde(rename = "permanently_failed")] + PermanentlyFailed, + + #[serde(rename = "success")] + Success, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(RenditionStatus); +impl_sqlite_enum_coders!(RenditionStatus); + +/// NB: Legacy API for older code. +impl RenditionStatus { + pub fn to_str(&self) -> &'static str { + match self { + Self::New => "new", + Self::Skipped => "skipped", + Self::Failed => "failed", + Self::PermanentlyFailed => "permanently_failed", + Self::Success => "success", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "new" => Ok(Self::New), + "skipped" => Ok(Self::Skipped), + "failed" => Ok(Self::Failed), + "permanently_failed" => Ok(Self::PermanentlyFailed), + "success" => Ok(Self::Success), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::web_rendition_targets::rendition_status::RenditionStatus; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(RenditionStatus::New, "new"); + assert_serialization(RenditionStatus::Skipped, "skipped"); + assert_serialization(RenditionStatus::Failed, "failed"); + assert_serialization(RenditionStatus::PermanentlyFailed, "permanently_failed"); + assert_serialization(RenditionStatus::Success, "success"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(RenditionStatus::New.to_str(), "new"); + assert_eq!(RenditionStatus::Skipped.to_str(), "skipped"); + assert_eq!(RenditionStatus::Failed.to_str(), "failed"); + assert_eq!(RenditionStatus::PermanentlyFailed.to_str(), "permanently_failed"); + assert_eq!(RenditionStatus::Success.to_str(), "success"); + } + + #[test] + fn test_from_str() { + assert_eq!(RenditionStatus::from_str("new").unwrap(), RenditionStatus::New); + assert_eq!(RenditionStatus::from_str("skipped").unwrap(), RenditionStatus::Skipped); + assert_eq!(RenditionStatus::from_str("failed").unwrap(), RenditionStatus::Failed); + assert_eq!(RenditionStatus::from_str("permanently_failed").unwrap(), RenditionStatus::PermanentlyFailed); + assert_eq!(RenditionStatus::from_str("success").unwrap(), RenditionStatus::Success); + assert!(RenditionStatus::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/web_scraping_targets/mod.rs b/crates/schema/public/enums/src/by_table/web_scraping_targets/mod.rs new file mode 100644 index 0000000000..2dea4d4509 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/web_scraping_targets/mod.rs @@ -0,0 +1 @@ +pub mod scraping_status; diff --git a/crates/schema/public/enums/src/by_table/web_scraping_targets/scraping_status.rs b/crates/schema/public/enums/src/by_table/web_scraping_targets/scraping_status.rs new file mode 100644 index 0000000000..870e842f31 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/web_scraping_targets/scraping_status.rs @@ -0,0 +1,96 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the SqLite `web_scraping_targets` table in a `TEXT` field named `scraping_status`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum ScrapingStatus { + #[serde(rename = "new")] + New, + + #[serde(rename = "skipped")] + Skipped, + + #[serde(rename = "failed")] + Failed, + + #[serde(rename = "permanently_failed")] + PermanentlyFailed, + + #[serde(rename = "success")] + Success, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(ScrapingStatus); +impl_sqlite_enum_coders!(ScrapingStatus); + +/// NB: Legacy API for older code. +impl ScrapingStatus { + pub fn to_str(&self) -> &'static str { + match self { + Self::New => "new", + Self::Skipped => "skipped", + Self::Failed => "failed", + Self::PermanentlyFailed => "permanently_failed", + Self::Success => "success", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "new" => Ok(Self::New), + "skipped" => Ok(Self::Skipped), + "failed" => Ok(Self::Failed), + "permanently_failed" => Ok(Self::PermanentlyFailed), + "success" => Ok(Self::Success), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::web_scraping_targets::scraping_status::ScrapingStatus; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ScrapingStatus::New, "new"); + assert_serialization(ScrapingStatus::Skipped, "skipped"); + assert_serialization(ScrapingStatus::Failed, "failed"); + assert_serialization(ScrapingStatus::PermanentlyFailed, "permanently_failed"); + assert_serialization(ScrapingStatus::Success, "success"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(ScrapingStatus::New.to_str(), "new"); + assert_eq!(ScrapingStatus::Skipped.to_str(), "skipped"); + assert_eq!(ScrapingStatus::Failed.to_str(), "failed"); + assert_eq!(ScrapingStatus::PermanentlyFailed.to_str(), "permanently_failed"); + assert_eq!(ScrapingStatus::Success.to_str(), "success"); + } + + #[test] + fn test_from_str() { + assert_eq!(ScrapingStatus::from_str("new").unwrap(), ScrapingStatus::New); + assert_eq!(ScrapingStatus::from_str("skipped").unwrap(), ScrapingStatus::Skipped); + assert_eq!(ScrapingStatus::from_str("failed").unwrap(), ScrapingStatus::Failed); + assert_eq!(ScrapingStatus::from_str("permanently_failed").unwrap(), ScrapingStatus::PermanentlyFailed); + assert_eq!(ScrapingStatus::from_str("success").unwrap(), ScrapingStatus::Success); + assert!(ScrapingStatus::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/by_table/zs_voices/encoding_type.rs b/crates/schema/public/enums/src/by_table/zs_voices/encoding_type.rs new file mode 100644 index 0000000000..34692f9bff --- /dev/null +++ b/crates/schema/public/enums/src/by_table/zs_voices/encoding_type.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `zs_voices` table in a `VARCHAR(16)` field named `encoding_type`. +/// +/// This indicates what type of features are used in the embeddings. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum ZsVoiceEncodingType { + /// Encodec features + #[serde(rename = "encodec")] + Encodec, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(ZsVoiceEncodingType); +impl_mysql_enum_coders!(ZsVoiceEncodingType); + +/// NB: Legacy API for older code. +impl ZsVoiceEncodingType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Encodec => "encodec", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "encodec" => Ok(Self::Encodec), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Encodec, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::zs_voices::encoding_type::ZsVoiceEncodingType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ZsVoiceEncodingType::Encodec, "encodec"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(ZsVoiceEncodingType::Encodec.to_str(), "encodec"); + } + + #[test] + fn from_str() { + assert_eq!(ZsVoiceEncodingType::from_str("encodec").unwrap(), ZsVoiceEncodingType::Encodec); + assert!(ZsVoiceEncodingType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = ZsVoiceEncodingType::all_variants(); + assert_eq!(variants.len(), 1); + assert_eq!(variants.pop_first(), Some(ZsVoiceEncodingType::Encodec)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(ZsVoiceEncodingType::all_variants().len(), ZsVoiceEncodingType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in ZsVoiceEncodingType::all_variants() { + assert_eq!(variant, ZsVoiceEncodingType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, ZsVoiceEncodingType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, ZsVoiceEncodingType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/zs_voices/mod.rs b/crates/schema/public/enums/src/by_table/zs_voices/mod.rs new file mode 100644 index 0000000000..67a116c3d5 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/zs_voices/mod.rs @@ -0,0 +1,3 @@ +pub mod encoding_type; +pub mod model_category; +pub mod model_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/by_table/zs_voices/model_category.rs b/crates/schema/public/enums/src/by_table/zs_voices/model_category.rs new file mode 100644 index 0000000000..89b561a1fd --- /dev/null +++ b/crates/schema/public/enums/src/by_table/zs_voices/model_category.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `zs_voices` table in a `VARCHAR(16)` field named `model_category`. +/// +/// This indicates what type of features are used in the embeddings. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum ZsVoiceModelCategory { + /// TTS-type zero shot models + #[serde(rename = "tts")] + Tts, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(ZsVoiceModelCategory); +impl_mysql_enum_coders!(ZsVoiceModelCategory); + +/// NB: Legacy API for older code. +impl ZsVoiceModelCategory { + pub fn to_str(&self) -> &'static str { + match self { + Self::Tts => "tts", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "tts" => Ok(Self::Tts), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Tts, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::zs_voices::model_category::ZsVoiceModelCategory; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ZsVoiceModelCategory::Tts, "tts"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(ZsVoiceModelCategory::Tts.to_str(), "tts"); + } + + #[test] + fn from_str() { + assert_eq!(ZsVoiceModelCategory::from_str("tts").unwrap(), ZsVoiceModelCategory::Tts); + assert!(ZsVoiceModelCategory::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = ZsVoiceModelCategory::all_variants(); + assert_eq!(variants.len(), 1); + assert_eq!(variants.pop_first(), Some(ZsVoiceModelCategory::Tts)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(ZsVoiceModelCategory::all_variants().len(), ZsVoiceModelCategory::iter().len()); + } + + #[test] + fn round_trip() { + for variant in ZsVoiceModelCategory::all_variants() { + assert_eq!(variant, ZsVoiceModelCategory::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, ZsVoiceModelCategory::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, ZsVoiceModelCategory::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/zs_voices/model_type.rs b/crates/schema/public/enums/src/by_table/zs_voices/model_type.rs new file mode 100644 index 0000000000..4af95aa9c6 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/zs_voices/model_type.rs @@ -0,0 +1,116 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `zs_voices` table in a `VARCHAR(16)` field named `model_type`. +/// +/// This indicates what type of features are used in the embeddings. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum ZsVoiceModelType { + /// TTS-type zero shot models + #[serde(rename = "vall-e-x")] + VallEX, + #[serde(rename = "styletts2")] + StyleTTS2, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(ZsVoiceModelType); +impl_mysql_enum_coders!(ZsVoiceModelType); + +/// NB: Legacy API for older code. +impl ZsVoiceModelType { + pub fn to_str(&self) -> &'static str { + match self { + Self::VallEX=> "vall-e-x", + Self::StyleTTS2 => "styletts2", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "vall-e-x" => Ok(Self::VallEX), + "styletts2" => Ok(Self::StyleTTS2), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::VallEX, + Self::StyleTTS2, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::zs_voices::model_type::ZsVoiceModelType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ZsVoiceModelType::VallEX, "vall-e-x"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(ZsVoiceModelType::VallEX.to_str(), "vall-e-x"); + assert_eq!(ZsVoiceModelType::StyleTTS2.to_str(), "styletts2"); + } + + #[test] + fn from_str() { + assert_eq!(ZsVoiceModelType::from_str("vall-e-x").unwrap(), ZsVoiceModelType::VallEX); + assert_eq!(ZsVoiceModelType::from_str("styletts2").unwrap(), ZsVoiceModelType::StyleTTS2); + assert!(ZsVoiceModelType::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let mut variants = ZsVoiceModelType::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(ZsVoiceModelType::VallEX)); + assert_eq!(variants.pop_first(), Some(ZsVoiceModelType::StyleTTS2)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(ZsVoiceModelType::all_variants().len(), ZsVoiceModelType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in ZsVoiceModelType::all_variants() { + assert_eq!(variant, ZsVoiceModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, ZsVoiceModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, ZsVoiceModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/artcraft_credits_pack_slug.rs b/crates/schema/public/enums/src/common/artcraft_credits_pack_slug.rs new file mode 100644 index 0000000000..3aadbaacd0 --- /dev/null +++ b/crates/schema/public/enums/src/common/artcraft_credits_pack_slug.rs @@ -0,0 +1,155 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// NB: This will be used by a variety of tables (MySQL and sqlite)! +/// Keep the max length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ArtcraftCreditsPackSlug { + #[serde(rename= "artcraft_1000")] + Artcraft1000, + #[serde(rename= "artcraft_2500")] + Artcraft2500, + #[serde(rename= "artcraft_5000")] + Artcraft5000, + #[serde(rename= "artcraft_10000")] + Artcraft10000, + #[serde(rename= "artcraft_25000")] + Artcraft25000, + #[serde(rename= "artcraft_50000")] + Artcraft50000, +} + +impl_enum_display_and_debug_using_to_str!(ArtcraftCreditsPackSlug); +impl_mysql_enum_coders!(ArtcraftCreditsPackSlug); +impl_mysql_from_row!(ArtcraftCreditsPackSlug); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl ArtcraftCreditsPackSlug { + pub fn to_str(&self) -> &'static str { + match self { + Self::Artcraft1000 => "artcraft_1000", + Self::Artcraft2500 => "artcraft_2500", + Self::Artcraft5000 => "artcraft_5000", + Self::Artcraft10000 => "artcraft_10000", + Self::Artcraft25000 => "artcraft_25000", + Self::Artcraft50000 => "artcraft_50000", + } + } + + pub fn from_str(s: &str) -> Result { + match s { + "artcraft_1000" => Ok(Self::Artcraft1000), + "artcraft_2500" => Ok(Self::Artcraft2500), + "artcraft_5000" => Ok(Self::Artcraft5000), + "artcraft_10000" => Ok(Self::Artcraft10000), + "artcraft_25000" => Ok(Self::Artcraft25000), + "artcraft_50000" => Ok(Self::Artcraft50000), + _ => Err(format!("invalid artcraft_credits_pack_slug: {:?}", s)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Artcraft1000, + Self::Artcraft2500, + Self::Artcraft5000, + Self::Artcraft10000, + Self::Artcraft25000, + Self::Artcraft50000, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::test_helpers::assert_serialization; + use crate::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ArtcraftCreditsPackSlug::Artcraft1000, "artcraft_1000"); + assert_serialization(ArtcraftCreditsPackSlug::Artcraft2500, "artcraft_2500"); + assert_serialization(ArtcraftCreditsPackSlug::Artcraft5000, "artcraft_5000"); + assert_serialization(ArtcraftCreditsPackSlug::Artcraft10000, "artcraft_10000"); + assert_serialization(ArtcraftCreditsPackSlug::Artcraft25000, "artcraft_25000"); + assert_serialization(ArtcraftCreditsPackSlug::Artcraft50000, "artcraft_50000"); + } + + #[test] + fn to_str() { + assert_eq!(ArtcraftCreditsPackSlug::Artcraft1000.to_str(), "artcraft_1000"); + assert_eq!(ArtcraftCreditsPackSlug::Artcraft2500.to_str(), "artcraft_2500"); + assert_eq!(ArtcraftCreditsPackSlug::Artcraft5000.to_str(), "artcraft_5000"); + assert_eq!(ArtcraftCreditsPackSlug::Artcraft10000.to_str(), "artcraft_10000"); + assert_eq!(ArtcraftCreditsPackSlug::Artcraft25000.to_str(), "artcraft_25000"); + assert_eq!(ArtcraftCreditsPackSlug::Artcraft50000.to_str(), "artcraft_50000"); + } + + #[test] + fn from_str() { + assert_eq!(ArtcraftCreditsPackSlug::from_str("artcraft_1000").unwrap(), ArtcraftCreditsPackSlug::Artcraft1000); + assert_eq!(ArtcraftCreditsPackSlug::from_str("artcraft_2500").unwrap(), ArtcraftCreditsPackSlug::Artcraft2500); + assert_eq!(ArtcraftCreditsPackSlug::from_str("artcraft_5000").unwrap(), ArtcraftCreditsPackSlug::Artcraft5000); + assert_eq!(ArtcraftCreditsPackSlug::from_str("artcraft_10000").unwrap(), ArtcraftCreditsPackSlug::Artcraft10000); + assert_eq!(ArtcraftCreditsPackSlug::from_str("artcraft_25000").unwrap(), ArtcraftCreditsPackSlug::Artcraft25000); + assert_eq!(ArtcraftCreditsPackSlug::from_str("artcraft_50000").unwrap(), ArtcraftCreditsPackSlug::Artcraft50000); + + } + + #[test] + fn all_variants() { + let mut variants = ArtcraftCreditsPackSlug::all_variants(); + assert_eq!(variants.len(), 6); + assert_eq!(variants.pop_first(), Some(ArtcraftCreditsPackSlug::Artcraft1000)); + assert_eq!(variants.pop_first(), Some(ArtcraftCreditsPackSlug::Artcraft2500)); + assert_eq!(variants.pop_first(), Some(ArtcraftCreditsPackSlug::Artcraft5000)); + assert_eq!(variants.pop_first(), Some(ArtcraftCreditsPackSlug::Artcraft10000)); + assert_eq!(variants.pop_first(), Some(ArtcraftCreditsPackSlug::Artcraft25000)); + assert_eq!(variants.pop_first(), Some(ArtcraftCreditsPackSlug::Artcraft50000)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(ArtcraftCreditsPackSlug::all_variants().len(), ArtcraftCreditsPackSlug::iter().len()); + } + + #[test] + fn round_trip() { + for variant in ArtcraftCreditsPackSlug::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, ArtcraftCreditsPackSlug::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, ArtcraftCreditsPackSlug::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, ArtcraftCreditsPackSlug::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in ArtcraftCreditsPackSlug::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/artcraft_subscription_slug.rs b/crates/schema/public/enums/src/common/artcraft_subscription_slug.rs new file mode 100644 index 0000000000..de7454af9f --- /dev/null +++ b/crates/schema/public/enums/src/common/artcraft_subscription_slug.rs @@ -0,0 +1,124 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// NB: This will be used by a variety of tables (MySQL and sqlite)! +/// Keep the max length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ArtcraftSubscriptionSlug { + ArtcraftBasic, + ArtcraftPro, + ArtcraftMax, +} + +impl_enum_display_and_debug_using_to_str!(ArtcraftSubscriptionSlug); +impl_mysql_enum_coders!(ArtcraftSubscriptionSlug); +impl_mysql_from_row!(ArtcraftSubscriptionSlug); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl ArtcraftSubscriptionSlug { + pub fn to_str(&self) -> &'static str { + match self { + Self::ArtcraftBasic => "artcraft_basic", + Self::ArtcraftPro => "artcraft_pro", + Self::ArtcraftMax => "artcraft_max", + } + } + + pub fn from_str(s: &str) -> Result { + match s { + "artcraft_basic" => Ok(Self::ArtcraftBasic), + "artcraft_pro" => Ok(Self::ArtcraftPro), + "artcraft_max" => Ok(Self::ArtcraftMax), + _ => Err(format!("invalid artcraft_subscription_slug: {:?}", s)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ArtcraftBasic, + Self::ArtcraftPro, + Self::ArtcraftMax, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ArtcraftSubscriptionSlug::ArtcraftBasic, "artcraft_basic"); + assert_serialization(ArtcraftSubscriptionSlug::ArtcraftPro, "artcraft_pro"); + assert_serialization(ArtcraftSubscriptionSlug::ArtcraftMax, "artcraft_max"); + } + + #[test] + fn to_str() { + assert_eq!(ArtcraftSubscriptionSlug::ArtcraftBasic.to_str(), "artcraft_basic"); + assert_eq!(ArtcraftSubscriptionSlug::ArtcraftPro.to_str(), "artcraft_pro"); + assert_eq!(ArtcraftSubscriptionSlug::ArtcraftMax.to_str(), "artcraft_max"); + } + + #[test] + fn from_str() { + assert_eq!(ArtcraftSubscriptionSlug::from_str("artcraft_basic").unwrap(), ArtcraftSubscriptionSlug::ArtcraftBasic); + assert_eq!(ArtcraftSubscriptionSlug::from_str("artcraft_pro").unwrap(), ArtcraftSubscriptionSlug::ArtcraftPro); + assert_eq!(ArtcraftSubscriptionSlug::from_str("artcraft_max").unwrap(), ArtcraftSubscriptionSlug::ArtcraftMax); + } + + #[test] + fn all_variants() { + let mut variants = ArtcraftSubscriptionSlug::all_variants(); + assert_eq!(variants.len(), 3); + assert_eq!(variants.pop_first(), Some(ArtcraftSubscriptionSlug::ArtcraftBasic)); + assert_eq!(variants.pop_first(), Some(ArtcraftSubscriptionSlug::ArtcraftPro)); + assert_eq!(variants.pop_first(), Some(ArtcraftSubscriptionSlug::ArtcraftMax)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(ArtcraftSubscriptionSlug::all_variants().len(), ArtcraftSubscriptionSlug::iter().len()); + } + + #[test] + fn round_trip() { + for variant in ArtcraftSubscriptionSlug::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, ArtcraftSubscriptionSlug::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, ArtcraftSubscriptionSlug::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, ArtcraftSubscriptionSlug::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in ArtcraftSubscriptionSlug::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_aspect_ratio.rs b/crates/schema/public/enums/src/common/generation/common_aspect_ratio.rs new file mode 100644 index 0000000000..bb2829fd08 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_aspect_ratio.rs @@ -0,0 +1,271 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 24; + +/// Common aspect ratios for image and video generation. +/// +/// NB: Keep the max serialized length to 24 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonAspectRatio { + Auto, + Square, + WideThreeByTwo, + WideFourByThree, + WideFiveByFour, + WideSixteenByNine, + WideTwentyOneByNine, + TallTwoByThree, + TallThreeByFour, + TallFourByFive, + TallNineBySixteen, + TallNineByTwentyOne, + Wide, + Tall, + #[serde(rename = "auto_2k")] + Auto2k, + #[serde(rename = "auto_3k")] + Auto3k, + #[serde(rename = "auto_4k")] + Auto4k, + SquareHd, +} + +impl_enum_display_and_debug_using_to_str!(CommonAspectRatio); +impl_mysql_enum_coders!(CommonAspectRatio); +impl_mysql_from_row!(CommonAspectRatio); + +impl CommonAspectRatio { + pub fn to_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Square => "square", + Self::WideThreeByTwo => "wide_three_by_two", + Self::WideFourByThree => "wide_four_by_three", + Self::WideFiveByFour => "wide_five_by_four", + Self::WideSixteenByNine => "wide_sixteen_by_nine", + Self::WideTwentyOneByNine => "wide_twenty_one_by_nine", + Self::TallTwoByThree => "tall_two_by_three", + Self::TallThreeByFour => "tall_three_by_four", + Self::TallFourByFive => "tall_four_by_five", + Self::TallNineBySixteen => "tall_nine_by_sixteen", + Self::TallNineByTwentyOne => "tall_nine_by_twenty_one", + Self::Wide => "wide", + Self::Tall => "tall", + Self::Auto2k => "auto_2k", + Self::Auto3k => "auto_3k", + Self::Auto4k => "auto_4k", + Self::SquareHd => "square_hd", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "auto" => Ok(Self::Auto), + "square" => Ok(Self::Square), + "wide_three_by_two" => Ok(Self::WideThreeByTwo), + "wide_four_by_three" => Ok(Self::WideFourByThree), + "wide_five_by_four" => Ok(Self::WideFiveByFour), + "wide_sixteen_by_nine" => Ok(Self::WideSixteenByNine), + "wide_twenty_one_by_nine" => Ok(Self::WideTwentyOneByNine), + "tall_two_by_three" => Ok(Self::TallTwoByThree), + "tall_three_by_four" => Ok(Self::TallThreeByFour), + "tall_four_by_five" => Ok(Self::TallFourByFive), + "tall_nine_by_sixteen" => Ok(Self::TallNineBySixteen), + "tall_nine_by_twenty_one" => Ok(Self::TallNineByTwentyOne), + "wide" => Ok(Self::Wide), + "tall" => Ok(Self::Tall), + "auto_2k" => Ok(Self::Auto2k), + "auto_3k" => Ok(Self::Auto3k), + "auto_4k" => Ok(Self::Auto4k), + "square_hd" => Ok(Self::SquareHd), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Auto, + Self::Square, + Self::WideThreeByTwo, + Self::WideFourByThree, + Self::WideFiveByFour, + Self::WideSixteenByNine, + Self::WideTwentyOneByNine, + Self::TallTwoByThree, + Self::TallThreeByFour, + Self::TallFourByFive, + Self::TallNineBySixteen, + Self::TallNineByTwentyOne, + Self::Wide, + Self::Tall, + Self::Auto2k, + Self::Auto3k, + Self::Auto4k, + Self::SquareHd, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_aspect_ratio::CommonAspectRatio; + use crate::common::generation::common_aspect_ratio::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonAspectRatio::Auto, "auto"); + assert_serialization(CommonAspectRatio::Square, "square"); + assert_serialization(CommonAspectRatio::WideThreeByTwo, "wide_three_by_two"); + assert_serialization(CommonAspectRatio::WideFourByThree, "wide_four_by_three"); + assert_serialization(CommonAspectRatio::WideFiveByFour, "wide_five_by_four"); + assert_serialization(CommonAspectRatio::WideSixteenByNine, "wide_sixteen_by_nine"); + assert_serialization(CommonAspectRatio::WideTwentyOneByNine, "wide_twenty_one_by_nine"); + assert_serialization(CommonAspectRatio::TallTwoByThree, "tall_two_by_three"); + assert_serialization(CommonAspectRatio::TallThreeByFour, "tall_three_by_four"); + assert_serialization(CommonAspectRatio::TallFourByFive, "tall_four_by_five"); + assert_serialization(CommonAspectRatio::TallNineBySixteen, "tall_nine_by_sixteen"); + assert_serialization(CommonAspectRatio::TallNineByTwentyOne, "tall_nine_by_twenty_one"); + assert_serialization(CommonAspectRatio::Wide, "wide"); + assert_serialization(CommonAspectRatio::Tall, "tall"); + assert_serialization(CommonAspectRatio::Auto2k, "auto_2k"); + assert_serialization(CommonAspectRatio::Auto3k, "auto_3k"); + assert_serialization(CommonAspectRatio::Auto4k, "auto_4k"); + assert_serialization(CommonAspectRatio::SquareHd, "square_hd"); + } + + #[test] + fn to_str() { + assert_eq!(CommonAspectRatio::Auto.to_str(), "auto"); + assert_eq!(CommonAspectRatio::Square.to_str(), "square"); + assert_eq!(CommonAspectRatio::WideThreeByTwo.to_str(), "wide_three_by_two"); + assert_eq!(CommonAspectRatio::WideFourByThree.to_str(), "wide_four_by_three"); + assert_eq!(CommonAspectRatio::WideFiveByFour.to_str(), "wide_five_by_four"); + assert_eq!(CommonAspectRatio::WideSixteenByNine.to_str(), "wide_sixteen_by_nine"); + assert_eq!(CommonAspectRatio::WideTwentyOneByNine.to_str(), "wide_twenty_one_by_nine"); + assert_eq!(CommonAspectRatio::TallTwoByThree.to_str(), "tall_two_by_three"); + assert_eq!(CommonAspectRatio::TallThreeByFour.to_str(), "tall_three_by_four"); + assert_eq!(CommonAspectRatio::TallFourByFive.to_str(), "tall_four_by_five"); + assert_eq!(CommonAspectRatio::TallNineBySixteen.to_str(), "tall_nine_by_sixteen"); + assert_eq!(CommonAspectRatio::TallNineByTwentyOne.to_str(), "tall_nine_by_twenty_one"); + assert_eq!(CommonAspectRatio::Wide.to_str(), "wide"); + assert_eq!(CommonAspectRatio::Tall.to_str(), "tall"); + assert_eq!(CommonAspectRatio::Auto2k.to_str(), "auto_2k"); + assert_eq!(CommonAspectRatio::Auto3k.to_str(), "auto_3k"); + assert_eq!(CommonAspectRatio::Auto4k.to_str(), "auto_4k"); + assert_eq!(CommonAspectRatio::SquareHd.to_str(), "square_hd"); + } + + #[test] + fn from_str() { + assert_eq!(CommonAspectRatio::from_str("auto").unwrap(), CommonAspectRatio::Auto); + assert_eq!(CommonAspectRatio::from_str("square").unwrap(), CommonAspectRatio::Square); + assert_eq!(CommonAspectRatio::from_str("wide_three_by_two").unwrap(), CommonAspectRatio::WideThreeByTwo); + assert_eq!(CommonAspectRatio::from_str("wide_four_by_three").unwrap(), CommonAspectRatio::WideFourByThree); + assert_eq!(CommonAspectRatio::from_str("wide_five_by_four").unwrap(), CommonAspectRatio::WideFiveByFour); + assert_eq!(CommonAspectRatio::from_str("wide_sixteen_by_nine").unwrap(), CommonAspectRatio::WideSixteenByNine); + assert_eq!(CommonAspectRatio::from_str("wide_twenty_one_by_nine").unwrap(), CommonAspectRatio::WideTwentyOneByNine); + assert_eq!(CommonAspectRatio::from_str("tall_two_by_three").unwrap(), CommonAspectRatio::TallTwoByThree); + assert_eq!(CommonAspectRatio::from_str("tall_three_by_four").unwrap(), CommonAspectRatio::TallThreeByFour); + assert_eq!(CommonAspectRatio::from_str("tall_four_by_five").unwrap(), CommonAspectRatio::TallFourByFive); + assert_eq!(CommonAspectRatio::from_str("tall_nine_by_sixteen").unwrap(), CommonAspectRatio::TallNineBySixteen); + assert_eq!(CommonAspectRatio::from_str("tall_nine_by_twenty_one").unwrap(), CommonAspectRatio::TallNineByTwentyOne); + assert_eq!(CommonAspectRatio::from_str("wide").unwrap(), CommonAspectRatio::Wide); + assert_eq!(CommonAspectRatio::from_str("tall").unwrap(), CommonAspectRatio::Tall); + assert_eq!(CommonAspectRatio::from_str("auto_2k").unwrap(), CommonAspectRatio::Auto2k); + assert_eq!(CommonAspectRatio::from_str("auto_3k").unwrap(), CommonAspectRatio::Auto3k); + assert_eq!(CommonAspectRatio::from_str("auto_4k").unwrap(), CommonAspectRatio::Auto4k); + assert_eq!(CommonAspectRatio::from_str("square_hd").unwrap(), CommonAspectRatio::SquareHd); + } + + #[test] + fn from_str_err() { + let result = CommonAspectRatio::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let variants = CommonAspectRatio::all_variants(); + assert_eq!(variants.len(), 18); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonAspectRatio::all_variants().len(), CommonAspectRatio::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonAspectRatio::all_variants() { + assert_eq!(variant, CommonAspectRatio::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonAspectRatio::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonAspectRatio::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonAspectRatio::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonAspectRatio::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonAspectRatio::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonAspectRatio::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_audio_model.rs b/crates/schema/public/enums/src/common/generation/common_audio_model.rs new file mode 100644 index 0000000000..6ee50003dd --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_audio_model.rs @@ -0,0 +1,97 @@ +use utoipa::ToSchema; + +/// Audio models available for generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonAudioModel { + #[serde(rename = "suno_music")] + SunoMusic, + + #[serde(rename = "suno_remix")] + SunoRemix, + + #[serde(rename = "suno_sounds")] + SunoSounds, + + #[serde(rename = "suno_sample")] + SunoSample, + + #[serde(rename = "seed_audio_1p0")] + SeedAudio1p0, +} + +impl CommonAudioModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::SunoMusic => CommonModelType::SunoMusic, + Self::SunoRemix => CommonModelType::SunoRemix, + Self::SunoSounds => CommonModelType::SunoSounds, + Self::SunoSample => CommonModelType::SunoSample, + Self::SeedAudio1p0 => CommonModelType::SeedAudio1p0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonAudioModel::SunoMusic, "suno_music"); + assert_serialization(CommonAudioModel::SunoRemix, "suno_remix"); + assert_serialization(CommonAudioModel::SunoSounds, "suno_sounds"); + assert_serialization(CommonAudioModel::SunoSample, "suno_sample"); + assert_serialization(CommonAudioModel::SeedAudio1p0, "seed_audio_1p0"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("suno_music", CommonAudioModel::SunoMusic), + ("suno_remix", CommonAudioModel::SunoRemix), + ("suno_sounds", CommonAudioModel::SunoSounds), + ("suno_sample", CommonAudioModel::SunoSample), + ("seed_audio_1p0", CommonAudioModel::SeedAudio1p0), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonAudioModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonAudioModel::SunoMusic, + CommonAudioModel::SunoRemix, + CommonAudioModel::SunoSounds, + CommonAudioModel::SunoSample, + CommonAudioModel::SeedAudio1p0, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonAudioModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } + + #[test] + fn all_audio_models_convert_to_common_model_type() { + let models = [ + (CommonAudioModel::SunoMusic, CommonModelType::SunoMusic), + (CommonAudioModel::SunoRemix, CommonModelType::SunoRemix), + (CommonAudioModel::SunoSounds, CommonModelType::SunoSounds), + (CommonAudioModel::SunoSample, CommonModelType::SunoSample), + (CommonAudioModel::SeedAudio1p0, CommonModelType::SeedAudio1p0), + ]; + for (audio_model, expected) in models { + assert_eq!(audio_model.to_common_model_type(), expected); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_bitrate.rs b/crates/schema/public/enums/src/common/generation/common_bitrate.rs new file mode 100644 index 0000000000..3dde645ffe --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_bitrate.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 16; + +/// Common output bitrate levels for generation. +/// +/// Most models default to `Normal`; `High` requests a higher bitrate where the +/// model supports it. Models that don't support it simply ignore the value. +/// +/// NB: Keep the max serialized length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonBitrate { + Normal, + High, +} + +impl_enum_display_and_debug_using_to_str!(CommonBitrate); +impl_mysql_enum_coders!(CommonBitrate); +impl_mysql_from_row!(CommonBitrate); + +impl CommonBitrate { + pub fn to_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::High => "high", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "normal" => Ok(Self::Normal), + "high" => Ok(Self::High), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Normal, + Self::High, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_bitrate::CommonBitrate; + use crate::common::generation::common_bitrate::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonBitrate::Normal, "normal"); + assert_serialization(CommonBitrate::High, "high"); + } + + #[test] + fn to_str() { + assert_eq!(CommonBitrate::Normal.to_str(), "normal"); + assert_eq!(CommonBitrate::High.to_str(), "high"); + } + + #[test] + fn from_str() { + assert_eq!(CommonBitrate::from_str("normal").unwrap(), CommonBitrate::Normal); + assert_eq!(CommonBitrate::from_str("high").unwrap(), CommonBitrate::High); + } + + #[test] + fn from_str_err() { + let result = CommonBitrate::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonBitrate::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(CommonBitrate::Normal)); + assert_eq!(variants.pop_first(), Some(CommonBitrate::High)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonBitrate::all_variants().len(), CommonBitrate::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonBitrate::all_variants() { + assert_eq!(variant, CommonBitrate::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonBitrate::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonBitrate::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonBitrate::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonBitrate::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonBitrate::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonBitrate::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_generation_mode.rs b/crates/schema/public/enums/src/common/generation/common_generation_mode.rs new file mode 100644 index 0000000000..4eef69fc02 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_generation_mode.rs @@ -0,0 +1,205 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +/// Stored in the `prompts` table (`maybe_generation_mode` column) as VARCHAR(24). +pub const MAX_LENGTH: usize = 24; + +/// The generation mode describes how the user wants to generate content. +/// +/// Stored in the `prompts` table (`maybe_generation_mode` column). +/// +/// NB: Keep the max serialized length to 24 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonGenerationMode { + /// Generate from a keyframe (eg. image-to-video with a start/end frame) + Keyframe, + + /// Generate using a reference image or style + Reference, + + /// Edit an existing image or video + Edit, + + /// Inpaint a masked region of an image + Inpaint, + + /// Outpaint / extend beyond the borders of an image + Outpaint, + + /// Generate from a text prompt only (no image input) + Text, +} + +impl_enum_display_and_debug_using_to_str!(CommonGenerationMode); +impl_mysql_enum_coders!(CommonGenerationMode); +impl_mysql_from_row!(CommonGenerationMode); + +impl CommonGenerationMode { + pub fn to_str(&self) -> &'static str { + match self { + Self::Keyframe => "keyframe", + Self::Reference => "reference", + Self::Edit => "edit", + Self::Inpaint => "inpaint", + Self::Outpaint => "outpaint", + Self::Text => "text", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "keyframe" => Ok(Self::Keyframe), + "reference" => Ok(Self::Reference), + "edit" => Ok(Self::Edit), + "inpaint" => Ok(Self::Inpaint), + "outpaint" => Ok(Self::Outpaint), + "text" => Ok(Self::Text), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Keyframe, + Self::Reference, + Self::Edit, + Self::Inpaint, + Self::Outpaint, + Self::Text, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_generation_mode::CommonGenerationMode; + use crate::common::generation::common_generation_mode::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonGenerationMode::Keyframe, "keyframe"); + assert_serialization(CommonGenerationMode::Reference, "reference"); + assert_serialization(CommonGenerationMode::Edit, "edit"); + assert_serialization(CommonGenerationMode::Inpaint, "inpaint"); + assert_serialization(CommonGenerationMode::Outpaint, "outpaint"); + assert_serialization(CommonGenerationMode::Text, "text"); + } + + #[test] + fn to_str() { + assert_eq!(CommonGenerationMode::Keyframe.to_str(), "keyframe"); + assert_eq!(CommonGenerationMode::Reference.to_str(), "reference"); + assert_eq!(CommonGenerationMode::Edit.to_str(), "edit"); + assert_eq!(CommonGenerationMode::Inpaint.to_str(), "inpaint"); + assert_eq!(CommonGenerationMode::Outpaint.to_str(), "outpaint"); + assert_eq!(CommonGenerationMode::Text.to_str(), "text"); + } + + #[test] + fn from_str() { + assert_eq!(CommonGenerationMode::from_str("keyframe").unwrap(), CommonGenerationMode::Keyframe); + assert_eq!(CommonGenerationMode::from_str("reference").unwrap(), CommonGenerationMode::Reference); + assert_eq!(CommonGenerationMode::from_str("edit").unwrap(), CommonGenerationMode::Edit); + assert_eq!(CommonGenerationMode::from_str("inpaint").unwrap(), CommonGenerationMode::Inpaint); + assert_eq!(CommonGenerationMode::from_str("outpaint").unwrap(), CommonGenerationMode::Outpaint); + assert_eq!(CommonGenerationMode::from_str("text").unwrap(), CommonGenerationMode::Text); + } + + #[test] + fn from_str_err() { + let result = CommonGenerationMode::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonGenerationMode::all_variants(); + assert_eq!(variants.len(), 6); + assert_eq!(variants.pop_first(), Some(CommonGenerationMode::Keyframe)); + assert_eq!(variants.pop_first(), Some(CommonGenerationMode::Reference)); + assert_eq!(variants.pop_first(), Some(CommonGenerationMode::Edit)); + assert_eq!(variants.pop_first(), Some(CommonGenerationMode::Inpaint)); + assert_eq!(variants.pop_first(), Some(CommonGenerationMode::Outpaint)); + assert_eq!(variants.pop_first(), Some(CommonGenerationMode::Text)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonGenerationMode::all_variants().len(), CommonGenerationMode::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonGenerationMode::all_variants() { + assert_eq!(variant, CommonGenerationMode::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonGenerationMode::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonGenerationMode::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonGenerationMode::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonGenerationMode::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonGenerationMode::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonGenerationMode::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_image_model.rs b/crates/schema/public/enums/src/common/generation/common_image_model.rs new file mode 100644 index 0000000000..5a458e2a2e --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_image_model.rs @@ -0,0 +1,223 @@ +use utoipa::ToSchema; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Image models available for generation. +/// Mirrors artcraft_router::api::router_image_model::RouterImageModel. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[serde(rename_all = "snake_case")] +pub enum CommonImageModel { + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_pro_1p1")] + FluxPro11, + #[serde(rename = "flux_pro_1p1_ultra")] + FluxPro11Ultra, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + #[serde(rename = "grok_imagine_image")] + GrokImagineImage, + #[serde(rename = "grok_imagine_image_q")] + GrokImagineImageQuality, + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + #[serde(rename = "seedream_5p0_pro")] + Seedream5p0Pro, + #[serde(rename = "seedream_5p0_pro_u")] + Seedream5p0ProUltra, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, +} + +impl CommonImageModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::Flux1Dev => CommonModelType::Flux1Dev, + Self::Flux1Schnell => CommonModelType::Flux1Schnell, + Self::FluxPro11 => CommonModelType::FluxPro11, + Self::FluxPro11Ultra => CommonModelType::FluxPro11Ultra, + Self::GptImage1 => CommonModelType::GptImage1, + Self::GptImage1p5 => CommonModelType::GptImage1p5, + Self::GptImage2 => CommonModelType::GptImage2, + Self::GptImage2p5Flare => CommonModelType::GptImage2p5Flare, + Self::GptImage2p5Sunburst => CommonModelType::GptImage2p5Sunburst, + Self::GrokImagineImage => CommonModelType::GrokImagineImage, + Self::GrokImagineImageQuality => CommonModelType::GrokImagineImageQuality, + Self::Midjourney7 => CommonModelType::Midjourney7, + Self::Midjourney7Niji => CommonModelType::Midjourney7Niji, + Self::Midjourney8 => CommonModelType::Midjourney8, + Self::NanoBanana => CommonModelType::NanoBanana, + Self::NanoBanana2 => CommonModelType::NanoBanana2, + Self::NanoBananaPro => CommonModelType::NanoBananaPro, + Self::Seedream4 => CommonModelType::Seedream4, + Self::Seedream4p5 => CommonModelType::Seedream4p5, + Self::Seedream5Lite => CommonModelType::Seedream5Lite, + Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro, + Self::Seedream5p0ProUltra => CommonModelType::Seedream5p0ProUltra, + Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles, + Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_class::CommonModelClass; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn serialization() { + assert_serialization(CommonImageModel::Flux1Dev, "flux_1_dev"); + assert_serialization(CommonImageModel::Flux1Schnell, "flux_1_schnell"); + assert_serialization(CommonImageModel::FluxPro11, "flux_pro_1p1"); + assert_serialization(CommonImageModel::FluxPro11Ultra, "flux_pro_1p1_ultra"); + assert_serialization(CommonImageModel::GptImage1, "gpt_image_1"); + assert_serialization(CommonImageModel::GptImage1p5, "gpt_image_1p5"); + assert_serialization(CommonImageModel::GptImage2, "gpt_image_2"); + assert_serialization(CommonImageModel::GptImage2p5Flare, "gpt_image_2p5_flare"); + assert_serialization(CommonImageModel::GptImage2p5Sunburst, "gpt_image_2p5_sunburst"); + assert_serialization(CommonImageModel::GrokImagineImage, "grok_imagine_image"); + assert_serialization(CommonImageModel::GrokImagineImageQuality, "grok_imagine_image_q"); + assert_serialization(CommonImageModel::Midjourney7, "midjourney_7"); + assert_serialization(CommonImageModel::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(CommonImageModel::Midjourney8, "midjourney_8"); + assert_serialization(CommonImageModel::NanoBanana, "nano_banana"); + assert_serialization(CommonImageModel::NanoBanana2, "nano_banana_2"); + assert_serialization(CommonImageModel::NanoBananaPro, "nano_banana_pro"); + assert_serialization(CommonImageModel::Seedream4, "seedream_4"); + assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5"); + assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite"); + assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro"); + assert_serialization(CommonImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u"); + assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles"); + assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles"); + } + + #[test] + fn all_image_models_convert_to_common_model_type() { + let models = [ + (CommonImageModel::Flux1Dev, CommonModelType::Flux1Dev), + (CommonImageModel::Flux1Schnell, CommonModelType::Flux1Schnell), + (CommonImageModel::FluxPro11, CommonModelType::FluxPro11), + (CommonImageModel::FluxPro11Ultra, CommonModelType::FluxPro11Ultra), + (CommonImageModel::GptImage1, CommonModelType::GptImage1), + (CommonImageModel::GptImage1p5, CommonModelType::GptImage1p5), + (CommonImageModel::GptImage2, CommonModelType::GptImage2), + (CommonImageModel::GptImage2p5Flare, CommonModelType::GptImage2p5Flare), + (CommonImageModel::GptImage2p5Sunburst, CommonModelType::GptImage2p5Sunburst), + (CommonImageModel::GrokImagineImage, CommonModelType::GrokImagineImage), + (CommonImageModel::GrokImagineImageQuality, CommonModelType::GrokImagineImageQuality), + (CommonImageModel::Midjourney7, CommonModelType::Midjourney7), + (CommonImageModel::Midjourney7Niji, CommonModelType::Midjourney7Niji), + (CommonImageModel::Midjourney8, CommonModelType::Midjourney8), + (CommonImageModel::NanoBanana, CommonModelType::NanoBanana), + (CommonImageModel::NanoBanana2, CommonModelType::NanoBanana2), + (CommonImageModel::NanoBananaPro, CommonModelType::NanoBananaPro), + (CommonImageModel::Seedream4, CommonModelType::Seedream4), + (CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5), + (CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite), + (CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro), + (CommonImageModel::Seedream5p0ProUltra, CommonModelType::Seedream5p0ProUltra), + (CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles), + (CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles), + ]; + for (image_model, expected) in models { + assert_eq!(image_model.to_common_model_type(), expected); + } + } + + #[test] + fn all_image_models_map_to_image_class() { + use strum::IntoEnumIterator; + for variant in CommonImageModel::iter() { + let model_type = variant.to_common_model_type(); + assert_eq!( + model_type.get_model_class(), CommonModelClass::Image, + "{:?} mapped to {:?} which has class {:?}, expected Image", + variant, model_type, model_type.get_model_class(), + ); + } + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_count() { + use strum::IntoEnumIterator; + assert_eq!(CommonImageModel::iter().len(), 24); + } + + #[test] + fn serde_round_trip() { + use strum::IntoEnumIterator; + for variant in CommonImageModel::iter() { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonImageModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "round-trip failed for {:?}", variant); + } + } + + #[test] + fn serialized_names_are_lowercase_alphanumeric_underscore() { + use strum::IntoEnumIterator; + let valid = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + for variant in CommonImageModel::iter() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!( + valid.is_match(&json), + "{:?} serializes to {:?} which contains invalid characters", + variant, json, + ); + } + } + + #[test] + fn to_common_model_type_covers_all_variants() { + use strum::IntoEnumIterator; + // This test ensures to_common_model_type doesn't panic for any variant. + for variant in CommonImageModel::iter() { + let _ = variant.to_common_model_type(); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_mesh_model.rs b/crates/schema/public/enums/src/common/generation/common_mesh_model.rs new file mode 100644 index 0000000000..c6271017e7 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_mesh_model.rs @@ -0,0 +1,162 @@ +use utoipa::ToSchema; + +/// Mesh (3D object) models available for generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonMeshModel { + #[serde(rename = "hunyuan_3d_2p0")] + Hunyuan3d2p0, + + #[serde(rename = "hunyuan_3d_2p1")] + Hunyuan3d2p1, + + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + + /// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as + /// [`Self::Hunyuan3d3`], but takes a sketch image as input. + #[serde(rename = "hunyuan_3d_3_sketch")] + Hunyuan3d3Sketch, + + /// Hunyuan 3D v3.1 Pro: text or (multi-view) image input. + #[serde(rename = "hunyuan_3d_3p1_pro")] + Hunyuan3d3p1Pro, + + /// Hunyuan 3D v3.1 Rapid: the fast, low-cost tier. Text or single-image + /// input. + #[serde(rename = "hunyuan_3d_3p1_rapid")] + Hunyuan3d3p1Rapid, + + /// Hunyuan 3D v3.1 Part: splits an existing mesh into semantic parts. + /// Takes a mesh file as input, not text/images. + #[serde(rename = "hunyuan_3d_3p1_part")] + Hunyuan3d3p1Part, + + /// Hunyuan 3D v3.1 Smart Topology: retopologizes an existing mesh. + /// Takes a mesh file as input, not text/images. + #[serde(rename = "hunyuan_3d_3p1_topology")] + Hunyuan3d3p1SmartTopology, + + /// Tripo3D H3.1: text, single-image, or multi-view image input. + #[serde(rename = "tripo3d_h3p1")] + Tripo3dH3p1, + + /// Meshy 6: text or single-image input. + #[serde(rename = "meshy_v6")] + MeshyV6, + + /// Hyper3D Rodin v2.5 Fast: text or image(s) input. + #[serde(rename = "rodin_2p5_fast")] + Rodin2p5Fast, +} + +impl CommonMeshModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::Hunyuan3d2p0 => CommonModelType::Hunyuan3d2_0, + Self::Hunyuan3d2p1 => CommonModelType::Hunyuan3d2_1, + Self::Hunyuan3d3 => CommonModelType::Hunyuan3d3, + // Sketch mode is the same model, just a different input mode. + Self::Hunyuan3d3Sketch => CommonModelType::Hunyuan3d3, + Self::Hunyuan3d3p1Pro => CommonModelType::Hunyuan3d3_1Pro, + Self::Hunyuan3d3p1Rapid => CommonModelType::Hunyuan3d3_1Rapid, + Self::Hunyuan3d3p1Part => CommonModelType::Hunyuan3d3_1Part, + Self::Hunyuan3d3p1SmartTopology => CommonModelType::Hunyuan3d3_1SmartTopology, + Self::Tripo3dH3p1 => CommonModelType::Tripo3dH3_1, + Self::MeshyV6 => CommonModelType::MeshyV6, + Self::Rodin2p5Fast => CommonModelType::Rodin2_5Fast, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + const ALL_VARIANTS: [CommonMeshModel; 11] = [ + CommonMeshModel::Hunyuan3d2p0, + CommonMeshModel::Hunyuan3d2p1, + CommonMeshModel::Hunyuan3d3, + CommonMeshModel::Hunyuan3d3Sketch, + CommonMeshModel::Hunyuan3d3p1Pro, + CommonMeshModel::Hunyuan3d3p1Rapid, + CommonMeshModel::Hunyuan3d3p1Part, + CommonMeshModel::Hunyuan3d3p1SmartTopology, + CommonMeshModel::Tripo3dH3p1, + CommonMeshModel::MeshyV6, + CommonMeshModel::Rodin2p5Fast, + ]; + + #[test] + fn test_serialization() { + assert_serialization(CommonMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0"); + assert_serialization(CommonMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1"); + assert_serialization(CommonMeshModel::Hunyuan3d3, "hunyuan_3d_3"); + assert_serialization(CommonMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch"); + assert_serialization(CommonMeshModel::Hunyuan3d3p1Pro, "hunyuan_3d_3p1_pro"); + assert_serialization(CommonMeshModel::Hunyuan3d3p1Rapid, "hunyuan_3d_3p1_rapid"); + assert_serialization(CommonMeshModel::Hunyuan3d3p1Part, "hunyuan_3d_3p1_part"); + assert_serialization(CommonMeshModel::Hunyuan3d3p1SmartTopology, "hunyuan_3d_3p1_topology"); + assert_serialization(CommonMeshModel::Tripo3dH3p1, "tripo3d_h3p1"); + assert_serialization(CommonMeshModel::MeshyV6, "meshy_v6"); + assert_serialization(CommonMeshModel::Rodin2p5Fast, "rodin_2p5_fast"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("hunyuan_3d_2p0", CommonMeshModel::Hunyuan3d2p0), + ("hunyuan_3d_2p1", CommonMeshModel::Hunyuan3d2p1), + ("hunyuan_3d_3", CommonMeshModel::Hunyuan3d3), + ("hunyuan_3d_3_sketch", CommonMeshModel::Hunyuan3d3Sketch), + ("hunyuan_3d_3p1_pro", CommonMeshModel::Hunyuan3d3p1Pro), + ("hunyuan_3d_3p1_rapid", CommonMeshModel::Hunyuan3d3p1Rapid), + ("hunyuan_3d_3p1_part", CommonMeshModel::Hunyuan3d3p1Part), + ("hunyuan_3d_3p1_topology", CommonMeshModel::Hunyuan3d3p1SmartTopology), + ("tripo3d_h3p1", CommonMeshModel::Tripo3dH3p1), + ("meshy_v6", CommonMeshModel::MeshyV6), + ("rodin_2p5_fast", CommonMeshModel::Rodin2p5Fast), + ]; + assert_eq!(cases.len(), ALL_VARIANTS.len()); + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonMeshModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + for variant in ALL_VARIANTS { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonMeshModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } + + #[test] + fn all_mesh_models_convert_to_common_model_type() { + let models = [ + (CommonMeshModel::Hunyuan3d2p0, CommonModelType::Hunyuan3d2_0), + (CommonMeshModel::Hunyuan3d2p1, CommonModelType::Hunyuan3d2_1), + (CommonMeshModel::Hunyuan3d3, CommonModelType::Hunyuan3d3), + // Sketch mode maps to the same underlying model. + (CommonMeshModel::Hunyuan3d3Sketch, CommonModelType::Hunyuan3d3), + (CommonMeshModel::Hunyuan3d3p1Pro, CommonModelType::Hunyuan3d3_1Pro), + (CommonMeshModel::Hunyuan3d3p1Rapid, CommonModelType::Hunyuan3d3_1Rapid), + (CommonMeshModel::Hunyuan3d3p1Part, CommonModelType::Hunyuan3d3_1Part), + (CommonMeshModel::Hunyuan3d3p1SmartTopology, CommonModelType::Hunyuan3d3_1SmartTopology), + (CommonMeshModel::Tripo3dH3p1, CommonModelType::Tripo3dH3_1), + (CommonMeshModel::MeshyV6, CommonModelType::MeshyV6), + (CommonMeshModel::Rodin2p5Fast, CommonModelType::Rodin2_5Fast), + ]; + assert_eq!(models.len(), ALL_VARIANTS.len()); + for (mesh_model, expected) in models { + assert_eq!(mesh_model.to_common_model_type(), expected); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_mesh_output_type.rs b/crates/schema/public/enums/src/common/generation/common_mesh_output_type.rs new file mode 100644 index 0000000000..0e0a97b3d7 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_mesh_output_type.rs @@ -0,0 +1,60 @@ +use utoipa::ToSchema; + +/// Mesh output types for 3D object generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonMeshOutputType { + /// A standard, fully-detailed mesh. + #[serde(rename = "normal")] + Normal, + + /// A reduced-polygon mesh suitable for real-time rendering. + #[serde(rename = "low_poly")] + LowPoly, + + /// Geometry only, without textures/materials. + #[serde(rename = "geometry")] + Geometry, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonMeshOutputType::Normal, "normal"); + assert_serialization(CommonMeshOutputType::LowPoly, "low_poly"); + assert_serialization(CommonMeshOutputType::Geometry, "geometry"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("normal", CommonMeshOutputType::Normal), + ("low_poly", CommonMeshOutputType::LowPoly), + ("geometry", CommonMeshOutputType::Geometry), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonMeshOutputType = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonMeshOutputType::Normal, + CommonMeshOutputType::LowPoly, + CommonMeshOutputType::Geometry, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonMeshOutputType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_mesh_quality.rs b/crates/schema/public/enums/src/common/generation/common_mesh_quality.rs new file mode 100644 index 0000000000..de44b6bf5e --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_mesh_quality.rs @@ -0,0 +1,49 @@ +use utoipa::ToSchema; + +/// Quality level for a mesh generation sub-feature (e.g. texture quality or +/// geometry quality). Some models charge extra for `Detailed`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonMeshQuality { + #[serde(rename = "standard")] + Standard, + + #[serde(rename = "detailed")] + Detailed, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonMeshQuality::Standard, "standard"); + assert_serialization(CommonMeshQuality::Detailed, "detailed"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("standard", CommonMeshQuality::Standard), + ("detailed", CommonMeshQuality::Detailed), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonMeshQuality = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [CommonMeshQuality::Standard, CommonMeshQuality::Detailed]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonMeshQuality = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_model_class.rs b/crates/schema/public/enums/src/common/generation/common_model_class.rs new file mode 100644 index 0000000000..3e4b19c13d --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_model_class.rs @@ -0,0 +1,217 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for future database storage. +pub const MAX_LENGTH: usize = 24; + +/// The broad class of model (image, video, audio, 3D, etc.) +/// +/// NB: This is not currently stored in the database, so it does not need +/// `impl_mysql_enum_coders!` or `impl_mysql_from_row!` macros. If it is +/// stored in the future, add those macros and a migration. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +pub enum CommonModelClass { + #[serde(rename = "image")] + Image, + + #[serde(rename = "video")] + Video, + + #[serde(rename = "audio")] + Audio, + + #[serde(rename = "audio_music")] + AudioMusic, + + #[serde(rename = "audio_voice")] + AudioVoice, + + #[serde(rename = "3d")] + Dimensional, + + #[serde(rename = "3d_mesh")] + DimensionalMesh, + + #[serde(rename = "3d_splat")] + DimensionalSplat, +} + +impl_enum_display_and_debug_using_to_str!(CommonModelClass); + +impl CommonModelClass { + pub fn to_str(&self) -> &'static str { + match self { + Self::Image => "image", + Self::Video => "video", + Self::Audio => "audio", + Self::AudioMusic => "audio_music", + Self::AudioVoice => "audio_voice", + Self::Dimensional => "3d", + Self::DimensionalMesh => "3d_mesh", + Self::DimensionalSplat => "3d_splat", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "image" => Ok(Self::Image), + "video" => Ok(Self::Video), + "audio" => Ok(Self::Audio), + "audio_music" => Ok(Self::AudioMusic), + "audio_voice" => Ok(Self::AudioVoice), + "3d" => Ok(Self::Dimensional), + "3d_mesh" => Ok(Self::DimensionalMesh), + "3d_splat" => Ok(Self::DimensionalSplat), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Image, + Self::Video, + Self::Audio, + Self::AudioMusic, + Self::AudioVoice, + Self::Dimensional, + Self::DimensionalMesh, + Self::DimensionalSplat, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_model_class::CommonModelClass; + use crate::common::generation::common_model_class::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonModelClass::Image, "image"); + assert_serialization(CommonModelClass::Video, "video"); + assert_serialization(CommonModelClass::Audio, "audio"); + assert_serialization(CommonModelClass::AudioMusic, "audio_music"); + assert_serialization(CommonModelClass::AudioVoice, "audio_voice"); + assert_serialization(CommonModelClass::Dimensional, "3d"); + assert_serialization(CommonModelClass::DimensionalMesh, "3d_mesh"); + assert_serialization(CommonModelClass::DimensionalSplat, "3d_splat"); + } + + #[test] + fn to_str() { + assert_eq!(CommonModelClass::Image.to_str(), "image"); + assert_eq!(CommonModelClass::Video.to_str(), "video"); + assert_eq!(CommonModelClass::Audio.to_str(), "audio"); + assert_eq!(CommonModelClass::AudioMusic.to_str(), "audio_music"); + assert_eq!(CommonModelClass::AudioVoice.to_str(), "audio_voice"); + assert_eq!(CommonModelClass::Dimensional.to_str(), "3d"); + assert_eq!(CommonModelClass::DimensionalMesh.to_str(), "3d_mesh"); + assert_eq!(CommonModelClass::DimensionalSplat.to_str(), "3d_splat"); + } + + #[test] + fn from_str() { + assert_eq!(CommonModelClass::from_str("image").unwrap(), CommonModelClass::Image); + assert_eq!(CommonModelClass::from_str("video").unwrap(), CommonModelClass::Video); + assert_eq!(CommonModelClass::from_str("audio").unwrap(), CommonModelClass::Audio); + assert_eq!(CommonModelClass::from_str("audio_music").unwrap(), CommonModelClass::AudioMusic); + assert_eq!(CommonModelClass::from_str("audio_voice").unwrap(), CommonModelClass::AudioVoice); + assert_eq!(CommonModelClass::from_str("3d").unwrap(), CommonModelClass::Dimensional); + assert_eq!(CommonModelClass::from_str("3d_mesh").unwrap(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelClass::from_str("3d_splat").unwrap(), CommonModelClass::DimensionalSplat); + } + + #[test] + fn from_str_err() { + let result = CommonModelClass::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonModelClass::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(CommonModelClass::Image)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::Video)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::Audio)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::AudioMusic)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::AudioVoice)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::Dimensional)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::DimensionalMesh)); + assert_eq!(variants.pop_first(), Some(CommonModelClass::DimensionalSplat)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonModelClass::all_variants().len(), CommonModelClass::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonModelClass::all_variants() { + assert_eq!(variant, CommonModelClass::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonModelClass::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonModelClass::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonModelClass::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonModelClass::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonModelClass::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_model_type.rs b/crates/schema/public/enums/src/common/generation/common_model_type.rs new file mode 100644 index 0000000000..22ea6e6cfa --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_model_type.rs @@ -0,0 +1,1380 @@ +// The deprecated Seedance 2.0 Ultra variants must stay referenced by this +// enum's own coders, variant lists, and tests; suppress the lint file-wide. +#![allow(deprecated)] + +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::common::generation::common_model_class::CommonModelClass; + +/// Maximum serialized string length for database storage. +/// Stored in the `prompts` table (`maybe_model_type` column) as VARCHAR(24). +pub const MAX_LENGTH: usize = 24; + +/// Common model type enum used across image, video, 3D, and splat generation. +/// This forms part of our core data model for generative models. +/// +/// This is used in the MySQL database, the HTTP API, and throughout +/// ArtCraft (the Tauri app). +/// +/// Stored in the `prompts` table (`maybe_model_type` column). +/// Also used by other tables and the sqlite tasks table. +/// +/// NB: This will be used by a variety of tables (MySQL and sqlite)! +/// Keep the max length to 24 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonModelType { + // Image models + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_dev_juggernaut")] + FluxDevJuggernaut, + #[serde(rename = "flux_pro_1")] + FluxPro1, + #[serde(rename = "flux_pro_1p1")] + FluxPro11, + #[serde(rename = "flux_pro_1p1_ultra")] + FluxPro11Ultra, + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + // Generic grok image model without a version + #[serde(rename = "grok_image")] + GrokImage, + // xAI's Grok Imagine image model (grok-imagine-image), standard tier. + #[serde(rename = "grok_imagine_image")] + GrokImagineImage, + // xAI's Grok Imagine image model (grok-imagine-image-quality), high-quality tier. + #[serde(rename = "grok_imagine_image_q")] + GrokImagineImageQuality, + #[serde(rename = "recraft_3")] + Recraft3, + #[serde(rename = "seededit_3")] + SeedEdit3, + #[serde(rename = "qwen")] + Qwen, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + /// Gemini 2.5 Flash, AKA "Nano Banana" + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + /// Seedream 5.0 Pro as routed via kinovi + #[serde(rename = "seedream_5p0_pro")] + Seedream5p0Pro, + /// Seedream 5.0 Pro as routed via the kinovi ultra account + #[serde(rename = "seedream_5p0_pro_u")] + Seedream5p0ProUltra, + + /// Midjourney without distinguishing a model type or version + #[serde(rename = "midjourney")] + Midjourney, + #[serde(rename = "midjourney_v6")] + MidjourneyV6, + #[serde(rename = "midjourney_v6p1")] + MidjourneyV6p1, + #[serde(rename = "midjourney_v6p1_raw")] + MidjourneyV6p1Raw, + #[serde(rename = "midjourney_v7")] + MidjourneyV7, + #[serde(rename = "midjourney_v7_draft")] + MidjourneyV7Draft, + #[serde(rename = "midjourney_v7_draft_raw")] + MidjourneyV7DraftRaw, + #[serde(rename = "midjourney_v7_raw")] + MidjourneyV7Raw, + /// Midjourney v7 as routed via kinovi + #[serde(rename = "midjourney_7")] + Midjourney7, + /// Midjourney v7 niji as routed via kinovi + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + /// Midjourney v8 as routed via kinovi + #[serde(rename = "midjourney_8")] + Midjourney8, + + //// Image Infill models + //#[serde(rename = "flux_pro_1_infill")] + //FluxPro1Infill, + + // Video models + + /// Generic grok video model without a version + /// NB: This was for the consumer web login API + #[deprecated(note = "use GrokImagineVideo instead")] + #[serde(rename = "grok_video")] + GrokVideo, + /// xAI's Grok Imagine video model (grok-imagine-video). + /// This is for the API-based version. + /// No version is specified. + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + /// xAI's Grok Imagine video 1.5 preview model (grok-imagine-video-1.5-preview). + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "flux_3")] + Flux3, + #[serde(rename = "flux_3_draft")] + Flux3Draft, + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + #[serde(rename = "kling_2p1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "minimax_h3")] + MinimaxH3, + #[serde(rename = "minimax_h3_turbo")] + MinimaxH3Turbo, + #[serde(rename = "minimax_h3_ultra")] + MinimaxH3Ultra, + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p0_pro")] + Seedance10Pro, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + #[serde(rename = "seedance_2p0")] + Seedance2p0, + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + #[serde(rename = "seedance_2p0_mini")] + Seedance2p0Mini, + #[serde(rename = "seedance_2p0_bp_mini")] + Seedance2p0BytePlusMini, + #[serde(rename = "seedance_2p0_bpu_mini")] + Seedance2p0BytePlusUltraMini, + #[serde(rename = "seedance_2p5_preview")] + Seedance2p5Preview, + #[serde(rename = "seedance_2p5")] + Seedance2p5, + #[serde(rename = "seedance_2p5_u")] + Seedance2p5Ultra, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, + #[serde(rename = "veo_3p1_lite")] + Veo3p1Lite, + #[serde(rename = "vidu_q3")] + ViduQ3, + #[serde(rename = "vidu_q3_turbo")] + ViduQ3Turbo, + + // Audio models + #[serde(rename = "suno_music")] + SunoMusic, + #[serde(rename = "suno_remix")] + SunoRemix, + #[serde(rename = "suno_sounds")] + SunoSounds, + #[serde(rename = "suno_sample")] + SunoSample, + #[serde(rename = "seed_audio_1p0")] + SeedAudio1p0, + + #[serde(rename = "preview_model")] + PreviewModel, + #[serde(rename = "preview_model_fast")] + PreviewModelFast, + #[serde(rename = "switch_x")] + SwitchX, + + // 3D Object generation models + #[serde(rename = "hunyuan_3d_2p0")] + Hunyuan3d2_0, + #[serde(rename = "hunyuan_3d_2p1")] + Hunyuan3d2_1, + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + #[serde(rename = "hunyuan_3d_3p1_pro")] + Hunyuan3d3_1Pro, + #[serde(rename = "hunyuan_3d_3p1_rapid")] + Hunyuan3d3_1Rapid, + #[serde(rename = "hunyuan_3d_3p1_part")] + Hunyuan3d3_1Part, + #[serde(rename = "hunyuan_3d_3p1_topology")] + Hunyuan3d3_1SmartTopology, + #[serde(rename = "tripo3d_h3p1")] + Tripo3dH3_1, + #[serde(rename = "meshy_v6")] + MeshyV6, + #[serde(rename = "rodin_2p5_fast")] + Rodin2_5Fast, + + // Splat generation models (World Labs) + #[serde(rename = "marble_0p1_mini")] + Marble0p1Mini, + #[serde(rename = "marble_0p1_plus")] + Marble0p1Plus, + #[serde(rename = "marble_1p0")] + Marble1p0, + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + #[serde(rename = "marble_1p1")] + Marble1p1, + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, + #[serde(rename = "triposplat")] + TripoSplat, +} + +impl_enum_display_and_debug_using_to_str!(CommonModelType); +impl_mysql_enum_coders!(CommonModelType); +impl_mysql_from_row!(CommonModelType); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl CommonModelType { + pub fn to_str(&self) -> &'static str { + match self { + // Image models + Self::Flux1Dev => "flux_1_dev", + Self::Flux1Schnell => "flux_1_schnell", + Self::FluxDevJuggernaut => "flux_dev_juggernaut", + Self::FluxPro1 => "flux_pro_1", + Self::FluxPro11 => "flux_pro_1p1", + Self::FluxPro11Ultra => "flux_pro_1p1_ultra", + Self::FluxProKontextMax => "flux_pro_kontext_max", + Self::Flux2LoraAngles => "flux_2_lora_angles", + Self::GptImage1 => "gpt_image_1", + Self::GptImage1p5 => "gpt_image_1p5", + Self::GptImage2 => "gpt_image_2", + Self::GptImage2p5Flare => "gpt_image_2p5_flare", + Self::GptImage2p5Sunburst => "gpt_image_2p5_sunburst", + Self::GrokImage => "grok_image", + Self::GrokImagineImage => "grok_imagine_image", + Self::GrokImagineImageQuality => "grok_imagine_image_q", + Self::Recraft3 => "recraft_3", + Self::SeedEdit3 => "seededit_3", + Self::Qwen => "qwen", + Self::QwenEdit2511Angles => "qwen_edit_2511_angles", + Self::Gemini25Flash => "gemini_25_flash", + Self::NanoBanana => "nano_banana", + Self::NanoBanana2 => "nano_banana_2", + Self::NanoBananaPro => "nano_banana_pro", + Self::Seedream4 => "seedream_4", + Self::Seedream4p5 => "seedream_4p5", + Self::Seedream5Lite => "seedream_5_lite", + Self::Seedream5p0Pro => "seedream_5p0_pro", + Self::Seedream5p0ProUltra => "seedream_5p0_pro_u", + Self::Midjourney => "midjourney", + Self::MidjourneyV6 => "midjourney_v6", + Self::MidjourneyV6p1 => "midjourney_v6p1", + Self::MidjourneyV6p1Raw => "midjourney_v6p1_raw", + Self::MidjourneyV7 => "midjourney_v7", + Self::MidjourneyV7Draft => "midjourney_v7_draft", + Self::MidjourneyV7DraftRaw => "midjourney_v7_draft_raw", + Self::MidjourneyV7Raw => "midjourney_v7_raw", + Self::Midjourney7 => "midjourney_7", + Self::Midjourney7Niji => "midjourney_7_niji", + Self::Midjourney8 => "midjourney_8", + + // Video models + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Flux3 => "flux_3", + Self::Flux3Draft => "flux_3_draft", + Self::Kling16Pro => "kling_1p6_pro", + Self::Kling21Pro => "kling_2p1_pro", + Self::Kling21Master => "kling_2p1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::MinimaxH3 => "minimax_h3", + Self::MinimaxH3Turbo => "minimax_h3_turbo", + Self::MinimaxH3Ultra => "minimax_h3_ultra", + Self::Seedance10Lite => "seedance_1p0_lite", + Self::Seedance10Pro => "seedance_1p0_pro", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Seedance2p0BytePlus => "seedance_2p0_bp", + Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast", + Self::Seedance2p0Ultra => "seedance_2p0_u", + Self::Seedance2p0UltraFast => "seedance_2p0_u_fast", + Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu", + Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast", + Self::Seedance2p0Mini => "seedance_2p0_mini", + Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini", + Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini", + Self::Seedance2p5Preview => "seedance_2p5_preview", + Self::Seedance2p5 => "seedance_2p5", + Self::Seedance2p5Ultra => "seedance_2p5_u", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + Self::Veo3p1Lite => "veo_3p1_lite", + Self::ViduQ3 => "vidu_q3", + Self::ViduQ3Turbo => "vidu_q3_turbo", + + // Audio models + Self::SunoMusic => "suno_music", + Self::SunoRemix => "suno_remix", + Self::SunoSounds => "suno_sounds", + Self::SunoSample => "suno_sample", + Self::SeedAudio1p0 => "seed_audio_1p0", + + Self::PreviewModel => "preview_model", + Self::PreviewModelFast => "preview_model_fast", + Self::SwitchX => "switch_x", + + // 3D Object generation models + Self::Hunyuan3d2_0 => "hunyuan_3d_2p0", + Self::Hunyuan3d2_1 => "hunyuan_3d_2p1", + Self::Hunyuan3d3 => "hunyuan_3d_3", + Self::Hunyuan3d3_1Pro => "hunyuan_3d_3p1_pro", + Self::Hunyuan3d3_1Rapid => "hunyuan_3d_3p1_rapid", + Self::Hunyuan3d3_1Part => "hunyuan_3d_3p1_part", + Self::Hunyuan3d3_1SmartTopology => "hunyuan_3d_3p1_topology", + Self::Tripo3dH3_1 => "tripo3d_h3p1", + Self::MeshyV6 => "meshy_v6", + Self::Rodin2_5Fast => "rodin_2p5_fast", + + // Splat generation models (World Labs) + Self::Marble0p1Mini => "marble_0p1_mini", + Self::Marble0p1Plus => "marble_0p1_plus", + Self::Marble1p0 => "marble_1p0", + Self::Marble1p0Draft => "marble_1p0_draft", + Self::Marble1p1 => "marble_1p1", + Self::Marble1p1Plus => "marble_1p1_plus", + Self::TripoSplat => "triposplat", + } + } + + pub fn from_str(job_status: &str) -> Result { + match job_status { + // Image models + "flux_1_dev" => Ok(Self::Flux1Dev), + "flux_1_schnell" => Ok(Self::Flux1Schnell), + "flux_dev_juggernaut" => Ok(Self::FluxDevJuggernaut), + "flux_pro_1" => Ok(Self::FluxPro1), + "flux_pro_1p1" => Ok(Self::FluxPro11), + "flux_pro_1p1_ultra" => Ok(Self::FluxPro11Ultra), + "flux_pro_kontext_max" => Ok(Self::FluxProKontextMax), + "flux_2_lora_angles" => Ok(Self::Flux2LoraAngles), + "gpt_image_1" => Ok(Self::GptImage1), + "gpt_image_1p5" => Ok(Self::GptImage1p5), + "gpt_image_2" => Ok(Self::GptImage2), + "gpt_image_2p5_flare" => Ok(Self::GptImage2p5Flare), + "gpt_image_2p5_sunburst" => Ok(Self::GptImage2p5Sunburst), + "grok_image" => Ok(Self::GrokImage), + "grok_imagine_image" => Ok(Self::GrokImagineImage), + "grok_imagine_image_q" => Ok(Self::GrokImagineImageQuality), + "recraft_3" => Ok(Self::Recraft3), + "seededit_3" => Ok(Self::SeedEdit3), + "qwen" => Ok(Self::Qwen), + "qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles), + "gemini_25_flash" => Ok(Self::Gemini25Flash), + "nano_banana" => Ok(Self::NanoBanana), + "nano_banana_2" => Ok(Self::NanoBanana2), + "nano_banana_pro" => Ok(Self::NanoBananaPro), + "seedream_4" => Ok(Self::Seedream4), + "seedream_4p5" => Ok(Self::Seedream4p5), + "seedream_5_lite" => Ok(Self::Seedream5Lite), + "seedream_5p0_pro" => Ok(Self::Seedream5p0Pro), + "seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra), + "midjourney" => Ok(Self::Midjourney), + "midjourney_v6" => Ok(Self::MidjourneyV6), + "midjourney_v6p1" => Ok(Self::MidjourneyV6p1), + "midjourney_v6p1_raw" => Ok(Self::MidjourneyV6p1Raw), + "midjourney_v7" => Ok(Self::MidjourneyV7), + "midjourney_v7_draft" => Ok(Self::MidjourneyV7Draft), + "midjourney_v7_draft_raw" => Ok(Self::MidjourneyV7DraftRaw), + "midjourney_v7_raw" => Ok(Self::MidjourneyV7Raw), + "midjourney_7" => Ok(Self::Midjourney7), + "midjourney_7_niji" => Ok(Self::Midjourney7Niji), + "midjourney_8" => Ok(Self::Midjourney8), + + // Video models + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "flux_3" => Ok(Self::Flux3), + "flux_3_draft" => Ok(Self::Flux3Draft), + "kling_1p6_pro" => Ok(Self::Kling16Pro), + "kling_2p1_pro" => Ok(Self::Kling21Pro), + "kling_2p1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "minimax_h3" => Ok(Self::MinimaxH3), + "minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo), + "minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra), + "seedance_1p0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p0_pro" => Ok(Self::Seedance10Pro), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus), + "seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast), + "seedance_2p0_u" => Ok(Self::Seedance2p0Ultra), + "seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast), + "seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra), + "seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast), + "seedance_2p0_mini" => Ok(Self::Seedance2p0Mini), + "seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini), + "seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini), + "seedance_2p5_preview" => Ok(Self::Seedance2p5Preview), + "seedance_2p5" => Ok(Self::Seedance2p5), + "seedance_2p5_u" => Ok(Self::Seedance2p5Ultra), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + "veo_3p1_lite" => Ok(Self::Veo3p1Lite), + "vidu_q3" => Ok(Self::ViduQ3), + "vidu_q3_turbo" => Ok(Self::ViduQ3Turbo), + + // Audio models + "suno_music" => Ok(Self::SunoMusic), + "suno_remix" => Ok(Self::SunoRemix), + "suno_sounds" => Ok(Self::SunoSounds), + "suno_sample" => Ok(Self::SunoSample), + "seed_audio_1p0" => Ok(Self::SeedAudio1p0), + + "preview_model" => Ok(Self::PreviewModel), + "preview_model_fast" => Ok(Self::PreviewModelFast), + "switch_x" => Ok(Self::SwitchX), + + // 3D Object generation models + "hunyuan_3d_2p0" => Ok(Self::Hunyuan3d2_0), + "hunyuan_3d_2p1" => Ok(Self::Hunyuan3d2_1), + "hunyuan_3d_3" => Ok(Self::Hunyuan3d3), + "hunyuan_3d_3p1_pro" => Ok(Self::Hunyuan3d3_1Pro), + "hunyuan_3d_3p1_rapid" => Ok(Self::Hunyuan3d3_1Rapid), + "hunyuan_3d_3p1_part" => Ok(Self::Hunyuan3d3_1Part), + "hunyuan_3d_3p1_topology" => Ok(Self::Hunyuan3d3_1SmartTopology), + "tripo3d_h3p1" => Ok(Self::Tripo3dH3_1), + "meshy_v6" => Ok(Self::MeshyV6), + "rodin_2p5_fast" => Ok(Self::Rodin2_5Fast), + + // Splat generation models (World Labs) + "marble_0p1_mini" => Ok(Self::Marble0p1Mini), + "marble_0p1_plus" => Ok(Self::Marble0p1Plus), + "marble_1p0" => Ok(Self::Marble1p0), + "marble_1p0_draft" => Ok(Self::Marble1p0Draft), + "marble_1p1" => Ok(Self::Marble1p1), + "marble_1p1_plus" => Ok(Self::Marble1p1Plus), + "triposplat" => Ok(Self::TripoSplat), + + _ => Err(format!("invalid model_type: {:?}", job_status)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + // Image models + Self::Flux1Dev, + Self::Flux1Schnell, + Self::FluxDevJuggernaut, + Self::FluxPro1, + Self::FluxPro11, + Self::FluxPro11Ultra, + Self::FluxProKontextMax, + Self::Flux2LoraAngles, + Self::GptImage1, + Self::GptImage1p5, + Self::GptImage2, + Self::GptImage2p5Flare, + Self::GptImage2p5Sunburst, + Self::GrokImage, + Self::GrokImagineImage, + Self::GrokImagineImageQuality, + Self::Recraft3, + Self::SeedEdit3, + Self::Qwen, + Self::QwenEdit2511Angles, + Self::Gemini25Flash, + Self::NanoBanana, + Self::NanoBanana2, + Self::NanoBananaPro, + Self::Seedream4, + Self::Seedream4p5, + Self::Seedream5Lite, + Self::Seedream5p0Pro, + Self::Seedream5p0ProUltra, + Self::Midjourney, + Self::MidjourneyV6, + Self::MidjourneyV6p1, + Self::MidjourneyV6p1Raw, + Self::MidjourneyV7, + Self::MidjourneyV7Draft, + Self::MidjourneyV7DraftRaw, + Self::MidjourneyV7Raw, + Self::Midjourney7, + Self::Midjourney7Niji, + Self::Midjourney8, + + // Video models + Self::GrokVideo, + Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, + Self::Flux3, + Self::Flux3Draft, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::MinimaxH3, + Self::MinimaxH3Turbo, + Self::MinimaxH3Ultra, + Self::Seedance10Lite, + Self::Seedance10Pro, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra, + Self::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast, + Self::Seedance2p0Mini, + Self::Seedance2p0BytePlusMini, + Self::Seedance2p0BytePlusUltraMini, + Self::Seedance2p5Preview, + Self::Seedance2p5, + Self::Seedance2p5Ultra, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + Self::Veo3p1Lite, + Self::ViduQ3, + Self::ViduQ3Turbo, + + // Audio models + Self::SunoMusic, + Self::SunoRemix, + Self::SunoSounds, + Self::SunoSample, + Self::SeedAudio1p0, + + Self::PreviewModel, + Self::PreviewModelFast, + Self::SwitchX, + + // 3D Object generation models + Self::Hunyuan3d2_0, + Self::Hunyuan3d2_1, + Self::Hunyuan3d3, + Self::Hunyuan3d3_1Pro, + Self::Hunyuan3d3_1Rapid, + Self::Hunyuan3d3_1Part, + Self::Hunyuan3d3_1SmartTopology, + Self::Tripo3dH3_1, + Self::MeshyV6, + Self::Rodin2_5Fast, + + // Splat generation models (World Labs) + Self::Marble0p1Mini, + Self::Marble0p1Plus, + Self::Marble1p0, + Self::Marble1p0Draft, + Self::Marble1p1, + Self::Marble1p1Plus, + Self::TripoSplat, + ]) + } + + /// Returns the broad model class for this model type. + pub fn get_model_class(&self) -> CommonModelClass { + match self { + // Image models + Self::Flux1Dev => CommonModelClass::Image, + Self::Flux1Schnell => CommonModelClass::Image, + Self::FluxDevJuggernaut => CommonModelClass::Image, + Self::FluxPro1 => CommonModelClass::Image, + Self::FluxPro11 => CommonModelClass::Image, + Self::FluxPro11Ultra => CommonModelClass::Image, + Self::FluxProKontextMax => CommonModelClass::Image, + Self::Flux2LoraAngles => CommonModelClass::Image, + Self::GptImage1 => CommonModelClass::Image, + Self::GptImage1p5 => CommonModelClass::Image, + Self::GptImage2 => CommonModelClass::Image, + Self::GptImage2p5Flare => CommonModelClass::Image, + Self::GptImage2p5Sunburst => CommonModelClass::Image, + Self::GrokImage => CommonModelClass::Image, + Self::GrokImagineImage => CommonModelClass::Image, + Self::GrokImagineImageQuality => CommonModelClass::Image, + Self::Recraft3 => CommonModelClass::Image, + Self::SeedEdit3 => CommonModelClass::Image, + Self::Qwen => CommonModelClass::Image, + Self::QwenEdit2511Angles => CommonModelClass::Image, + Self::Gemini25Flash => CommonModelClass::Image, + Self::NanoBanana => CommonModelClass::Image, + Self::NanoBanana2 => CommonModelClass::Image, + Self::NanoBananaPro => CommonModelClass::Image, + Self::Seedream4 => CommonModelClass::Image, + Self::Seedream4p5 => CommonModelClass::Image, + Self::Seedream5Lite => CommonModelClass::Image, + Self::Seedream5p0Pro => CommonModelClass::Image, + Self::Seedream5p0ProUltra => CommonModelClass::Image, + Self::Midjourney => CommonModelClass::Image, + Self::MidjourneyV6 => CommonModelClass::Image, + Self::MidjourneyV6p1 => CommonModelClass::Image, + Self::MidjourneyV6p1Raw => CommonModelClass::Image, + Self::MidjourneyV7 => CommonModelClass::Image, + Self::MidjourneyV7Draft => CommonModelClass::Image, + Self::MidjourneyV7DraftRaw => CommonModelClass::Image, + Self::MidjourneyV7Raw => CommonModelClass::Image, + Self::Midjourney7 => CommonModelClass::Image, + Self::Midjourney7Niji => CommonModelClass::Image, + Self::Midjourney8 => CommonModelClass::Image, + + // Video models + Self::GrokVideo => CommonModelClass::Video, + Self::GrokImagineVideo => CommonModelClass::Video, + Self::GrokImagineVideo1p5 => CommonModelClass::Video, + Self::Flux3 => CommonModelClass::Video, + Self::Flux3Draft => CommonModelClass::Video, + Self::Kling16Pro => CommonModelClass::Video, + Self::Kling21Pro => CommonModelClass::Video, + Self::Kling21Master => CommonModelClass::Video, + Self::Kling2p5TurboPro => CommonModelClass::Video, + Self::Kling2p6Pro => CommonModelClass::Video, + Self::Kling3p0Standard => CommonModelClass::Video, + Self::Kling3p0Pro => CommonModelClass::Video, + Self::HappyHorse1p0 => CommonModelClass::Video, + Self::MinimaxH3 => CommonModelClass::Video, + Self::MinimaxH3Turbo => CommonModelClass::Video, + Self::MinimaxH3Ultra => CommonModelClass::Video, + Self::Seedance10Lite => CommonModelClass::Video, + Self::Seedance10Pro => CommonModelClass::Video, + Self::Seedance1p5Pro => CommonModelClass::Video, + Self::Seedance2p0 => CommonModelClass::Video, + Self::Seedance2p0Fast => CommonModelClass::Video, + Self::Seedance2p0BytePlus => CommonModelClass::Video, + Self::Seedance2p0BytePlusFast => CommonModelClass::Video, + Self::Seedance2p0Ultra => CommonModelClass::Video, + Self::Seedance2p0UltraFast => CommonModelClass::Video, + Self::Seedance2p0BytePlusUltra => CommonModelClass::Video, + Self::Seedance2p0BytePlusUltraFast => CommonModelClass::Video, + Self::Seedance2p0Mini => CommonModelClass::Video, + Self::Seedance2p0BytePlusMini => CommonModelClass::Video, + Self::Seedance2p0BytePlusUltraMini => CommonModelClass::Video, + Self::Seedance2p5Preview => CommonModelClass::Video, + Self::Seedance2p5 => CommonModelClass::Video, + Self::Seedance2p5Ultra => CommonModelClass::Video, + Self::Sora2 => CommonModelClass::Video, + Self::Sora2Pro => CommonModelClass::Video, + Self::Veo2 => CommonModelClass::Video, + Self::Veo3 => CommonModelClass::Video, + Self::Veo3Fast => CommonModelClass::Video, + Self::Veo3p1 => CommonModelClass::Video, + Self::Veo3p1Fast => CommonModelClass::Video, + Self::Veo3p1Lite => CommonModelClass::Video, + Self::ViduQ3 => CommonModelClass::Video, + Self::ViduQ3Turbo => CommonModelClass::Video, + + // Audio models + Self::SunoMusic => CommonModelClass::Audio, + Self::SunoRemix => CommonModelClass::Audio, + Self::SunoSounds => CommonModelClass::Audio, + Self::SunoSample => CommonModelClass::Audio, + Self::SeedAudio1p0 => CommonModelClass::Audio, + + Self::PreviewModel => CommonModelClass::Video, + Self::PreviewModelFast => CommonModelClass::Video, + Self::SwitchX => CommonModelClass::Video, + + // 3D Object generation models (mesh) + Self::Hunyuan3d2_0 => CommonModelClass::DimensionalMesh, + Self::Hunyuan3d2_1 => CommonModelClass::DimensionalMesh, + Self::Hunyuan3d3 => CommonModelClass::DimensionalMesh, + Self::Hunyuan3d3_1Pro => CommonModelClass::DimensionalMesh, + Self::Hunyuan3d3_1Rapid => CommonModelClass::DimensionalMesh, + Self::Hunyuan3d3_1Part => CommonModelClass::DimensionalMesh, + Self::Hunyuan3d3_1SmartTopology => CommonModelClass::DimensionalMesh, + Self::Tripo3dH3_1 => CommonModelClass::DimensionalMesh, + Self::MeshyV6 => CommonModelClass::DimensionalMesh, + Self::Rodin2_5Fast => CommonModelClass::DimensionalMesh, + + // Splat generation models (World Labs) + Self::Marble0p1Mini => CommonModelClass::DimensionalSplat, + Self::Marble0p1Plus => CommonModelClass::DimensionalSplat, + Self::Marble1p0 => CommonModelClass::DimensionalSplat, + Self::Marble1p0Draft => CommonModelClass::DimensionalSplat, + Self::Marble1p1 => CommonModelClass::DimensionalSplat, + Self::Marble1p1Plus => CommonModelClass::DimensionalSplat, + Self::TripoSplat => CommonModelClass::DimensionalSplat, + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_model_class::CommonModelClass; + use crate::common::generation::common_model_type::CommonModelType; + use crate::common::generation::common_model_type::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + // Image models + assert_serialization(CommonModelType::Flux1Dev, "flux_1_dev"); + assert_serialization(CommonModelType::Flux1Schnell, "flux_1_schnell"); + assert_serialization(CommonModelType::FluxDevJuggernaut, "flux_dev_juggernaut"); + assert_serialization(CommonModelType::FluxPro1, "flux_pro_1"); + assert_serialization(CommonModelType::FluxPro11, "flux_pro_1p1"); + assert_serialization(CommonModelType::FluxPro11Ultra, "flux_pro_1p1_ultra"); + assert_serialization(CommonModelType::FluxProKontextMax, "flux_pro_kontext_max"); + assert_serialization(CommonModelType::Flux2LoraAngles, "flux_2_lora_angles"); + assert_serialization(CommonModelType::GptImage1, "gpt_image_1"); + assert_serialization(CommonModelType::GptImage1p5, "gpt_image_1p5"); + assert_serialization(CommonModelType::GptImage2, "gpt_image_2"); + assert_serialization(CommonModelType::GptImage2p5Flare, "gpt_image_2p5_flare"); + assert_serialization(CommonModelType::GptImage2p5Sunburst, "gpt_image_2p5_sunburst"); + assert_serialization(CommonModelType::GrokImage, "grok_image"); + assert_serialization(CommonModelType::GrokImagineImage, "grok_imagine_image"); + assert_serialization(CommonModelType::GrokImagineImageQuality, "grok_imagine_image_q"); + assert_serialization(CommonModelType::Recraft3, "recraft_3"); + assert_serialization(CommonModelType::SeedEdit3, "seededit_3"); + assert_serialization(CommonModelType::Qwen, "qwen"); + assert_serialization(CommonModelType::QwenEdit2511Angles, "qwen_edit_2511_angles"); + assert_serialization(CommonModelType::Gemini25Flash, "gemini_25_flash"); + assert_serialization(CommonModelType::NanoBanana, "nano_banana"); + assert_serialization(CommonModelType::NanoBanana2, "nano_banana_2"); + assert_serialization(CommonModelType::NanoBananaPro, "nano_banana_pro"); + assert_serialization(CommonModelType::Seedream4, "seedream_4"); + assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5"); + assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite"); + assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro"); + assert_serialization(CommonModelType::Seedream5p0ProUltra, "seedream_5p0_pro_u"); + assert_serialization(CommonModelType::Midjourney, "midjourney"); + assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6"); + assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1"); + assert_serialization(CommonModelType::MidjourneyV6p1Raw, "midjourney_v6p1_raw"); + assert_serialization(CommonModelType::MidjourneyV7, "midjourney_v7"); + assert_serialization(CommonModelType::MidjourneyV7Draft, "midjourney_v7_draft"); + assert_serialization(CommonModelType::MidjourneyV7DraftRaw, "midjourney_v7_draft_raw"); + assert_serialization(CommonModelType::MidjourneyV7Raw, "midjourney_v7_raw"); + assert_serialization(CommonModelType::Midjourney7, "midjourney_7"); + assert_serialization(CommonModelType::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(CommonModelType::Midjourney8, "midjourney_8"); + // Video models + assert_serialization(CommonModelType::GrokVideo, "grok_video"); + assert_serialization(CommonModelType::GrokImagineVideo, "grok_imagine_video"); + assert_serialization(CommonModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5"); + assert_serialization(CommonModelType::Flux3, "flux_3"); + assert_serialization(CommonModelType::Flux3Draft, "flux_3_draft"); + assert_serialization(CommonModelType::Kling16Pro, "kling_1p6_pro"); + assert_serialization(CommonModelType::Kling21Pro, "kling_2p1_pro"); + assert_serialization(CommonModelType::Kling21Master, "kling_2p1_master"); + assert_serialization(CommonModelType::Kling2p5TurboPro, "kling_2p5_turbo_pro"); + assert_serialization(CommonModelType::Kling2p6Pro, "kling_2p6_pro"); + assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard"); + assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro"); + assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(CommonModelType::MinimaxH3, "minimax_h3"); + assert_serialization(CommonModelType::MinimaxH3Turbo, "minimax_h3_turbo"); + assert_serialization(CommonModelType::MinimaxH3Ultra, "minimax_h3_ultra"); + assert_serialization(CommonModelType::Seedance10Lite, "seedance_1p0_lite"); + assert_serialization(CommonModelType::Seedance10Pro, "seedance_1p0_pro"); + assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro"); + assert_serialization(CommonModelType::Seedance2p0, "seedance_2p0"); + assert_serialization(CommonModelType::Seedance2p0Fast, "seedance_2p0_fast"); + assert_serialization(CommonModelType::Seedance2p0BytePlus, "seedance_2p0_bp"); + assert_serialization(CommonModelType::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"); + assert_serialization(CommonModelType::Seedance2p0Ultra, "seedance_2p0_u"); + assert_serialization(CommonModelType::Seedance2p0UltraFast, "seedance_2p0_u_fast"); + assert_serialization(CommonModelType::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"); + assert_serialization(CommonModelType::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"); + assert_serialization(CommonModelType::Seedance2p0Mini, "seedance_2p0_mini"); + assert_serialization(CommonModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"); + assert_serialization(CommonModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"); + assert_serialization(CommonModelType::Seedance2p5Preview, "seedance_2p5_preview"); + assert_serialization(CommonModelType::Seedance2p5, "seedance_2p5"); + assert_serialization(CommonModelType::Seedance2p5Ultra, "seedance_2p5_u"); + assert_serialization(CommonModelType::Sora2, "sora_2"); + assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro"); + assert_serialization(CommonModelType::Veo2, "veo_2"); + assert_serialization(CommonModelType::Veo3, "veo_3"); + assert_serialization(CommonModelType::Veo3Fast, "veo_3_fast"); + assert_serialization(CommonModelType::Veo3p1, "veo_3p1"); + assert_serialization(CommonModelType::Veo3p1Fast, "veo_3p1_fast"); + assert_serialization(CommonModelType::Veo3p1Lite, "veo_3p1_lite"); + assert_serialization(CommonModelType::ViduQ3, "vidu_q3"); + assert_serialization(CommonModelType::ViduQ3Turbo, "vidu_q3_turbo"); + // Audio models + assert_serialization(CommonModelType::SunoMusic, "suno_music"); + assert_serialization(CommonModelType::SunoRemix, "suno_remix"); + assert_serialization(CommonModelType::SunoSounds, "suno_sounds"); + assert_serialization(CommonModelType::SunoSample, "suno_sample"); + assert_serialization(CommonModelType::SeedAudio1p0, "seed_audio_1p0"); + assert_serialization(CommonModelType::PreviewModel, "preview_model"); + assert_serialization(CommonModelType::PreviewModelFast, "preview_model_fast"); + assert_serialization(CommonModelType::SwitchX, "switch_x"); + // 3D Object generation models + assert_serialization(CommonModelType::Hunyuan3d2_0, "hunyuan_3d_2p0"); + assert_serialization(CommonModelType::Hunyuan3d2_1, "hunyuan_3d_2p1"); + assert_serialization(CommonModelType::Hunyuan3d3, "hunyuan_3d_3"); + assert_serialization(CommonModelType::Hunyuan3d3_1Pro, "hunyuan_3d_3p1_pro"); + assert_serialization(CommonModelType::Hunyuan3d3_1Rapid, "hunyuan_3d_3p1_rapid"); + assert_serialization(CommonModelType::Hunyuan3d3_1Part, "hunyuan_3d_3p1_part"); + assert_serialization(CommonModelType::Hunyuan3d3_1SmartTopology, "hunyuan_3d_3p1_topology"); + assert_serialization(CommonModelType::Tripo3dH3_1, "tripo3d_h3p1"); + assert_serialization(CommonModelType::MeshyV6, "meshy_v6"); + assert_serialization(CommonModelType::Rodin2_5Fast, "rodin_2p5_fast"); + // Splat generation models (World Labs) + assert_serialization(CommonModelType::Marble0p1Mini, "marble_0p1_mini"); + assert_serialization(CommonModelType::Marble0p1Plus, "marble_0p1_plus"); + assert_serialization(CommonModelType::Marble1p0, "marble_1p0"); + assert_serialization(CommonModelType::Marble1p0Draft, "marble_1p0_draft"); + assert_serialization(CommonModelType::Marble1p1, "marble_1p1"); + assert_serialization(CommonModelType::Marble1p1Plus, "marble_1p1_plus"); + assert_serialization(CommonModelType::TripoSplat, "triposplat"); + } + + #[test] + fn to_str() { + // Image models + assert_eq!(CommonModelType::Flux1Dev.to_str(), "flux_1_dev"); + assert_eq!(CommonModelType::Flux1Schnell.to_str(), "flux_1_schnell"); + assert_eq!(CommonModelType::FluxDevJuggernaut.to_str(), "flux_dev_juggernaut"); + assert_eq!(CommonModelType::FluxPro1.to_str(), "flux_pro_1"); + assert_eq!(CommonModelType::FluxPro11.to_str(), "flux_pro_1p1"); + assert_eq!(CommonModelType::FluxPro11Ultra.to_str(), "flux_pro_1p1_ultra"); + assert_eq!(CommonModelType::FluxProKontextMax.to_str(), "flux_pro_kontext_max"); + assert_eq!(CommonModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles"); + assert_eq!(CommonModelType::GptImage1.to_str(), "gpt_image_1"); + assert_eq!(CommonModelType::GptImage1p5.to_str(), "gpt_image_1p5"); + assert_eq!(CommonModelType::GptImage2.to_str(), "gpt_image_2"); + assert_eq!(CommonModelType::GptImage2p5Flare.to_str(), "gpt_image_2p5_flare"); + assert_eq!(CommonModelType::GptImage2p5Sunburst.to_str(), "gpt_image_2p5_sunburst"); + assert_eq!(CommonModelType::GrokImage.to_str(), "grok_image"); + assert_eq!(CommonModelType::GrokImagineImage.to_str(), "grok_imagine_image"); + assert_eq!(CommonModelType::GrokImagineImageQuality.to_str(), "grok_imagine_image_q"); + assert_eq!(CommonModelType::Recraft3.to_str(), "recraft_3"); + assert_eq!(CommonModelType::SeedEdit3.to_str(), "seededit_3"); + assert_eq!(CommonModelType::Qwen.to_str(), "qwen"); + assert_eq!(CommonModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles"); + assert_eq!(CommonModelType::Gemini25Flash.to_str(), "gemini_25_flash"); + assert_eq!(CommonModelType::NanoBanana.to_str(), "nano_banana"); + assert_eq!(CommonModelType::NanoBanana2.to_str(), "nano_banana_2"); + assert_eq!(CommonModelType::NanoBananaPro.to_str(), "nano_banana_pro"); + assert_eq!(CommonModelType::Seedream4.to_str(), "seedream_4"); + assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5"); + assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite"); + assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro"); + assert_eq!(CommonModelType::Seedream5p0ProUltra.to_str(), "seedream_5p0_pro_u"); + assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney"); + assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6"); + assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1"); + assert_eq!(CommonModelType::MidjourneyV6p1Raw.to_str(), "midjourney_v6p1_raw"); + assert_eq!(CommonModelType::MidjourneyV7.to_str(), "midjourney_v7"); + assert_eq!(CommonModelType::MidjourneyV7Draft.to_str(), "midjourney_v7_draft"); + assert_eq!(CommonModelType::MidjourneyV7DraftRaw.to_str(), "midjourney_v7_draft_raw"); + assert_eq!(CommonModelType::MidjourneyV7Raw.to_str(), "midjourney_v7_raw"); + assert_eq!(CommonModelType::Midjourney7.to_str(), "midjourney_7"); + assert_eq!(CommonModelType::Midjourney7Niji.to_str(), "midjourney_7_niji"); + assert_eq!(CommonModelType::Midjourney8.to_str(), "midjourney_8"); + + // Video models + assert_eq!(CommonModelType::GrokVideo.to_str(), "grok_video"); + assert_eq!(CommonModelType::GrokImagineVideo.to_str(), "grok_imagine_video"); + assert_eq!(CommonModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5"); + assert_eq!(CommonModelType::Flux3.to_str(), "flux_3"); + assert_eq!(CommonModelType::Flux3Draft.to_str(), "flux_3_draft"); + assert_eq!(CommonModelType::Kling16Pro.to_str(), "kling_1p6_pro"); + assert_eq!(CommonModelType::Kling21Pro.to_str(), "kling_2p1_pro"); + assert_eq!(CommonModelType::Kling21Master.to_str(), "kling_2p1_master"); + assert_eq!(CommonModelType::Kling2p5TurboPro.to_str(), "kling_2p5_turbo_pro"); + assert_eq!(CommonModelType::Kling2p6Pro.to_str(), "kling_2p6_pro"); + assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); + assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); + assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(CommonModelType::MinimaxH3.to_str(), "minimax_h3"); + assert_eq!(CommonModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo"); + assert_eq!(CommonModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra"); + assert_eq!(CommonModelType::Seedance10Lite.to_str(), "seedance_1p0_lite"); + assert_eq!(CommonModelType::Seedance10Pro.to_str(), "seedance_1p0_pro"); + assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro"); + assert_eq!(CommonModelType::Seedance2p0.to_str(), "seedance_2p0"); + assert_eq!(CommonModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast"); + assert_eq!(CommonModelType::Seedance2p0BytePlus.to_str(), "seedance_2p0_bp"); + assert_eq!(CommonModelType::Seedance2p0BytePlusFast.to_str(), "seedance_2p0_bp_fast"); + assert_eq!(CommonModelType::Seedance2p0Ultra.to_str(), "seedance_2p0_u"); + assert_eq!(CommonModelType::Seedance2p0UltraFast.to_str(), "seedance_2p0_u_fast"); + assert_eq!(CommonModelType::Seedance2p0BytePlusUltra.to_str(), "seedance_2p0_bpu"); + assert_eq!(CommonModelType::Seedance2p0BytePlusUltraFast.to_str(), "seedance_2p0_bpu_fast"); + assert_eq!(CommonModelType::Seedance2p0Mini.to_str(), "seedance_2p0_mini"); + assert_eq!(CommonModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini"); + assert_eq!(CommonModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini"); + assert_eq!(CommonModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview"); + assert_eq!(CommonModelType::Seedance2p5.to_str(), "seedance_2p5"); + assert_eq!(CommonModelType::Seedance2p5Ultra.to_str(), "seedance_2p5_u"); + assert_eq!(CommonModelType::Sora2.to_str(), "sora_2"); + assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro"); + assert_eq!(CommonModelType::Veo2.to_str(), "veo_2"); + assert_eq!(CommonModelType::Veo3.to_str(), "veo_3"); + assert_eq!(CommonModelType::Veo3Fast.to_str(), "veo_3_fast"); + assert_eq!(CommonModelType::Veo3p1.to_str(), "veo_3p1"); + assert_eq!(CommonModelType::Veo3p1Fast.to_str(), "veo_3p1_fast"); + assert_eq!(CommonModelType::Veo3p1Lite.to_str(), "veo_3p1_lite"); + assert_eq!(CommonModelType::ViduQ3.to_str(), "vidu_q3"); + assert_eq!(CommonModelType::ViduQ3Turbo.to_str(), "vidu_q3_turbo"); + + // Audio models + assert_eq!(CommonModelType::SunoMusic.to_str(), "suno_music"); + assert_eq!(CommonModelType::SunoRemix.to_str(), "suno_remix"); + assert_eq!(CommonModelType::SunoSounds.to_str(), "suno_sounds"); + assert_eq!(CommonModelType::SunoSample.to_str(), "suno_sample"); + assert_eq!(CommonModelType::SeedAudio1p0.to_str(), "seed_audio_1p0"); + + assert_eq!(CommonModelType::PreviewModel.to_str(), "preview_model"); + assert_eq!(CommonModelType::PreviewModelFast.to_str(), "preview_model_fast"); + assert_eq!(CommonModelType::SwitchX.to_str(), "switch_x"); + + // 3D Object generation models + assert_eq!(CommonModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2p0"); + assert_eq!(CommonModelType::Hunyuan3d2_1.to_str(), "hunyuan_3d_2p1"); + assert_eq!(CommonModelType::Hunyuan3d3.to_str(), "hunyuan_3d_3"); + assert_eq!(CommonModelType::Hunyuan3d3_1Pro.to_str(), "hunyuan_3d_3p1_pro"); + assert_eq!(CommonModelType::Hunyuan3d3_1Rapid.to_str(), "hunyuan_3d_3p1_rapid"); + assert_eq!(CommonModelType::Hunyuan3d3_1Part.to_str(), "hunyuan_3d_3p1_part"); + assert_eq!(CommonModelType::Hunyuan3d3_1SmartTopology.to_str(), "hunyuan_3d_3p1_topology"); + assert_eq!(CommonModelType::Tripo3dH3_1.to_str(), "tripo3d_h3p1"); + assert_eq!(CommonModelType::MeshyV6.to_str(), "meshy_v6"); + assert_eq!(CommonModelType::Rodin2_5Fast.to_str(), "rodin_2p5_fast"); + // Splat generation models (World Labs) + assert_eq!(CommonModelType::Marble0p1Mini.to_str(), "marble_0p1_mini"); + assert_eq!(CommonModelType::Marble0p1Plus.to_str(), "marble_0p1_plus"); + assert_eq!(CommonModelType::Marble1p0.to_str(), "marble_1p0"); + assert_eq!(CommonModelType::Marble1p0Draft.to_str(), "marble_1p0_draft"); + assert_eq!(CommonModelType::Marble1p1.to_str(), "marble_1p1"); + assert_eq!(CommonModelType::Marble1p1Plus.to_str(), "marble_1p1_plus"); + assert_eq!(CommonModelType::TripoSplat.to_str(), "triposplat"); + } + + #[test] + fn from_str() { + // Image models + assert_eq!(CommonModelType::from_str("flux_1_dev").unwrap(), CommonModelType::Flux1Dev); + assert_eq!(CommonModelType::from_str("flux_1_schnell").unwrap(), CommonModelType::Flux1Schnell); + assert_eq!(CommonModelType::from_str("flux_dev_juggernaut").unwrap(), CommonModelType::FluxDevJuggernaut); + assert_eq!(CommonModelType::from_str("flux_pro_1").unwrap(), CommonModelType::FluxPro1); + assert_eq!(CommonModelType::from_str("flux_pro_1p1").unwrap(), CommonModelType::FluxPro11); + assert_eq!(CommonModelType::from_str("flux_pro_1p1_ultra").unwrap(), CommonModelType::FluxPro11Ultra); + assert_eq!(CommonModelType::from_str("flux_pro_kontext_max").unwrap(), CommonModelType::FluxProKontextMax); + assert_eq!(CommonModelType::from_str("flux_2_lora_angles").unwrap(), CommonModelType::Flux2LoraAngles); + assert_eq!(CommonModelType::from_str("gpt_image_1").unwrap(), CommonModelType::GptImage1); + assert_eq!(CommonModelType::from_str("gpt_image_1p5").unwrap(), CommonModelType::GptImage1p5); + assert_eq!(CommonModelType::from_str("gpt_image_2").unwrap(), CommonModelType::GptImage2); + assert_eq!(CommonModelType::from_str("gpt_image_2p5_flare").unwrap(), CommonModelType::GptImage2p5Flare); + assert_eq!(CommonModelType::from_str("gpt_image_2p5_sunburst").unwrap(), CommonModelType::GptImage2p5Sunburst); + assert_eq!(CommonModelType::from_str("grok_image").unwrap(), CommonModelType::GrokImage); + assert_eq!(CommonModelType::from_str("grok_imagine_image").unwrap(), CommonModelType::GrokImagineImage); + assert_eq!(CommonModelType::from_str("grok_imagine_image_q").unwrap(), CommonModelType::GrokImagineImageQuality); + assert_eq!(CommonModelType::from_str("recraft_3").unwrap(), CommonModelType::Recraft3); + assert_eq!(CommonModelType::from_str("seededit_3").unwrap(), CommonModelType::SeedEdit3); + assert_eq!(CommonModelType::from_str("qwen").unwrap(), CommonModelType::Qwen); + assert_eq!(CommonModelType::from_str("qwen_edit_2511_angles").unwrap(), CommonModelType::QwenEdit2511Angles); + assert_eq!(CommonModelType::from_str("gemini_25_flash").unwrap(), CommonModelType::Gemini25Flash); + assert_eq!(CommonModelType::from_str("nano_banana").unwrap(), CommonModelType::NanoBanana); + assert_eq!(CommonModelType::from_str("nano_banana_2").unwrap(), CommonModelType::NanoBanana2); + assert_eq!(CommonModelType::from_str("nano_banana_pro").unwrap(), CommonModelType::NanoBananaPro); + assert_eq!(CommonModelType::from_str("seedream_4").unwrap(), CommonModelType::Seedream4); + assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5); + assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite); + assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro); + assert_eq!(CommonModelType::from_str("seedream_5p0_pro_u").unwrap(), CommonModelType::Seedream5p0ProUltra); + assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney); + assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6); + assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1); + assert_eq!(CommonModelType::from_str("midjourney_v6p1_raw").unwrap(), CommonModelType::MidjourneyV6p1Raw); + assert_eq!(CommonModelType::from_str("midjourney_v7").unwrap(), CommonModelType::MidjourneyV7); + assert_eq!(CommonModelType::from_str("midjourney_v7_draft").unwrap(), CommonModelType::MidjourneyV7Draft); + assert_eq!(CommonModelType::from_str("midjourney_v7_draft_raw").unwrap(), CommonModelType::MidjourneyV7DraftRaw); + assert_eq!(CommonModelType::from_str("midjourney_v7_raw").unwrap(), CommonModelType::MidjourneyV7Raw); + assert_eq!(CommonModelType::from_str("midjourney_7").unwrap(), CommonModelType::Midjourney7); + assert_eq!(CommonModelType::from_str("midjourney_7_niji").unwrap(), CommonModelType::Midjourney7Niji); + assert_eq!(CommonModelType::from_str("midjourney_8").unwrap(), CommonModelType::Midjourney8); + // Video models + assert_eq!(CommonModelType::from_str("grok_video").unwrap(), CommonModelType::GrokVideo); + assert_eq!(CommonModelType::from_str("grok_imagine_video").unwrap(), CommonModelType::GrokImagineVideo); + assert_eq!(CommonModelType::from_str("grok_imagine_video_1p5").unwrap(), CommonModelType::GrokImagineVideo1p5); + assert_eq!(CommonModelType::from_str("flux_3").unwrap(), CommonModelType::Flux3); + assert_eq!(CommonModelType::from_str("flux_3_draft").unwrap(), CommonModelType::Flux3Draft); + assert_eq!(CommonModelType::from_str("kling_1p6_pro").unwrap(), CommonModelType::Kling16Pro); + assert_eq!(CommonModelType::from_str("kling_2p1_pro").unwrap(), CommonModelType::Kling21Pro); + assert_eq!(CommonModelType::from_str("kling_2p1_master").unwrap(), CommonModelType::Kling21Master); + assert_eq!(CommonModelType::from_str("kling_2p5_turbo_pro").unwrap(), CommonModelType::Kling2p5TurboPro); + assert_eq!(CommonModelType::from_str("kling_2p6_pro").unwrap(), CommonModelType::Kling2p6Pro); + assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard); + assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro); + assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0); + assert_eq!(CommonModelType::from_str("minimax_h3").unwrap(), CommonModelType::MinimaxH3); + assert_eq!(CommonModelType::from_str("minimax_h3_turbo").unwrap(), CommonModelType::MinimaxH3Turbo); + assert_eq!(CommonModelType::from_str("minimax_h3_ultra").unwrap(), CommonModelType::MinimaxH3Ultra); + assert_eq!(CommonModelType::from_str("seedance_1p0_lite").unwrap(), CommonModelType::Seedance10Lite); + assert_eq!(CommonModelType::from_str("seedance_1p0_pro").unwrap(), CommonModelType::Seedance10Pro); + assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro); + assert_eq!(CommonModelType::from_str("seedance_2p0").unwrap(), CommonModelType::Seedance2p0); + assert_eq!(CommonModelType::from_str("seedance_2p0_fast").unwrap(), CommonModelType::Seedance2p0Fast); + assert_eq!(CommonModelType::from_str("seedance_2p0_bp").unwrap(), CommonModelType::Seedance2p0BytePlus); + assert_eq!(CommonModelType::from_str("seedance_2p0_bp_fast").unwrap(), CommonModelType::Seedance2p0BytePlusFast); + assert_eq!(CommonModelType::from_str("seedance_2p0_u").unwrap(), CommonModelType::Seedance2p0Ultra); + assert_eq!(CommonModelType::from_str("seedance_2p0_u_fast").unwrap(), CommonModelType::Seedance2p0UltraFast); + assert_eq!(CommonModelType::from_str("seedance_2p0_bpu").unwrap(), CommonModelType::Seedance2p0BytePlusUltra); + assert_eq!(CommonModelType::from_str("seedance_2p0_bpu_fast").unwrap(), CommonModelType::Seedance2p0BytePlusUltraFast); + assert_eq!(CommonModelType::from_str("seedance_2p0_mini").unwrap(), CommonModelType::Seedance2p0Mini); + assert_eq!(CommonModelType::from_str("seedance_2p0_bp_mini").unwrap(), CommonModelType::Seedance2p0BytePlusMini); + assert_eq!(CommonModelType::from_str("seedance_2p0_bpu_mini").unwrap(), CommonModelType::Seedance2p0BytePlusUltraMini); + assert_eq!(CommonModelType::from_str("seedance_2p5_preview").unwrap(), CommonModelType::Seedance2p5Preview); + assert_eq!(CommonModelType::from_str("seedance_2p5").unwrap(), CommonModelType::Seedance2p5); + assert_eq!(CommonModelType::from_str("seedance_2p5_u").unwrap(), CommonModelType::Seedance2p5Ultra); + assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2); + assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro); + assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2); + assert_eq!(CommonModelType::from_str("veo_3").unwrap(), CommonModelType::Veo3); + assert_eq!(CommonModelType::from_str("veo_3_fast").unwrap(), CommonModelType::Veo3Fast); + assert_eq!(CommonModelType::from_str("veo_3p1").unwrap(), CommonModelType::Veo3p1); + assert_eq!(CommonModelType::from_str("veo_3p1_fast").unwrap(), CommonModelType::Veo3p1Fast); + assert_eq!(CommonModelType::from_str("veo_3p1_lite").unwrap(), CommonModelType::Veo3p1Lite); + assert_eq!(CommonModelType::from_str("vidu_q3").unwrap(), CommonModelType::ViduQ3); + assert_eq!(CommonModelType::from_str("vidu_q3_turbo").unwrap(), CommonModelType::ViduQ3Turbo); + // Audio models + assert_eq!(CommonModelType::from_str("suno_music").unwrap(), CommonModelType::SunoMusic); + assert_eq!(CommonModelType::from_str("suno_remix").unwrap(), CommonModelType::SunoRemix); + assert_eq!(CommonModelType::from_str("suno_sounds").unwrap(), CommonModelType::SunoSounds); + assert_eq!(CommonModelType::from_str("suno_sample").unwrap(), CommonModelType::SunoSample); + assert_eq!(CommonModelType::from_str("seed_audio_1p0").unwrap(), CommonModelType::SeedAudio1p0); + assert_eq!(CommonModelType::from_str("preview_model").unwrap(), CommonModelType::PreviewModel); + assert_eq!(CommonModelType::from_str("preview_model_fast").unwrap(), CommonModelType::PreviewModelFast); + assert_eq!(CommonModelType::from_str("switch_x").unwrap(), CommonModelType::SwitchX); + + // 3D Object generation models + assert_eq!(CommonModelType::from_str("hunyuan_3d_2p0").unwrap(), CommonModelType::Hunyuan3d2_0); + assert_eq!(CommonModelType::from_str("hunyuan_3d_2p1").unwrap(), CommonModelType::Hunyuan3d2_1); + assert_eq!(CommonModelType::from_str("hunyuan_3d_3").unwrap(), CommonModelType::Hunyuan3d3); + assert_eq!(CommonModelType::from_str("hunyuan_3d_3p1_pro").unwrap(), CommonModelType::Hunyuan3d3_1Pro); + assert_eq!(CommonModelType::from_str("hunyuan_3d_3p1_rapid").unwrap(), CommonModelType::Hunyuan3d3_1Rapid); + assert_eq!(CommonModelType::from_str("hunyuan_3d_3p1_part").unwrap(), CommonModelType::Hunyuan3d3_1Part); + assert_eq!(CommonModelType::from_str("hunyuan_3d_3p1_topology").unwrap(), CommonModelType::Hunyuan3d3_1SmartTopology); + assert_eq!(CommonModelType::from_str("tripo3d_h3p1").unwrap(), CommonModelType::Tripo3dH3_1); + assert_eq!(CommonModelType::from_str("meshy_v6").unwrap(), CommonModelType::MeshyV6); + assert_eq!(CommonModelType::from_str("rodin_2p5_fast").unwrap(), CommonModelType::Rodin2_5Fast); + // Splat generation models (World Labs) + assert_eq!(CommonModelType::from_str("marble_0p1_mini").unwrap(), CommonModelType::Marble0p1Mini); + assert_eq!(CommonModelType::from_str("marble_0p1_plus").unwrap(), CommonModelType::Marble0p1Plus); + assert_eq!(CommonModelType::from_str("marble_1p0").unwrap(), CommonModelType::Marble1p0); + assert_eq!(CommonModelType::from_str("marble_1p0_draft").unwrap(), CommonModelType::Marble1p0Draft); + assert_eq!(CommonModelType::from_str("marble_1p1").unwrap(), CommonModelType::Marble1p1); + assert_eq!(CommonModelType::from_str("marble_1p1_plus").unwrap(), CommonModelType::Marble1p1Plus); + assert_eq!(CommonModelType::from_str("triposplat").unwrap(), CommonModelType::TripoSplat); + } + + #[test] + fn all_variants() { + let mut variants = CommonModelType::all_variants(); + assert_eq!(variants.len(), 108); + // Image models + assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell)); + assert_eq!(variants.pop_first(), Some(CommonModelType::FluxDevJuggernaut)); + assert_eq!(variants.pop_first(), Some(CommonModelType::FluxPro1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::FluxPro11)); + assert_eq!(variants.pop_first(), Some(CommonModelType::FluxPro11Ultra)); + assert_eq!(variants.pop_first(), Some(CommonModelType::FluxProKontextMax)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Flux2LoraAngles)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage1p5)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage2)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage2p5Flare)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage2p5Sunburst)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImage)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineImage)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineImageQuality)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Recraft3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::SeedEdit3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Qwen)); + assert_eq!(variants.pop_first(), Some(CommonModelType::QwenEdit2511Angles)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Gemini25Flash)); + assert_eq!(variants.pop_first(), Some(CommonModelType::NanoBanana)); + assert_eq!(variants.pop_first(), Some(CommonModelType::NanoBanana2)); + assert_eq!(variants.pop_first(), Some(CommonModelType::NanoBananaPro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0ProUltra)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1Raw)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7Draft)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7DraftRaw)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7Raw)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney7)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney7Niji)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney8)); + // Video models + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokVideo)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo1p5)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Flux3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Flux3Draft)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling16Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Master)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling2p5TurboPro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling2p6Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Turbo)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Ultra)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Lite)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Fast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlus)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusFast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Ultra)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0UltraFast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusUltra)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusUltraFast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Mini)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusMini)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusUltraMini)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5Preview)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5Ultra)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3Fast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1Fast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1Lite)); + assert_eq!(variants.pop_first(), Some(CommonModelType::ViduQ3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::ViduQ3Turbo)); + // Audio models + assert_eq!(variants.pop_first(), Some(CommonModelType::SunoMusic)); + assert_eq!(variants.pop_first(), Some(CommonModelType::SunoRemix)); + assert_eq!(variants.pop_first(), Some(CommonModelType::SunoSounds)); + assert_eq!(variants.pop_first(), Some(CommonModelType::SunoSample)); + assert_eq!(variants.pop_first(), Some(CommonModelType::SeedAudio1p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::PreviewModel)); + assert_eq!(variants.pop_first(), Some(CommonModelType::PreviewModelFast)); + assert_eq!(variants.pop_first(), Some(CommonModelType::SwitchX)); + // 3D Object generation models + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d3)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d3_1Pro)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d3_1Rapid)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d3_1Part)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d3_1SmartTopology)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Tripo3dH3_1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MeshyV6)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Rodin2_5Fast)); + // Splat generation models (World Labs) + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble0p1Mini)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble0p1Plus)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p0Draft)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p1Plus)); + assert_eq!(variants.pop_first(), Some(CommonModelType::TripoSplat)); + + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonModelType::all_variants().len(), CommonModelType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonModelType::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, CommonModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonModelType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonModelType::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonModelType::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + + #[test] + fn every_variant_has_a_model_class() { + for variant in CommonModelType::all_variants() { + let class = variant.get_model_class(); + // Verify the class is a known value (not panicking is the main test) + assert!(CommonModelClass::all_variants().contains(&class), + "get_model_class() for {:?} returned {:?} which is not a known CommonModelClass variant", variant, class); + } + } + + #[test] + fn image_models_return_image_class() { + assert_eq!(CommonModelType::Flux1Dev.get_model_class(), CommonModelClass::Image); + assert_eq!(CommonModelType::GptImage1.get_model_class(), CommonModelClass::Image); + assert_eq!(CommonModelType::Midjourney.get_model_class(), CommonModelClass::Image); + } + + #[test] + fn video_models_return_video_class() { + assert_eq!(CommonModelType::Veo3.get_model_class(), CommonModelClass::Video); + assert_eq!(CommonModelType::Sora2.get_model_class(), CommonModelClass::Video); + assert_eq!(CommonModelType::Kling3p0Pro.get_model_class(), CommonModelClass::Video); + assert_eq!(CommonModelType::Seedance2p0Mini.get_model_class(), CommonModelClass::Video); + assert_eq!(CommonModelType::Seedance2p0BytePlusMini.get_model_class(), CommonModelClass::Video); + assert_eq!(CommonModelType::Seedance2p0BytePlusUltraMini.get_model_class(), CommonModelClass::Video); + } + + #[test] + fn dimensional_models_return_correct_class() { + assert_eq!(CommonModelType::Hunyuan3d2_0.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Hunyuan3d3.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Hunyuan3d3_1Pro.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Hunyuan3d3_1Rapid.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Hunyuan3d3_1Part.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Hunyuan3d3_1SmartTopology.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Tripo3dH3_1.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::MeshyV6.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Rodin2_5Fast.get_model_class(), CommonModelClass::DimensionalMesh); + assert_eq!(CommonModelType::Marble0p1Mini.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble0p1Plus.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p0.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p0Draft.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p1.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p1Plus.get_model_class(), CommonModelClass::DimensionalSplat); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_musical_key.rs b/crates/schema/public/enums/src/common/generation/common_musical_key.rs new file mode 100644 index 0000000000..69d0e3f883 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_musical_key.rs @@ -0,0 +1,120 @@ +use utoipa::ToSchema; + +/// Musical keys available for audio generation (eg. Suno Sounds). +/// +/// NB: There are intentionally no E keys, per product spec. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonMusicalKey { + /// Let the model pick the key. + #[serde(rename = "auto")] + Auto, + + #[serde(rename = "c_major")] + CMajor, + + #[serde(rename = "c_minor")] + CMinor, + + #[serde(rename = "d_major")] + DMajor, + + #[serde(rename = "d_minor")] + DMinor, + + #[serde(rename = "f_major")] + FMajor, + + #[serde(rename = "f_minor")] + FMinor, + + #[serde(rename = "g_major")] + GMajor, + + #[serde(rename = "g_minor")] + GMinor, + + #[serde(rename = "a_major")] + AMajor, + + #[serde(rename = "a_minor")] + AMinor, + + #[serde(rename = "b_major")] + BMajor, + + #[serde(rename = "b_minor")] + BMinor, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonMusicalKey::Auto, "auto"); + assert_serialization(CommonMusicalKey::CMajor, "c_major"); + assert_serialization(CommonMusicalKey::CMinor, "c_minor"); + assert_serialization(CommonMusicalKey::DMajor, "d_major"); + assert_serialization(CommonMusicalKey::DMinor, "d_minor"); + assert_serialization(CommonMusicalKey::FMajor, "f_major"); + assert_serialization(CommonMusicalKey::FMinor, "f_minor"); + assert_serialization(CommonMusicalKey::GMajor, "g_major"); + assert_serialization(CommonMusicalKey::GMinor, "g_minor"); + assert_serialization(CommonMusicalKey::AMajor, "a_major"); + assert_serialization(CommonMusicalKey::AMinor, "a_minor"); + assert_serialization(CommonMusicalKey::BMajor, "b_major"); + assert_serialization(CommonMusicalKey::BMinor, "b_minor"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("auto", CommonMusicalKey::Auto), + ("c_major", CommonMusicalKey::CMajor), + ("c_minor", CommonMusicalKey::CMinor), + ("d_major", CommonMusicalKey::DMajor), + ("d_minor", CommonMusicalKey::DMinor), + ("f_major", CommonMusicalKey::FMajor), + ("f_minor", CommonMusicalKey::FMinor), + ("g_major", CommonMusicalKey::GMajor), + ("g_minor", CommonMusicalKey::GMinor), + ("a_major", CommonMusicalKey::AMajor), + ("a_minor", CommonMusicalKey::AMinor), + ("b_major", CommonMusicalKey::BMajor), + ("b_minor", CommonMusicalKey::BMinor), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonMusicalKey = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonMusicalKey::Auto, + CommonMusicalKey::CMajor, + CommonMusicalKey::CMinor, + CommonMusicalKey::DMajor, + CommonMusicalKey::DMinor, + CommonMusicalKey::FMajor, + CommonMusicalKey::FMinor, + CommonMusicalKey::GMajor, + CommonMusicalKey::GMinor, + CommonMusicalKey::AMajor, + CommonMusicalKey::AMinor, + CommonMusicalKey::BMajor, + CommonMusicalKey::BMinor, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonMusicalKey = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_polygon_type.rs b/crates/schema/public/enums/src/common/generation/common_polygon_type.rs new file mode 100644 index 0000000000..bf6a96d274 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_polygon_type.rs @@ -0,0 +1,51 @@ +use utoipa::ToSchema; + +/// Polygon types for generated meshes. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonPolygonType { + #[serde(rename = "triangle")] + Triangle, + + #[serde(rename = "quad")] + Quad, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonPolygonType::Triangle, "triangle"); + assert_serialization(CommonPolygonType::Quad, "quad"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("triangle", CommonPolygonType::Triangle), + ("quad", CommonPolygonType::Quad), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonPolygonType = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonPolygonType::Triangle, + CommonPolygonType::Quad, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonPolygonType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_quality.rs b/crates/schema/public/enums/src/common/generation/common_quality.rs new file mode 100644 index 0000000000..cdcc1e5560 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_quality.rs @@ -0,0 +1,167 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 16; + +/// Common quality levels for generation. +/// +/// NB: Keep the max serialized length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonQuality { + High, + Medium, + Low, +} + +impl_enum_display_and_debug_using_to_str!(CommonQuality); +impl_mysql_enum_coders!(CommonQuality); +impl_mysql_from_row!(CommonQuality); + +impl CommonQuality { + pub fn to_str(&self) -> &'static str { + match self { + Self::High => "high", + Self::Medium => "medium", + Self::Low => "low", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "high" => Ok(Self::High), + "medium" => Ok(Self::Medium), + "low" => Ok(Self::Low), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::High, + Self::Medium, + Self::Low, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_quality::CommonQuality; + use crate::common::generation::common_quality::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonQuality::High, "high"); + assert_serialization(CommonQuality::Medium, "medium"); + assert_serialization(CommonQuality::Low, "low"); + } + + #[test] + fn to_str() { + assert_eq!(CommonQuality::High.to_str(), "high"); + assert_eq!(CommonQuality::Medium.to_str(), "medium"); + assert_eq!(CommonQuality::Low.to_str(), "low"); + } + + #[test] + fn from_str() { + assert_eq!(CommonQuality::from_str("high").unwrap(), CommonQuality::High); + assert_eq!(CommonQuality::from_str("medium").unwrap(), CommonQuality::Medium); + assert_eq!(CommonQuality::from_str("low").unwrap(), CommonQuality::Low); + } + + #[test] + fn from_str_err() { + let result = CommonQuality::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonQuality::all_variants(); + assert_eq!(variants.len(), 3); + assert_eq!(variants.pop_first(), Some(CommonQuality::High)); + assert_eq!(variants.pop_first(), Some(CommonQuality::Medium)); + assert_eq!(variants.pop_first(), Some(CommonQuality::Low)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonQuality::all_variants().len(), CommonQuality::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonQuality::all_variants() { + assert_eq!(variant, CommonQuality::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonQuality::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonQuality::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonQuality::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonQuality::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonQuality::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonQuality::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_resolution.rs b/crates/schema/public/enums/src/common/generation/common_resolution.rs new file mode 100644 index 0000000000..6c4eaf6d5c --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_resolution.rs @@ -0,0 +1,215 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 16; + +/// Common resolutions for generation. +/// +/// NB: Keep the max serialized length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonResolution { + OneK, + TwoK, + ThreeK, + FourK, + + /// Used by Nano Banana 2 (and others) + HalfK, + + /// Used by the Seedance family (and others) + FourEightyP, + + /// Used by the Seedance family (and others) + SevenTwentyP, + + /// Used by the Seedance family (and others) + TenEightyP, +} + +impl_enum_display_and_debug_using_to_str!(CommonResolution); +impl_mysql_enum_coders!(CommonResolution); +impl_mysql_from_row!(CommonResolution); + +impl CommonResolution { + pub fn to_str(&self) -> &'static str { + match self { + Self::OneK => "one_k", + Self::TwoK => "two_k", + Self::ThreeK => "three_k", + Self::FourK => "four_k", + Self::HalfK => "half_k", + Self::FourEightyP => "four_eighty_p", + Self::SevenTwentyP => "seven_twenty_p", + Self::TenEightyP => "ten_eighty_p", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "one_k" => Ok(Self::OneK), + "two_k" => Ok(Self::TwoK), + "three_k" => Ok(Self::ThreeK), + "four_k" => Ok(Self::FourK), + "half_k" => Ok(Self::HalfK), + "four_eighty_p" => Ok(Self::FourEightyP), + "seven_twenty_p" => Ok(Self::SevenTwentyP), + "ten_eighty_p" => Ok(Self::TenEightyP), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::OneK, + Self::TwoK, + Self::ThreeK, + Self::FourK, + Self::HalfK, + Self::FourEightyP, + Self::SevenTwentyP, + Self::TenEightyP, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_resolution::CommonResolution; + use crate::common::generation::common_resolution::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonResolution::OneK, "one_k"); + assert_serialization(CommonResolution::TwoK, "two_k"); + assert_serialization(CommonResolution::ThreeK, "three_k"); + assert_serialization(CommonResolution::FourK, "four_k"); + assert_serialization(CommonResolution::HalfK, "half_k"); + assert_serialization(CommonResolution::FourEightyP, "four_eighty_p"); + assert_serialization(CommonResolution::SevenTwentyP, "seven_twenty_p"); + assert_serialization(CommonResolution::TenEightyP, "ten_eighty_p"); + } + + #[test] + fn to_str() { + assert_eq!(CommonResolution::OneK.to_str(), "one_k"); + assert_eq!(CommonResolution::TwoK.to_str(), "two_k"); + assert_eq!(CommonResolution::ThreeK.to_str(), "three_k"); + assert_eq!(CommonResolution::FourK.to_str(), "four_k"); + assert_eq!(CommonResolution::HalfK.to_str(), "half_k"); + assert_eq!(CommonResolution::FourEightyP.to_str(), "four_eighty_p"); + assert_eq!(CommonResolution::SevenTwentyP.to_str(), "seven_twenty_p"); + assert_eq!(CommonResolution::TenEightyP.to_str(), "ten_eighty_p"); + } + + #[test] + fn from_str() { + assert_eq!(CommonResolution::from_str("one_k").unwrap(), CommonResolution::OneK); + assert_eq!(CommonResolution::from_str("two_k").unwrap(), CommonResolution::TwoK); + assert_eq!(CommonResolution::from_str("three_k").unwrap(), CommonResolution::ThreeK); + assert_eq!(CommonResolution::from_str("four_k").unwrap(), CommonResolution::FourK); + assert_eq!(CommonResolution::from_str("half_k").unwrap(), CommonResolution::HalfK); + assert_eq!(CommonResolution::from_str("four_eighty_p").unwrap(), CommonResolution::FourEightyP); + assert_eq!(CommonResolution::from_str("seven_twenty_p").unwrap(), CommonResolution::SevenTwentyP); + assert_eq!(CommonResolution::from_str("ten_eighty_p").unwrap(), CommonResolution::TenEightyP); + } + + #[test] + fn from_str_err() { + let result = CommonResolution::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonResolution::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(CommonResolution::OneK)); + assert_eq!(variants.pop_first(), Some(CommonResolution::TwoK)); + assert_eq!(variants.pop_first(), Some(CommonResolution::ThreeK)); + assert_eq!(variants.pop_first(), Some(CommonResolution::FourK)); + assert_eq!(variants.pop_first(), Some(CommonResolution::HalfK)); + assert_eq!(variants.pop_first(), Some(CommonResolution::FourEightyP)); + assert_eq!(variants.pop_first(), Some(CommonResolution::SevenTwentyP)); + assert_eq!(variants.pop_first(), Some(CommonResolution::TenEightyP)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonResolution::all_variants().len(), CommonResolution::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonResolution::all_variants() { + assert_eq!(variant, CommonResolution::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonResolution::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonResolution::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonResolution::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonResolution::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonResolution::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonResolution::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_splat_model.rs b/crates/schema/public/enums/src/common/generation/common_splat_model.rs new file mode 100644 index 0000000000..3bf50e72bd --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_splat_model.rs @@ -0,0 +1,114 @@ +use utoipa::ToSchema; + +/// Splat models available for generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonSplatModel { + #[serde(rename = "marble_0p1_mini")] + Marble0p1Mini, + + #[serde(rename = "marble_0p1_plus")] + Marble0p1Plus, + + #[serde(rename = "marble_1p0")] + Marble1p0, + + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + + #[serde(rename = "marble_1p1")] + Marble1p1, + + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, + + /// TripoSplat: single-image to 3D Gaussian splat (via Fal). + #[serde(rename = "triposplat")] + TripoSplat, +} + +impl CommonSplatModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::Marble0p1Mini => CommonModelType::Marble0p1Mini, + Self::Marble0p1Plus => CommonModelType::Marble0p1Plus, + Self::Marble1p0 => CommonModelType::Marble1p0, + Self::Marble1p0Draft => CommonModelType::Marble1p0Draft, + Self::Marble1p1 => CommonModelType::Marble1p1, + Self::Marble1p1Plus => CommonModelType::Marble1p1Plus, + Self::TripoSplat => CommonModelType::TripoSplat, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonSplatModel::Marble0p1Mini, "marble_0p1_mini"); + assert_serialization(CommonSplatModel::Marble0p1Plus, "marble_0p1_plus"); + assert_serialization(CommonSplatModel::Marble1p0, "marble_1p0"); + assert_serialization(CommonSplatModel::Marble1p0Draft, "marble_1p0_draft"); + assert_serialization(CommonSplatModel::Marble1p1, "marble_1p1"); + assert_serialization(CommonSplatModel::Marble1p1Plus, "marble_1p1_plus"); + assert_serialization(CommonSplatModel::TripoSplat, "triposplat"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("marble_0p1_mini", CommonSplatModel::Marble0p1Mini), + ("marble_0p1_plus", CommonSplatModel::Marble0p1Plus), + ("marble_1p0", CommonSplatModel::Marble1p0), + ("marble_1p0_draft", CommonSplatModel::Marble1p0Draft), + ("marble_1p1", CommonSplatModel::Marble1p1), + ("marble_1p1_plus", CommonSplatModel::Marble1p1Plus), + ("triposplat", CommonSplatModel::TripoSplat), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonSplatModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonSplatModel::Marble0p1Mini, + CommonSplatModel::Marble0p1Plus, + CommonSplatModel::Marble1p0, + CommonSplatModel::Marble1p0Draft, + CommonSplatModel::Marble1p1, + CommonSplatModel::Marble1p1Plus, + CommonSplatModel::TripoSplat, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonSplatModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } + + #[test] + fn all_splat_models_convert_to_common_model_type() { + let models = [ + (CommonSplatModel::Marble0p1Mini, CommonModelType::Marble0p1Mini), + (CommonSplatModel::Marble0p1Plus, CommonModelType::Marble0p1Plus), + (CommonSplatModel::Marble1p0, CommonModelType::Marble1p0), + (CommonSplatModel::Marble1p0Draft, CommonModelType::Marble1p0Draft), + (CommonSplatModel::Marble1p1, CommonModelType::Marble1p1), + (CommonSplatModel::Marble1p1Plus, CommonModelType::Marble1p1Plus), + (CommonSplatModel::TripoSplat, CommonModelType::TripoSplat), + ]; + for (splat_model, expected) in models { + assert_eq!(splat_model.to_common_model_type(), expected); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_video_model.rs b/crates/schema/public/enums/src/common/generation/common_video_model.rs new file mode 100644 index 0000000000..6e0a4f3e9d --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_video_model.rs @@ -0,0 +1,423 @@ +// The deprecated Seedance 2.0 Ultra variants must stay referenced by this +// enum's own coders, variant lists, and tests; suppress the lint file-wide. +#![allow(deprecated)] + +use utoipa::ToSchema; + +/// Video models available for generation. +/// Mirrors artcraft_router::api::router_video_model::RouterVideoModel. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonVideoModel { + /// NB: This was for the web login version + #[deprecated = "Use `GrokImagineVideo` instead."] + #[serde(rename = "grok_video")] + GrokVideo, + + /// NB: This is for the API version + #[serde(rename = "grok_imagine_video")] + GrokImagineVideo, + + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + + #[serde(rename = "flux_3")] + Flux3, + + #[serde(rename = "flux_3_draft")] + Flux3Draft, + + #[serde(rename = "kling_1p6_pro")] + Kling16Pro, + + #[serde(rename = "kling_2p1_pro")] + Kling21Pro, + + #[serde(rename = "kling_2p1_master")] + Kling21Master, + + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + + #[serde(rename = "minimax_h3")] + MinimaxH3, + + #[serde(rename = "minimax_h3_turbo")] + MinimaxH3Turbo, + + #[serde(rename = "minimax_h3_ultra")] + MinimaxH3Ultra, + + #[serde(rename = "seedance_1p0_lite")] + Seedance10Lite, + + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + + #[serde(rename = "seedance_2p0")] + Seedance2p0, + + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + + #[serde(rename = "seedance_2p0_bp")] + Seedance2p0BytePlus, + + #[serde(rename = "seedance_2p0_bp_fast")] + Seedance2p0BytePlusFast, + + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u")] + Seedance2p0Ultra, + + #[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")] + #[serde(rename = "seedance_2p0_u_fast")] + Seedance2p0UltraFast, + + #[serde(rename = "seedance_2p0_bpu")] + Seedance2p0BytePlusUltra, + + #[serde(rename = "seedance_2p0_bpu_fast")] + Seedance2p0BytePlusUltraFast, + + #[serde(rename = "seedance_2p0_mini")] + Seedance2p0Mini, + + #[serde(rename = "seedance_2p0_bp_mini")] + Seedance2p0BytePlusMini, + + #[serde(rename = "seedance_2p0_bpu_mini")] + Seedance2p0BytePlusUltraMini, + + #[serde(rename = "seedance_2p5_preview")] + Seedance2p5Preview, + + #[serde(rename = "seedance_2p5")] + Seedance2p5, + + #[serde(rename = "seedance_2p5_u")] + Seedance2p5Ultra, + + #[serde(rename = "sora_2")] + Sora2, + + #[serde(rename = "sora_2_pro")] + Sora2Pro, + + #[serde(rename = "veo_2")] + Veo2, + + #[serde(rename = "veo_3")] + Veo3, + + #[serde(rename = "veo_3_fast")] + Veo3Fast, + + #[serde(rename = "veo_3p1")] + Veo3p1, + + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, + + #[serde(rename = "veo_3p1_lite")] + Veo3p1Lite, + + #[serde(rename = "vidu_q3")] + ViduQ3, + + #[serde(rename = "vidu_q3_turbo")] + ViduQ3Turbo, + + // NB: Temporary model rollout. Previously used for "Seedance 2.0 BytePlus". + // This can be reused in the future. + #[serde(rename = "preview_model")] + PreviewModel, + + // NB: Temporary model rollout. Previously used for "Seedance 2.0 BytePlus Fast". + // This can be reused in the future. + #[serde(rename = "preview_model_fast")] + PreviewModelFast, +} + +impl CommonVideoModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::GrokVideo => CommonModelType::GrokVideo, + Self::GrokImagineVideo => CommonModelType::GrokImagineVideo, + Self::GrokImagineVideo1p5 => CommonModelType::GrokImagineVideo1p5, + Self::Flux3 => CommonModelType::Flux3, + Self::Flux3Draft => CommonModelType::Flux3Draft, + Self::Kling16Pro => CommonModelType::Kling16Pro, + Self::Kling21Pro => CommonModelType::Kling21Pro, + Self::Kling21Master => CommonModelType::Kling21Master, + Self::Kling2p5TurboPro => CommonModelType::Kling2p5TurboPro, + Self::Kling2p6Pro => CommonModelType::Kling2p6Pro, + Self::Kling3p0Standard => CommonModelType::Kling3p0Standard, + Self::Kling3p0Pro => CommonModelType::Kling3p0Pro, + Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0, + Self::MinimaxH3 => CommonModelType::MinimaxH3, + Self::MinimaxH3Turbo => CommonModelType::MinimaxH3Turbo, + Self::MinimaxH3Ultra => CommonModelType::MinimaxH3Ultra, + Self::Seedance10Lite => CommonModelType::Seedance10Lite, + Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro, + Self::Seedance2p0 => CommonModelType::Seedance2p0, + Self::Seedance2p0Fast => CommonModelType::Seedance2p0Fast, + Self::Seedance2p0BytePlus => CommonModelType::Seedance2p0BytePlus, + Self::Seedance2p0BytePlusFast => CommonModelType::Seedance2p0BytePlusFast, + Self::Seedance2p0Ultra => CommonModelType::Seedance2p0Ultra, + Self::Seedance2p0UltraFast => CommonModelType::Seedance2p0UltraFast, + Self::Seedance2p0BytePlusUltra => CommonModelType::Seedance2p0BytePlusUltra, + Self::Seedance2p0BytePlusUltraFast => CommonModelType::Seedance2p0BytePlusUltraFast, + Self::Seedance2p0Mini => CommonModelType::Seedance2p0Mini, + Self::Seedance2p0BytePlusMini => CommonModelType::Seedance2p0BytePlusMini, + Self::Seedance2p0BytePlusUltraMini => CommonModelType::Seedance2p0BytePlusUltraMini, + Self::Seedance2p5Preview => CommonModelType::Seedance2p5Preview, + Self::Seedance2p5 => CommonModelType::Seedance2p5, + Self::Seedance2p5Ultra => CommonModelType::Seedance2p5Ultra, + Self::Sora2 => CommonModelType::Sora2, + Self::Sora2Pro => CommonModelType::Sora2Pro, + Self::Veo2 => CommonModelType::Veo2, + Self::Veo3 => CommonModelType::Veo3, + Self::Veo3Fast => CommonModelType::Veo3Fast, + Self::Veo3p1 => CommonModelType::Veo3p1, + Self::Veo3p1Fast => CommonModelType::Veo3p1Fast, + Self::Veo3p1Lite => CommonModelType::Veo3p1Lite, + Self::ViduQ3 => CommonModelType::ViduQ3, + Self::ViduQ3Turbo => CommonModelType::ViduQ3Turbo, + Self::PreviewModel => CommonModelType::PreviewModel, + Self::PreviewModelFast => CommonModelType::PreviewModelFast, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonVideoModel::GrokVideo, "grok_video"); + assert_serialization(CommonVideoModel::GrokImagineVideo, "grok_imagine_video"); + assert_serialization(CommonVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5"); + assert_serialization(CommonVideoModel::Flux3, "flux_3"); + assert_serialization(CommonVideoModel::Flux3Draft, "flux_3_draft"); + assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro"); + assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro"); + assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master"); + assert_serialization(CommonVideoModel::Kling2p5TurboPro, "kling_2p5_turbo_pro"); + assert_serialization(CommonVideoModel::Kling2p6Pro, "kling_2p6_pro"); + assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard"); + assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro"); + assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(CommonVideoModel::MinimaxH3, "minimax_h3"); + assert_serialization(CommonVideoModel::MinimaxH3Turbo, "minimax_h3_turbo"); + assert_serialization(CommonVideoModel::MinimaxH3Ultra, "minimax_h3_ultra"); + assert_serialization(CommonVideoModel::Seedance10Lite, "seedance_1p0_lite"); + assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro"); + assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0"); + assert_serialization(CommonVideoModel::Seedance2p0Fast, "seedance_2p0_fast"); + assert_serialization(CommonVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"); + assert_serialization(CommonVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"); + assert_serialization(CommonVideoModel::Seedance2p0Ultra, "seedance_2p0_u"); + assert_serialization(CommonVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"); + assert_serialization(CommonVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"); + assert_serialization(CommonVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"); + assert_serialization(CommonVideoModel::Seedance2p0Mini, "seedance_2p0_mini"); + assert_serialization(CommonVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"); + assert_serialization(CommonVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"); + assert_serialization(CommonVideoModel::Seedance2p5Preview, "seedance_2p5_preview"); + assert_serialization(CommonVideoModel::Seedance2p5, "seedance_2p5"); + assert_serialization(CommonVideoModel::Seedance2p5Ultra, "seedance_2p5_u"); + assert_serialization(CommonVideoModel::Sora2, "sora_2"); + assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro"); + assert_serialization(CommonVideoModel::Veo2, "veo_2"); + assert_serialization(CommonVideoModel::Veo3, "veo_3"); + assert_serialization(CommonVideoModel::Veo3Fast, "veo_3_fast"); + assert_serialization(CommonVideoModel::Veo3p1, "veo_3p1"); + assert_serialization(CommonVideoModel::Veo3p1Fast, "veo_3p1_fast"); + assert_serialization(CommonVideoModel::Veo3p1Lite, "veo_3p1_lite"); + assert_serialization(CommonVideoModel::ViduQ3, "vidu_q3"); + assert_serialization(CommonVideoModel::ViduQ3Turbo, "vidu_q3_turbo"); + assert_serialization(CommonVideoModel::PreviewModel, "preview_model"); + assert_serialization(CommonVideoModel::PreviewModelFast, "preview_model_fast"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("grok_video", CommonVideoModel::GrokVideo), + ("grok_imagine_video", CommonVideoModel::GrokImagineVideo), + ("grok_imagine_video_1p5", CommonVideoModel::GrokImagineVideo1p5), + ("flux_3", CommonVideoModel::Flux3), + ("flux_3_draft", CommonVideoModel::Flux3Draft), + ("kling_1p6_pro", CommonVideoModel::Kling16Pro), + ("kling_2p1_pro", CommonVideoModel::Kling21Pro), + ("kling_2p1_master", CommonVideoModel::Kling21Master), + ("kling_2p5_turbo_pro", CommonVideoModel::Kling2p5TurboPro), + ("kling_2p6_pro", CommonVideoModel::Kling2p6Pro), + ("kling_3p0_standard", CommonVideoModel::Kling3p0Standard), + ("kling_3p0_pro", CommonVideoModel::Kling3p0Pro), + ("happy_horse_1p0", CommonVideoModel::HappyHorse1p0), + ("minimax_h3", CommonVideoModel::MinimaxH3), + ("minimax_h3_turbo", CommonVideoModel::MinimaxH3Turbo), + ("minimax_h3_ultra", CommonVideoModel::MinimaxH3Ultra), + ("seedance_1p0_lite", CommonVideoModel::Seedance10Lite), + ("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro), + ("seedance_2p0", CommonVideoModel::Seedance2p0), + ("seedance_2p0_fast", CommonVideoModel::Seedance2p0Fast), + ("seedance_2p0_bp", CommonVideoModel::Seedance2p0BytePlus), + ("seedance_2p0_bp_fast", CommonVideoModel::Seedance2p0BytePlusFast), + ("seedance_2p0_u", CommonVideoModel::Seedance2p0Ultra), + ("seedance_2p0_u_fast", CommonVideoModel::Seedance2p0UltraFast), + ("seedance_2p0_bpu", CommonVideoModel::Seedance2p0BytePlusUltra), + ("seedance_2p0_bpu_fast", CommonVideoModel::Seedance2p0BytePlusUltraFast), + ("seedance_2p0_mini", CommonVideoModel::Seedance2p0Mini), + ("seedance_2p0_bp_mini", CommonVideoModel::Seedance2p0BytePlusMini), + ("seedance_2p0_bpu_mini", CommonVideoModel::Seedance2p0BytePlusUltraMini), + ("seedance_2p5_preview", CommonVideoModel::Seedance2p5Preview), + ("seedance_2p5", CommonVideoModel::Seedance2p5), + ("seedance_2p5_u", CommonVideoModel::Seedance2p5Ultra), + ("sora_2", CommonVideoModel::Sora2), + ("sora_2_pro", CommonVideoModel::Sora2Pro), + ("veo_2", CommonVideoModel::Veo2), + ("veo_3", CommonVideoModel::Veo3), + ("veo_3_fast", CommonVideoModel::Veo3Fast), + ("veo_3p1", CommonVideoModel::Veo3p1), + ("veo_3p1_fast", CommonVideoModel::Veo3p1Fast), + ("veo_3p1_lite", CommonVideoModel::Veo3p1Lite), + ("vidu_q3", CommonVideoModel::ViduQ3), + ("vidu_q3_turbo", CommonVideoModel::ViduQ3Turbo), + ("preview_model", CommonVideoModel::PreviewModel), + ("preview_model_fast", CommonVideoModel::PreviewModelFast), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonVideoModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonVideoModel::GrokVideo, + CommonVideoModel::GrokImagineVideo, + CommonVideoModel::GrokImagineVideo1p5, + CommonVideoModel::Flux3, + CommonVideoModel::Flux3Draft, + CommonVideoModel::Kling16Pro, + CommonVideoModel::Kling21Pro, + CommonVideoModel::Kling21Master, + CommonVideoModel::Kling2p5TurboPro, + CommonVideoModel::Kling2p6Pro, + CommonVideoModel::Kling3p0Standard, + CommonVideoModel::Kling3p0Pro, + CommonVideoModel::HappyHorse1p0, + CommonVideoModel::MinimaxH3, + CommonVideoModel::MinimaxH3Turbo, + CommonVideoModel::MinimaxH3Ultra, + CommonVideoModel::Seedance10Lite, + CommonVideoModel::Seedance1p5Pro, + CommonVideoModel::Seedance2p0, + CommonVideoModel::Seedance2p0Fast, + CommonVideoModel::Seedance2p0BytePlus, + CommonVideoModel::Seedance2p0BytePlusFast, + CommonVideoModel::Seedance2p0Ultra, + CommonVideoModel::Seedance2p0UltraFast, + CommonVideoModel::Seedance2p0BytePlusUltra, + CommonVideoModel::Seedance2p0BytePlusUltraFast, + CommonVideoModel::Seedance2p0Mini, + CommonVideoModel::Seedance2p0BytePlusMini, + CommonVideoModel::Seedance2p0BytePlusUltraMini, + CommonVideoModel::Seedance2p5Preview, + CommonVideoModel::Seedance2p5, + CommonVideoModel::Seedance2p5Ultra, + CommonVideoModel::Sora2, + CommonVideoModel::Sora2Pro, + CommonVideoModel::Veo2, + CommonVideoModel::Veo3, + CommonVideoModel::Veo3Fast, + CommonVideoModel::Veo3p1, + CommonVideoModel::Veo3p1Fast, + CommonVideoModel::Veo3p1Lite, + CommonVideoModel::ViduQ3, + CommonVideoModel::ViduQ3Turbo, + CommonVideoModel::PreviewModel, + CommonVideoModel::PreviewModelFast, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonVideoModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } + + #[test] + fn all_video_models_convert_to_common_model_type() { + let models = [ + (CommonVideoModel::GrokVideo, CommonModelType::GrokVideo), + (CommonVideoModel::GrokImagineVideo, CommonModelType::GrokImagineVideo), + (CommonVideoModel::GrokImagineVideo1p5, CommonModelType::GrokImagineVideo1p5), + (CommonVideoModel::Flux3, CommonModelType::Flux3), + (CommonVideoModel::Flux3Draft, CommonModelType::Flux3Draft), + (CommonVideoModel::Kling16Pro, CommonModelType::Kling16Pro), + (CommonVideoModel::Kling21Pro, CommonModelType::Kling21Pro), + (CommonVideoModel::Kling21Master, CommonModelType::Kling21Master), + (CommonVideoModel::Kling2p5TurboPro, CommonModelType::Kling2p5TurboPro), + (CommonVideoModel::Kling2p6Pro, CommonModelType::Kling2p6Pro), + (CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard), + (CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro), + (CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0), + (CommonVideoModel::MinimaxH3, CommonModelType::MinimaxH3), + (CommonVideoModel::MinimaxH3Turbo, CommonModelType::MinimaxH3Turbo), + (CommonVideoModel::MinimaxH3Ultra, CommonModelType::MinimaxH3Ultra), + (CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite), + (CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro), + (CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0), + (CommonVideoModel::Seedance2p0Fast, CommonModelType::Seedance2p0Fast), + (CommonVideoModel::Seedance2p0BytePlus, CommonModelType::Seedance2p0BytePlus), + (CommonVideoModel::Seedance2p0BytePlusFast, CommonModelType::Seedance2p0BytePlusFast), + (CommonVideoModel::Seedance2p0Ultra, CommonModelType::Seedance2p0Ultra), + (CommonVideoModel::Seedance2p0UltraFast, CommonModelType::Seedance2p0UltraFast), + (CommonVideoModel::Seedance2p0BytePlusUltra, CommonModelType::Seedance2p0BytePlusUltra), + (CommonVideoModel::Seedance2p0BytePlusUltraFast, CommonModelType::Seedance2p0BytePlusUltraFast), + (CommonVideoModel::Seedance2p0Mini, CommonModelType::Seedance2p0Mini), + (CommonVideoModel::Seedance2p0BytePlusMini, CommonModelType::Seedance2p0BytePlusMini), + (CommonVideoModel::Seedance2p0BytePlusUltraMini, CommonModelType::Seedance2p0BytePlusUltraMini), + (CommonVideoModel::Seedance2p5Preview, CommonModelType::Seedance2p5Preview), + (CommonVideoModel::Seedance2p5, CommonModelType::Seedance2p5), + (CommonVideoModel::Seedance2p5Ultra, CommonModelType::Seedance2p5Ultra), + (CommonVideoModel::Sora2, CommonModelType::Sora2), + (CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro), + (CommonVideoModel::Veo2, CommonModelType::Veo2), + (CommonVideoModel::Veo3, CommonModelType::Veo3), + (CommonVideoModel::Veo3Fast, CommonModelType::Veo3Fast), + (CommonVideoModel::Veo3p1, CommonModelType::Veo3p1), + (CommonVideoModel::Veo3p1Fast, CommonModelType::Veo3p1Fast), + (CommonVideoModel::Veo3p1Lite, CommonModelType::Veo3p1Lite), + (CommonVideoModel::ViduQ3, CommonModelType::ViduQ3), + (CommonVideoModel::ViduQ3Turbo, CommonModelType::ViduQ3Turbo), + (CommonVideoModel::PreviewModel, CommonModelType::PreviewModel), + (CommonVideoModel::PreviewModelFast, CommonModelType::PreviewModelFast), + ]; + for (video_model, expected) in models { + assert_eq!(video_model.to_common_model_type(), expected); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/mod.rs b/crates/schema/public/enums/src/common/generation/mod.rs new file mode 100644 index 0000000000..d8a346ed3b --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/mod.rs @@ -0,0 +1,17 @@ +pub mod common_aspect_ratio; +pub mod common_audio_model; +pub mod common_bitrate; +pub mod common_generation_mode; +pub mod common_image_model; +pub mod common_mesh_model; +pub mod common_mesh_output_type; +pub mod common_mesh_quality; +pub mod common_musical_key; +pub mod common_polygon_type; +pub mod common_quality; +pub mod common_model_class; +pub mod common_model_type; +pub mod model_creator; +pub mod common_resolution; +pub mod common_splat_model; +pub mod common_video_model; diff --git a/crates/schema/public/enums/src/common/generation/model_creator.rs b/crates/schema/public/enums/src/common/generation/model_creator.rs new file mode 100644 index 0000000000..7c99b8855b --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/model_creator.rs @@ -0,0 +1,308 @@ +use std::collections::BTreeSet; +use utoipa::ToSchema; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// The company or organization that created a model. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +pub enum ModelCreator { + #[serde(rename = "alibaba")] + Alibaba, + #[serde(rename = "artcraft")] + ArtCraft, + #[serde(rename = "black_forest_labs")] + BlackForestLabs, + #[serde(rename = "bytedance")] + Bytedance, + #[serde(rename = "deemos")] + Deemos, + #[serde(rename = "fal")] + Fal, + #[serde(rename = "google")] + Google, + #[serde(rename = "grok")] + Grok, + #[serde(rename = "hailuo")] + Hailuo, + #[serde(rename = "higgsfield")] + Higgsfield, + #[serde(rename = "kling")] + Kling, + #[serde(rename = "krea")] + Krea, + #[serde(rename = "meshy")] + Meshy, + #[serde(rename = "midjourney")] + Midjourney, + #[serde(rename = "open_ai")] + OpenAi, + #[serde(rename = "open_art")] + OpenArt, + #[serde(rename = "recraft")] + Recraft, + #[serde(rename = "replicate")] + Replicate, + #[serde(rename = "runway")] + Runway, + #[serde(rename = "stability")] + Stability, + #[serde(rename = "suno")] + Suno, + #[serde(rename = "tencent")] + Tencent, + #[serde(rename = "tensor_art")] + TensorArt, + #[serde(rename = "tripo")] + Tripo, + #[serde(rename = "vidu")] + Vidu, + #[serde(rename = "world_labs")] + WorldLabs, +} + +impl ModelCreator { + /// Returns the properly formatted human-readable name. + pub fn get_name(&self) -> &'static str { + match self { + Self::Alibaba => "Alibaba", + Self::ArtCraft => "ArtCraft", + Self::BlackForestLabs => "Black Forest Labs", + Self::Bytedance => "ByteDance", + Self::Deemos => "Deemos", + Self::Fal => "fal", + Self::Google => "Google", + Self::Grok => "Grok", + Self::Hailuo => "Hailuo", + Self::Higgsfield => "Higgsfield", + Self::Kling => "Kling", + Self::Krea => "Krea", + Self::Meshy => "Meshy", + Self::Midjourney => "Midjourney", + Self::OpenAi => "OpenAI", + Self::OpenArt => "OpenArt", + Self::Recraft => "Recraft", + Self::Replicate => "Replicate", + Self::Runway => "Runway", + Self::Stability => "Stability AI", + Self::Suno => "Suno", + Self::Tencent => "Tencent", + Self::TensorArt => "TensorArt", + Self::Tripo => "Tripo", + Self::Vidu => "Vidu", + Self::WorldLabs => "World Labs", + } + } + + pub fn to_str(&self) -> &'static str { + match self { + Self::Alibaba => "alibaba", + Self::ArtCraft => "artcraft", + Self::BlackForestLabs => "black_forest_labs", + Self::Bytedance => "bytedance", + Self::Deemos => "deemos", + Self::Fal => "fal", + Self::Google => "google", + Self::Grok => "grok", + Self::Hailuo => "hailuo", + Self::Higgsfield => "higgsfield", + Self::Kling => "kling", + Self::Krea => "krea", + Self::Meshy => "meshy", + Self::Midjourney => "midjourney", + Self::OpenAi => "open_ai", + Self::OpenArt => "open_art", + Self::Recraft => "recraft", + Self::Replicate => "replicate", + Self::Runway => "runway", + Self::Stability => "stability", + Self::Suno => "suno", + Self::Tencent => "tencent", + Self::TensorArt => "tensor_art", + Self::Tripo => "tripo", + Self::Vidu => "vidu", + Self::WorldLabs => "world_labs", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "alibaba" => Ok(Self::Alibaba), + "artcraft" => Ok(Self::ArtCraft), + "black_forest_labs" => Ok(Self::BlackForestLabs), + "bytedance" => Ok(Self::Bytedance), + "deemos" => Ok(Self::Deemos), + "fal" => Ok(Self::Fal), + "google" => Ok(Self::Google), + "grok" => Ok(Self::Grok), + "hailuo" => Ok(Self::Hailuo), + "higgsfield" => Ok(Self::Higgsfield), + "kling" => Ok(Self::Kling), + "krea" => Ok(Self::Krea), + "meshy" => Ok(Self::Meshy), + "midjourney" => Ok(Self::Midjourney), + "open_ai" => Ok(Self::OpenAi), + "open_art" => Ok(Self::OpenArt), + "recraft" => Ok(Self::Recraft), + "replicate" => Ok(Self::Replicate), + "runway" => Ok(Self::Runway), + "stability" => Ok(Self::Stability), + "suno" => Ok(Self::Suno), + "tencent" => Ok(Self::Tencent), + "tensor_art" => Ok(Self::TensorArt), + "tripo" => Ok(Self::Tripo), + "vidu" => Ok(Self::Vidu), + "world_labs" => Ok(Self::WorldLabs), + _ => Err(format!("invalid ModelCreator value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Alibaba, + Self::ArtCraft, + Self::BlackForestLabs, + Self::Bytedance, + Self::Deemos, + Self::Fal, + Self::Google, + Self::Grok, + Self::Hailuo, + Self::Higgsfield, + Self::Kling, + Self::Krea, + Self::Meshy, + Self::Midjourney, + Self::OpenAi, + Self::OpenArt, + Self::Recraft, + Self::Replicate, + Self::Runway, + Self::Stability, + Self::Suno, + Self::Tencent, + Self::TensorArt, + Self::Tripo, + Self::Vidu, + Self::WorldLabs, + ]) + } +} + +impl_enum_display_and_debug_using_to_str!(ModelCreator); + +#[cfg(test)] +mod tests { + use crate::common::generation::model_creator::ModelCreator; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ModelCreator::Alibaba, "alibaba"); + assert_serialization(ModelCreator::ArtCraft, "artcraft"); + assert_serialization(ModelCreator::BlackForestLabs, "black_forest_labs"); + assert_serialization(ModelCreator::Bytedance, "bytedance"); + assert_serialization(ModelCreator::Fal, "fal"); + assert_serialization(ModelCreator::Google, "google"); + assert_serialization(ModelCreator::Grok, "grok"); + assert_serialization(ModelCreator::Hailuo, "hailuo"); + assert_serialization(ModelCreator::Higgsfield, "higgsfield"); + assert_serialization(ModelCreator::Kling, "kling"); + assert_serialization(ModelCreator::Krea, "krea"); + assert_serialization(ModelCreator::Midjourney, "midjourney"); + assert_serialization(ModelCreator::OpenAi, "open_ai"); + assert_serialization(ModelCreator::OpenArt, "open_art"); + assert_serialization(ModelCreator::Recraft, "recraft"); + assert_serialization(ModelCreator::Replicate, "replicate"); + assert_serialization(ModelCreator::Runway, "runway"); + assert_serialization(ModelCreator::Stability, "stability"); + assert_serialization(ModelCreator::Suno, "suno"); + assert_serialization(ModelCreator::Tencent, "tencent"); + assert_serialization(ModelCreator::TensorArt, "tensor_art"); + assert_serialization(ModelCreator::Vidu, "vidu"); + assert_serialization(ModelCreator::WorldLabs, "world_labs"); + } + + #[test] + fn to_str() { + assert_eq!(ModelCreator::Alibaba.to_str(), "alibaba"); + assert_eq!(ModelCreator::ArtCraft.to_str(), "artcraft"); + assert_eq!(ModelCreator::BlackForestLabs.to_str(), "black_forest_labs"); + assert_eq!(ModelCreator::OpenAi.to_str(), "open_ai"); + assert_eq!(ModelCreator::Suno.to_str(), "suno"); + assert_eq!(ModelCreator::WorldLabs.to_str(), "world_labs"); + } + + #[test] + fn from_str() { + assert_eq!(ModelCreator::from_str("alibaba").unwrap(), ModelCreator::Alibaba); + assert_eq!(ModelCreator::from_str("artcraft").unwrap(), ModelCreator::ArtCraft); + assert_eq!(ModelCreator::from_str("black_forest_labs").unwrap(), ModelCreator::BlackForestLabs); + assert_eq!(ModelCreator::from_str("open_ai").unwrap(), ModelCreator::OpenAi); + assert_eq!(ModelCreator::from_str("suno").unwrap(), ModelCreator::Suno); + assert_eq!(ModelCreator::from_str("world_labs").unwrap(), ModelCreator::WorldLabs); + assert!(ModelCreator::from_str("invalid").is_err()); + } + + #[test] + fn get_name() { + assert_eq!(ModelCreator::Alibaba.get_name(), "Alibaba"); + assert_eq!(ModelCreator::ArtCraft.get_name(), "ArtCraft"); + assert_eq!(ModelCreator::BlackForestLabs.get_name(), "Black Forest Labs"); + assert_eq!(ModelCreator::Bytedance.get_name(), "ByteDance"); + assert_eq!(ModelCreator::Fal.get_name(), "fal"); + assert_eq!(ModelCreator::Google.get_name(), "Google"); + assert_eq!(ModelCreator::OpenAi.get_name(), "OpenAI"); + assert_eq!(ModelCreator::Stability.get_name(), "Stability AI"); + assert_eq!(ModelCreator::Suno.get_name(), "Suno"); + assert_eq!(ModelCreator::WorldLabs.get_name(), "World Labs"); + } + + #[test] + fn all_variants() { + const EXPECTED_COUNT: usize = 26; + assert_eq!(ModelCreator::all_variants().len(), EXPECTED_COUNT); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(ModelCreator::all_variants().len(), ModelCreator::iter().len()); + } + + #[test] + fn round_trip() { + for variant in ModelCreator::all_variants() { + assert_eq!(variant, ModelCreator::from_str(variant.to_str()).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in ModelCreator::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty()); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH); + } + } + + #[test] + fn every_variant_has_a_human_name() { + for variant in ModelCreator::all_variants() { + let name = variant.get_name(); + assert!(!name.is_empty(), "variant {:?} has empty name", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation_provider.rs b/crates/schema/public/enums/src/common/generation_provider.rs new file mode 100644 index 0000000000..61e0e2bc9c --- /dev/null +++ b/crates/schema/public/enums/src/common/generation_provider.rs @@ -0,0 +1,235 @@ +//! This is an important enum. +//! +//! It's used in storyteller-web inference generation, the cost estimate handler, +//! the ArtCraft Tauri app, and more. +//! +//! Do not change the values here without cause or care. + +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// NB: This will be used by a variety of tables (MySQL and sqlite)! +/// Keep the max length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GenerationProvider { + Artcraft, + Fal, + Grok, + Midjourney, + Sora, + WorldLabs, + Higgsfield, + Krea, + Leonardo, + Magnific, + Openart, + Picsart, + Pixverse, + Runway, +} + +impl_enum_display_and_debug_using_to_str!(GenerationProvider); +impl_mysql_enum_coders!(GenerationProvider); +impl_mysql_from_row!(GenerationProvider); + +// For Tauri +impl_sqlite_enum_coders!(GenerationProvider); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl GenerationProvider { + pub fn to_str(&self) -> &'static str { + match self { + Self::Artcraft => "artcraft", + Self::Fal => "fal", + Self::Grok => "grok", + Self::Midjourney => "midjourney", + Self::Sora => "sora", + Self::WorldLabs => "world_labs", + Self::Higgsfield => "higgsfield", + Self::Krea => "krea", + Self::Leonardo => "leonardo", + Self::Magnific => "magnific", + Self::Openart => "openart", + Self::Picsart => "picsart", + Self::Pixverse => "pixverse", + Self::Runway => "runway", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "artcraft" => Ok(Self::Artcraft), + "fal" => Ok(Self::Fal), + "grok" => Ok(Self::Grok), + "midjourney" => Ok(Self::Midjourney), + "sora" => Ok(Self::Sora), + "world_labs" => Ok(Self::WorldLabs), + "higgsfield" => Ok(Self::Higgsfield), + "krea" => Ok(Self::Krea), + "leonardo" => Ok(Self::Leonardo), + "magnific" => Ok(Self::Magnific), + "openart" => Ok(Self::Openart), + "picsart" => Ok(Self::Picsart), + "pixverse" => Ok(Self::Pixverse), + "runway" => Ok(Self::Runway), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Artcraft, + Self::Fal, + Self::Grok, + Self::Midjourney, + Self::Sora, + Self::WorldLabs, + Self::Higgsfield, + Self::Krea, + Self::Leonardo, + Self::Magnific, + Self::Openart, + Self::Picsart, + Self::Pixverse, + Self::Runway, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation_provider::GenerationProvider; + use crate::error::enum_error::EnumError; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(GenerationProvider::Artcraft, "artcraft"); + assert_serialization(GenerationProvider::Fal, "fal"); + assert_serialization(GenerationProvider::Grok, "grok"); + assert_serialization(GenerationProvider::Midjourney, "midjourney"); + assert_serialization(GenerationProvider::Sora, "sora"); + assert_serialization(GenerationProvider::WorldLabs, "world_labs"); + assert_serialization(GenerationProvider::Higgsfield, "higgsfield"); + assert_serialization(GenerationProvider::Krea, "krea"); + assert_serialization(GenerationProvider::Leonardo, "leonardo"); + assert_serialization(GenerationProvider::Magnific, "magnific"); + assert_serialization(GenerationProvider::Openart, "openart"); + assert_serialization(GenerationProvider::Picsart, "picsart"); + assert_serialization(GenerationProvider::Pixverse, "pixverse"); + assert_serialization(GenerationProvider::Runway, "runway"); + } + + #[test] + fn to_str() { + assert_eq!(GenerationProvider::Artcraft.to_str(), "artcraft"); + assert_eq!(GenerationProvider::Fal.to_str(), "fal"); + assert_eq!(GenerationProvider::Grok.to_str(), "grok"); + assert_eq!(GenerationProvider::Midjourney.to_str(), "midjourney"); + assert_eq!(GenerationProvider::Sora.to_str(), "sora"); + assert_eq!(GenerationProvider::WorldLabs.to_str(), "world_labs"); + assert_eq!(GenerationProvider::Higgsfield.to_str(), "higgsfield"); + assert_eq!(GenerationProvider::Krea.to_str(), "krea"); + assert_eq!(GenerationProvider::Leonardo.to_str(), "leonardo"); + assert_eq!(GenerationProvider::Magnific.to_str(), "magnific"); + assert_eq!(GenerationProvider::Openart.to_str(), "openart"); + assert_eq!(GenerationProvider::Picsart.to_str(), "picsart"); + assert_eq!(GenerationProvider::Pixverse.to_str(), "pixverse"); + assert_eq!(GenerationProvider::Runway.to_str(), "runway"); + } + + #[test] + fn from_str() { + assert_eq!(GenerationProvider::from_str("artcraft").unwrap(), GenerationProvider::Artcraft); + assert_eq!(GenerationProvider::from_str("fal").unwrap(), GenerationProvider::Fal); + assert_eq!(GenerationProvider::from_str("grok").unwrap(), GenerationProvider::Grok); + assert_eq!(GenerationProvider::from_str("midjourney").unwrap(), GenerationProvider::Midjourney); + assert_eq!(GenerationProvider::from_str("sora").unwrap(), GenerationProvider::Sora); + assert_eq!(GenerationProvider::from_str("world_labs").unwrap(), GenerationProvider::WorldLabs); + assert_eq!(GenerationProvider::from_str("higgsfield").unwrap(), GenerationProvider::Higgsfield); + assert_eq!(GenerationProvider::from_str("krea").unwrap(), GenerationProvider::Krea); + assert_eq!(GenerationProvider::from_str("leonardo").unwrap(), GenerationProvider::Leonardo); + assert_eq!(GenerationProvider::from_str("magnific").unwrap(), GenerationProvider::Magnific); + assert_eq!(GenerationProvider::from_str("openart").unwrap(), GenerationProvider::Openart); + assert_eq!(GenerationProvider::from_str("picsart").unwrap(), GenerationProvider::Picsart); + assert_eq!(GenerationProvider::from_str("pixverse").unwrap(), GenerationProvider::Pixverse); + assert_eq!(GenerationProvider::from_str("runway").unwrap(), GenerationProvider::Runway); + } + + #[test] + fn from_str_err() { + let result = GenerationProvider::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = GenerationProvider::all_variants(); + assert_eq!(variants.len(), 14); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Artcraft)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Fal)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Grok)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Midjourney)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Sora)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::WorldLabs)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Higgsfield)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Krea)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Leonardo)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Magnific)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Openart)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Picsart)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Pixverse)); + assert_eq!(variants.pop_first(), Some(GenerationProvider::Runway)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(GenerationProvider::all_variants().len(), GenerationProvider::iter().len()); + } + + #[test] + fn round_trip() { + for variant in GenerationProvider::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, GenerationProvider::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, GenerationProvider::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, GenerationProvider::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in GenerationProvider::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/job_status.rs b/crates/schema/public/enums/src/common/job_status.rs new file mode 100644 index 0000000000..df7665d5df --- /dev/null +++ b/crates/schema/public/enums/src/common/job_status.rs @@ -0,0 +1,148 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// This is used in nearly every job system as an actual MySQL ENUM value: +/// +/// - tts_download_job +/// - tts_inference_job +/// - w2l_download_job +/// - w2l_inference_job +/// - generic_download_job +/// - (NOT generic_inference_job, which uses JobStatusPlus) +/// +/// See the documentation on the table for usage. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(rename_all = "snake_case"))] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[serde(rename_all = "snake_case")] +pub enum JobStatus { + Pending, + Started, + CompleteSuccess, + CompleteFailure, + AttemptFailed, + Dead, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(JobStatus); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl JobStatus { + pub fn to_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Started => "started", + Self::CompleteSuccess => "complete_success", + Self::CompleteFailure => "complete_failure", + Self::AttemptFailed => "attempt_failed", + Self::Dead => "dead", + } + } + + pub fn from_str(job_status: &str) -> Result { + match job_status { + "pending" => Ok(Self::Pending), + "started" => Ok(Self::Started), + "complete_success" => Ok(Self::CompleteSuccess), + "complete_failure" => Ok(Self::CompleteFailure), + "attempt_failed" => Ok(Self::AttemptFailed), + "dead" => Ok(Self::Dead), + _ => Err(format!("invalid job_status: {:?}", job_status)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Pending, + Self::Started, + Self::CompleteSuccess, + Self::CompleteFailure, + Self::AttemptFailed, + Self::Dead, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::job_status::JobStatus; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(JobStatus::Pending, "pending"); + assert_serialization(JobStatus::Started, "started"); + assert_serialization(JobStatus::CompleteSuccess, "complete_success"); + assert_serialization(JobStatus::CompleteFailure, "complete_failure"); + assert_serialization(JobStatus::AttemptFailed, "attempt_failed"); + assert_serialization(JobStatus::Dead, "dead"); + } + + #[test] + fn to_str() { + assert_eq!(JobStatus::Pending.to_str(), "pending"); + assert_eq!(JobStatus::Started.to_str(), "started"); + assert_eq!(JobStatus::CompleteSuccess.to_str(), "complete_success"); + assert_eq!(JobStatus::CompleteFailure.to_str(), "complete_failure"); + assert_eq!(JobStatus::AttemptFailed.to_str(), "attempt_failed"); + assert_eq!(JobStatus::Dead.to_str(), "dead"); + } + + #[test] + fn from_str() { + assert_eq!(JobStatus::from_str("pending").unwrap(), JobStatus::Pending); + assert_eq!(JobStatus::from_str("started").unwrap(), JobStatus::Started); + assert_eq!(JobStatus::from_str("complete_success").unwrap(), JobStatus::CompleteSuccess); + assert_eq!(JobStatus::from_str("complete_failure").unwrap(), JobStatus::CompleteFailure); + assert_eq!(JobStatus::from_str("attempt_failed").unwrap(), JobStatus::AttemptFailed); + assert_eq!(JobStatus::from_str("dead").unwrap(), JobStatus::Dead); + } + + #[test] + fn all_variants() { + let mut variants = JobStatus::all_variants(); + assert_eq!(variants.len(), 6); + assert_eq!(variants.pop_first(), Some(JobStatus::Pending)); + assert_eq!(variants.pop_first(), Some(JobStatus::Started)); + assert_eq!(variants.pop_first(), Some(JobStatus::CompleteSuccess)); + assert_eq!(variants.pop_first(), Some(JobStatus::CompleteFailure)); + assert_eq!(variants.pop_first(), Some(JobStatus::AttemptFailed)); + assert_eq!(variants.pop_first(), Some(JobStatus::Dead)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(JobStatus::all_variants().len(), JobStatus::iter().len()); + } + + #[test] + fn round_trip() { + for variant in JobStatus::all_variants() { + assert_eq!(variant, JobStatus::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, JobStatus::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, JobStatus::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/job_status_plus.rs b/crates/schema/public/enums/src/common/job_status_plus.rs new file mode 100644 index 0000000000..1138673137 --- /dev/null +++ b/crates/schema/public/enums/src/common/job_status_plus.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// This is used in newer jobs (that add additional enum states) +/// +/// - generic_inference_job +/// - (no other jobs yet) +/// +/// See the documentation on the table for usage. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum JobStatusPlus { + Pending, + Started, + CompleteSuccess, + CompleteFailure, + AttemptFailed, + Dead, + CancelledByUser, + CancelledBySystem, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(JobStatusPlus); +impl_mysql_enum_coders!(JobStatusPlus); +impl_mysql_from_row!(JobStatusPlus); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl Default for JobStatusPlus { + fn default() -> Self { + Self::Pending + } +} + +impl JobStatusPlus { + pub const fn to_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Started => "started", + Self::CompleteSuccess => "complete_success", + Self::CompleteFailure => "complete_failure", + Self::AttemptFailed => "attempt_failed", + Self::Dead => "dead", + Self::CancelledByUser => "cancelled_by_user", + Self::CancelledBySystem => "cancelled_by_system", + } + } + + pub fn from_str(job_status: &str) -> Result { + match job_status { + "pending" => Ok(Self::Pending), + "started" => Ok(Self::Started), + "complete_success" => Ok(Self::CompleteSuccess), + "complete_failure" => Ok(Self::CompleteFailure), + "attempt_failed" => Ok(Self::AttemptFailed), + "dead" => Ok(Self::Dead), + "cancelled_by_user" => Ok(Self::CancelledByUser), + "cancelled_by_system" => Ok(Self::CancelledBySystem), + _ => Err(format!("invalid job_status: {:?}", job_status)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Pending, + Self::Started, + Self::CompleteSuccess, + Self::CompleteFailure, + Self::AttemptFailed, + Self::Dead, + Self::CancelledByUser, + Self::CancelledBySystem, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::job_status_plus::JobStatusPlus; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_default() { + assert_eq!(JobStatusPlus::default(), JobStatusPlus::Pending); + } + + #[test] + fn test_serialization() { + assert_serialization(JobStatusPlus::Pending, "pending"); + assert_serialization(JobStatusPlus::Started, "started"); + assert_serialization(JobStatusPlus::CompleteSuccess, "complete_success"); + assert_serialization(JobStatusPlus::CompleteFailure, "complete_failure"); + assert_serialization(JobStatusPlus::AttemptFailed, "attempt_failed"); + assert_serialization(JobStatusPlus::Dead, "dead"); + assert_serialization(JobStatusPlus::CancelledByUser, "cancelled_by_user"); + assert_serialization(JobStatusPlus::CancelledBySystem, "cancelled_by_system"); + } + + #[test] + fn to_str() { + assert_eq!(JobStatusPlus::Pending.to_str(), "pending"); + assert_eq!(JobStatusPlus::Started.to_str(), "started"); + assert_eq!(JobStatusPlus::CompleteSuccess.to_str(), "complete_success"); + assert_eq!(JobStatusPlus::CompleteFailure.to_str(), "complete_failure"); + assert_eq!(JobStatusPlus::AttemptFailed.to_str(), "attempt_failed"); + assert_eq!(JobStatusPlus::Dead.to_str(), "dead"); + assert_eq!(JobStatusPlus::CancelledByUser.to_str(), "cancelled_by_user"); + assert_eq!(JobStatusPlus::CancelledBySystem.to_str(), "cancelled_by_system"); + } + + #[test] + fn from_str() { + assert_eq!(JobStatusPlus::from_str("pending").unwrap(), JobStatusPlus::Pending); + assert_eq!(JobStatusPlus::from_str("started").unwrap(), JobStatusPlus::Started); + assert_eq!(JobStatusPlus::from_str("complete_success").unwrap(), JobStatusPlus::CompleteSuccess); + assert_eq!(JobStatusPlus::from_str("complete_failure").unwrap(), JobStatusPlus::CompleteFailure); + assert_eq!(JobStatusPlus::from_str("attempt_failed").unwrap(), JobStatusPlus::AttemptFailed); + assert_eq!(JobStatusPlus::from_str("dead").unwrap(), JobStatusPlus::Dead); + assert_eq!(JobStatusPlus::from_str("cancelled_by_user").unwrap(), JobStatusPlus::CancelledByUser); + assert_eq!(JobStatusPlus::from_str("cancelled_by_system").unwrap(), JobStatusPlus::CancelledBySystem); + } + + #[test] + fn all_variants() { + let mut variants = JobStatusPlus::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::Pending)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::Started)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::CompleteSuccess)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::CompleteFailure)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::AttemptFailed)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::Dead)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::CancelledByUser)); + assert_eq!(variants.pop_first(), Some(JobStatusPlus::CancelledBySystem)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(JobStatusPlus::all_variants().len(), JobStatusPlus::iter().len()); + } + + #[test] + fn round_trip() { + for variant in JobStatusPlus::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, JobStatusPlus::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, JobStatusPlus::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, JobStatusPlus::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/mod.rs b/crates/schema/public/enums/src/common/mod.rs new file mode 100644 index 0000000000..af957f675d --- /dev/null +++ b/crates/schema/public/enums/src/common/mod.rs @@ -0,0 +1,15 @@ +pub mod artcraft_credits_pack_slug; +pub mod artcraft_subscription_slug; +pub mod generation; +pub mod generation_provider; +pub mod job_status; +pub mod job_status_plus; +pub mod payments_namespace; +pub mod platform_type; +pub mod stripe_subscription_status; +pub mod view_as; +pub mod visibility; +pub mod vocoder_type; + +// Sqlite specific types. +pub mod sqlite; diff --git a/crates/schema/public/enums/src/common/payments_namespace.rs b/crates/schema/public/enums/src/common/payments_namespace.rs new file mode 100644 index 0000000000..716020ac1e --- /dev/null +++ b/crates/schema/public/enums/src/common/payments_namespace.rs @@ -0,0 +1,118 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// NB: This will be used by a variety of tables (MySQL and sqlite)! +/// Keep the max length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PaymentsNamespace { + #[serde(rename = "artcraft")] + Artcraft, + #[serde(rename = "fakeyou")] + FakeYou, +} + +impl_enum_display_and_debug_using_to_str!(PaymentsNamespace); +impl_mysql_enum_coders!(PaymentsNamespace); +impl_mysql_from_row!(PaymentsNamespace); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl PaymentsNamespace { + pub const fn to_str(&self) -> &'static str { + match self { + Self::Artcraft => "artcraft", + Self::FakeYou => "fakeyou", + } + } + + pub fn from_str(s: &str) -> Result { + match s { + "artcraft" => Ok(Self::Artcraft), + "fakeyou" => Ok(Self::FakeYou), + _ => Err(format!("invalid subscription_namespace: {:?}", s)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Artcraft, + Self::FakeYou, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::test_helpers::assert_serialization; + use crate::common::payments_namespace::PaymentsNamespace; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PaymentsNamespace::Artcraft, "artcraft"); + assert_serialization(PaymentsNamespace::FakeYou, "fakeyou"); + } + + #[test] + fn to_str() { + assert_eq!(PaymentsNamespace::Artcraft.to_str(), "artcraft"); + assert_eq!(PaymentsNamespace::FakeYou.to_str(), "fakeyou"); + } + + #[test] + fn from_str() { + assert_eq!(PaymentsNamespace::from_str("artcraft").unwrap(), PaymentsNamespace::Artcraft); + assert_eq!(PaymentsNamespace::from_str("fakeyou").unwrap(), PaymentsNamespace::FakeYou); + } + + #[test] + fn all_variants() { + let mut variants = PaymentsNamespace::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(PaymentsNamespace::Artcraft)); + assert_eq!(variants.pop_first(), Some(PaymentsNamespace::FakeYou)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PaymentsNamespace::all_variants().len(), PaymentsNamespace::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PaymentsNamespace::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, PaymentsNamespace::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PaymentsNamespace::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PaymentsNamespace::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in PaymentsNamespace::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/platform_type.rs b/crates/schema/public/enums/src/common/platform_type.rs new file mode 100644 index 0000000000..a792c173f8 --- /dev/null +++ b/crates/schema/public/enums/src/common/platform_type.rs @@ -0,0 +1,183 @@ +//! The platform a request originated from, inferred from the User-Agent header. +//! +//! Stored on `generic_inference_jobs` (set at enqueue time) and copied to the +//! resulting `media_files` rows when jobs complete. + +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// NB: This is used by multiple tables (`generic_inference_jobs`, `media_files`). +/// Keep the max length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PlatformType { + // ========== Our clients ========== + + /// Website users + Web, + + /// Desktop app users + DesktopApp, + + /// Requests authenticated via an `Authorization` header API key (the Omni API surface). + ApiKey, + + /// Requests authenticated via an `Authorization` header MCP session credential. + Mcp, + + // ========== Other clients ========== + + Curl, + PythonRequests, + Postman, +} + +impl_enum_display_and_debug_using_to_str!(PlatformType); +impl_mysql_enum_coders!(PlatformType); +impl_mysql_from_row!(PlatformType); + +impl PlatformType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Web => "web", + Self::DesktopApp => "desktop_app", + Self::ApiKey => "api_key", + Self::Mcp => "mcp", + Self::Curl => "curl", + Self::PythonRequests => "python_requests", + Self::Postman => "postman", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "web" => Ok(Self::Web), + "desktop_app" => Ok(Self::DesktopApp), + "api_key" => Ok(Self::ApiKey), + "mcp" => Ok(Self::Mcp), + "curl" => Ok(Self::Curl), + "python_requests" => Ok(Self::PythonRequests), + "postman" => Ok(Self::Postman), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + BTreeSet::from([ + Self::Web, + Self::DesktopApp, + Self::ApiKey, + Self::Mcp, + Self::Curl, + Self::PythonRequests, + Self::Postman, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::platform_type::PlatformType; + use crate::error::enum_error::EnumError; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PlatformType::Web, "web"); + assert_serialization(PlatformType::DesktopApp, "desktop_app"); + assert_serialization(PlatformType::ApiKey, "api_key"); + assert_serialization(PlatformType::Mcp, "mcp"); + assert_serialization(PlatformType::Curl, "curl"); + assert_serialization(PlatformType::PythonRequests, "python_requests"); + assert_serialization(PlatformType::Postman, "postman"); + } + + #[test] + 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::Mcp.to_str(), "mcp"); + assert_eq!(PlatformType::Curl.to_str(), "curl"); + assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests"); + assert_eq!(PlatformType::Postman.to_str(), "postman"); + } + + #[test] + 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("mcp").unwrap(), PlatformType::Mcp); + 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); + } + + #[test] + fn from_str_err() { + let result = PlatformType::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = PlatformType::all_variants(); + assert_eq!(variants.len(), 7); + 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::Mcp)); + assert_eq!(variants.pop_first(), Some(PlatformType::Curl)); + assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests)); + assert_eq!(variants.pop_first(), Some(PlatformType::Postman)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PlatformType::all_variants().len(), PlatformType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PlatformType::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, PlatformType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PlatformType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PlatformType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in PlatformType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/sqlite/awaitable_job_status.rs b/crates/schema/public/enums/src/common/sqlite/awaitable_job_status.rs new file mode 100644 index 0000000000..a58498e018 --- /dev/null +++ b/crates/schema/public/enums/src/common/sqlite/awaitable_job_status.rs @@ -0,0 +1,113 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the following SqLite tables and columns: +/// `web_scraping_targets` . `maybe_skip_reason`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum AwaitableJobStatus { + #[serde(rename = "not_ready")] + NotReady, + + #[serde(rename = "ready_waiting")] + ReadyWaiting, + + #[serde(rename = "processing")] + Processing, + + #[serde(rename = "retryably_failed")] + RetryablyFailed, + + #[serde(rename = "permanently_failed")] + PermanentlyFailed, + + #[serde(rename = "skipped")] + Skipped, + + #[serde(rename = "done")] + Done, +} + +// TODO(bt, 2023-02-08): This desperately needs Sqlite integration tests! +impl_enum_display_and_debug_using_to_str!(AwaitableJobStatus); +impl_sqlite_enum_coders!(AwaitableJobStatus); + +/// NB: Legacy API for older code. +impl AwaitableJobStatus { + pub fn to_str(&self) -> &'static str { + match self { + Self::NotReady => "not_ready", + Self::ReadyWaiting => "ready_waiting", + Self::Processing => "processing", + Self::RetryablyFailed => "retryably_failed", + Self::PermanentlyFailed => "permanently_failed", + Self::Skipped => "skipped", + Self::Done => "done", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "not_ready" => Ok(Self::NotReady), + "ready_waiting" => Ok(Self::ReadyWaiting), + "processing" => Ok(Self::Processing), + "retryably_failed" => Ok(Self::RetryablyFailed), + "permanently_failed" => Ok(Self::PermanentlyFailed), + "skipped" => Ok(Self::Skipped), + "done" => Ok(Self::Done), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::sqlite::awaitable_job_status::AwaitableJobStatus; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(AwaitableJobStatus::NotReady, "not_ready"); + assert_serialization(AwaitableJobStatus::ReadyWaiting, "ready_waiting"); + assert_serialization(AwaitableJobStatus::Processing, "processing"); + assert_serialization(AwaitableJobStatus::RetryablyFailed, "retryably_failed"); + assert_serialization(AwaitableJobStatus::PermanentlyFailed, "permanently_failed"); + assert_serialization(AwaitableJobStatus::Skipped, "skipped"); + assert_serialization(AwaitableJobStatus::Done, "done"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(AwaitableJobStatus::NotReady.to_str(), "not_ready"); + assert_eq!(AwaitableJobStatus::ReadyWaiting.to_str(), "ready_waiting"); + assert_eq!(AwaitableJobStatus::Processing.to_str(), "processing"); + assert_eq!(AwaitableJobStatus::RetryablyFailed.to_str(), "retryably_failed"); + assert_eq!(AwaitableJobStatus::PermanentlyFailed.to_str(), "permanently_failed"); + assert_eq!(AwaitableJobStatus::Skipped.to_str(), "skipped"); + assert_eq!(AwaitableJobStatus::Done.to_str(), "done"); + } + + #[test] + fn test_from_str() { + assert_eq!(AwaitableJobStatus::from_str("not_ready").unwrap(), AwaitableJobStatus::NotReady); + assert_eq!(AwaitableJobStatus::from_str("ready_waiting").unwrap(), AwaitableJobStatus::ReadyWaiting); + assert_eq!(AwaitableJobStatus::from_str("processing").unwrap(), AwaitableJobStatus::Processing); + assert_eq!(AwaitableJobStatus::from_str("retryably_failed").unwrap(), AwaitableJobStatus::RetryablyFailed); + assert_eq!(AwaitableJobStatus::from_str("permanently_failed").unwrap(), AwaitableJobStatus::PermanentlyFailed); + assert_eq!(AwaitableJobStatus::from_str("skipped").unwrap(), AwaitableJobStatus::Skipped); + assert_eq!(AwaitableJobStatus::from_str("done").unwrap(), AwaitableJobStatus::Done); + assert!(AwaitableJobStatus::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/common/sqlite/chatbot_job_status.rs b/crates/schema/public/enums/src/common/sqlite/chatbot_job_status.rs new file mode 100644 index 0000000000..63416304d0 --- /dev/null +++ b/crates/schema/public/enums/src/common/sqlite/chatbot_job_status.rs @@ -0,0 +1,96 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the SqLite `web_scraping_targets` table in a `TEXT` field named `scraping_status`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum ChatbotJobStatus { + #[serde(rename = "new")] + New, + + #[serde(rename = "skipped")] + Skipped, + + #[serde(rename = "failed")] + Failed, + + #[serde(rename = "permanently_failed")] + PermanentlyFailed, + + #[serde(rename = "success")] + Success, +} + +// TODO(bt, 2023-02-10): This desperately needs Sqlite integration tests! +impl_enum_display_and_debug_using_to_str!(ChatbotJobStatus); +impl_sqlite_enum_coders!(ChatbotJobStatus); + +/// NB: Legacy API for older code. +impl ChatbotJobStatus { + pub fn to_str(&self) -> &'static str { + match self { + Self::New => "new", + Self::Skipped => "skipped", + Self::Failed => "failed", + Self::PermanentlyFailed => "permanently_failed", + Self::Success => "success", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "new" => Ok(Self::New), + "skipped" => Ok(Self::Skipped), + "failed" => Ok(Self::Failed), + "permanently_failed" => Ok(Self::PermanentlyFailed), + "success" => Ok(Self::Success), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::sqlite::chatbot_job_status::ChatbotJobStatus; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(ChatbotJobStatus::New, "new"); + assert_serialization(ChatbotJobStatus::Skipped, "skipped"); + assert_serialization(ChatbotJobStatus::Failed, "failed"); + assert_serialization(ChatbotJobStatus::PermanentlyFailed, "permanently_failed"); + assert_serialization(ChatbotJobStatus::Success, "success"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(ChatbotJobStatus::New.to_str(), "new"); + assert_eq!(ChatbotJobStatus::Skipped.to_str(), "skipped"); + assert_eq!(ChatbotJobStatus::Failed.to_str(), "failed"); + assert_eq!(ChatbotJobStatus::PermanentlyFailed.to_str(), "permanently_failed"); + assert_eq!(ChatbotJobStatus::Success.to_str(), "success"); + } + + #[test] + fn test_from_str() { + assert_eq!(ChatbotJobStatus::from_str("new").unwrap(), ChatbotJobStatus::New); + assert_eq!(ChatbotJobStatus::from_str("skipped").unwrap(), ChatbotJobStatus::Skipped); + assert_eq!(ChatbotJobStatus::from_str("failed").unwrap(), ChatbotJobStatus::Failed); + assert_eq!(ChatbotJobStatus::from_str("permanently_failed").unwrap(), ChatbotJobStatus::PermanentlyFailed); + assert_eq!(ChatbotJobStatus::from_str("success").unwrap(), ChatbotJobStatus::Success); + assert!(ChatbotJobStatus::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/common/sqlite/mod.rs b/crates/schema/public/enums/src/common/sqlite/mod.rs new file mode 100644 index 0000000000..7659a1e044 --- /dev/null +++ b/crates/schema/public/enums/src/common/sqlite/mod.rs @@ -0,0 +1,4 @@ +pub mod awaitable_job_status; +pub mod chatbot_job_status; +pub mod skip_reason; +pub mod web_content_type; diff --git a/crates/schema/public/enums/src/common/sqlite/skip_reason.rs b/crates/schema/public/enums/src/common/sqlite/skip_reason.rs new file mode 100644 index 0000000000..b7f941123b --- /dev/null +++ b/crates/schema/public/enums/src/common/sqlite/skip_reason.rs @@ -0,0 +1,105 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the following SqLite tables and columns: +/// `web_scraping_targets` . `maybe_skip_reason`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum SkipReason { + #[serde(rename = "empty_content")] + EmptyContent, + + #[serde(rename = "advertisement")] + Advertisement, + + #[serde(rename = "video_content")] + VideoContent, + + #[serde(rename = "filtered_topic")] + FilteredTopic, + + #[serde(rename = "filtered_topic_politics")] + FilteredTopicPolitics, + + #[serde(rename = "nobody_cares")] + NobodyCares, +} + +// TODO(bt, 2023-02-08): This desperately needs Sqlite integration tests! +impl_enum_display_and_debug_using_to_str!(SkipReason); +impl_sqlite_enum_coders!(SkipReason); + +/// NB: Legacy API for older code. +impl SkipReason { + pub fn to_str(&self) -> &'static str { + match self { + Self::EmptyContent => "empty_content", + Self::Advertisement => "advertisement", + Self::VideoContent => "video_content", + Self::FilteredTopic => "filtered_topic", + Self::FilteredTopicPolitics => "filtered_topic_politics", + Self::NobodyCares => "nobody_cares", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "empty_content" => Ok(Self::EmptyContent), + "advertisement" => Ok(Self::Advertisement), + "video_content" => Ok(Self::VideoContent), + "filtered_topic" => Ok(Self::FilteredTopic), + "filtered_topic_politics" => Ok(Self::FilteredTopicPolitics), + "nobody_cares" => Ok(Self::NobodyCares), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::sqlite::skip_reason::SkipReason; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(SkipReason::EmptyContent, "empty_content"); + assert_serialization(SkipReason::Advertisement, "advertisement"); + assert_serialization(SkipReason::VideoContent, "video_content"); + assert_serialization(SkipReason::FilteredTopic, "filtered_topic"); + assert_serialization(SkipReason::FilteredTopicPolitics, "filtered_topic_politics"); + assert_serialization(SkipReason::NobodyCares, "nobody_cares"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(SkipReason::EmptyContent.to_str(), "empty_content"); + assert_eq!(SkipReason::Advertisement.to_str(), "advertisement"); + assert_eq!(SkipReason::VideoContent.to_str(), "video_content"); + assert_eq!(SkipReason::FilteredTopic.to_str(), "filtered_topic"); + assert_eq!(SkipReason::FilteredTopicPolitics.to_str(), "filtered_topic_politics"); + assert_eq!(SkipReason::NobodyCares.to_str(), "nobody_cares"); + } + + #[test] + fn test_from_str() { + assert_eq!(SkipReason::from_str("empty_content").unwrap(), SkipReason::EmptyContent); + assert_eq!(SkipReason::from_str("advertisement").unwrap(), SkipReason::Advertisement); + assert_eq!(SkipReason::from_str("video_content").unwrap(), SkipReason::VideoContent); + assert_eq!(SkipReason::from_str("filtered_topic").unwrap(), SkipReason::FilteredTopic); + assert_eq!(SkipReason::from_str("filtered_topic_politics").unwrap(), SkipReason::FilteredTopicPolitics); + assert_eq!(SkipReason::from_str("nobody_cares").unwrap(), SkipReason::NobodyCares); + assert!(SkipReason::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/common/sqlite/web_content_type.rs b/crates/schema/public/enums/src/common/sqlite/web_content_type.rs new file mode 100644 index 0000000000..46675e606b --- /dev/null +++ b/crates/schema/public/enums/src/common/sqlite/web_content_type.rs @@ -0,0 +1,138 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the SqLite `web_scraping_targets` table in a `TEXT` field named `web_content_type`. +/// Used in the SqLite `news_story_productions` table in a `TEXT` field named `web_content_type`. +/// Used in the SqLite `news_stories` table in a `TEXT` field named `web_content_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum WebContentType { + #[serde(rename = "cbs_news_article")] + CbsNewsArticle, + + #[serde(rename = "cnn_article")] + CnnArticle, + + #[serde(rename = "gizmodo_article")] + GizmodoArticle, + + #[serde(rename = "hacker_news_thread")] + HackerNewsThread, + + #[serde(rename = "kotaku_article")] + KotakuArticle, + + #[serde(rename = "reddit_thread")] + RedditThread, + + #[serde(rename = "slashdot_article")] + SlashdotArticle, + + #[serde(rename = "substack_post")] + SubstackPost, + + #[serde(rename = "techcrunch_article")] + TechCrunchArticle, + + #[serde(rename = "the_guardian_article")] + TheGuardianArticle, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(WebContentType); +impl_sqlite_enum_coders!(WebContentType); + +/// NB: Legacy API for older code. +impl WebContentType { + pub fn to_str(&self) -> &'static str { + match self { + Self::CbsNewsArticle => "cbs_news_article", + Self::CnnArticle => "cnn_article", + Self::GizmodoArticle => "gizmodo_article", + Self::HackerNewsThread => "hacker_news_thread", + Self::KotakuArticle => "kotaku_article", + Self::RedditThread => "reddit_thread", + Self::SlashdotArticle => "slashdot_article", + Self::SubstackPost => "substack_post", + Self::TechCrunchArticle => "techcrunch_article", + Self::TheGuardianArticle => "the_guardian_article", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "cbs_news_article" => Ok(Self::CbsNewsArticle), + "cnn_article" => Ok(Self::CnnArticle), + "gizmodo_article" => Ok(Self::GizmodoArticle), + "hacker_news_thread" => Ok(Self::HackerNewsThread), + "kotaku_article" => Ok(Self::KotakuArticle), + "reddit_thread" => Ok(Self::RedditThread), + "slashdot_article" => Ok(Self::SlashdotArticle), + "substack_post" => Ok(Self::SubstackPost), + "techcrunch_article" => Ok(Self::TechCrunchArticle), + "the_guardian_article" => Ok(Self::TheGuardianArticle), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::sqlite::web_content_type::WebContentType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(WebContentType::CbsNewsArticle, "cbs_news_article"); + assert_serialization(WebContentType::CnnArticle, "cnn_article"); + assert_serialization(WebContentType::GizmodoArticle, "gizmodo_article"); + assert_serialization(WebContentType::HackerNewsThread, "hacker_news_thread"); + assert_serialization(WebContentType::KotakuArticle, "kotaku_article"); + assert_serialization(WebContentType::RedditThread, "reddit_thread"); + assert_serialization(WebContentType::SlashdotArticle, "slashdot_article"); + assert_serialization(WebContentType::SubstackPost, "substack_post"); + assert_serialization(WebContentType::TechCrunchArticle, "techcrunch_article"); + assert_serialization(WebContentType::TheGuardianArticle, "the_guardian_article"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(WebContentType::CbsNewsArticle.to_str(), "cbs_news_article"); + assert_eq!(WebContentType::CnnArticle.to_str(), "cnn_article"); + assert_eq!(WebContentType::GizmodoArticle.to_str(), "gizmodo_article"); + assert_eq!(WebContentType::HackerNewsThread.to_str(), "hacker_news_thread"); + assert_eq!(WebContentType::KotakuArticle.to_str(), "kotaku_article"); + assert_eq!(WebContentType::RedditThread.to_str(), "reddit_thread"); + assert_eq!(WebContentType::SlashdotArticle.to_str(), "slashdot_article"); + assert_eq!(WebContentType::SubstackPost.to_str(), "substack_post"); + assert_eq!(WebContentType::TechCrunchArticle.to_str(), "techcrunch_article"); + assert_eq!(WebContentType::TheGuardianArticle.to_str(), "the_guardian_article"); + } + + #[test] + fn test_from_str() { + assert_eq!(WebContentType::from_str("cbs_news_article").unwrap(), WebContentType::CbsNewsArticle); + assert_eq!(WebContentType::from_str("cnn_article").unwrap(), WebContentType::CnnArticle); + assert_eq!(WebContentType::from_str("gizmodo_article").unwrap(), WebContentType::GizmodoArticle); + assert_eq!(WebContentType::from_str("hacker_news_thread").unwrap(), WebContentType::HackerNewsThread); + assert_eq!(WebContentType::from_str("kotaku_article").unwrap(), WebContentType::KotakuArticle); + assert_eq!(WebContentType::from_str("reddit_thread").unwrap(), WebContentType::RedditThread); + assert_eq!(WebContentType::from_str("slashdot_article").unwrap(), WebContentType::SlashdotArticle); + assert_eq!(WebContentType::from_str("substack_post").unwrap(), WebContentType::SubstackPost); + assert_eq!(WebContentType::from_str("techcrunch_article").unwrap(), WebContentType::TechCrunchArticle); + assert_eq!(WebContentType::from_str("the_guardian_article").unwrap(), WebContentType::TheGuardianArticle); + assert!(WebContentType::from_str("foo").is_err()); + } + } +} diff --git a/crates/schema/public/enums/src/common/stripe_subscription_status.rs b/crates/schema/public/enums/src/common/stripe_subscription_status.rs new file mode 100644 index 0000000000..c5b4a8c2de --- /dev/null +++ b/crates/schema/public/enums/src/common/stripe_subscription_status.rs @@ -0,0 +1,248 @@ +//! This is copied from the stripe crate's `SubscriptionStatus`, +//! and then from `reusable_types`. + +// There are three changes from the source create: +// - Renamed the struct from SubscriptionStatus to StripeSubscriptionStatus +// - Added StripeSubscriptionStatus::from_str() +// - Added derive sqlx::Type and sqlx(rename_all) +// - Added Deref impl +// - Added tests +// +// Then, moving into enums: +// - Changed derives +// - as_st() -> to_str() (gross! but convention as of now...) +// - from_str() : changed error from string to EnumError +// - Added all_variants() +// - commented out all other impls. +// + +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + + +// NB: Added "sqlx::Type". +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StripeSubscriptionStatus { + Active, + Canceled, + Incomplete, + IncompleteExpired, + PastDue, + Trialing, + Unpaid, + Paused, +} + +impl_enum_display_and_debug_using_to_str!(StripeSubscriptionStatus); +impl_mysql_enum_coders!(StripeSubscriptionStatus); +impl_mysql_from_row!(StripeSubscriptionStatus); + +impl StripeSubscriptionStatus { + pub fn to_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Canceled => "canceled", + Self::Incomplete => "incomplete", + Self::IncompleteExpired => "incomplete_expired", + Self::PastDue => "past_due", + Self::Trialing => "trialing", + Self::Unpaid => "unpaid", + Self::Paused => "paused" + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "canceled" => Ok(Self::Canceled), + "incomplete" => Ok(Self::Incomplete), + "incomplete_expired" => Ok(Self::IncompleteExpired), + "past_due" => Ok(Self::PastDue), + "trialing" => Ok(Self::Trialing), + "unpaid" => Ok(Self::Unpaid), + "paused" => Ok(Self::Paused), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Active, + Self::Canceled, + Self::Incomplete, + Self::IncompleteExpired, + Self::PastDue, + Self::Trialing, + Self::Unpaid, + Self::Paused, + ]) + } +} + +//impl AsRef for StripeSubscriptionStatus { +// fn as_ref(&self) -> &str { +// self.as_str() +// } +//} +// +//// NB: Added by us. +//impl std::ops::Deref for StripeSubscriptionStatus { +// type Target = str; +// +// fn deref(&self) -> &Self::Target { +// self.as_str() +// } +//} +// +//impl std::fmt::Display for StripeSubscriptionStatus { +// fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { +// self.as_str().fmt(f) +// } +//} +// +//impl std::default::Default for StripeSubscriptionStatus { +// fn default() -> Self { +// Self::Active +// } +//} + +#[cfg(test)] +mod tests { + use crate::error::enum_error::EnumError; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::common::stripe_subscription_status::StripeSubscriptionStatus; + + #[test] + fn test_serialization() { + assert_serialization(StripeSubscriptionStatus::Active, "active"); + assert_serialization(StripeSubscriptionStatus::Canceled, "canceled"); + assert_serialization(StripeSubscriptionStatus::Incomplete, "incomplete"); + assert_serialization(StripeSubscriptionStatus::IncompleteExpired, "incomplete_expired"); + assert_serialization(StripeSubscriptionStatus::PastDue, "past_due"); + assert_serialization(StripeSubscriptionStatus::Trialing, "trialing"); + assert_serialization(StripeSubscriptionStatus::Unpaid, "unpaid"); + assert_serialization(StripeSubscriptionStatus::Paused, "paused"); + } + + #[test] + fn to_str() { + assert_eq!(StripeSubscriptionStatus::Active.to_str(), "active"); + assert_eq!(StripeSubscriptionStatus::Canceled.to_str(), "canceled"); + assert_eq!(StripeSubscriptionStatus::Incomplete.to_str(), "incomplete"); + assert_eq!(StripeSubscriptionStatus::IncompleteExpired.to_str(), "incomplete_expired"); + assert_eq!(StripeSubscriptionStatus::PastDue.to_str(), "past_due"); + assert_eq!(StripeSubscriptionStatus::Trialing.to_str(), "trialing"); + assert_eq!(StripeSubscriptionStatus::Unpaid.to_str(), "unpaid"); + assert_eq!(StripeSubscriptionStatus::Paused.to_str(), "paused"); + } + + #[test] + fn from_str() { + assert_eq!(StripeSubscriptionStatus::from_str("active").unwrap(), StripeSubscriptionStatus::Active); + assert_eq!(StripeSubscriptionStatus::from_str("canceled").unwrap(), StripeSubscriptionStatus::Canceled); + assert_eq!(StripeSubscriptionStatus::from_str("incomplete").unwrap(), StripeSubscriptionStatus::Incomplete); + assert_eq!(StripeSubscriptionStatus::from_str("incomplete_expired").unwrap(), StripeSubscriptionStatus::IncompleteExpired); + assert_eq!(StripeSubscriptionStatus::from_str("past_due").unwrap(), StripeSubscriptionStatus::PastDue); + assert_eq!(StripeSubscriptionStatus::from_str("trialing").unwrap(), StripeSubscriptionStatus::Trialing); + assert_eq!(StripeSubscriptionStatus::from_str("unpaid").unwrap(), StripeSubscriptionStatus::Unpaid); + assert_eq!(StripeSubscriptionStatus::from_str("paused").unwrap(), StripeSubscriptionStatus::Paused); + } + + #[test] + fn from_str_err() { + let result = StripeSubscriptionStatus::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = StripeSubscriptionStatus::all_variants(); + assert_eq!(variants.len(), 8); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Active)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Canceled)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Incomplete)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::IncompleteExpired)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::PastDue)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Trialing)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Unpaid)); + assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Paused)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use crate::common::stripe_subscription_status::StripeSubscriptionStatus; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StripeSubscriptionStatus::all_variants().len(), StripeSubscriptionStatus::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StripeSubscriptionStatus::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, StripeSubscriptionStatus::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StripeSubscriptionStatus::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StripeSubscriptionStatus::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in StripeSubscriptionStatus::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/schema/public/enums/src/common/view_as.rs b/crates/schema/public/enums/src/common/view_as.rs new file mode 100644 index 0000000000..4ce9429cfe --- /dev/null +++ b/crates/schema/public/enums/src/common/view_as.rs @@ -0,0 +1,153 @@ +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(rename_all = "snake_case"))] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[serde(rename_all = "snake_case")] +pub enum ViewAs { + /// Public entities are able to be listed in public lists. + /// It does not mean that they necessarily will be (eg. they could be "mod unapproved" or deleted). + Author, + /// Hidden entities are not shown in public lists, but the URL to them may be given out freely. + /// They are available to non-logged-in users as long as they have the URL. + Moderator, + /// Private entities should only be available to the creator, a list of approved users, and + /// website moderation staff. + AnotherUser, +} + + +impl_enum_display_and_debug_using_to_str!(ViewAs); +impl_mysql_from_row!(ViewAs); + +impl Default for ViewAs { + fn default() -> Self { Self::AnotherUser } +} + +/// NB: Legacy API for older code. +impl ViewAs { + pub fn to_str(&self) -> &'static str { + match self { + Self::Author => "author", + Self::Moderator => "moderator", + Self::AnotherUser => "another_user", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "author" => Ok(Self::Author), + "moderator" => Ok(Self::Moderator), + "another_user" => Ok(Self::AnotherUser), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::view_as::ViewAs; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_default() { + assert_eq!(ViewAs::default(), ViewAs::AnotherUser); + + } + + #[test] + fn test_serialization() { + assert_serialization(ViewAs::Author, "author"); + assert_serialization(ViewAs::Moderator, "moderator"); + assert_serialization(ViewAs::AnotherUser, "another_user"); + } + + #[test] + fn test_to_str() { + assert_eq!(ViewAs::Author.to_str(), "author"); + assert_eq!(ViewAs::Moderator.to_str(), "moderator"); + assert_eq!(ViewAs::AnotherUser.to_str(), "another_user"); + } + + #[test] + fn test_from_str() { + assert_eq!(ViewAs::from_str("author").unwrap(), ViewAs::Author); + assert_eq!(ViewAs::from_str("moderator").unwrap(), ViewAs::Moderator); + assert_eq!(ViewAs::from_str("another_user").unwrap(), ViewAs::AnotherUser); + assert!(ViewAs::from_str("foo").is_err()); + } + + mod traits { + use crate::common::view_as::ViewAs; + + #[test] + fn display() { + let view_as = ViewAs::Moderator; + assert_eq!(format!("{}", view_as), "moderator".to_string()); + } + + #[test] + fn debug() { + let view_as = ViewAs::Moderator; + assert_eq!(format!("{:?}", view_as), "moderator".to_string()); + } + } + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct CompositeType { + view_as: ViewAs, + string: String, + } + + mod serde_serialization { + use crate::common::view_as::ViewAs; + + use super::CompositeType; + + #[test] + fn serialize() { + let expected = "\"author\"".to_string(); // NB: Quoted + + assert_eq!(expected, serde_json::to_string(&ViewAs::Author).unwrap()); + + // Just to show this serializes the same as a string + assert_eq!(expected, serde_json::to_string("author").unwrap()); + } + + #[test] + fn nested_serialize() { + let value = CompositeType { view_as: ViewAs::Moderator, string: "bar".to_string() }; + let expected = r#"{"view_as":"moderator","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } + } + + mod serde_deserialization { + use crate::common::view_as::ViewAs; + + use super::CompositeType; + + #[test] + fn deserialize() { + let payload = "\"another_user\""; // NB: Quoted + let value: ViewAs = serde_json::from_str(payload).unwrap(); + assert_eq!(value, ViewAs::AnotherUser); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"view_as":"another_user","string":"bar"}"#.to_string(); + let expected = CompositeType { + view_as: ViewAs::AnotherUser, + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } + } +} diff --git a/crates/schema/public/enums/src/common/visibility.rs b/crates/schema/public/enums/src/common/visibility.rs new file mode 100644 index 0000000000..ce1c5ba4be --- /dev/null +++ b/crates/schema/public/enums/src/common/visibility.rs @@ -0,0 +1,194 @@ +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +/// Visibility +/// +/// Used in various database tables (as enums! careful!) and the HTTP API to convey +/// how the associated entity should be made visible to the public. +/// +/// To use this in a query, the query must have type annotations. +/// See: https://www.gitmemory.com/issue/launchbadge/sqlx/1241/847154375 +/// eg. preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility` +/// +/// See also: https://docs.rs/sqlx/0.4.0-beta.1/sqlx/trait.Type.html +/// +/// *DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY!* + +use utoipa::ToSchema; + +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(rename_all = "lowercase"))] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + /// Public entities are able to be listed in public lists. + /// It does not mean that they necessarily will be (eg. they could be "mod unapproved" or deleted). + Public, + /// Hidden entities are not shown in public lists, but the URL to them may be given out freely. + /// They are available to non-logged-in users as long as they have the URL. + Hidden, + /// Private entities should only be available to the creator, a list of approved users, and + /// website moderation staff. + Private, + + // TODO(bt, 2022-12-20): We need a "Shared" option where users can share it with a specified group. + // This should perhaps be its own type, eg. VisibilityV2., so that we don't use it in tables that + // have not yet been migrated to this scheme. +} + + +impl_enum_display_and_debug_using_to_str!(Visibility); +impl_mysql_from_row!(Visibility); + +// For reference, here's what the serde implementation might be if manually written. +// This may be useful for designing composite types in the future: +// +// use serde::{Deserializer, Serializer}; +// +// impl serde::Serialize for UserToken { +// fn serialize(&self, serializer: S) -> Result { +// serializer.serialize_str(&self.0) +// } +// } +// +// impl<'de> serde::Deserialize<'de> for UserToken { +// fn deserialize>(d: D) -> Result { +// let s = String::deserialize(d)?; +// Ok(UserToken(s)) +// } +// } + +//impl sqlx::Type for Visibility { +// fn type_info() -> sqlx_core::database::TypeInfo { +// todo!() +// } +//} + +impl Default for Visibility { + fn default() -> Self { Self::Public } +} + +/// NB: Legacy API for older code. +impl Visibility { + pub fn to_str(&self) -> &'static str { + match self { + Self::Public => "public", + Self::Hidden => "hidden", + Self::Private => "private", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "public" => Ok(Self::Public), + "hidden" => Ok(Self::Hidden), + "private" => Ok(Self::Private), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::visibility::Visibility; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_default() { + assert_eq!(Visibility::default(), Visibility::Public); + + } + + #[test] + fn test_serialization() { + assert_serialization(Visibility::Public, "public"); + assert_serialization(Visibility::Hidden, "hidden"); + assert_serialization(Visibility::Private, "private"); + } + + #[test] + fn test_to_str() { + assert_eq!(Visibility::Public.to_str(), "public"); + assert_eq!(Visibility::Hidden.to_str(), "hidden"); + assert_eq!(Visibility::Private.to_str(), "private"); + } + + #[test] + fn test_from_str() { + assert_eq!(Visibility::from_str("public").unwrap(), Visibility::Public); + assert_eq!(Visibility::from_str("hidden").unwrap(), Visibility::Hidden); + assert_eq!(Visibility::from_str("private").unwrap(), Visibility::Private); + assert!(Visibility::from_str("foo").is_err()); + } + + mod traits { + use crate::common::visibility::Visibility; + + #[test] + fn display() { + let visibility = Visibility::Hidden; + assert_eq!(format!("{}", visibility), "hidden".to_string()); + } + + #[test] + fn debug() { + let visibility = Visibility::Private; + assert_eq!(format!("{:?}", visibility), "private".to_string()); + } + } + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct CompositeType { + visibility: Visibility, + string: String, + } + + mod serde_serialization { + use crate::common::visibility::Visibility; + + use super::CompositeType; + + #[test] + fn serialize() { + let expected = "\"public\"".to_string(); // NB: Quoted + + assert_eq!(expected, serde_json::to_string(&Visibility::Public).unwrap()); + + // Just to show this serializes the same as a string + assert_eq!(expected, serde_json::to_string("public").unwrap()); + } + + #[test] + fn nested_serialize() { + let value = CompositeType { visibility: Visibility::Hidden, string: "bar".to_string() }; + let expected = r#"{"visibility":"hidden","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } + } + + mod serde_deserialization { + use crate::common::visibility::Visibility; + + use super::CompositeType; + + #[test] + fn deserialize() { + let payload = "\"private\""; // NB: Quoted + let value: Visibility = serde_json::from_str(payload).unwrap(); + assert_eq!(value, Visibility::Private); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"visibility":"hidden","string":"bar"}"#.to_string(); + let expected = CompositeType { + visibility: Visibility::Hidden, + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } + } +} diff --git a/crates/schema/public/enums/src/common/vocoder_type.rs b/crates/schema/public/enums/src/common/vocoder_type.rs new file mode 100644 index 0000000000..bbcc25bce8 --- /dev/null +++ b/crates/schema/public/enums/src/common/vocoder_type.rs @@ -0,0 +1,68 @@ +#[derive(Clone, Copy, Eq, PartialEq, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +pub enum VocoderType { + /// NB: Note - this is hifigan for Tacotron2. + /// Some work will be needed to unify this with other hifigan types. + #[serde(rename = "hifigan")] + #[cfg_attr(feature = "database", sqlx(rename = "hifigan"))] + HifiGan, + + #[serde(rename = "hifigan-superres")] + #[cfg_attr(feature = "database", sqlx(rename = "hifigan-superres"))] + HifiGanSuperResolution, + + /// NB: Note - this is hifigan for SoftVC (our internal codename is "rocketvc"). + /// Some work will need to be done to unify this with other hifigan types. + /// NB(bt, 2025-07-09): It was so silly to try to obscure this. Both FakeYou and Uberduck are irrelevant now. + #[serde(rename = "hifigan_rocket_vc")] + #[cfg_attr(feature = "database", sqlx(rename = "hifigan_rocket_vc"))] + HifiGanRocketVc, + +} + +/// NB: Legacy API for older code. +impl VocoderType { + pub fn to_str(&self) -> &'static str { + match self { + Self::HifiGan=> "hifigan", + Self::HifiGanSuperResolution => "hifigan-superres", + Self::HifiGanRocketVc => "hifigan_rocket_vc", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "hifigan" => Ok(Self::HifiGan), + "hifigan-superres" => Ok(Self::HifiGanSuperResolution), + "hifigan_rocket_vc" => Ok(Self::HifiGanRocketVc), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::vocoder_type::VocoderType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(VocoderType::HifiGan, "hifigan"); + assert_serialization(VocoderType::HifiGanSuperResolution, "hifigan-superres"); + assert_serialization(VocoderType::HifiGanRocketVc, "hifigan_rocket_vc"); + } + + #[test] + fn to_str() { + assert_eq!(VocoderType::HifiGan.to_str(), "hifigan"); + assert_eq!(VocoderType::HifiGanSuperResolution.to_str(), "hifigan-superres"); + assert_eq!(VocoderType::HifiGanRocketVc.to_str(), "hifigan_rocket_vc"); + } + + #[test] + fn from_str() { + assert_eq!(VocoderType::from_str("hifigan").unwrap(), VocoderType::HifiGan); + assert_eq!(VocoderType::from_str("hifigan-superres").unwrap(), VocoderType::HifiGanSuperResolution); + assert_eq!(VocoderType::from_str("hifigan_rocket_vc").unwrap(), VocoderType::HifiGanRocketVc); + } +} diff --git a/crates/schema/public/enums/src/error/enum_error.rs b/crates/schema/public/enums/src/error/enum_error.rs new file mode 100644 index 0000000000..a86cf4ed4c --- /dev/null +++ b/crates/schema/public/enums/src/error/enum_error.rs @@ -0,0 +1,18 @@ +use std::error::Error; + +#[derive(Debug)] +pub enum EnumError { + CouldNotConvertFromString(String), +} + +impl Error for EnumError {} + +impl std::fmt::Display for EnumError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnumError::CouldNotConvertFromString(value) => { + write!(f, "Could not convert from string: {}", value) + } + } + } +} diff --git a/crates/schema/public/enums/src/error/mod.rs b/crates/schema/public/enums/src/error/mod.rs new file mode 100644 index 0000000000..4f9259a491 --- /dev/null +++ b/crates/schema/public/enums/src/error/mod.rs @@ -0,0 +1 @@ +pub mod enum_error; \ No newline at end of file diff --git a/crates/schema/public/enums/src/lib.rs b/crates/schema/public/enums/src/lib.rs new file mode 100644 index 0000000000..ece0ad8b1d --- /dev/null +++ b/crates/schema/public/enums/src/lib.rs @@ -0,0 +1,40 @@ +//! enums +//! +//! The purpose of this library is to have a strongly-typed MySQL enum-type wrapper. +//! This should also work for CHAR/VARCHAR fields that work similarly to enums (typically +//! as part of a composite key) +//! +//! These types should also be friendly for API usage in JSON payloads. +//! +//! In the future this should be *CODEGEN DRIVEN* and should get checked into source control. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +#[macro_use] extern crate serde_derive; + +#[macro_use] +mod macros; + +#[cfg(test)] pub mod test_helpers; + +pub mod by_table; +pub mod common; +pub mod error; +pub mod no_table; +pub mod tauri; +pub mod traits; +pub mod api_safe; diff --git a/crates/schema/public/enums/src/macros/impl_enum_display_and_debug_using_to_str.rs b/crates/schema/public/enums/src/macros/impl_enum_display_and_debug_using_to_str.rs new file mode 100644 index 0000000000..356b8a75b8 --- /dev/null +++ b/crates/schema/public/enums/src/macros/impl_enum_display_and_debug_using_to_str.rs @@ -0,0 +1,44 @@ + +/// Implement `Display` and `Debug` for enums that have a `.to_str()` method. +/// This ensures that the casing follows whatever `.to_str()` specifies. +macro_rules! impl_enum_display_and_debug_using_to_str { + ($t:ident) => { + + // Debug trait. + // Requires that the type has `.to_str()`. + impl std::fmt::Debug for $t { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_str()) + } + } + + // Display trait. + // Requires that the type has `.to_str()`. + impl std::fmt::Display for $t { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_str()) + } + } + + // NB: This test requires `strum::{EnumIter}` on the enum + #[cfg(test)] + #[test] + fn test_display_trait_matches_to_str() { + use strum::IntoEnumIterator; + for variant in $t::iter() { + assert_eq!(format!("{}", variant), variant.to_str()); + } + } + + // NB: This test requires `strum::{EnumIter}` on the enum + #[cfg(test)] + #[test] + fn test_debug_trait_matches_to_str() { + use strum::IntoEnumIterator; + for variant in $t::iter() { + assert_eq!(format!("{:?}", variant), variant.to_str()); + } + } + } +} + diff --git a/crates/schema/public/enums/src/macros/impl_mysql_enum_coders.rs b/crates/schema/public/enums/src/macros/impl_mysql_enum_coders.rs new file mode 100644 index 0000000000..6f143ad6d4 --- /dev/null +++ b/crates/schema/public/enums/src/macros/impl_mysql_enum_coders.rs @@ -0,0 +1,68 @@ +#[cfg(feature = "database")] +/// This overt approach is being taken because of the following error: +/// +/// `MySqlDatabaseError { code: Some("HY000"), number: 1210, message: "Incorrect arguments to mysqld_stmt_execute" }` +/// +/// Basically, sqlx can't turn our enum into a VARCHAR when using #[derive(sqlx::Type)]. +/// Unfortunately, by not using this, we also lose the ability to `#[sqlx(rename_all="lowercase")]`, etc., +/// so our encoder/decoder need to set the rules. +/// +/// Solution adapted from https://github.com/launchbadge/sqlx/discussions/1502 +/// The 0.6.2 series solution adapted from https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 +/// +macro_rules! impl_mysql_enum_coders { + ($t:ident) => { + + impl sqlx::Type for $t { + fn type_info() -> sqlx_mysql::MySqlTypeInfo { + // // 0.4.x series: + // String::type_info() + + // NB: https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 serves as an example + >::type_info() + } + } + + impl<'q> sqlx::Encode<'q, sqlx::MySql> for $t { + fn encode_by_ref( + &self, + buf: &mut >::ArgumentBuffer + ) -> sqlx_core::encode::IsNull { + // // 0.4.x series: + // // NB: In the absence of `#[derive(sqlx::Type)]` and `#sqlx(rename_all="lowercase")]`, + // // this controls the casing of the variants when sent to MySQL. + // self.to_str().encode_by_ref(buf) + + // NB: https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 and + // https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/str.rs.html#75-78 serves as examples + let value = self.to_str(); + <&str as sqlx::Encode>::encode(&*value, buf) + } + } + + impl<'r> sqlx::Decode<'r, sqlx::MySql> for $t { + fn decode( + value: sqlx_mysql::MySqlValueRef<'r>, + ) -> Result { + // // 0.4.x series: + // let string = String::decode(value)?; + // let value = $t::from_str(&string)?; + // Ok(value) + + // NB: https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 serves as an example + // delegate to the &str type to decode from MySQL + let text = <&str as sqlx::Decode>::decode(value)?; + let value = $t::from_str(&text)?; + Ok(value) + } + } + + } +} + +#[cfg(not(feature = "database"))] +macro_rules! impl_mysql_enum_coders { + ($t:ident) => { + // Intentionally empty + } +} diff --git a/crates/schema/public/enums/src/macros/impl_mysql_from_row.rs b/crates/schema/public/enums/src/macros/impl_mysql_from_row.rs new file mode 100644 index 0000000000..c2d0ebf6c4 --- /dev/null +++ b/crates/schema/public/enums/src/macros/impl_mysql_from_row.rs @@ -0,0 +1,71 @@ + +#[cfg(feature = "database")] +/// Implement `MySqlFromRow` on a type. +macro_rules! impl_mysql_from_row { + ($t:ident) => { + + // Try to convert a MySQL row and named field into the value type (for non-nullable fields). + impl crate::traits::mysql_from_row::MySqlFromRow<$t> for $t { + fn try_from_mysql_row(row: &sqlx_mysql::MySqlRow, field_name: &str) -> Result<$t, sqlx::Error> { + use sqlx::{Error, Row}; + + // NB(bt,2023-12-05): For now only string encodings are considered. + // We may want to revisit in the future if we deal with binary data. + let value : String = row.try_get(field_name)?; + + let output_type = Self::from_str(&value) + .map_err(|err| { + Error::ColumnDecode { + index: format!("mysql_from_row failure on field {}: {:?}", field_name, err), + source: format!("mysql_from_row failure on field {}: {:?}", field_name, err).into(), + } + })?; + + Ok(output_type) + } + + // Try to convert a MySQL row and named field into the value type (for nullable fields). + fn try_from_mysql_row_nullable(row: &sqlx_mysql::MySqlRow, field_name: &str) -> Result, sqlx::Error> { + use sqlx::{Error, Row}; + + // NB(bt,2023-12-05): For now only string encodings are considered. + // We may want to revisit in the future if we deal with binary data. + // NB(2): Nullable fields decode as Option. + let maybe_value : Option = row.try_get(field_name)?; + + let value = match maybe_value { + Some(value) => value, + None => return Ok(None), + }; + + let output_type = Self::from_str(&value) + .map_err(|err| { + Error::ColumnDecode { + index: format!("mysql_from_row failure on field {}: {:?}", field_name, err), + source: format!("mysql_from_row failure on field {}: {:?}", field_name, err).into(), + } + })?; + + Ok(Some(output_type)) + } + } + + //// TODO: Tests. + //#[cfg(test)] + //#[test] + //fn test_name() { + // use strum::IntoEnumIterator; + // for variant in $t::iter() { + // assert_eq!(format!("{}", variant), variant.to_str()); + // } + //} + + } +} + +#[cfg(not(feature = "database"))] +macro_rules! impl_mysql_from_row { + ($t:ident) => { + // Intentionally empty + } +} diff --git a/crates/schema/public/enums/src/macros/impl_sqlite_enum_coders.rs b/crates/schema/public/enums/src/macros/impl_sqlite_enum_coders.rs new file mode 100644 index 0000000000..577f89bd6b --- /dev/null +++ b/crates/schema/public/enums/src/macros/impl_sqlite_enum_coders.rs @@ -0,0 +1,56 @@ +#[cfg(feature = "database")] +/// This overt approach is being taken because of the following error: +/// +/// `MySqlDatabaseError { code: Some("HY000"), number: 1210, message: "Incorrect arguments to mysqld_stmt_execute" }` +/// +/// Basically, sqlx can't turn our enum into a VARCHAR when using #[derive(sqlx::Type)]. +/// Unfortunately, by not using this, we also lose the ability to `#[sqlx(rename_all="lowercase")]`, etc., +/// so our encoder/decoder need to set the rules. +/// +/// Solution adapted from https://github.com/launchbadge/sqlx/discussions/1502 +/// The 0.6.2 series solution adapted from https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 +/// +macro_rules! impl_sqlite_enum_coders { + ($t:ident) => { + + // TODO(bt,2023-11-19): We're not using sqlite anymore. Consider removing this. + //impl sqlx::Type for $t { + // fn type_info() -> sqlx_core::sqlite::SqliteTypeInfo { + // // NB: https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 serves as an example + // >::type_info() + // } + //} + + //impl<'q> sqlx::Encode<'q, sqlx_core::sqlite::Sqlite> for $t { + // fn encode_by_ref( + // &self, + // buf: &mut >::ArgumentBuffer + // ) -> sqlx_core::encode::IsNull { + // // NB: https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 and + // // https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/str.rs.html#75-78 serves as examples + // let value = self.to_str(); + // <&str as sqlx::Encode>::encode(&*value, buf) + // } + //} + + //impl<'r> sqlx::Decode<'r, sqlx_core::sqlite::Sqlite> for $t { + // fn decode( + // value: sqlx_core::sqlite::SqliteValueRef<'r>, + // ) -> Result { + // // NB: https://docs.rs/sqlx-core/0.6.2/src/sqlx_core/mysql/types/uuid.rs.html#38-66 serves as an example + // // delegate to the &str type to decode from MySQL + // let text = <&str as sqlx::Decode>::decode(value)?; + // let value = $t::from_str(&text)?; + // Ok(value) + // } + //} + + } +} + +#[cfg(not(feature = "database"))] +macro_rules! impl_sqlite_enum_coders { + ($t:ident) => { + // Intentionally empty + } +} diff --git a/crates/schema/public/enums/src/macros/mod.rs b/crates/schema/public/enums/src/macros/mod.rs new file mode 100644 index 0000000000..93cba5b4dc --- /dev/null +++ b/crates/schema/public/enums/src/macros/mod.rs @@ -0,0 +1,4 @@ +#[macro_use] pub mod impl_enum_display_and_debug_using_to_str; +#[macro_use] pub mod impl_mysql_enum_coders; +#[macro_use] pub mod impl_mysql_from_row; +#[macro_use] pub mod impl_sqlite_enum_coders; diff --git a/crates/schema/public/enums/src/no_table/mod.rs b/crates/schema/public/enums/src/no_table/mod.rs new file mode 100644 index 0000000000..977ace7de9 --- /dev/null +++ b/crates/schema/public/enums/src/no_table/mod.rs @@ -0,0 +1,3 @@ +pub mod premium_product; +pub mod style_transfer; +pub mod video_dimensions; \ No newline at end of file diff --git a/crates/schema/public/enums/src/no_table/premium_product/mod.rs b/crates/schema/public/enums/src/no_table/premium_product/mod.rs new file mode 100644 index 0000000000..c6890ee97f --- /dev/null +++ b/crates/schema/public/enums/src/no_table/premium_product/mod.rs @@ -0,0 +1 @@ +pub mod premium_product_name; \ No newline at end of file diff --git a/crates/schema/public/enums/src/no_table/premium_product/premium_product_name.rs b/crates/schema/public/enums/src/no_table/premium_product/premium_product_name.rs new file mode 100644 index 0000000000..ca3d2c463f --- /dev/null +++ b/crates/schema/public/enums/src/no_table/premium_product/premium_product_name.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// This enum is not backed by a particular database table. +/// This is used to count premium product uses for free and paid users. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PremiumProductName { + // NB: These keys are kept short to preserve space + #[serde(rename = "fa")] + FaceAnimator, + // NB: These keys are kept short to preserve space + #[serde(rename = "fm")] + FaceMirror, + // NB: These keys are kept short to preserve space + #[serde(rename = "lip")] + Lipsync, + // NB: These keys are kept short to preserve space + #[serde(rename = "vst")] + VideoStyleTransfer, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(PremiumProductName); +//impl_mysql_enum_coders!(PremiumProductName); +//impl_mysql_from_row!(PremiumProductName); + +/// NB: Legacy API for older code. +impl PremiumProductName { + pub fn to_str(&self) -> &'static str { + match self { + Self::FaceAnimator => "fa", + Self::FaceMirror => "fm", + Self::Lipsync => "lip", + Self::VideoStyleTransfer => "vst", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "fa" => Ok(Self::FaceAnimator), + "fm" => Ok(Self::FaceMirror), + "lip" => Ok(Self::Lipsync), + "vst" => Ok(Self::VideoStyleTransfer), + _ => Err(format!("Unknown PremiumProductName: {}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::FaceAnimator, + Self::FaceMirror, + Self::Lipsync, + Self::VideoStyleTransfer, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::no_table::premium_product::premium_product_name::PremiumProductName; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(PremiumProductName::FaceAnimator, "fa"); + assert_serialization(PremiumProductName::FaceMirror, "fm"); + assert_serialization(PremiumProductName::Lipsync, "lip"); + assert_serialization(PremiumProductName::VideoStyleTransfer, "vst"); + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(PremiumProductName::FaceAnimator.to_str(), "fa"); + assert_eq!(PremiumProductName::FaceMirror.to_str(), "fm"); + assert_eq!(PremiumProductName::Lipsync.to_str(), "lip"); + assert_eq!(PremiumProductName::VideoStyleTransfer.to_str(), "vst"); + } + + #[test] + fn from_str() { + assert_eq!(PremiumProductName::from_str("fa").unwrap(), PremiumProductName::FaceAnimator); + assert_eq!(PremiumProductName::from_str("fm").unwrap(), PremiumProductName::FaceMirror); + assert_eq!(PremiumProductName::from_str("lip").unwrap(), PremiumProductName::Lipsync); + assert_eq!(PremiumProductName::from_str("vst").unwrap(), PremiumProductName::VideoStyleTransfer); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let variants = PremiumProductName::all_variants(); + assert_eq!(variants.len(), 4); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(PremiumProductName::all_variants().len(), PremiumProductName::iter().len()); + } + + #[test] + fn round_trip() { + for variant in PremiumProductName::all_variants() { + assert_eq!(variant, PremiumProductName::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, PremiumProductName::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, PremiumProductName::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } + } +} diff --git a/crates/schema/public/enums/src/no_table/style_transfer/mod.rs b/crates/schema/public/enums/src/no_table/style_transfer/mod.rs new file mode 100644 index 0000000000..84c0a980bc --- /dev/null +++ b/crates/schema/public/enums/src/no_table/style_transfer/mod.rs @@ -0,0 +1 @@ +pub mod style_transfer_name; \ No newline at end of file diff --git a/crates/schema/public/enums/src/no_table/style_transfer/style_transfer_name.rs b/crates/schema/public/enums/src/no_table/style_transfer/style_transfer_name.rs new file mode 100644 index 0000000000..57e0c5a32e --- /dev/null +++ b/crates/schema/public/enums/src/no_table/style_transfer/style_transfer_name.rs @@ -0,0 +1,431 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// This enum is not backed by a particular database table. +/// It's used in APIs and Jobs to agree upon ComfyUI style transfer style configurations. +/// +/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StyleTransferName { + #[serde(rename = "anime_2_5d")] + Anime2_5D, + #[serde(rename = "anime_2d_flat")] + Anime2DFlat, + #[serde(rename = "cartoon_3d")] + Cartoon3D, + #[serde(rename = "comic_book")] + ComicBook, + #[serde(rename = "anime_ghibli")] + AnimeGhibli, + #[serde(rename = "ink_punk")] + InkPunk, + #[serde(rename = "ink_splash")] + InkSplash, + #[serde(rename = "ink_bw_style")] + InkBWStyle, + #[serde(rename = "jojo_style")] + JojoStyle, + #[serde(rename = "paper_origami")] + PaperOrigami, + #[serde(rename = "pixel_art")] + PixelArt, + #[serde(rename = "pop_art")] + PopArt, + #[serde(rename = "realistic_1")] + Realistic1, + #[serde(rename = "realistic_2")] + Realistic2, + #[serde(rename = "anime_retro_neon")] + AnimeRetroNeon, + #[serde(rename = "anime_standard")] + AnimeStandard, + + // New Styles (2024-05-03) + + #[serde(rename = "hr_giger")] + HrGiger, + #[serde(rename = "simpsons")] + Simpsons, + #[serde(rename = "carnage")] + Carnage, + #[serde(rename = "pastel_cute_anime")] // TODO: Rename + AnimePastelCute, + #[serde(rename = "bloom_lighting")] + BloomLighting, + #[serde(rename = "25d_horror")] // TODO: Rename + Horror2_5D, + #[serde(rename = "creepy")] + Creepy, + #[serde(rename = "creepy_vhs")] + CreepyVhs, + #[serde(rename = "trail_cam_footage")] + TrailCamFootage, + #[serde(rename = "old_black_white_movie")] + OldBlackWhiteMovie, + #[serde(rename = "horror_noir_black_white")] + HorrorNoirBlackWhite, + #[serde(rename = "techno_noir_black_white")] + TechnoNoirBlackWhite, + #[serde(rename = "black_white_20s")] + BlackWhite20s, + #[serde(rename = "cyberpunk_anime")] + CyberpunkAnime, + #[serde(rename = "dragonball")] + Dragonball, + #[serde(rename = "realistic_matrix")] + RealisticMatrix, + #[serde(rename = "realistic_cyberpunk")] + RealisticCyberpunk, + + // New Styles (2024-06-27) + + #[serde(rename = "dreamer")] // TODO: Land this in Gitub + Dreamer, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(StyleTransferName); +//impl_mysql_enum_coders!(StyleTransferName); +//impl_mysql_from_row!(StyleTransferName); + +/// NB: Legacy API for older code. +impl StyleTransferName { + pub fn to_str(&self) -> &'static str { + match self { + Self::Anime2_5D => "anime_2_5d", + Self::Anime2DFlat => "anime_2d_flat", + Self::Cartoon3D => "cartoon_3d", + Self::ComicBook => "comic_book", + Self::AnimeGhibli => "anime_ghibli", + Self::InkPunk => "ink_punk", + Self::InkSplash => "ink_splash", + Self::InkBWStyle => "ink_bw_style", + Self::JojoStyle => "jojo_style", + Self::PaperOrigami => "paper_origami", + Self::PixelArt => "pixel_art", + Self::PopArt => "pop_art", + Self::Realistic1 => "realistic_1", + Self::Realistic2 => "realistic_2", + Self::AnimeRetroNeon => "anime_retro_neon", + Self::AnimeStandard => "anime_standard", + + // New Styles (2024-05-03) + Self::HrGiger => "hr_giger", + Self::Simpsons => "simpsons", + Self::Carnage => "carnage", + Self::AnimePastelCute => "pastel_cute_anime", // TODO: Rename + Self::BloomLighting => "bloom_lighting", + Self::Horror2_5D => "25d_horror", // TODO: Rename + Self::Creepy => "creepy", + Self::CreepyVhs => "creepy_vhs", + Self::TrailCamFootage => "trail_cam_footage", + Self::OldBlackWhiteMovie => "old_black_white_movie", + Self::HorrorNoirBlackWhite => "horror_noir_black_white", + Self::TechnoNoirBlackWhite => "techno_noir_black_white", + Self::BlackWhite20s => "black_white_20s", + Self::CyberpunkAnime => "cyberpunk_anime", + Self::Dragonball => "dragonball", + Self::RealisticMatrix => "realistic_matrix", + Self::RealisticCyberpunk => "realistic_cyberpunk", + + // New Styles (2024-06-27) + Self::Dreamer => "dreamer", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "anime_2_5d" => Ok(Self::Anime2_5D), + "anime_2d_flat" => Ok(Self::Anime2DFlat), + "cartoon_3d" => Ok(Self::Cartoon3D), + "comic_book" => Ok(Self::ComicBook), + "anime_ghibli" => Ok(Self::AnimeGhibli), + "ink_punk" => Ok(Self::InkPunk), + "ink_splash" => Ok(Self::InkSplash), + "ink_bw_style" => Ok(Self::InkBWStyle), + "jojo_style" => Ok(Self::JojoStyle), + "paper_origami" => Ok(Self::PaperOrigami), + "pixel_art" => Ok(Self::PixelArt), + "pop_art" => Ok(Self::PopArt), + "realistic_1" => Ok(Self::Realistic1), + "realistic_2" => Ok(Self::Realistic2), + "anime_retro_neon" => Ok(Self::AnimeRetroNeon), + "anime_standard" => Ok(Self::AnimeStandard), + // New styles + "hr_giger" => Ok(Self::HrGiger), + "simpsons" => Ok(Self::Simpsons), + "carnage" => Ok(Self::Carnage), + "pastel_cute_anime" => Ok(Self::AnimePastelCute), // TODO: Rename + "bloom_lighting" => Ok(Self::BloomLighting), + "25d_horror" => Ok(Self::Horror2_5D), // TODO: Rename + "creepy" => Ok(Self::Creepy), + "creepy_vhs" => Ok(Self::CreepyVhs), + "trail_cam_footage" => Ok(Self::TrailCamFootage), + "old_black_white_movie" => Ok(Self::OldBlackWhiteMovie), + "horror_noir_black_white" => Ok(Self::HorrorNoirBlackWhite), + "techno_noir_black_white" => Ok(Self::TechnoNoirBlackWhite), + "black_white_20s" => Ok(Self::BlackWhite20s), + "cyberpunk_anime" => Ok(Self::CyberpunkAnime), + "dragonball" => Ok(Self::Dragonball), + "realistic_matrix" => Ok(Self::RealisticMatrix), + "realistic_cyberpunk" => Ok(Self::RealisticCyberpunk), + "dreamer" => Ok(Self::Dreamer), + _ => Err(format!("Unknown StyleTransferName: {}", value)), + } + } + + pub fn to_filename(&self) -> &'static str { + match self { + Self::Anime2_5D => "1_2.5d_anime_model.json", + Self::Anime2DFlat => "2_2d_flat_anime_model.json", + Self::Cartoon3D => "3_3d_cartoon_style.json", + Self::ComicBook => "4_comic_book_model.json", + Self::AnimeGhibli => "5_ghibli_anime_model.json", + Self::InkPunk => "6_ink_punk.json", + Self::InkSplash => "7_ink_splash.json", + Self::InkBWStyle => "8_ink_w_and_b_style.json", + Self::JojoStyle => "9_jojo_style.json", + Self::PaperOrigami => "10_paper_origami.json", + Self::PixelArt => "11_pixel_art.json", + Self::PopArt => "12_pop_art.json", + Self::Realistic1 => "13_realistic_1.json", + Self::Realistic2 => "14_realistic_2.json", + Self::AnimeRetroNeon => "15_retro_neon_anime_90.json", + Self::AnimeStandard => "16_standard_anime_model.json", + + Self::HrGiger => "17_hr_giger.json", + Self::Simpsons => "18_simpsons.json", + Self::Carnage => "19_carnage.json", + Self::AnimePastelCute => "20_pastel_cute_anime.json", + Self::BloomLighting => "21_bloom_lighting.json", + Self::Horror2_5D => "22_25D_Horror.json", + Self::Creepy => "23_creepy.json", + Self::CreepyVhs => "24_creepy_vhs.json", + Self::TrailCamFootage => "25_trail_cam_footage.json", + Self::OldBlackWhiteMovie => "26_old_black_white_movie.json", + Self::HorrorNoirBlackWhite => "27_horror_noir_black_white.json", + Self::TechnoNoirBlackWhite => "28_techno_noir_black_white.json", + Self::BlackWhite20s => "29_black_white_20s.json", + Self::CyberpunkAnime => "30_cyberpunk_anime.json", + Self::Dragonball => "31_dragonball.json", + Self::RealisticMatrix => "32_realistic_matrix.json", + Self::RealisticCyberpunk => "33_realistic_cyberpunk.json", + Self::Dreamer => "34_dreamer.json", + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Anime2_5D, + Self::Anime2DFlat, + Self::Cartoon3D, + Self::ComicBook, + Self::AnimeGhibli, + Self::InkPunk, + Self::InkSplash, + Self::InkBWStyle, + Self::JojoStyle, + Self::PaperOrigami, + Self::PixelArt, + Self::PopArt, + Self::Realistic1, + Self::Realistic2, + Self::AnimeRetroNeon, + Self::AnimeStandard, + + Self::HrGiger, + Self::Simpsons, + Self::Carnage, + Self::AnimePastelCute, + Self::BloomLighting, + Self::Horror2_5D, + Self::Creepy, + Self::CreepyVhs, + Self::TrailCamFootage, + Self::OldBlackWhiteMovie, + Self::HorrorNoirBlackWhite, + Self::TechnoNoirBlackWhite, + Self::BlackWhite20s, + Self::CyberpunkAnime, + Self::Dragonball, + Self::RealisticMatrix, + Self::RealisticCyberpunk, + + Self::Dreamer, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::no_table::style_transfer::style_transfer_name::StyleTransferName; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(StyleTransferName::Anime2_5D, "anime_2_5d"); + assert_serialization(StyleTransferName::Anime2DFlat, "anime_2d_flat"); + assert_serialization(StyleTransferName::Cartoon3D, "cartoon_3d"); + assert_serialization(StyleTransferName::ComicBook, "comic_book"); + assert_serialization(StyleTransferName::AnimeGhibli, "anime_ghibli"); + assert_serialization(StyleTransferName::InkPunk, "ink_punk"); + assert_serialization(StyleTransferName::InkSplash, "ink_splash"); + assert_serialization(StyleTransferName::InkBWStyle, "ink_bw_style"); + assert_serialization(StyleTransferName::JojoStyle, "jojo_style"); + assert_serialization(StyleTransferName::PaperOrigami, "paper_origami"); + assert_serialization(StyleTransferName::PixelArt, "pixel_art"); + assert_serialization(StyleTransferName::PopArt, "pop_art"); + assert_serialization(StyleTransferName::Realistic1, "realistic_1"); + assert_serialization(StyleTransferName::Realistic2, "realistic_2"); + assert_serialization(StyleTransferName::AnimeRetroNeon, "anime_retro_neon"); + assert_serialization(StyleTransferName::AnimeStandard, "anime_standard"); + + assert_serialization(StyleTransferName::HrGiger, "hr_giger"); + assert_serialization(StyleTransferName::Simpsons, "simpsons"); + assert_serialization(StyleTransferName::Carnage, "carnage"); + assert_serialization(StyleTransferName::AnimePastelCute, "pastel_cute_anime"); + assert_serialization(StyleTransferName::BloomLighting, "bloom_lighting"); + assert_serialization(StyleTransferName::Horror2_5D, "25d_horror"); + assert_serialization(StyleTransferName::Creepy, "creepy"); + assert_serialization(StyleTransferName::CreepyVhs, "creepy_vhs"); + assert_serialization(StyleTransferName::TrailCamFootage, "trail_cam_footage"); + assert_serialization(StyleTransferName::OldBlackWhiteMovie, "old_black_white_movie"); + assert_serialization(StyleTransferName::HorrorNoirBlackWhite, "horror_noir_black_white"); + assert_serialization(StyleTransferName::TechnoNoirBlackWhite, "techno_noir_black_white"); + assert_serialization(StyleTransferName::BlackWhite20s, "black_white_20s"); + assert_serialization(StyleTransferName::CyberpunkAnime, "cyberpunk_anime"); + assert_serialization(StyleTransferName::Dragonball, "dragonball"); + assert_serialization(StyleTransferName::RealisticMatrix, "realistic_matrix"); + assert_serialization(StyleTransferName::RealisticCyberpunk, "realistic_cyberpunk"); + + assert_serialization(StyleTransferName::Dreamer, "dreamer"); + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(StyleTransferName::Anime2_5D.to_str(), "anime_2_5d"); + assert_eq!(StyleTransferName::Anime2DFlat.to_str(), "anime_2d_flat"); + assert_eq!(StyleTransferName::Cartoon3D.to_str(), "cartoon_3d"); + assert_eq!(StyleTransferName::ComicBook.to_str(), "comic_book"); + assert_eq!(StyleTransferName::AnimeGhibli.to_str(), "anime_ghibli"); + assert_eq!(StyleTransferName::InkPunk.to_str(), "ink_punk"); + assert_eq!(StyleTransferName::InkSplash.to_str(), "ink_splash"); + assert_eq!(StyleTransferName::InkBWStyle.to_str(), "ink_bw_style"); + assert_eq!(StyleTransferName::JojoStyle.to_str(), "jojo_style"); + assert_eq!(StyleTransferName::PaperOrigami.to_str(), "paper_origami"); + assert_eq!(StyleTransferName::PixelArt.to_str(), "pixel_art"); + assert_eq!(StyleTransferName::PopArt.to_str(), "pop_art"); + assert_eq!(StyleTransferName::Realistic1.to_str(), "realistic_1"); + assert_eq!(StyleTransferName::Realistic2.to_str(), "realistic_2"); + assert_eq!(StyleTransferName::AnimeRetroNeon.to_str(), "anime_retro_neon"); + assert_eq!(StyleTransferName::AnimeStandard.to_str(), "anime_standard"); + + assert_eq!(StyleTransferName::HrGiger.to_str(), "hr_giger"); + assert_eq!(StyleTransferName::Simpsons.to_str(), "simpsons"); + assert_eq!(StyleTransferName::Carnage.to_str(), "carnage"); + assert_eq!(StyleTransferName::AnimePastelCute.to_str(), "pastel_cute_anime"); + assert_eq!(StyleTransferName::BloomLighting.to_str(), "bloom_lighting"); + assert_eq!(StyleTransferName::Horror2_5D.to_str(), "25d_horror"); + assert_eq!(StyleTransferName::Creepy.to_str(), "creepy"); + assert_eq!(StyleTransferName::CreepyVhs.to_str(), "creepy_vhs"); + assert_eq!(StyleTransferName::TrailCamFootage.to_str(), "trail_cam_footage"); + assert_eq!(StyleTransferName::OldBlackWhiteMovie.to_str(), "old_black_white_movie"); + assert_eq!(StyleTransferName::HorrorNoirBlackWhite.to_str(), "horror_noir_black_white"); + assert_eq!(StyleTransferName::TechnoNoirBlackWhite.to_str(), "techno_noir_black_white"); + assert_eq!(StyleTransferName::BlackWhite20s.to_str(), "black_white_20s"); + assert_eq!(StyleTransferName::CyberpunkAnime.to_str(), "cyberpunk_anime"); + assert_eq!(StyleTransferName::Dragonball.to_str(), "dragonball"); + assert_eq!(StyleTransferName::RealisticMatrix.to_str(), "realistic_matrix"); + assert_eq!(StyleTransferName::RealisticCyberpunk.to_str(), "realistic_cyberpunk"); + + assert_eq!(StyleTransferName::Dreamer.to_str(), "dreamer"); + } + + #[test] + fn from_str() { + assert_eq!(StyleTransferName::from_str("anime_2_5d").unwrap(), StyleTransferName::Anime2_5D); + assert_eq!(StyleTransferName::from_str("anime_2d_flat").unwrap(), StyleTransferName::Anime2DFlat); + assert_eq!(StyleTransferName::from_str("cartoon_3d").unwrap(), StyleTransferName::Cartoon3D); + assert_eq!(StyleTransferName::from_str("comic_book").unwrap(), StyleTransferName::ComicBook); + assert_eq!(StyleTransferName::from_str("anime_ghibli").unwrap(), StyleTransferName::AnimeGhibli); + assert_eq!(StyleTransferName::from_str("ink_punk").unwrap(), StyleTransferName::InkPunk); + assert_eq!(StyleTransferName::from_str("ink_splash").unwrap(), StyleTransferName::InkSplash); + assert_eq!(StyleTransferName::from_str("ink_bw_style").unwrap(), StyleTransferName::InkBWStyle); + assert_eq!(StyleTransferName::from_str("jojo_style").unwrap(), StyleTransferName::JojoStyle); + assert_eq!(StyleTransferName::from_str("paper_origami").unwrap(), StyleTransferName::PaperOrigami); + assert_eq!(StyleTransferName::from_str("pixel_art").unwrap(), StyleTransferName::PixelArt); + assert_eq!(StyleTransferName::from_str("pop_art").unwrap(), StyleTransferName::PopArt); + assert_eq!(StyleTransferName::from_str("realistic_1").unwrap(), StyleTransferName::Realistic1); + assert_eq!(StyleTransferName::from_str("realistic_2").unwrap(), StyleTransferName::Realistic2); + assert_eq!(StyleTransferName::from_str("anime_retro_neon").unwrap(), StyleTransferName::AnimeRetroNeon); + assert_eq!(StyleTransferName::from_str("anime_standard").unwrap(), StyleTransferName::AnimeStandard); + + assert_eq!(StyleTransferName::from_str("hr_giger").unwrap(), StyleTransferName::HrGiger); + assert_eq!(StyleTransferName::from_str("simpsons").unwrap(), StyleTransferName::Simpsons); + assert_eq!(StyleTransferName::from_str("carnage").unwrap(), StyleTransferName::Carnage); + assert_eq!(StyleTransferName::from_str("pastel_cute_anime").unwrap(), StyleTransferName::AnimePastelCute); + assert_eq!(StyleTransferName::from_str("bloom_lighting").unwrap(), StyleTransferName::BloomLighting); + assert_eq!(StyleTransferName::from_str("25d_horror").unwrap(), StyleTransferName::Horror2_5D); + assert_eq!(StyleTransferName::from_str("creepy").unwrap(), StyleTransferName::Creepy); + assert_eq!(StyleTransferName::from_str("creepy_vhs").unwrap(), StyleTransferName::CreepyVhs); + assert_eq!(StyleTransferName::from_str("trail_cam_footage").unwrap(), StyleTransferName::TrailCamFootage); + assert_eq!(StyleTransferName::from_str("old_black_white_movie").unwrap(), StyleTransferName::OldBlackWhiteMovie); + assert_eq!(StyleTransferName::from_str("horror_noir_black_white").unwrap(), StyleTransferName::HorrorNoirBlackWhite); + assert_eq!(StyleTransferName::from_str("techno_noir_black_white").unwrap(), StyleTransferName::TechnoNoirBlackWhite); + assert_eq!(StyleTransferName::from_str("black_white_20s").unwrap(), StyleTransferName::BlackWhite20s); + assert_eq!(StyleTransferName::from_str("cyberpunk_anime").unwrap(), StyleTransferName::CyberpunkAnime); + assert_eq!(StyleTransferName::from_str("dragonball").unwrap(), StyleTransferName::Dragonball); + assert_eq!(StyleTransferName::from_str("realistic_matrix").unwrap(), StyleTransferName::RealisticMatrix); + assert_eq!(StyleTransferName::from_str("realistic_cyberpunk").unwrap(), StyleTransferName::RealisticCyberpunk); + + assert_eq!(StyleTransferName::from_str("dreamer").unwrap(), StyleTransferName::Dreamer); + + assert!(StyleTransferName::from_str("foo").is_err()); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let variants = StyleTransferName::all_variants(); + assert_eq!(variants.len(), 34); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StyleTransferName::all_variants().len(), StyleTransferName::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StyleTransferName::all_variants() { + assert_eq!(variant, StyleTransferName::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StyleTransferName::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StyleTransferName::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } + } +} \ No newline at end of file diff --git a/crates/schema/public/enums/src/no_table/video_dimensions/mod.rs b/crates/schema/public/enums/src/no_table/video_dimensions/mod.rs new file mode 100644 index 0000000000..6f8ed8967c --- /dev/null +++ b/crates/schema/public/enums/src/no_table/video_dimensions/mod.rs @@ -0,0 +1 @@ +mod video_dimensions; \ No newline at end of file diff --git a/crates/schema/public/enums/src/no_table/video_dimensions/video_dimensions.rs b/crates/schema/public/enums/src/no_table/video_dimensions/video_dimensions.rs new file mode 100644 index 0000000000..6b96ee47fc --- /dev/null +++ b/crates/schema/public/enums/src/no_table/video_dimensions/video_dimensions.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// This enum is not backed by a particular database table. +/// This is used to determine the video generation size. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum VideoDimensions { + Landscape, + Portrait, + Square, +} + +// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(VideoDimensions); +//impl_mysql_enum_coders!(VideoDimensions); +//impl_mysql_from_row!(VideoDimensions); + +/// NB: Legacy API for older code. +impl VideoDimensions { + pub fn to_str(&self) -> &'static str { + match self { + Self::Landscape => "landscape", + Self::Portrait => "portrait", + Self::Square => "square", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "landscape" => Ok(Self::Landscape), + "portrait" => Ok(Self::Portrait), + "square" => Ok(Self::Square), + _ => Err(format!("Unknown VideoDimensions: {}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Landscape, + Self::Portrait, + Self::Square, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::no_table::video_dimensions::video_dimensions::VideoDimensions; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(VideoDimensions::Landscape, "landscape"); + assert_serialization(VideoDimensions::Portrait, "portrait"); + assert_serialization(VideoDimensions::Square, "square"); + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(VideoDimensions::Landscape.to_str(), "landscape"); + assert_eq!(VideoDimensions::Portrait.to_str(), "portrait"); + assert_eq!(VideoDimensions::Square.to_str(), "square"); + } + + #[test] + fn from_str() { + assert_eq!(VideoDimensions::from_str("landscape").unwrap(), VideoDimensions::Landscape); + assert_eq!(VideoDimensions::from_str("portrait").unwrap(), VideoDimensions::Portrait); + assert_eq!(VideoDimensions::from_str("square").unwrap(), VideoDimensions::Square); + } + } + + mod manual_variant_checks { + use super::*; + + #[test] + fn all_variants() { + let variants = VideoDimensions::all_variants(); + assert_eq!(variants.len(), 3); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(VideoDimensions::all_variants().len(), VideoDimensions::iter().len()); + } + + #[test] + fn round_trip() { + for variant in VideoDimensions::all_variants() { + assert_eq!(variant, VideoDimensions::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, VideoDimensions::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, VideoDimensions::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } + } +} diff --git a/crates/schema/public/enums/src/tauri/mod.rs b/crates/schema/public/enums/src/tauri/mod.rs new file mode 100644 index 0000000000..b6956616d1 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/mod.rs @@ -0,0 +1,2 @@ +pub mod tasks; +pub mod ux; \ No newline at end of file diff --git a/crates/schema/public/enums/src/tauri/tasks/mod.rs b/crates/schema/public/enums/src/tauri/tasks/mod.rs new file mode 100644 index 0000000000..780c9c03db --- /dev/null +++ b/crates/schema/public/enums/src/tauri/tasks/mod.rs @@ -0,0 +1,5 @@ +pub mod task_failure_type; +pub mod task_media_file_class; +pub mod task_model_type; +pub mod task_status; +pub mod task_type; \ No newline at end of file diff --git a/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs b/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs new file mode 100644 index 0000000000..f531dcd6d9 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs @@ -0,0 +1,253 @@ +use std::collections::BTreeSet; + +use crate::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients; +use crate::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory; +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Failure type for tasks in the Tauri desktop app. +/// +/// Mirrors the relevant variants from `FrontendFailureCategory` so the desktop +/// client can display localized failure information without depending / breaking on the +/// server-side enum directly. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TaskFailureType { + /// Catch-all for unknown failures. + Unknown, + + RuleBansUserImage, + RuleBansUserImageWithFaces, + RuleBansUserTextPrompt, + RuleBansUserContent, + + RuleBansGeneratedVideo, + RuleBansGeneratedAudio, + RuleBansGeneratedContent, + + /// No foreground subject could be detected in the input image after + /// background removal (e.g. TripoSplat image-to-splat). + NoForegroundSubjectDetected, + + /// The input file's format isn't supported by the model (e.g. Hunyuan 3D + /// Part only accepts FBX input). + FormatNotSupported, + + /// No reason given for generation failure, but this matches what we were told. + GenerationFailed, +} + +impl_enum_display_and_debug_using_to_str!(TaskFailureType); + +impl TaskFailureType { + + /// Convert the web API's `FrontendFailureCategory` to a Tauri-facing type, if there is a matching variant. + /// If there isn't a matching variant, return `Unknown`. + pub fn from_frontend_failure_category(category: FrontendFailureCategory) -> Self { + match category { + FrontendFailureCategory::ModelRulesViolation => Self::RuleBansUserContent, // NB: This is a legacy enum value. + FrontendFailureCategory::RuleBansUserImage => Self::RuleBansUserImage, + FrontendFailureCategory::RuleBansUserImageWithFaces => Self::RuleBansUserImageWithFaces, + FrontendFailureCategory::RuleBansUserTextPrompt => Self::RuleBansUserTextPrompt, + FrontendFailureCategory::RuleBansUserContent => Self::RuleBansUserContent, + FrontendFailureCategory::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo, + FrontendFailureCategory::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio, + FrontendFailureCategory::RuleBansGeneratedContent => Self::RuleBansGeneratedContent, + FrontendFailureCategory::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected, + FrontendFailureCategory::FormatNotSupported => Self::FormatNotSupported, + FrontendFailureCategory::GenerationFailed => Self::GenerationFailed, + _ => Self::Unknown, + } + } + + /// Convert the API-client-facing `FrontendFailureCategoryForApiClients` to a Tauri-facing type. + /// `Unknown(String)` maps to `Unknown` with a debug log. + pub fn from_frontend_failure_category_for_api(category: &FrontendFailureCategoryForApiClients) -> Self { + match category { + FrontendFailureCategoryForApiClients::ModelRulesViolation => Self::RuleBansUserContent, + FrontendFailureCategoryForApiClients::RuleBansUserImage => Self::RuleBansUserImage, + FrontendFailureCategoryForApiClients::RuleBansUserImageWithFaces => Self::RuleBansUserImageWithFaces, + FrontendFailureCategoryForApiClients::RuleBansUserTextPrompt => Self::RuleBansUserTextPrompt, + FrontendFailureCategoryForApiClients::RuleBansUserContent => Self::RuleBansUserContent, + FrontendFailureCategoryForApiClients::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo, + FrontendFailureCategoryForApiClients::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio, + FrontendFailureCategoryForApiClients::RuleBansGeneratedContent => Self::RuleBansGeneratedContent, + FrontendFailureCategoryForApiClients::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected, + FrontendFailureCategoryForApiClients::FormatNotSupported => Self::FormatNotSupported, + FrontendFailureCategoryForApiClients::GenerationFailed => Self::GenerationFailed, + + // Types ArtCraft doesn't care about + FrontendFailureCategoryForApiClients::FaceNotDetected => Self::Unknown, + FrontendFailureCategoryForApiClients::KeepAliveElapsed => Self::Unknown, + FrontendFailureCategoryForApiClients::NotYetImplemented => Self::Unknown, + FrontendFailureCategoryForApiClients::RetryableWorkerError => Self::Unknown, + FrontendFailureCategoryForApiClients::FilesizeTooLarge => Self::Unknown, + FrontendFailureCategoryForApiClients::ImageDimensionsTooSmall => Self::Unknown, + FrontendFailureCategoryForApiClients::ImageDimensionsTooLarge => Self::Unknown, + + // Unknown (future-proof) variant + FrontendFailureCategoryForApiClients::Unknown(ref value) => { + log::debug!("Unknown FrontendFailureCategoryForApiClients variant: {}", value); + Self::Unknown + } + } + } + + pub const fn to_str(&self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::RuleBansUserImage => "rule_bans_user_image", + Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces", + Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt", + Self::RuleBansUserContent => "rule_bans_user_content", + Self::RuleBansGeneratedVideo => "rule_bans_generated_video", + Self::RuleBansGeneratedAudio => "rule_bans_generated_audio", + Self::RuleBansGeneratedContent => "rule_bans_generated_content", + Self::NoForegroundSubjectDetected => "no_foreground_subject_detected", + Self::FormatNotSupported => "format_not_supported", + Self::GenerationFailed => "generation_failed", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "unknown" => Ok(Self::Unknown), + "rule_bans_user_image" => Ok(Self::RuleBansUserImage), + "rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces), + "rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt), + "rule_bans_user_content" => Ok(Self::RuleBansUserContent), + "rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo), + "rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio), + "rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent), + "no_foreground_subject_detected" => Ok(Self::NoForegroundSubjectDetected), + "format_not_supported" => Ok(Self::FormatNotSupported), + "generation_failed" => Ok(Self::GenerationFailed), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Unknown, + Self::RuleBansUserImage, + Self::RuleBansUserImageWithFaces, + Self::RuleBansUserTextPrompt, + Self::RuleBansUserContent, + Self::RuleBansGeneratedVideo, + Self::RuleBansGeneratedAudio, + Self::RuleBansGeneratedContent, + Self::NoForegroundSubjectDetected, + Self::FormatNotSupported, + Self::GenerationFailed, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::tauri::tasks::task_failure_type::TaskFailureType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(TaskFailureType::Unknown, "unknown"); + assert_serialization(TaskFailureType::RuleBansUserImage, "rule_bans_user_image"); + assert_serialization(TaskFailureType::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces"); + assert_serialization(TaskFailureType::RuleBansUserTextPrompt, "rule_bans_user_text_prompt"); + assert_serialization(TaskFailureType::RuleBansUserContent, "rule_bans_user_content"); + assert_serialization(TaskFailureType::RuleBansGeneratedVideo, "rule_bans_generated_video"); + assert_serialization(TaskFailureType::RuleBansGeneratedAudio, "rule_bans_generated_audio"); + assert_serialization(TaskFailureType::RuleBansGeneratedContent, "rule_bans_generated_content"); + assert_serialization(TaskFailureType::NoForegroundSubjectDetected, "no_foreground_subject_detected"); + assert_serialization(TaskFailureType::FormatNotSupported, "format_not_supported"); + assert_serialization(TaskFailureType::GenerationFailed, "generation_failed"); + } + + #[test] + fn to_str() { + assert_eq!(TaskFailureType::Unknown.to_str(), "unknown"); + assert_eq!(TaskFailureType::RuleBansUserImage.to_str(), "rule_bans_user_image"); + assert_eq!(TaskFailureType::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces"); + assert_eq!(TaskFailureType::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt"); + assert_eq!(TaskFailureType::RuleBansUserContent.to_str(), "rule_bans_user_content"); + assert_eq!(TaskFailureType::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video"); + assert_eq!(TaskFailureType::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio"); + assert_eq!(TaskFailureType::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content"); + assert_eq!(TaskFailureType::NoForegroundSubjectDetected.to_str(), "no_foreground_subject_detected"); + assert_eq!(TaskFailureType::FormatNotSupported.to_str(), "format_not_supported"); + assert_eq!(TaskFailureType::GenerationFailed.to_str(), "generation_failed"); + } + + #[test] + fn from_str() { + assert_eq!(TaskFailureType::from_str("unknown").unwrap(), TaskFailureType::Unknown); + assert_eq!(TaskFailureType::from_str("rule_bans_user_image").unwrap(), TaskFailureType::RuleBansUserImage); + assert_eq!(TaskFailureType::from_str("rule_bans_user_image_with_faces").unwrap(), TaskFailureType::RuleBansUserImageWithFaces); + assert_eq!(TaskFailureType::from_str("rule_bans_user_text_prompt").unwrap(), TaskFailureType::RuleBansUserTextPrompt); + assert_eq!(TaskFailureType::from_str("rule_bans_user_content").unwrap(), TaskFailureType::RuleBansUserContent); + assert_eq!(TaskFailureType::from_str("rule_bans_generated_video").unwrap(), TaskFailureType::RuleBansGeneratedVideo); + assert_eq!(TaskFailureType::from_str("rule_bans_generated_audio").unwrap(), TaskFailureType::RuleBansGeneratedAudio); + assert_eq!(TaskFailureType::from_str("rule_bans_generated_content").unwrap(), TaskFailureType::RuleBansGeneratedContent); + assert_eq!(TaskFailureType::from_str("no_foreground_subject_detected").unwrap(), TaskFailureType::NoForegroundSubjectDetected); + assert_eq!(TaskFailureType::from_str("format_not_supported").unwrap(), TaskFailureType::FormatNotSupported); + assert_eq!(TaskFailureType::from_str("generation_failed").unwrap(), TaskFailureType::GenerationFailed); + } + + #[test] + fn from_str_err() { + let result = TaskFailureType::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = TaskFailureType::all_variants(); + assert_eq!(variants.len(), 11); + assert_eq!(variants.pop_first(), Some(TaskFailureType::Unknown)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImage)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImageWithFaces)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserTextPrompt)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserContent)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedVideo)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedAudio)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedContent)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::NoForegroundSubjectDetected)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::FormatNotSupported)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::GenerationFailed)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(TaskFailureType::all_variants().len(), TaskFailureType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TaskFailureType::all_variants() { + assert_eq!(variant, TaskFailureType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TaskFailureType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TaskFailureType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/tauri/tasks/task_media_file_class.rs b/crates/schema/public/enums/src/tauri/tasks/task_media_file_class.rs new file mode 100644 index 0000000000..3f6515915b --- /dev/null +++ b/crates/schema/public/enums/src/tauri/tasks/task_media_file_class.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TaskMediaFileClass { + /// Audio files: wav, mp3, etc. + Audio, + + /// Image files: png, jpeg, etc. + Image, + + /// Video files: mp4, etc. + Video, + + /// 3D engine data: glb, gltf, etc. + Dimensional, +} + +impl_enum_display_and_debug_using_to_str!(TaskMediaFileClass); +//impl_mysql_enum_coders!(TaskMediaFileType); +//impl_mysql_from_row!(TaskMediaFileType); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl TaskMediaFileClass { + pub fn to_str(&self) -> &'static str { + match self { + Self::Audio => "audio", + Self::Image => "image", + Self::Video => "video", + Self::Dimensional => "dimensional", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "audio" => Ok(Self::Audio), + "image" => Ok(Self::Image), + "video" => Ok(Self::Video), + "dimensional" => Ok(Self::Dimensional), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Audio, + Self::Image, + Self::Video, + Self::Dimensional, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::tauri::tasks::task_media_file_class::TaskMediaFileClass; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(TaskMediaFileClass::Audio, "audio"); + assert_serialization(TaskMediaFileClass::Image, "image"); + assert_serialization(TaskMediaFileClass::Video, "video"); + assert_serialization(TaskMediaFileClass::Dimensional, "dimensional"); + } + + #[test] + fn to_str() { + assert_eq!(TaskMediaFileClass::Audio.to_str(), "audio"); + assert_eq!(TaskMediaFileClass::Image.to_str(), "image"); + assert_eq!(TaskMediaFileClass::Video.to_str(), "video"); + assert_eq!(TaskMediaFileClass::Dimensional.to_str(), "dimensional"); + } + + #[test] + fn from_str() { + assert_eq!(TaskMediaFileClass::from_str("audio").unwrap(), TaskMediaFileClass::Audio); + assert_eq!(TaskMediaFileClass::from_str("image").unwrap(), TaskMediaFileClass::Image); + assert_eq!(TaskMediaFileClass::from_str("video").unwrap(), TaskMediaFileClass::Video); + assert_eq!(TaskMediaFileClass::from_str("dimensional").unwrap(), TaskMediaFileClass::Dimensional); + } + + #[test] + fn from_str_err() { + let result = TaskMediaFileClass::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = TaskMediaFileClass::all_variants(); + assert_eq!(variants.len(), 4); + assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Audio)); + assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Image)); + assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Video)); + assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Dimensional)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(TaskMediaFileClass::all_variants().len(), TaskMediaFileClass::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TaskMediaFileClass::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, TaskMediaFileClass::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TaskMediaFileClass::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TaskMediaFileClass::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs b/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs new file mode 100644 index 0000000000..7a948839fd --- /dev/null +++ b/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs @@ -0,0 +1,622 @@ +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TaskModelType { + // Image models + #[serde(rename = "flux_1_dev")] + Flux1Dev, + #[serde(rename = "flux_1_schnell")] + Flux1Schnell, + #[serde(rename = "flux_dev_juggernaut")] + FluxDevJuggernaut, + // NB: For inpainting for now + #[serde(rename = "flux_pro_1")] + FluxPro1, + #[serde(rename = "flux_pro_1.1")] + FluxPro11, + #[serde(rename = "flux_pro_1.1_ultra")] + FluxPro11Ultra, + #[serde(rename = "flux_pro_kontext_max")] + FluxProKontextMax, + #[serde(rename = "gemini_25_flash")] + Gemini25Flash, + #[serde(rename = "nano_banana")] + NanoBanana, + #[serde(rename = "nano_banana_2")] + NanoBanana2, + #[serde(rename = "nano_banana_pro")] + NanoBananaPro, + #[serde(rename = "gpt_image_1")] + GptImage1, + #[serde(rename = "gpt_image_1p5")] + GptImage1p5, + #[serde(rename = "gpt_image_2")] + GptImage2, + #[serde(rename = "gpt_image_2p5_flare")] + GptImage2p5Flare, + #[serde(rename = "gpt_image_2p5_sunburst")] + GptImage2p5Sunburst, + #[serde(rename = "seedream_4")] + Seedream4, + #[serde(rename = "seedream_4p5")] + Seedream4p5, + #[serde(rename = "seedream_5_lite")] + Seedream5Lite, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + #[serde(rename = "grok_image")] + GrokImage, + #[serde(rename = "recraft_3")] + Recraft3, + + // Generic Midjourney model, version unknown. + #[serde(rename = "midjourney")] + Midjourney, + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, + + // Video models + #[serde(rename = "grok_video")] + GrokVideo, // Video version unspecified/unknown + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1.6_pro")] + Kling16Pro, + #[serde(rename = "kling_2.1_pro")] + Kling21Pro, + #[serde(rename = "kling_2.1_master")] + Kling21Master, + #[serde(rename = "kling_2p5_turbo_pro")] + Kling2p5TurboPro, + #[serde(rename = "kling_2p6_pro")] + Kling2p6Pro, + #[serde(rename = "kling_3p0_standard")] + Kling3p0Standard, + #[serde(rename = "kling_3p0_pro")] + Kling3p0Pro, + #[serde(rename = "happy_horse_1p0")] + HappyHorse1p0, + #[serde(rename = "seedance_1.0_lite")] + Seedance10Lite, + #[serde(rename = "seedance_1p5_pro")] + Seedance1p5Pro, + #[serde(rename = "seedance_2p0")] + Seedance2p0, + #[serde(rename = "seedance_2p0_fast")] + Seedance2p0Fast, + #[serde(rename = "sora_2")] + Sora2, + #[serde(rename = "sora_2_pro")] + Sora2Pro, + #[serde(rename = "veo_2")] + Veo2, + #[serde(rename = "veo_3")] + Veo3, + #[serde(rename = "veo_3_fast")] + Veo3Fast, + #[serde(rename = "veo_3p1")] + Veo3p1, + #[serde(rename = "veo_3p1_fast")] + Veo3p1Fast, + + // 3D Object generation models + #[serde(rename = "hunyuan_3d_2.0")] + Hunyuan3d2_0, + #[serde(rename = "hunyuan_3d_2.1")] + Hunyuan3d2_1, + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + #[serde(rename = "worldlabs_marble")] + WorldlabsMarble, + #[serde(rename = "marble_0p1_mini")] + WorldlabsMarble0p1Mini, + #[serde(rename = "marble_0p1_plus")] + WorldlabsMarble0p1Plus, + + /// Preserve catalog IDs introduced after this desktop build. + #[serde(untagged)] + #[cfg_attr(test, strum(disabled))] + Unknown(String), +} + +impl_enum_display_and_debug_using_to_str!(TaskModelType); +//impl_mysql_enum_coders!(TaskModelType); +//impl_mysql_from_row!(TaskModelType); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl TaskModelType { + pub fn to_str(&self) -> &str { + match self { + Self::Unknown(value) => value, + // Image models + Self::Flux1Dev => "flux_1_dev", + Self::Flux1Schnell => "flux_1_schnell", + Self::FluxDevJuggernaut => "flux_dev_juggernaut", + Self::FluxPro1 => "flux_pro_1", + Self::FluxPro11 => "flux_pro_1.1", + Self::FluxPro11Ultra => "flux_pro_1.1_ultra", + Self::FluxProKontextMax => "flux_pro_kontext_max", + Self::Gemini25Flash => "gemini_25_flash", + Self::NanoBanana => "nano_banana", + Self::NanoBanana2 => "nano_banana_2", + Self::NanoBananaPro => "nano_banana_pro", + Self::GptImage1 => "gpt_image_1", + Self::GptImage1p5 => "gpt_image_1p5", + Self::GptImage2 => "gpt_image_2", + Self::GptImage2p5Flare => "gpt_image_2p5_flare", + Self::GptImage2p5Sunburst => "gpt_image_2p5_sunburst", + Self::Seedream4 => "seedream_4", + Self::Seedream4p5 => "seedream_4p5", + Self::Seedream5Lite => "seedream_5_lite", + Self::QwenEdit2511Angles => "qwen_edit_2511_angles", + Self::Flux2LoraAngles => "flux_2_lora_angles", + Self::GrokImage => "grok_image", + Self::Recraft3 => "recraft_3", + Self::Midjourney => "midjourney", + Self::Midjourney7 => "midjourney_7", + Self::Midjourney7Niji => "midjourney_7_niji", + Self::Midjourney8 => "midjourney_8", + // Video models + Self::GrokVideo => "grok_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", + Self::Kling16Pro => "kling_1.6_pro", + Self::Kling21Pro => "kling_2.1_pro", + Self::Kling21Master => "kling_2.1_master", + Self::Kling2p5TurboPro => "kling_2p5_turbo_pro", + Self::Kling2p6Pro => "kling_2p6_pro", + Self::Kling3p0Standard => "kling_3p0_standard", + Self::Kling3p0Pro => "kling_3p0_pro", + Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Seedance10Lite => "seedance_1.0_lite", + Self::Seedance1p5Pro => "seedance_1p5_pro", + Self::Seedance2p0 => "seedance_2p0", + Self::Seedance2p0Fast => "seedance_2p0_fast", + Self::Sora2 => "sora_2", + Self::Sora2Pro => "sora_2_pro", + Self::Veo2 => "veo_2", + Self::Veo3 => "veo_3", + Self::Veo3Fast => "veo_3_fast", + Self::Veo3p1 => "veo_3p1", + Self::Veo3p1Fast => "veo_3p1_fast", + // 3D Object generation models + Self::Hunyuan3d2_0 => "hunyuan_3d_2.0", + Self::Hunyuan3d2_1 => "hunyuan_3d_2.1", + Self::Hunyuan3d3 => "hunyuan_3d_3", + Self::WorldlabsMarble => "worldlabs_marble", + Self::WorldlabsMarble0p1Mini => "marble_0p1_mini", + Self::WorldlabsMarble0p1Plus => "marble_0p1_plus", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + // Image models + "flux_1_dev" => Ok(Self::Flux1Dev), + "flux_1_schnell" => Ok(Self::Flux1Schnell), + "flux_dev_juggernaut" => Ok(Self::FluxDevJuggernaut), + "flux_pro_1" => Ok(Self::FluxPro1), + "flux_pro_1.1" => Ok(Self::FluxPro11), + "flux_pro_1.1_ultra" => Ok(Self::FluxPro11Ultra), + "flux_pro_kontext_max" => Ok(Self::FluxProKontextMax), + "gemini_25_flash" => Ok(Self::Gemini25Flash), + "nano_banana" => Ok(Self::NanoBanana), + "nano_banana_2" => Ok(Self::NanoBanana2), + "nano_banana_pro" => Ok(Self::NanoBananaPro), + "gpt_image_1" => Ok(Self::GptImage1), + "gpt_image_1p5" => Ok(Self::GptImage1p5), + "gpt_image_2" => Ok(Self::GptImage2), + "gpt_image_2p5_flare" => Ok(Self::GptImage2p5Flare), + "gpt_image_2p5_sunburst" => Ok(Self::GptImage2p5Sunburst), + "seedream_4" => Ok(Self::Seedream4), + "seedream_4p5" => Ok(Self::Seedream4p5), + "seedream_5_lite" => Ok(Self::Seedream5Lite), + "qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles), + "flux_2_lora_angles" => Ok(Self::Flux2LoraAngles), + "grok_image" => Ok(Self::GrokImage), + "recraft_3" => Ok(Self::Recraft3), + "midjourney" => Ok(Self::Midjourney), + "midjourney_7" => Ok(Self::Midjourney7), + "midjourney_7_niji" => Ok(Self::Midjourney7Niji), + "midjourney_8" => Ok(Self::Midjourney8), + // Video models + "grok_video" => Ok(Self::GrokVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), + "kling_1.6_pro" => Ok(Self::Kling16Pro), + "kling_2.1_pro" => Ok(Self::Kling21Pro), + "kling_2.1_master" => Ok(Self::Kling21Master), + "kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro), + "kling_2p6_pro" => Ok(Self::Kling2p6Pro), + "kling_3p0_standard" => Ok(Self::Kling3p0Standard), + "kling_3p0_pro" => Ok(Self::Kling3p0Pro), + "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "seedance_1.0_lite" => Ok(Self::Seedance10Lite), + "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), + "seedance_2p0" => Ok(Self::Seedance2p0), + "seedance_2p0_fast" => Ok(Self::Seedance2p0Fast), + "sora_2" => Ok(Self::Sora2), + "sora_2_pro" => Ok(Self::Sora2Pro), + "veo_2" => Ok(Self::Veo2), + "veo_3" => Ok(Self::Veo3), + "veo_3_fast" => Ok(Self::Veo3Fast), + "veo_3p1" => Ok(Self::Veo3p1), + "veo_3p1_fast" => Ok(Self::Veo3p1Fast), + // 3D Object generation models + "hunyuan_3d_2.0" => Ok(Self::Hunyuan3d2_0), + "hunyuan_3d_2.1" => Ok(Self::Hunyuan3d2_1), + "hunyuan_3d_3" => Ok(Self::Hunyuan3d3), + "worldlabs_marble" => Ok(Self::WorldlabsMarble), + "marble_0p1_mini" => Ok(Self::WorldlabsMarble0p1Mini), + "marble_0p1_plus" => Ok(Self::WorldlabsMarble0p1Plus), + _ => Ok(Self::Unknown(value.to_owned())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + // Image models + Self::Flux1Dev, + Self::Flux1Schnell, + Self::FluxDevJuggernaut, + Self::FluxPro1, + Self::FluxPro11, + Self::FluxPro11Ultra, + Self::FluxProKontextMax, + Self::Gemini25Flash, + Self::NanoBanana, + Self::NanoBanana2, + Self::NanoBananaPro, + Self::GptImage1, + Self::GptImage1p5, + Self::GptImage2, + Self::GptImage2p5Flare, + Self::GptImage2p5Sunburst, + Self::Seedream4, + Self::Seedream4p5, + Self::Seedream5Lite, + Self::QwenEdit2511Angles, + Self::Flux2LoraAngles, + Self::GrokImage, + Self::Recraft3, + Self::Midjourney, + Self::Midjourney7, + Self::Midjourney7Niji, + Self::Midjourney8, + // Video models + Self::GrokVideo, + Self::GrokImagineVideo1p5, + Self::Kling16Pro, + Self::Kling21Pro, + Self::Kling21Master, + Self::Kling2p5TurboPro, + Self::Kling2p6Pro, + Self::Kling3p0Standard, + Self::Kling3p0Pro, + Self::HappyHorse1p0, + Self::Seedance10Lite, + Self::Seedance1p5Pro, + Self::Seedance2p0, + Self::Seedance2p0Fast, + Self::Sora2, + Self::Sora2Pro, + Self::Veo2, + Self::Veo3, + Self::Veo3Fast, + Self::Veo3p1, + Self::Veo3p1Fast, + // 3D Object generation models + Self::Hunyuan3d2_0, + Self::Hunyuan3d2_1, + Self::Hunyuan3d3, + Self::WorldlabsMarble, + Self::WorldlabsMarble0p1Mini, + Self::WorldlabsMarble0p1Plus, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::tauri::tasks::task_model_type::TaskModelType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + // Image models + assert_serialization(TaskModelType::Flux1Dev, "flux_1_dev"); + assert_serialization(TaskModelType::Flux1Schnell, "flux_1_schnell"); + assert_serialization(TaskModelType::FluxDevJuggernaut, "flux_dev_juggernaut"); + assert_serialization(TaskModelType::FluxPro1, "flux_pro_1"); + assert_serialization(TaskModelType::FluxPro11, "flux_pro_1.1"); + assert_serialization(TaskModelType::FluxPro11Ultra, "flux_pro_1.1_ultra"); + assert_serialization(TaskModelType::FluxProKontextMax, "flux_pro_kontext_max"); + assert_serialization(TaskModelType::Gemini25Flash, "gemini_25_flash"); + assert_serialization(TaskModelType::NanoBanana, "nano_banana"); + assert_serialization(TaskModelType::NanoBanana2, "nano_banana_2"); + assert_serialization(TaskModelType::NanoBananaPro, "nano_banana_pro"); + assert_serialization(TaskModelType::GptImage1, "gpt_image_1"); + assert_serialization(TaskModelType::GptImage1p5, "gpt_image_1p5"); + assert_serialization(TaskModelType::GptImage2, "gpt_image_2"); + assert_serialization(TaskModelType::GptImage2p5Flare, "gpt_image_2p5_flare"); + assert_serialization(TaskModelType::GptImage2p5Sunburst, "gpt_image_2p5_sunburst"); + assert_serialization(TaskModelType::Seedream4, "seedream_4"); + assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5"); + assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite"); + assert_serialization(TaskModelType::QwenEdit2511Angles, "qwen_edit_2511_angles"); + assert_serialization(TaskModelType::Flux2LoraAngles, "flux_2_lora_angles"); + assert_serialization(TaskModelType::GrokImage, "grok_image"); + assert_serialization(TaskModelType::Recraft3, "recraft_3"); + assert_serialization(TaskModelType::Midjourney, "midjourney"); + // Video models + assert_serialization(TaskModelType::Midjourney7, "midjourney_7"); + assert_serialization(TaskModelType::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(TaskModelType::Midjourney8, "midjourney_8"); + assert_serialization(TaskModelType::GrokVideo, "grok_video"); + assert_serialization(TaskModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5"); + assert_serialization(TaskModelType::Kling16Pro, "kling_1.6_pro"); + assert_serialization(TaskModelType::Kling21Pro, "kling_2.1_pro"); + assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master"); + assert_serialization(TaskModelType::Kling2p5TurboPro, "kling_2p5_turbo_pro"); + assert_serialization(TaskModelType::Kling2p6Pro, "kling_2p6_pro"); + assert_serialization(TaskModelType::Kling3p0Standard, "kling_3p0_standard"); + assert_serialization(TaskModelType::Kling3p0Pro, "kling_3p0_pro"); + assert_serialization(TaskModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite"); + assert_serialization(TaskModelType::Seedance1p5Pro, "seedance_1p5_pro"); + assert_serialization(TaskModelType::Seedance2p0, "seedance_2p0"); + assert_serialization(TaskModelType::Seedance2p0Fast, "seedance_2p0_fast"); + assert_serialization(TaskModelType::Sora2, "sora_2"); + assert_serialization(TaskModelType::Sora2Pro, "sora_2_pro"); + assert_serialization(TaskModelType::Veo2, "veo_2"); + assert_serialization(TaskModelType::Veo3, "veo_3"); + assert_serialization(TaskModelType::Veo3Fast, "veo_3_fast"); + assert_serialization(TaskModelType::Veo3p1, "veo_3p1"); + assert_serialization(TaskModelType::Veo3p1Fast, "veo_3p1_fast"); + // 3D Object generation models + assert_serialization(TaskModelType::Hunyuan3d2_0, "hunyuan_3d_2.0"); + assert_serialization(TaskModelType::Hunyuan3d2_1, "hunyuan_3d_2.1"); + assert_serialization(TaskModelType::Hunyuan3d3, "hunyuan_3d_3"); + assert_serialization(TaskModelType::WorldlabsMarble, "worldlabs_marble"); + assert_serialization(TaskModelType::WorldlabsMarble0p1Mini, "marble_0p1_mini"); + assert_serialization(TaskModelType::WorldlabsMarble0p1Plus, "marble_0p1_plus"); + } + + #[test] + fn to_str() { + // Image models + assert_eq!(TaskModelType::Flux1Dev.to_str(), "flux_1_dev"); + assert_eq!(TaskModelType::Flux1Schnell.to_str(), "flux_1_schnell"); + assert_eq!(TaskModelType::FluxDevJuggernaut.to_str(), "flux_dev_juggernaut"); + assert_eq!(TaskModelType::FluxPro1.to_str(), "flux_pro_1"); + assert_eq!(TaskModelType::FluxPro11.to_str(), "flux_pro_1.1"); + assert_eq!(TaskModelType::FluxPro11Ultra.to_str(), "flux_pro_1.1_ultra"); + assert_eq!(TaskModelType::FluxProKontextMax.to_str(), "flux_pro_kontext_max"); + assert_eq!(TaskModelType::Gemini25Flash.to_str(), "gemini_25_flash"); + assert_eq!(TaskModelType::NanoBanana.to_str(), "nano_banana"); + assert_eq!(TaskModelType::NanoBanana2.to_str(), "nano_banana_2"); + assert_eq!(TaskModelType::NanoBananaPro.to_str(), "nano_banana_pro"); + assert_eq!(TaskModelType::GptImage1.to_str(), "gpt_image_1"); + assert_eq!(TaskModelType::GptImage1p5.to_str(), "gpt_image_1p5"); + assert_eq!(TaskModelType::GptImage2.to_str(), "gpt_image_2"); + assert_eq!(TaskModelType::GptImage2p5Flare.to_str(), "gpt_image_2p5_flare"); + assert_eq!(TaskModelType::GptImage2p5Sunburst.to_str(), "gpt_image_2p5_sunburst"); + assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4"); + assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5"); + assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite"); + assert_eq!(TaskModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles"); + assert_eq!(TaskModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles"); + assert_eq!(TaskModelType::GrokImage.to_str(), "grok_image"); + assert_eq!(TaskModelType::Recraft3.to_str(), "recraft_3"); + assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney"); + // Video models + assert_eq!(TaskModelType::GrokVideo.to_str(), "grok_video"); + assert_eq!(TaskModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5"); + assert_eq!(TaskModelType::Kling16Pro.to_str(), "kling_1.6_pro"); + assert_eq!(TaskModelType::Kling21Pro.to_str(), "kling_2.1_pro"); + assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master"); + assert_eq!(TaskModelType::Kling2p5TurboPro.to_str(), "kling_2p5_turbo_pro"); + assert_eq!(TaskModelType::Kling2p6Pro.to_str(), "kling_2p6_pro"); + assert_eq!(TaskModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); + assert_eq!(TaskModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); + assert_eq!(TaskModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite"); + assert_eq!(TaskModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro"); + assert_eq!(TaskModelType::Seedance2p0.to_str(), "seedance_2p0"); + assert_eq!(TaskModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast"); + assert_eq!(TaskModelType::Sora2.to_str(), "sora_2"); + assert_eq!(TaskModelType::Sora2Pro.to_str(), "sora_2_pro"); + assert_eq!(TaskModelType::Veo2.to_str(), "veo_2"); + assert_eq!(TaskModelType::Veo3.to_str(), "veo_3"); + assert_eq!(TaskModelType::Veo3Fast.to_str(), "veo_3_fast"); + assert_eq!(TaskModelType::Veo3p1.to_str(), "veo_3p1"); + assert_eq!(TaskModelType::Veo3p1Fast.to_str(), "veo_3p1_fast"); + // 3D Object generation models + assert_eq!(TaskModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2.0"); + assert_eq!(TaskModelType::Hunyuan3d2_1.to_str(), "hunyuan_3d_2.1"); + assert_eq!(TaskModelType::Hunyuan3d3.to_str(), "hunyuan_3d_3"); + assert_eq!(TaskModelType::WorldlabsMarble.to_str(), "worldlabs_marble"); + assert_eq!(TaskModelType::WorldlabsMarble0p1Mini.to_str(), "marble_0p1_mini"); + assert_eq!(TaskModelType::WorldlabsMarble0p1Plus.to_str(), "marble_0p1_plus"); + } + + #[test] + fn from_str() { + // Image models + assert_eq!(TaskModelType::from_str("flux_1_dev").unwrap(), TaskModelType::Flux1Dev); + assert_eq!(TaskModelType::from_str("flux_1_schnell").unwrap(), TaskModelType::Flux1Schnell); + assert_eq!(TaskModelType::from_str("flux_dev_juggernaut").unwrap(), TaskModelType::FluxDevJuggernaut); + assert_eq!(TaskModelType::from_str("flux_pro_1").unwrap(), TaskModelType::FluxPro1); + assert_eq!(TaskModelType::from_str("flux_pro_1.1").unwrap(), TaskModelType::FluxPro11); + assert_eq!(TaskModelType::from_str("flux_pro_1.1_ultra").unwrap(), TaskModelType::FluxPro11Ultra); + assert_eq!(TaskModelType::from_str("flux_pro_kontext_max").unwrap(), TaskModelType::FluxProKontextMax); + assert_eq!(TaskModelType::from_str("gemini_25_flash").unwrap(), TaskModelType::Gemini25Flash); + assert_eq!(TaskModelType::from_str("nano_banana").unwrap(), TaskModelType::NanoBanana); + assert_eq!(TaskModelType::from_str("nano_banana_2").unwrap(), TaskModelType::NanoBanana2); + assert_eq!(TaskModelType::from_str("nano_banana_pro").unwrap(), TaskModelType::NanoBananaPro); + assert_eq!(TaskModelType::from_str("gpt_image_1").unwrap(), TaskModelType::GptImage1); + assert_eq!(TaskModelType::from_str("gpt_image_1p5").unwrap(), TaskModelType::GptImage1p5); + assert_eq!(TaskModelType::from_str("gpt_image_2").unwrap(), TaskModelType::GptImage2); + assert_eq!(TaskModelType::from_str("gpt_image_2p5_flare").unwrap(), TaskModelType::GptImage2p5Flare); + assert_eq!(TaskModelType::from_str("gpt_image_2p5_sunburst").unwrap(), TaskModelType::GptImage2p5Sunburst); + assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4); + assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5); + assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite); + assert_eq!(TaskModelType::from_str("qwen_edit_2511_angles").unwrap(), TaskModelType::QwenEdit2511Angles); + assert_eq!(TaskModelType::from_str("flux_2_lora_angles").unwrap(), TaskModelType::Flux2LoraAngles); + assert_eq!(TaskModelType::from_str("grok_image").unwrap(), TaskModelType::GrokImage); + assert_eq!(TaskModelType::from_str("recraft_3").unwrap(), TaskModelType::Recraft3); + assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney); + // Video models + assert_eq!(TaskModelType::from_str("grok_video").unwrap(), TaskModelType::GrokVideo); + assert_eq!(TaskModelType::from_str("grok_imagine_video_1p5").unwrap(), TaskModelType::GrokImagineVideo1p5); + assert_eq!(TaskModelType::from_str("kling_1.6_pro").unwrap(), TaskModelType::Kling16Pro); + assert_eq!(TaskModelType::from_str("kling_2.1_pro").unwrap(), TaskModelType::Kling21Pro); + assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master); + assert_eq!(TaskModelType::from_str("kling_2p5_turbo_pro").unwrap(), TaskModelType::Kling2p5TurboPro); + assert_eq!(TaskModelType::from_str("kling_2p6_pro").unwrap(), TaskModelType::Kling2p6Pro); + assert_eq!(TaskModelType::from_str("kling_3p0_standard").unwrap(), TaskModelType::Kling3p0Standard); + assert_eq!(TaskModelType::from_str("kling_3p0_pro").unwrap(), TaskModelType::Kling3p0Pro); + assert_eq!(TaskModelType::from_str("happy_horse_1p0").unwrap(), TaskModelType::HappyHorse1p0); + assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite); + assert_eq!(TaskModelType::from_str("seedance_1p5_pro").unwrap(), TaskModelType::Seedance1p5Pro); + assert_eq!(TaskModelType::from_str("seedance_2p0").unwrap(), TaskModelType::Seedance2p0); + assert_eq!(TaskModelType::from_str("seedance_2p0_fast").unwrap(), TaskModelType::Seedance2p0Fast); + assert_eq!(TaskModelType::from_str("sora_2").unwrap(), TaskModelType::Sora2); + assert_eq!(TaskModelType::from_str("sora_2_pro").unwrap(), TaskModelType::Sora2Pro); + assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2); + assert_eq!(TaskModelType::from_str("veo_3").unwrap(), TaskModelType::Veo3); + assert_eq!(TaskModelType::from_str("veo_3_fast").unwrap(), TaskModelType::Veo3Fast); + assert_eq!(TaskModelType::from_str("veo_3p1").unwrap(), TaskModelType::Veo3p1); + assert_eq!(TaskModelType::from_str("veo_3p1_fast").unwrap(), TaskModelType::Veo3p1Fast); + // 3D Object generation models + assert_eq!(TaskModelType::from_str("hunyuan_3d_2.0").unwrap(), TaskModelType::Hunyuan3d2_0); + assert_eq!(TaskModelType::from_str("hunyuan_3d_2.1").unwrap(), TaskModelType::Hunyuan3d2_1); + assert_eq!(TaskModelType::from_str("hunyuan_3d_3").unwrap(), TaskModelType::Hunyuan3d3); + assert_eq!(TaskModelType::from_str("worldlabs_marble").unwrap(), TaskModelType::WorldlabsMarble); + assert_eq!(TaskModelType::from_str("marble_0p1_mini").unwrap(), TaskModelType::WorldlabsMarble0p1Mini); + assert_eq!(TaskModelType::from_str("marble_0p1_plus").unwrap(), TaskModelType::WorldlabsMarble0p1Plus); + } + + #[test] + fn unknown_catalog_id_round_trips() { + let model = TaskModelType::from_str("future_video_v9").unwrap(); + assert_eq!(model.to_str(), "future_video_v9"); + assert_eq!(serde_json::to_string(&model).unwrap(), "\"future_video_v9\""); + assert_eq!(serde_json::from_str::("\"future_video_v9\"").unwrap(), model); + } + + #[test] + fn all_variants() { + let mut variants = TaskModelType::all_variants(); + assert_eq!(variants.len(), 54); + // Image models + assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell)); + assert_eq!(variants.pop_first(), Some(TaskModelType::FluxDevJuggernaut)); + assert_eq!(variants.pop_first(), Some(TaskModelType::FluxPro1)); + assert_eq!(variants.pop_first(), Some(TaskModelType::FluxPro11)); + assert_eq!(variants.pop_first(), Some(TaskModelType::FluxPro11Ultra)); + assert_eq!(variants.pop_first(), Some(TaskModelType::FluxProKontextMax)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Gemini25Flash)); + assert_eq!(variants.pop_first(), Some(TaskModelType::NanoBanana)); + assert_eq!(variants.pop_first(), Some(TaskModelType::NanoBanana2)); + assert_eq!(variants.pop_first(), Some(TaskModelType::NanoBananaPro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1p5)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage2)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage2p5Flare)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage2p5Sunburst)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite)); + assert_eq!(variants.pop_first(), Some(TaskModelType::QwenEdit2511Angles)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Flux2LoraAngles)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImage)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney7)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney7Niji)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney8)); + // Video models + assert_eq!(variants.pop_first(), Some(TaskModelType::GrokVideo)); + assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImagineVideo1p5)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling16Pro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Pro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling2p5TurboPro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling2p6Pro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Standard)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Pro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::HappyHorse1p0)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance1p5Pro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0Fast)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2Pro)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3Fast)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3p1)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3p1Fast)); + // 3D Object generation models + assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_0)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_1)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d3)); + assert_eq!(variants.pop_first(), Some(TaskModelType::WorldlabsMarble)); + assert_eq!(variants.pop_first(), Some(TaskModelType::WorldlabsMarble0p1Mini)); + assert_eq!(variants.pop_first(), Some(TaskModelType::WorldlabsMarble0p1Plus)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(TaskModelType::all_variants().len(), TaskModelType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TaskModelType::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, TaskModelType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TaskModelType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TaskModelType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 24; + for variant in TaskModelType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/schema/public/enums/src/tauri/tasks/task_status.rs b/crates/schema/public/enums/src/tauri/tasks/task_status.rs new file mode 100644 index 0000000000..cc130c8c77 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/tasks/task_status.rs @@ -0,0 +1,184 @@ +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + Pending, + Started, + CompleteSuccess, + CompleteFailure, + AttemptFailed, + Dead, + CancelledByUser, + CancelledByProvider, + CancelledByUs, +} + +impl_enum_display_and_debug_using_to_str!(TaskStatus); +//impl_mysql_enum_coders!(TaskStatus); +//impl_mysql_from_row!(TaskStatus); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl Default for TaskStatus { + fn default() -> Self { + Self::Pending + } +} + +impl TaskStatus { + pub const fn to_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Started => "started", + Self::CompleteSuccess => "complete_success", + Self::CompleteFailure => "complete_failure", + Self::AttemptFailed => "attempt_failed", + Self::Dead => "dead", + Self::CancelledByUser => "cancelled_by_user", + Self::CancelledByProvider => "cancelled_by_provider", + Self::CancelledByUs => "cancelled_by_us", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "started" => Ok(Self::Started), + "complete_success" => Ok(Self::CompleteSuccess), + "complete_failure" => Ok(Self::CompleteFailure), + "attempt_failed" => Ok(Self::AttemptFailed), + "dead" => Ok(Self::Dead), + "cancelled_by_user" => Ok(Self::CancelledByUser), + "cancelled_by_provider" => Ok(Self::CancelledByProvider), + "cancelled_by_us" => Ok(Self::CancelledByUs), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Pending, + Self::Started, + Self::CompleteSuccess, + Self::CompleteFailure, + Self::AttemptFailed, + Self::Dead, + Self::CancelledByUser, + Self::CancelledByProvider, + Self::CancelledByUs, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::tauri::tasks::task_status::TaskStatus; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_default() { + assert_eq!(TaskStatus::default(), TaskStatus::Pending); + } + + #[test] + fn test_serialization() { + assert_serialization(TaskStatus::Pending, "pending"); + assert_serialization(TaskStatus::Started, "started"); + assert_serialization(TaskStatus::CompleteSuccess, "complete_success"); + assert_serialization(TaskStatus::CompleteFailure, "complete_failure"); + assert_serialization(TaskStatus::AttemptFailed, "attempt_failed"); + assert_serialization(TaskStatus::Dead, "dead"); + assert_serialization(TaskStatus::CancelledByUser, "cancelled_by_user"); + assert_serialization(TaskStatus::CancelledByProvider, "cancelled_by_provider"); + assert_serialization(TaskStatus::CancelledByUs, "cancelled_by_us"); + } + + #[test] + fn to_str() { + assert_eq!(TaskStatus::Pending.to_str(), "pending"); + assert_eq!(TaskStatus::Started.to_str(), "started"); + assert_eq!(TaskStatus::CompleteSuccess.to_str(), "complete_success"); + assert_eq!(TaskStatus::CompleteFailure.to_str(), "complete_failure"); + assert_eq!(TaskStatus::AttemptFailed.to_str(), "attempt_failed"); + assert_eq!(TaskStatus::Dead.to_str(), "dead"); + assert_eq!(TaskStatus::CancelledByUser.to_str(), "cancelled_by_user"); + assert_eq!(TaskStatus::CancelledByProvider.to_str(), "cancelled_by_provider"); + assert_eq!(TaskStatus::CancelledByUs.to_str(), "cancelled_by_us"); + } + + #[test] + fn from_str() { + assert_eq!(TaskStatus::from_str("pending").unwrap(), TaskStatus::Pending); + assert_eq!(TaskStatus::from_str("started").unwrap(), TaskStatus::Started); + assert_eq!(TaskStatus::from_str("complete_success").unwrap(), TaskStatus::CompleteSuccess); + assert_eq!(TaskStatus::from_str("complete_failure").unwrap(), TaskStatus::CompleteFailure); + assert_eq!(TaskStatus::from_str("attempt_failed").unwrap(), TaskStatus::AttemptFailed); + assert_eq!(TaskStatus::from_str("dead").unwrap(), TaskStatus::Dead); + assert_eq!(TaskStatus::from_str("cancelled_by_user").unwrap(), TaskStatus::CancelledByUser); + assert_eq!(TaskStatus::from_str("cancelled_by_provider").unwrap(), TaskStatus::CancelledByProvider); + assert_eq!(TaskStatus::from_str("cancelled_by_us").unwrap(), TaskStatus::CancelledByUs); + } + + #[test] + fn from_str_err() { + let result = TaskStatus::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = TaskStatus::all_variants(); + assert_eq!(variants.len(), 9); + assert_eq!(variants.pop_first(), Some(TaskStatus::Pending)); + assert_eq!(variants.pop_first(), Some(TaskStatus::Started)); + assert_eq!(variants.pop_first(), Some(TaskStatus::CompleteSuccess)); + assert_eq!(variants.pop_first(), Some(TaskStatus::CompleteFailure)); + assert_eq!(variants.pop_first(), Some(TaskStatus::AttemptFailed)); + assert_eq!(variants.pop_first(), Some(TaskStatus::Dead)); + assert_eq!(variants.pop_first(), Some(TaskStatus::CancelledByUser)); + assert_eq!(variants.pop_first(), Some(TaskStatus::CancelledByProvider)); + assert_eq!(variants.pop_first(), Some(TaskStatus::CancelledByUs)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(TaskStatus::all_variants().len(), TaskStatus::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TaskStatus::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, TaskStatus::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TaskStatus::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TaskStatus::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/tauri/tasks/task_type.rs b/crates/schema/public/enums/src/tauri/tasks/task_type.rs new file mode 100644 index 0000000000..97e8863336 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/tasks/task_type.rs @@ -0,0 +1,154 @@ +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TaskType { + ImageGeneration, + ImageInpaintEdit, + VideoGeneration, + AudioGeneration, + ObjectGeneration, + GaussianGeneration, + BackgroundRemoval, +} + +impl_enum_display_and_debug_using_to_str!(TaskType); +//impl_mysql_enum_coders!(TaskType); +//impl_mysql_from_row!(TaskType); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl TaskType { + pub fn to_str(&self) -> &'static str { + match self { + Self::ImageGeneration => "image_generation", + Self::ImageInpaintEdit => "image_inpaint_edit", + Self::VideoGeneration => "video_generation", + Self::AudioGeneration => "audio_generation", + Self::ObjectGeneration => "object_generation", + Self::GaussianGeneration => "gaussian_generation", + Self::BackgroundRemoval => "background_removal", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "image_generation" => Ok(Self::ImageGeneration), + "image_inpaint_edit" => Ok(Self::ImageInpaintEdit), + "video_generation" => Ok(Self::VideoGeneration), + "audio_generation" => Ok(Self::AudioGeneration), + "object_generation" => Ok(Self::ObjectGeneration), + "gaussian_generation" => Ok(Self::GaussianGeneration), + "background_removal" => Ok(Self::BackgroundRemoval), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::ImageGeneration, + Self::ImageInpaintEdit, + Self::VideoGeneration, + Self::AudioGeneration, + Self::ObjectGeneration, + Self::GaussianGeneration, + Self::BackgroundRemoval, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::tauri::tasks::task_type::TaskType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(TaskType::ImageGeneration, "image_generation"); + assert_serialization(TaskType::ImageInpaintEdit, "image_inpaint_edit"); + assert_serialization(TaskType::VideoGeneration, "video_generation"); + assert_serialization(TaskType::ObjectGeneration, "object_generation"); + assert_serialization(TaskType::GaussianGeneration, "gaussian_generation"); + assert_serialization(TaskType::BackgroundRemoval, "background_removal"); + } + + #[test] + fn to_str() { + assert_eq!(TaskType::ImageGeneration.to_str(), "image_generation"); + assert_eq!(TaskType::ImageInpaintEdit.to_str(), "image_inpaint_edit"); + assert_eq!(TaskType::VideoGeneration.to_str(), "video_generation"); + assert_eq!(TaskType::ObjectGeneration.to_str(), "object_generation"); + assert_eq!(TaskType::GaussianGeneration.to_str(), "gaussian_generation"); + assert_eq!(TaskType::BackgroundRemoval.to_str(), "background_removal"); + } + + #[test] + fn from_str() { + assert_eq!(TaskType::from_str("image_generation").unwrap(), TaskType::ImageGeneration); + assert_eq!(TaskType::from_str("image_inpaint_edit").unwrap(), TaskType::ImageInpaintEdit); + assert_eq!(TaskType::from_str("video_generation").unwrap(), TaskType::VideoGeneration); + assert_eq!(TaskType::from_str("object_generation").unwrap(), TaskType::ObjectGeneration); + assert_eq!(TaskType::from_str("gaussian_generation").unwrap(), TaskType::GaussianGeneration); + assert_eq!(TaskType::from_str("background_removal").unwrap(), TaskType::BackgroundRemoval); + } + + #[test] + fn from_str_err() { + let result = TaskType::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = TaskType::all_variants(); + assert_eq!(variants.len(), 7); + assert_eq!(variants.pop_first(), Some(TaskType::ImageGeneration)); + assert_eq!(variants.pop_first(), Some(TaskType::ImageInpaintEdit)); + assert_eq!(variants.pop_first(), Some(TaskType::VideoGeneration)); + assert_eq!(variants.pop_first(), Some(TaskType::AudioGeneration)); + assert_eq!(variants.pop_first(), Some(TaskType::ObjectGeneration)); + assert_eq!(variants.pop_first(), Some(TaskType::GaussianGeneration)); + assert_eq!(variants.pop_first(), Some(TaskType::BackgroundRemoval)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(TaskType::all_variants().len(), TaskType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TaskType::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, TaskType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TaskType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TaskType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + } +} diff --git a/crates/schema/public/enums/src/tauri/ux/mod.rs b/crates/schema/public/enums/src/tauri/ux/mod.rs new file mode 100644 index 0000000000..40215426d6 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/ux/mod.rs @@ -0,0 +1,2 @@ +pub mod tauri_command_caller; +pub mod tauri_event_name; diff --git a/crates/schema/public/enums/src/tauri/ux/tauri_command_caller.rs b/crates/schema/public/enums/src/tauri/ux/tauri_command_caller.rs new file mode 100644 index 0000000000..82b8d07993 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/ux/tauri_command_caller.rs @@ -0,0 +1,158 @@ +use std::collections::BTreeSet; + +use crate::error::enum_error::EnumError; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Defines the names of the Tauri-sent events that the frontend subscribes to. +/// These event names are also stored in the database, so keep them short-ish. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TauriCommandCaller { + /// The 2D canvas + Canvas, + /// The inpainting editor + ImageEditor, + /// The text-to-image page + TextToImage, + /// The image-to-video page + ImageToVideo, + /// A mini-app (doesn't specify which one) + MiniApp, +} + +impl_enum_display_and_debug_using_to_str!(TauriCommandCaller); +impl_mysql_enum_coders!(TauriCommandCaller); +impl_mysql_from_row!(TauriCommandCaller); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl TauriCommandCaller { + pub fn to_str(&self) -> &'static str { + match self { + Self::Canvas => "canvas", + Self::ImageEditor => "image_editor", + Self::TextToImage => "text_to_image", + Self::ImageToVideo => "image_to_video", + Self::MiniApp => "mini_app", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "canvas" => Ok(Self::Canvas), + "image_editor" => Ok(Self::ImageEditor), + "text_to_image" => Ok(Self::TextToImage), + "image_to_video" => Ok(Self::ImageToVideo), + "mini_app" => Ok(Self::MiniApp), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::Canvas, + Self::ImageEditor, + Self::TextToImage, + Self::ImageToVideo, + Self::MiniApp, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::error::enum_error::EnumError; + use crate::tauri::ux::tauri_command_caller::TauriCommandCaller; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(TauriCommandCaller::Canvas, "canvas"); + assert_serialization(TauriCommandCaller::ImageEditor, "image_editor"); + assert_serialization(TauriCommandCaller::TextToImage, "text_to_image"); + assert_serialization(TauriCommandCaller::ImageToVideo, "image_to_video"); + assert_serialization(TauriCommandCaller::MiniApp, "mini_app"); + } + + #[test] + fn to_str() { + assert_eq!(TauriCommandCaller::Canvas.to_str(), "canvas"); + assert_eq!(TauriCommandCaller::ImageEditor.to_str(), "image_editor"); + assert_eq!(TauriCommandCaller::TextToImage.to_str(), "text_to_image"); + assert_eq!(TauriCommandCaller::ImageToVideo.to_str(), "image_to_video"); + assert_eq!(TauriCommandCaller::MiniApp.to_str(), "mini_app"); + } + + #[test] + fn from_str() { + assert_eq!(TauriCommandCaller::from_str("canvas").unwrap(), TauriCommandCaller::Canvas); + assert_eq!(TauriCommandCaller::from_str("image_editor").unwrap(), TauriCommandCaller::ImageEditor); + assert_eq!(TauriCommandCaller::from_str("text_to_image").unwrap(), TauriCommandCaller::TextToImage); + assert_eq!(TauriCommandCaller::from_str("image_to_video").unwrap(), TauriCommandCaller::ImageToVideo); + assert_eq!(TauriCommandCaller::from_str("mini_app").unwrap(), TauriCommandCaller::MiniApp); + } + + #[test] + fn from_str_err() { + let result = TauriCommandCaller::from_str("asdf"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "asdf"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = TauriCommandCaller::all_variants(); + assert_eq!(variants.len(), 5); + assert_eq!(variants.pop_first(), Some(TauriCommandCaller::Canvas)); + assert_eq!(variants.pop_first(), Some(TauriCommandCaller::ImageEditor)); + assert_eq!(variants.pop_first(), Some(TauriCommandCaller::TextToImage)); + assert_eq!(variants.pop_first(), Some(TauriCommandCaller::ImageToVideo)); + assert_eq!(variants.pop_first(), Some(TauriCommandCaller::MiniApp)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + use strum::IntoEnumIterator; + + #[test] + fn variant_length() { + assert_eq!(TauriCommandCaller::all_variants().len(), TauriCommandCaller::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TauriCommandCaller::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, TauriCommandCaller::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TauriCommandCaller::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TauriCommandCaller::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + //#[test] + //fn serialized_length_ok_for_database() { + // const MAX_LENGTH : usize = 16; + // for variant in TauriCommandCaller::all_variants() { + // let serialized = variant.to_str(); + // assert!(serialized.len() > 0, "variant {:?} is too short", variant); + // assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + // } + //} + } +} diff --git a/crates/schema/public/enums/src/tauri/ux/tauri_event_name.rs b/crates/schema/public/enums/src/tauri/ux/tauri_event_name.rs new file mode 100644 index 0000000000..de161cd8e6 --- /dev/null +++ b/crates/schema/public/enums/src/tauri/ux/tauri_event_name.rs @@ -0,0 +1,321 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Defines the names of the Tauri-sent events that the frontend subscribes to. +/// These event names are also stored in the database, so keep them short-ish. +/// +/// NB: Events should end in "_event" so they're easy to grep for in Javascript. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TauriEventName { + // TODO: Get rid of kebab case. + /// General purpose event: + /// Generation enqueued + #[serde(rename = "generation-enqueue-success-event")] + GenerationEnqueueSuccessEvent, + + // TODO: Get rid of kebab case. + /// General purpose event: + /// Generation failed to enqueue + #[serde(rename = "generation-enqueue-failure-event")] + GenerationEnqueueFailureEvent, + + // TODO: Get rid of kebab case. + /// General purpose event: + /// Generation completed successfully + #[serde(rename = "generation-complete-event")] + GenerationCompleteEvent, + + // TODO: Get rid of kebab case. + /// General purpose event: + /// Generation failed + #[serde(rename = "generation-failed-event")] + GenerationFailedEvent, + + /// Informational event: + /// Credits were purchased or spent (but we don't know how much) + #[serde(rename = "credits_balance_changed_event")] + CreditsBalanceChangedEvent, + + /// Informational event: + /// Subscription was changed (but we don't know the details - upgrade, cancel, etc.) + #[serde(rename = "subscription_plan_changed_event")] + SubscriptionPlanChangedEvent, + + /// Informational event: + /// A media file was deleted (and we'll send the token) + #[serde(rename = "media_file_deleted_event")] + MediaFileDeletedEvent, + + /// Special event: + /// Background removal is complete + #[serde(rename = "canvas_bg_removed_event")] + CanvasBgRemovedEvent, + + /// Special event: + /// Image generation is complete + #[serde(rename = "text_to_image_generation_complete_event")] + TextToImageGenerationCompleteEvent, + + /// Special event: + /// Image edit is complete + #[serde(rename = "image_edit_complete_event")] + ImageEditCompleteEvent, + + /// Special event: + /// Object (3D mesh) generation is complete + #[serde(rename = "object_generation_complete_event")] + ObjectGenerationCompleteEvent, + + /// Special event: + /// Gaussian generation is complete + #[serde(rename = "gaussian_generation_complete_event")] + GaussianGenerationCompleteEvent, + + /// Special event: + /// Video generation is complete + #[serde(rename = "video_generation_complete_event")] + VideoGenerationCompleteEvent, + + /// Special event: + /// Refresh account states + #[serde(rename = "refresh_account_state_event")] + RefreshAccountStateEvent, + + /// Special event: + /// Show a billing modal (a suggestion to pay, etc. for a specific provider) + #[serde(rename = "show_provider_billing_modal_event")] + ShowProviderBillingModalEvent, + + /// Special event: + /// Show a login modal (or a suggestion to login) + #[serde(rename = "show_provider_login_modal_event")] + ShowProviderLoginModalEvent, + + /// Warning event: + /// Flash a user input error message + #[serde(rename = "flash_user_input_error_event")] + FlashUserInputErrorEvent, + + /// Warning event: + /// File with that name was already downloaded + #[serde(rename = "flash_file_download_error_event")] + FlashFileDownloadErrorEvent, +} + +impl_enum_display_and_debug_using_to_str!(TauriEventName); +impl_mysql_enum_coders!(TauriEventName); +impl_mysql_from_row!(TauriEventName); + +// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders` + +impl TauriEventName { + pub fn to_str(&self) -> &'static str { + match self { + Self::GenerationEnqueueSuccessEvent => "generation-enqueue-success-event", + Self::GenerationEnqueueFailureEvent => "generation-enqueue-failure-event", + Self::GenerationCompleteEvent => "generation-complete-event", + Self::GenerationFailedEvent => "generation-failed-event", + Self::CreditsBalanceChangedEvent => "credits_balance_changed_event", + Self::SubscriptionPlanChangedEvent => "subscription_plan_changed_event", + Self::MediaFileDeletedEvent => "media_file_deleted_event", + Self::CanvasBgRemovedEvent => "canvas_bg_removed_event", + Self::TextToImageGenerationCompleteEvent => "text_to_image_generation_complete_event", + Self::ImageEditCompleteEvent => "image_edit_complete_event", + Self::ObjectGenerationCompleteEvent => "object_generation_complete_event", + Self::GaussianGenerationCompleteEvent => "gaussian_generation_complete_event", + Self::VideoGenerationCompleteEvent => "video_generation_complete_event", + Self::RefreshAccountStateEvent => "refresh_account_state_event", + Self::ShowProviderBillingModalEvent => "show_provider_billing_modal_event", + Self::ShowProviderLoginModalEvent => "show_provider_login_modal_event", + Self::FlashUserInputErrorEvent => "flash_user_input_error_event", + Self::FlashFileDownloadErrorEvent => "flash_file_download_error_event", + } + } + + pub fn from_str(job_status: &str) -> Result { + match job_status { + "generation-enqueue-success-event" => Ok(Self::GenerationEnqueueSuccessEvent), + "generation-enqueue-failure-event" => Ok(Self::GenerationEnqueueFailureEvent), + "generation-complete-event" => Ok(Self::GenerationCompleteEvent), + "generation-failed-event" => Ok(Self::GenerationFailedEvent), + "credits_balance_changed_event" => Ok(Self::CreditsBalanceChangedEvent), + "subscription_plan_changed_event" => Ok(Self::SubscriptionPlanChangedEvent), + "media_file_deleted_event" => Ok(Self::MediaFileDeletedEvent), + "canvas_bg_removed_event" => Ok(Self::CanvasBgRemovedEvent), + "text_to_image_generation_complete_event" => Ok(Self::TextToImageGenerationCompleteEvent), + "image_edit_complete_event" => Ok(Self::ImageEditCompleteEvent), + "object_generation_complete_event" => Ok(Self::ObjectGenerationCompleteEvent), + "gaussian_generation_complete_event" => Ok(Self::GaussianGenerationCompleteEvent), + "video_generation_complete_event" => Ok(Self::VideoGenerationCompleteEvent), + "refresh_account_state_event" => Ok(Self::RefreshAccountStateEvent), + "show_provider_billing_modal_event" => Ok(Self::ShowProviderBillingModalEvent), + "show_provider_login_modal_event" => Ok(Self::ShowProviderLoginModalEvent), + "flash_user_input_error_event" => Ok(Self::FlashUserInputErrorEvent), + "flash_file_download_error_event" => Ok(Self::FlashFileDownloadErrorEvent), + _ => Err(format!("invalid tauri_event_name: {:?}", job_status)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::GenerationEnqueueSuccessEvent, + Self::GenerationEnqueueFailureEvent, + Self::GenerationCompleteEvent, + Self::GenerationFailedEvent, + Self::CreditsBalanceChangedEvent, + Self::SubscriptionPlanChangedEvent, + Self::MediaFileDeletedEvent, + Self::CanvasBgRemovedEvent, + Self::TextToImageGenerationCompleteEvent, + Self::ImageEditCompleteEvent, + Self::ObjectGenerationCompleteEvent, + Self::GaussianGenerationCompleteEvent, + Self::VideoGenerationCompleteEvent, + Self::RefreshAccountStateEvent, + Self::ShowProviderBillingModalEvent, + Self::ShowProviderLoginModalEvent, + Self::FlashUserInputErrorEvent, + Self::FlashFileDownloadErrorEvent, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::tauri::ux::tauri_event_name::TauriEventName; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(TauriEventName::GenerationEnqueueSuccessEvent, "generation-enqueue-success-event"); + assert_serialization(TauriEventName::GenerationEnqueueFailureEvent, "generation-enqueue-failure-event"); + assert_serialization(TauriEventName::GenerationCompleteEvent, "generation-complete-event"); + assert_serialization(TauriEventName::GenerationFailedEvent, "generation-failed-event"); + assert_serialization(TauriEventName::CreditsBalanceChangedEvent, "credits_balance_changed_event"); + assert_serialization(TauriEventName::SubscriptionPlanChangedEvent, "subscription_plan_changed_event"); + assert_serialization(TauriEventName::MediaFileDeletedEvent, "media_file_deleted_event"); + assert_serialization(TauriEventName::CanvasBgRemovedEvent, "canvas_bg_removed_event"); + assert_serialization(TauriEventName::TextToImageGenerationCompleteEvent, "text_to_image_generation_complete_event"); + assert_serialization(TauriEventName::ImageEditCompleteEvent, "image_edit_complete_event"); + assert_serialization(TauriEventName::ObjectGenerationCompleteEvent, "object_generation_complete_event"); + assert_serialization(TauriEventName::GaussianGenerationCompleteEvent, "gaussian_generation_complete_event"); + assert_serialization(TauriEventName::VideoGenerationCompleteEvent, "video_generation_complete_event"); + assert_serialization(TauriEventName::RefreshAccountStateEvent, "refresh_account_state_event"); + assert_serialization(TauriEventName::ShowProviderBillingModalEvent, "show_provider_billing_modal_event"); + assert_serialization(TauriEventName::ShowProviderLoginModalEvent, "show_provider_login_modal_event"); + assert_serialization(TauriEventName::FlashUserInputErrorEvent, "flash_user_input_error_event"); + assert_serialization(TauriEventName::FlashFileDownloadErrorEvent, "flash_file_download_error_event"); + } + + #[test] + fn to_str() { + assert_eq!(TauriEventName::GenerationEnqueueSuccessEvent.to_str(), "generation-enqueue-success-event"); + assert_eq!(TauriEventName::GenerationEnqueueFailureEvent.to_str(), "generation-enqueue-failure-event"); + assert_eq!(TauriEventName::GenerationCompleteEvent.to_str(), "generation-complete-event"); + assert_eq!(TauriEventName::GenerationFailedEvent.to_str(), "generation-failed-event"); + assert_eq!(TauriEventName::CreditsBalanceChangedEvent.to_str(), "credits_balance_changed_event"); + assert_eq!(TauriEventName::SubscriptionPlanChangedEvent.to_str(), "subscription_plan_changed_event"); + assert_eq!(TauriEventName::MediaFileDeletedEvent.to_str(), "media_file_deleted_event"); + assert_eq!(TauriEventName::CanvasBgRemovedEvent.to_str(), "canvas_bg_removed_event"); + assert_eq!(TauriEventName::TextToImageGenerationCompleteEvent.to_str(), "text_to_image_generation_complete_event"); + assert_eq!(TauriEventName::ImageEditCompleteEvent.to_str(), "image_edit_complete_event"); + assert_eq!(TauriEventName::ObjectGenerationCompleteEvent.to_str(), "object_generation_complete_event"); + assert_eq!(TauriEventName::GaussianGenerationCompleteEvent.to_str(), "gaussian_generation_complete_event"); + assert_eq!(TauriEventName::VideoGenerationCompleteEvent.to_str(), "video_generation_complete_event"); + assert_eq!(TauriEventName::RefreshAccountStateEvent.to_str(), "refresh_account_state_event"); + assert_eq!(TauriEventName::ShowProviderBillingModalEvent.to_str(), "show_provider_billing_modal_event"); + assert_eq!(TauriEventName::ShowProviderLoginModalEvent.to_str(), "show_provider_login_modal_event"); + assert_eq!(TauriEventName::FlashUserInputErrorEvent.to_str(), "flash_user_input_error_event"); + assert_eq!(TauriEventName::FlashFileDownloadErrorEvent.to_str(), "flash_file_download_error_event"); + } + + #[test] + fn from_str() { + assert_eq!(TauriEventName::from_str("generation-enqueue-success-event").unwrap(), TauriEventName::GenerationEnqueueSuccessEvent); + assert_eq!(TauriEventName::from_str("generation-enqueue-failure-event").unwrap(), TauriEventName::GenerationEnqueueFailureEvent); + assert_eq!(TauriEventName::from_str("generation-complete-event").unwrap(), TauriEventName::GenerationCompleteEvent); + assert_eq!(TauriEventName::from_str("generation-failed-event").unwrap(), TauriEventName::GenerationFailedEvent); + assert_eq!(TauriEventName::from_str("credits_balance_changed_event").unwrap(), TauriEventName::CreditsBalanceChangedEvent); + assert_eq!(TauriEventName::from_str("subscription_plan_changed_event").unwrap(), TauriEventName::SubscriptionPlanChangedEvent); + assert_eq!(TauriEventName::from_str("media_file_deleted_event").unwrap(), TauriEventName::MediaFileDeletedEvent); + assert_eq!(TauriEventName::from_str("canvas_bg_removed_event").unwrap(), TauriEventName::CanvasBgRemovedEvent); + assert_eq!(TauriEventName::from_str("text_to_image_generation_complete_event").unwrap(), TauriEventName::TextToImageGenerationCompleteEvent); + assert_eq!(TauriEventName::from_str("image_edit_complete_event").unwrap(), TauriEventName::ImageEditCompleteEvent); + assert_eq!(TauriEventName::from_str("object_generation_complete_event").unwrap(), TauriEventName::ObjectGenerationCompleteEvent); + assert_eq!(TauriEventName::from_str("gaussian_generation_complete_event").unwrap(), TauriEventName::GaussianGenerationCompleteEvent); + assert_eq!(TauriEventName::from_str("video_generation_complete_event").unwrap(), TauriEventName::VideoGenerationCompleteEvent); + assert_eq!(TauriEventName::from_str("refresh_account_state_event").unwrap(), TauriEventName::RefreshAccountStateEvent); + assert_eq!(TauriEventName::from_str("show_provider_billing_modal_event").unwrap(), TauriEventName::ShowProviderBillingModalEvent); + assert_eq!(TauriEventName::from_str("show_provider_login_modal_event").unwrap(), TauriEventName::ShowProviderLoginModalEvent); + assert_eq!(TauriEventName::from_str("flash_user_input_error_event").unwrap(), TauriEventName::FlashUserInputErrorEvent); + assert_eq!(TauriEventName::from_str("flash_file_download_error_event").unwrap(), TauriEventName::FlashFileDownloadErrorEvent); + } + + #[test] + fn all_variants() { + let mut variants = TauriEventName::all_variants(); + assert_eq!(variants.len(), 18); + assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationFailedEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::CreditsBalanceChangedEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::SubscriptionPlanChangedEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::MediaFileDeletedEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::CanvasBgRemovedEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::TextToImageGenerationCompleteEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::ImageEditCompleteEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::ObjectGenerationCompleteEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::GaussianGenerationCompleteEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::VideoGenerationCompleteEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::RefreshAccountStateEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::ShowProviderBillingModalEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::ShowProviderLoginModalEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::FlashUserInputErrorEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::FlashFileDownloadErrorEvent)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + use strum::IntoEnumIterator; + + #[test] + fn variant_length() { + assert_eq!(TauriEventName::all_variants().len(), TauriEventName::iter().len()); + } + + #[test] + fn round_trip() { + for variant in TauriEventName::all_variants() { + // Test to_str(), from_str(), Display, and Debug. + assert_eq!(variant, TauriEventName::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, TauriEventName::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, TauriEventName::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + //#[test] + //fn serialized_length_ok_for_database() { + // const MAX_LENGTH : usize = 16; + // for variant in TauriEventName::all_variants() { + // let serialized = variant.to_str(); + // assert!(serialized.len() > 0, "variant {:?} is too short", variant); + // assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + // } + //} + } +} diff --git a/crates/schema/public/enums/src/test_helpers.rs b/crates/schema/public/enums/src/test_helpers.rs new file mode 100644 index 0000000000..fdb23e0aaf --- /dev/null +++ b/crates/schema/public/enums/src/test_helpers.rs @@ -0,0 +1,27 @@ +//! Only imported in tests + +use serde::Serialize; + +pub fn to_toml(t: &T) -> String { + match toml::to_string(t) { + Ok(s) => s.replace("\"", ""), // JSON values are quoted, so we remove quotes + Err(err) => { + panic!("serialization error: {}", err); + } + } +} + +pub fn to_json(t: &T) -> String { + serde_json::to_string(t) + .expect("serialization error") + .replace("\"", "") // JSON values are quoted, so we remove quotes +} + +/// Assert that the Serialize is represented by the expected string value. +/// This is useful for ensuring stability of serialization. +pub fn assert_serialization(t: T, expected: &str) { + // TODO: See if there's a way to check the sqlx serialization + // TODO(bt,2024-06-27): Something broke with serde toml serialization, so we're disabling it + // assert_eq!(&to_toml(&t), expected); + assert_eq!(&to_json(&t), expected); +} diff --git a/crates/schema/public/enums/src/traits/mod.rs b/crates/schema/public/enums/src/traits/mod.rs new file mode 100644 index 0000000000..61a6c0a0c6 --- /dev/null +++ b/crates/schema/public/enums/src/traits/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "database")] +pub mod mysql_from_row; \ No newline at end of file diff --git a/crates/schema/public/enums/src/traits/mysql_from_row.rs b/crates/schema/public/enums/src/traits/mysql_from_row.rs new file mode 100644 index 0000000000..7d3c3cbb67 --- /dev/null +++ b/crates/schema/public/enums/src/traits/mysql_from_row.rs @@ -0,0 +1,11 @@ +use sqlx_mysql::MySqlRow; + +/// This is a trait (in conjunction with the macro implementation) that makes up for sqlx's +/// QueryBuilder not playing well with FromRow implementation or derivation of named types. +pub trait MySqlFromRow { + + /// Attempt to parse a named column from a MySqlRow into the type. + fn try_from_mysql_row(row: &MySqlRow, field_name: &str) -> Result; + + fn try_from_mysql_row_nullable(row: &MySqlRow, field_name: &str) -> Result, sqlx::Error>; +} diff --git a/crates/schema/public/tokens/AGENTS.md b/crates/schema/public/tokens/AGENTS.md new file mode 100644 index 0000000000..06c4a8293e --- /dev/null +++ b/crates/schema/public/tokens/AGENTS.md @@ -0,0 +1,22 @@ +# Tokens Crate + +Strongly-typed primary key identifiers with Stripe-like prefixes (e.g. `user_abc123`, `prompt_xyz789`, etc.) + +## Adding a New Token + +1. Create file in `src/tokens/{entity_name}.rs` +2. Define as a newtype wrapper: + ```rust + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] + #[cfg_attr(feature = "database", derive(sqlx::Type))] + #[cfg_attr(feature = "database", sqlx(transparent))] + pub struct MyToken(pub String); + ``` +3. Apply macros: + ```rust + impl_string_token!(MyToken); + impl_mysql_token_from_row!(MyToken); + impl_crockford_generator!(MyToken, 32usize, TokenPrefix::MyEntity, CrockfordLower); + ``` +4. Add the prefix to `TokenPrefix` enum in `src/token_prefix.rs` +5. Add to parent `mod.rs` diff --git a/crates/schema/public/tokens/CLAUDE.md b/crates/schema/public/tokens/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/crates/schema/public/tokens/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/schema/public/tokens/Cargo.toml b/crates/schema/public/tokens/Cargo.toml new file mode 100644 index 0000000000..6f5c4b5b03 --- /dev/null +++ b/crates/schema/public/tokens/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "tokens" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "tokens" +path = "src/lib.rs" + +[features] +default = [] +database = ["sqlx", "sqlx-core", "sqlx-mysql"] + +[dependencies] + +# Internal +# (none) + +# Serialization +serde.workspace = true +serde_derive.workspace = true + +# Random +rand = { version = "0.8.5", features = ["std_rng"] } + +once_cell.workspace = true + +# Database conversion of enum types is optional +sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ], optional = true } +sqlx-core = { version = "0.7.4", optional = true } +sqlx-mysql= { version = "0.7.4", optional = true } + +utoipa.workspace = true + +[dev-dependencies] + +# Enum utilities +strum.workspace = true + +# Test serialization +serde_json.workspace = true diff --git a/crates/schema/public/tokens/src/deterministic_rng.rs b/crates/schema/public/tokens/src/deterministic_rng.rs new file mode 100644 index 0000000000..84f7abe5a1 --- /dev/null +++ b/crates/schema/public/tokens/src/deterministic_rng.rs @@ -0,0 +1,43 @@ +use std::sync::{Arc, LockResult, RwLock, RwLockWriteGuard}; + +use once_cell::sync::Lazy; +use rand::rngs::StdRng; +use rand::SeedableRng; + +// FIXME(bt,2023-12-14): Is this threadsafe? Looks like it should be fine. +/// Only use this in tests (and dev seed tools), never in production! +#[derive(Clone)] +pub struct DeterministicRng { + rng : Arc> +} + +impl DeterministicRng { + pub fn get_instance() -> LockResult> { + static DETERMINISTIC_RNG : Lazy>> = Lazy::new(|| { + Arc::new(RwLock::new(DeterministicRng::new())) + }); + + DETERMINISTIC_RNG.write() + } + + fn new() -> Self { + Self { + rng: Arc::new(RwLock::new(StdRng::seed_from_u64(0))) + } + } + + /// Reset the deterministic RNG's seed. + pub fn reset_rng(&self, state: u64) { + match self.rng.write() { + Err(err) => panic!("test panic due to rng failure {:?}", err), + Ok(mut lock) => { + *lock = StdRng::seed_from_u64(state); + } + } + } + + /// Grab the deterministic RNG + pub fn get_rng(&self) -> LockResult>{ + self.rng.write() + } +} diff --git a/crates/schema/public/tokens/src/lib.rs b/crates/schema/public/tokens/src/lib.rs new file mode 100644 index 0000000000..21990db1d1 --- /dev/null +++ b/crates/schema/public/tokens/src/lib.rs @@ -0,0 +1,58 @@ +//! tokens +//! +//! The purpose of this library is to provide a strongly-typed primary/foreign key system with +//! well-known identifier prefixes to aid in manual database debugging. +//! +//! 1) All table foreign keys have a strongly-typed (non-stringly typed) wrapper type, +//! eg. `MediaFileToken` for the `token` field in the `media_files` table or `UserToken` for +//! the `token` field in the `users` table. +//! +//! 2) All such keys should have a well-defined constant prefix. Though tokens should be 100% +//! opaque to the user and the code, having well-defined prefixes can greatly aid when +//! debugging systems. Every table's primary key should have a unique prefix that identifies +//! that key, eg. `comment_` for the `comments` table or `m_` for the `media_files` table. +//! +//! This library does not directly introduce MySQL dependencies, so these strongly-typed token +//! types can be used in other crates, eg. for defining HTTP APIs and returning strongly-typed +//! tokens to the user. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +// Crockford characters +// https://en.wikipedia.org/wiki/Base32#Crockford's_Base32 +pub(crate) const CROCKFORD_UPPERCASE_CHARSET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +pub(crate) const CROCKFORD_LOWERCASE_CHARSET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; +pub(crate) const CROCKFORD_MIXED_CASE_CHARSET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz"; + +pub(crate) enum TokenCharacterSet { + CrockfordUpper, + CrockfordLower, + CrockfordMixed, +} + +/// Every token must have at least this many "characters" of entropy. +pub(crate) static MINIMUM_CHARACTER_ENTROPY : usize = 8; + +#[macro_use] +pub (crate) mod macros; + +pub (crate) mod deterministic_rng; +pub (crate) mod prefixes; + +pub mod tokens; +pub mod traits; +mod safe_entropy; \ No newline at end of file diff --git a/crates/schema/public/tokens/src/macros/impl_crockford_generator.rs b/crates/schema/public/tokens/src/macros/impl_crockford_generator.rs new file mode 100644 index 0000000000..ca8e251c28 --- /dev/null +++ b/crates/schema/public/tokens/src/macros/impl_crockford_generator.rs @@ -0,0 +1,178 @@ + +// TODO(bt, 2023-11-01): Don't generate slurs or dangerous terms in the token generator +// see: https://news.ycombinator.com/item?id=35337210 +// see: https://www.reddit.com/r/ProgrammerHumor/comments/3ov56n/randomly_generating_ids_can_be_dangerous/ +// see: https://stackoverflow.com/a/36294356 +macro_rules! impl_crockford_generator { + ($t:ident, $total_string_length:literal, $variant:path, $character_case:ident) => { + impl $t { + /// Constructor for a new token. + #[inline] + pub fn generate() -> Self { + use rand::Rng; + + let mut rng = rand::thread_rng(); + + let charset = Self::token_character_set(); + let entropy_length = Self::entropic_character_len(); + + let mut entropy_part: String = (0..entropy_length) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset[idx] as char + }) + .collect(); + + let mut i = 0; + while !crate::safe_entropy::entropy_is_safe(&entropy_part) && i < 10 { + i += 1; + entropy_part = (0..entropy_length) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset[idx] as char + }) + .collect(); + } + + let token_prefix = Self::token_prefix(); + + let token = format!("{}{}", token_prefix, entropy_part); + + $t(token) + } + + /// Constructor for a new token to be used **DETERMINISTICALLY** in tests and seeding. + /// This will not use a real RNG, so do not use in production code! + /// Once called, it will output the same random numbers in sequence. + pub fn reset_rng_for_testing_and_dev_seeding_never_use_in_production_seriously(state: u64) { + let deterministic = crate::deterministic_rng::DeterministicRng::get_instance().expect("tests should not fail due to mutex"); + deterministic.reset_rng(state); + } + + /// Constructor for a new token to be used **DETERMINISTICALLY** in tests and seeding. + /// This will not use a real RNG, so do not use in production code! + /// Once called, it will output the same random numbers in sequence. + pub fn generate_for_testing_and_dev_seeding_never_use_in_production_seriously() -> Self { + use rand::Rng; + + // FIXME(bt,2023-12-14): This is bad error handling. (Maybe panic if lock is poisoned?) + let deterministic = crate::deterministic_rng::DeterministicRng::get_instance().expect("tests should not fail due to mutex"); + let mut rng = deterministic.get_rng().expect("tests should not fail due to mutex"); + + let charset = Self::token_character_set(); + let entropy_length = Self::entropic_character_len(); + + let mut entropy_part: String = (0..entropy_length) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset[idx] as char + }) + .collect(); + + let mut i = 0; + while !crate::safe_entropy::entropy_is_safe(&entropy_part) && i < 10 { + i += 1; + entropy_part = (0..entropy_length) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset[idx] as char + }) + .collect(); + } + + let token_prefix = Self::token_prefix(); + + let token = format!("{}{}", token_prefix, entropy_part); + + $t(token) + } + + #[inline] + pub fn entropy_suffix(&self) -> &str { + use crate::prefixes::PrefixGenerator; + + &self.0 + .strip_prefix($variant.prefix()) + .unwrap_or(&self.0) + } + + #[inline] + pub fn entropic_character_len() -> usize { + let token_prefix = Self::token_prefix(); + $total_string_length.saturating_sub(token_prefix.len()) + } + + #[inline] + pub fn token_prefix() -> &'static str { + use crate::prefixes::PrefixGenerator; + + $variant.prefix() + } + + #[inline] + pub fn token_character_set() -> &'static [u8] { + match crate::TokenCharacterSet::$character_case { + crate::TokenCharacterSet::CrockfordUpper => crate::CROCKFORD_UPPERCASE_CHARSET, + crate::TokenCharacterSet::CrockfordLower => crate::CROCKFORD_LOWERCASE_CHARSET, + crate::TokenCharacterSet::CrockfordMixed => crate::CROCKFORD_MIXED_CASE_CHARSET, + } + } + } + + #[cfg(test)] + #[test] + fn test_entropy_is_sufficient() { + assert!($t::entropic_character_len() > crate::MINIMUM_CHARACTER_ENTROPY); + } + + #[cfg(test)] + #[test] + fn test_token_length() { + assert_eq!($t::generate().as_str().len(), $total_string_length); + } + + #[cfg(test)] + #[test] + fn test_tokens_are_random() { + let mut tokens = std::collections::HashSet::new(); + for _ in 0..100 { + tokens.insert($t::generate().to_string()); + } + assert_eq!(tokens.len(), 100); + } + + #[cfg(test)] + #[test] + fn test_character_set() { + let token_string = $t::generate().to_string(); + let prefix = $t::token_prefix(); + let random_part = token_string.replace(prefix, ""); + + assert!(random_part.len() > crate::MINIMUM_CHARACTER_ENTROPY); + + match crate::TokenCharacterSet::$character_case { + crate::TokenCharacterSet::CrockfordUpper => assert!(random_part.chars().all(|c| c.is_numeric() || c.is_uppercase())), + crate::TokenCharacterSet::CrockfordLower => assert!(random_part.chars().all(|c| c.is_numeric() || c.is_lowercase())), + crate::TokenCharacterSet::CrockfordMixed => assert!(random_part.chars().all(|c| c.is_numeric() || c.is_uppercase() || c.is_lowercase())), + } + } + + #[cfg(test)] + #[test] + fn test_prefix_ends_with_separator() { + let prefix = $t::token_prefix(); + assert!(prefix.ends_with(":") || prefix.ends_with("_")); + + let token_string = $t::generate().to_string(); + assert!(token_string.contains(":") || token_string.contains("_")); + } + + #[cfg(test)] + #[test] + fn test_token_begins_with_prefix() { + let prefix = $t::token_prefix(); + let token_string = $t::generate().to_string(); + assert!(token_string.starts_with(prefix)); + } + } +} diff --git a/crates/schema/public/tokens/src/macros/impl_mysql_token_from_row.rs b/crates/schema/public/tokens/src/macros/impl_mysql_token_from_row.rs new file mode 100644 index 0000000000..b1f61a43a2 --- /dev/null +++ b/crates/schema/public/tokens/src/macros/impl_mysql_token_from_row.rs @@ -0,0 +1,55 @@ + +#[cfg(feature = "database")] +/// Implement `MySqlTokenFromRow` on a type. +macro_rules! impl_mysql_token_from_row { + ($t:ident) => { + + // Try to convert a MySQL row and named field into the value type (for non-nullable fields). + impl crate::traits::mysql_token_from_row::MySqlTokenFromRow<$t> for $t { + fn try_from_mysql_row(row: &sqlx_mysql::MySqlRow, field_name: &str) -> Result<$t, sqlx::Error> { + use sqlx::Row; + + // NB(bt,2023-12-05): For now only string encodings are considered. + // We may want to revisit in the future if we deal with binary data. + let value : String = row.try_get(field_name)?; + + Ok(Self::new_from_str(&value)) + } + + // Try to convert a MySQL row and named field into the value type (for nullable fields). + fn try_from_mysql_row_nullable(row: &sqlx_mysql::MySqlRow, field_name: &str) -> Result, sqlx::Error> { + use sqlx::Row; + + // NB(bt,2023-12-05): For now only string encodings are considered. + // We may want to revisit in the future if we deal with binary data. + // NB(2): Nullable fields decode as Option. + let maybe_value : Option = row.try_get(field_name)?; + + let value = match maybe_value { + Some(value) => value, + None => return Ok(None), + }; + + Ok(Some(Self::new_from_str(&value))) + } + } + + //// TODO: Tests. + //#[cfg(test)] + //#[test] + //fn test_name() { + // use strum::IntoEnumIterator; + // for variant in $t::iter() { + // assert_eq!(format!("{}", variant), variant.to_str()); + // } + //} + + } +} + +#[cfg(not(feature = "database"))] +macro_rules! impl_mysql_token_from_row { + ($t:ident) => { + // Intentionally empty + } +} diff --git a/crates/schema/public/tokens/src/macros/impl_string_token.rs b/crates/schema/public/tokens/src/macros/impl_string_token.rs new file mode 100644 index 0000000000..e082e567d8 --- /dev/null +++ b/crates/schema/public/tokens/src/macros/impl_string_token.rs @@ -0,0 +1,30 @@ + +// Default methods for string wrapper types. +macro_rules! impl_string_token { + ($t:ident) => { + // Constructors and accessors. + impl $t { + #[inline] + pub fn new(value: String) -> Self { + $t(value) + } + + #[inline] + pub fn new_from_str(value: &str) -> Self { + $t(value.to_string()) + } + + #[inline] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + // Display trait. + impl std::fmt::Display for $t { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + } +} diff --git a/crates/schema/public/tokens/src/macros/mod.rs b/crates/schema/public/tokens/src/macros/mod.rs new file mode 100644 index 0000000000..d9b0ca3795 --- /dev/null +++ b/crates/schema/public/tokens/src/macros/mod.rs @@ -0,0 +1,3 @@ +#[macro_use] pub (crate) mod impl_crockford_generator; +#[macro_use] pub (crate) mod impl_mysql_token_from_row; +#[macro_use] pub (crate) mod impl_string_token; diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs new file mode 100644 index 0000000000..e292922499 --- /dev/null +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -0,0 +1,381 @@ +// Supposedly there is no limit on number of enum variants, so this shouldn't be exhaustible. +// https://www.reddit.com/r/rust/comments/lf10lv/any_limit_on_enum_variants_amount/ + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Each primary key or token type in our system gets a unique prefix so that they're easy to +/// identify by observation. (Random entropic tokens or UUIDs are ambiguous and easier to fumble +/// when manually debugging.) +/// +/// This set is the newest set of token prefixes in our system, which all end with underscore "_". +/// This is the Stripe-style prefixing which makes it easy to "double click" the token string to +/// select and copy the entire value -- this should work on all operating systems, too! +/// +/// These are incredibly ergonomic tokens for manually querying and debugging against. +/// +/// Older entities in our system (see `LegacyTokenPrefix`) have prefixes ending in ':', but these +/// do not have the nice "double click" property. Plus, they kind of look ugly. Don't make any more +/// of those. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +pub(crate) enum TokenPrefix { + AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables. + ApiKey, + AppSession, // NB: These are generated client-side (!) + AuditLog, + BatchGeneration, + BetaKey, + BrowserSessionLog, + Character, + Comment, + DebugLogEvent, + DownloadJob, + EmailSenderJob, + Folder, + GoogleSignInAccount, + InferenceJob, + McpSession, + McpSessionPrivate, // The private credential for an MCP session; carries more entropy. + McpSessionRefresh, // The rotating refresh credential for an MCP session; carries more entropy. + MediaFile, + MediaUpload, + ModelWeight, + NewsStory, // NB: aichatbot / sqlite + PasswordReset, + Prompt, + StaffAuditLog, + Tag, + TtsRenderTask, // NB: aichatbot / sqlite + UploadedVideo, + UploadedVideoNote, + User, + UserImpersonationRequest, + UserBookmark, + UserReferralCode, + UserSession, + UserSpendEvent, + UserSubscription, + VoiceConversionModel, + VoiceConversionResult, + Wallet, + WalletLedgerEntry, + ZsVoice, + ZsVoiceDataset, + ZsVoiceDatasetSample, +} + +/// These are tokens for Tauri / Sqlite +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +pub(crate) enum TauriTokenPrefix { + Task, +} + +/// These are old-style prefixes that end in colon (:). +/// Don't do this anymore, since tokens built like this are difficult to "double click to select". +/// The modern, Stripe-style token prefixes (which use underscores) are much better. +/// These tokens are also uglier (though that may be subjective). +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +pub(crate) enum LegacyTokenPrefix { + ApiTokenExternal, + ApiTokenInternal, + FirehoseEntry, + ModelCategory, + TtsInferenceJob, + TtsModel, + TtsModelUploadJob, + TtsResult, + TwitchEventRule, + TwitchOauthGrouping, + TwitchOauthInternal, + VocoderModel, + VoiceCloneRequest, + W2lInferenceJob, + W2lResult, + W2lTemplate, + W2lTemplateUploadJob, +} + +/// Do not use these token prefixes ever again. They're retired. +/// Tokens with these prefixes may still exist in the database. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[cfg_attr(test, derive(EnumIter, EnumCount))] +enum RetiredTokenPrefix { + _DownloadJobDeprecatedNotNotUse, + _UserDeprecatedDoNotUse, // NB: Users prior to 2023-10-24. Kept to prevent collision. + _UserSessionDeprecatedDoNotUse, // NB: Sessions prior to 2023-10-24. Kept to prevent collision. + _UserSubscriptionDeprecatedDoNotUse, // NB: Subscriptions prior to ~2025-09-08. Kept to prevent collision. +} + +pub trait PrefixGenerator { + fn prefix(self) -> &'static str; +} + +impl PrefixGenerator for TokenPrefix { + fn prefix(self) -> &'static str { + match self { + Self::AnonymousVisitorTracking => "avt_", + Self::ApiKey => "api_key_", + Self::AppSession => "app_session_", + Self::AuditLog => "audit_", + Self::BatchGeneration => "batch_g_", + Self::BetaKey => "beta_key_", + Self::BrowserSessionLog => "bsl_", + Self::Character => "character_", + Self::Comment => "comment_", + Self::DebugLogEvent => "dbg_", + Self::DownloadJob => "jdown_", // NB: Previously "JGUP:" + Self::EmailSenderJob => "email_job_", + Self::Folder => "folder_", + Self::GoogleSignInAccount => "gsi_", + Self::InferenceJob => "jinf_", + Self::McpSession => "mcp_", + Self::McpSessionPrivate => "mcp_session_", + Self::McpSessionRefresh => "mcp_refresh_", + Self::MediaFile => "m_", + Self::MediaUpload => "mu_", + Self::ModelWeight => "weight_", + Self::NewsStory => "news_story_", + Self::PasswordReset => "pw_reset_", + Self::Prompt => "prompt_", + Self::StaffAuditLog => "stfaud_", + Self::Tag => "tag_", + Self::TtsRenderTask => "tts_task_", + Self::UploadedVideo => "uvt_", + Self::UploadedVideoNote => "uvnote_", + Self::User => "user_", // NB: Previously "U:" + Self::UserImpersonationRequest => "uimpr_", + Self::UserBookmark => "ub_", + Self::UserReferralCode => "urc_", + Self::UserSession => "session_", + Self::UserSpendEvent => "spend_", + Self::UserSubscription => "subscription_", // NB: Previously "SUB:" + Self::VoiceConversionModel => "vcm_", + Self::VoiceConversionResult => "vcr_", + Self::Wallet => "wallet_", + Self::WalletLedgerEntry => "wle_", + Self::ZsVoice => "zsv_", + Self::ZsVoiceDataset => "zsd_", + Self::ZsVoiceDatasetSample => "zss_", + } + } +} + +impl PrefixGenerator for TauriTokenPrefix { + fn prefix(self) -> &'static str { + match self { + TauriTokenPrefix::Task => "task_", + } + } +} + +impl PrefixGenerator for LegacyTokenPrefix { + fn prefix(self) -> &'static str { + // NB: Old-style colon-suffixed prefixes. Do not use for future tokens! + match self { + Self::ApiTokenExternal => "API:", + Self::ApiTokenInternal => "INT_API:", + Self::FirehoseEntry => "EV:", + Self::ModelCategory => "CAT:", + Self::TtsInferenceJob => "JTINF:", + Self::TtsModel => "TM:", + Self::TtsModelUploadJob => "JTUP:", + Self::TtsResult => "TR:", + Self::TwitchEventRule => "TER:", + Self::TwitchOauthGrouping => "OG:", + Self::TwitchOauthInternal => "TOI:", + Self::VocoderModel => "VM:", + Self::VoiceCloneRequest => "VCR:", + Self::W2lInferenceJob => "JWINF:", + Self::W2lResult => "WR:", + Self::W2lTemplate => "WT:", + Self::W2lTemplateUploadJob => "JWUP:", + } + } +} + +impl PrefixGenerator for RetiredTokenPrefix { + fn prefix(self) -> &'static str { + match self { + Self::_DownloadJobDeprecatedNotNotUse => "JGUP:", // NB: Download jobs changed roughly around 2022-12-16 + Self::_UserDeprecatedDoNotUse => "U:", // NB: Users prior to 2023-10-24 used this prefix. + Self::_UserSessionDeprecatedDoNotUse => "SESSION:", // NB: Users prior to 2023-10-24 used this prefix. + Self::_UserSubscriptionDeprecatedDoNotUse => "SUB:", // NB: Subscriptions prior to ~2025-09-08 used this prefix. + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use strum::EnumCount; + use strum::IntoEnumIterator; + + use crate::prefixes::PrefixGenerator; + + mod modern_token_prefixes { + use crate::prefixes::TokenPrefix; + + use super::*; + + #[test] + pub fn no_duplicate_prefixes() { + let entities = TokenPrefix::iter() + .map(|entity| entity.prefix()) + .collect::>(); + + assert_eq!(entities.len(), TokenPrefix::COUNT); + } + + #[test] + fn all_prefixes_are_unique_regardless_of_case_and_suffix() { + let entities = TokenPrefix::iter() + .map(|entity| entity.prefix()) + .map(|prefix| prefix.to_lowercase()) + .map(|prefix| prefix.replace("-", "")) + .map(|prefix| prefix.replace(":", "")) + .map(|prefix| prefix.replace("_", "")) + .collect::>(); + + assert_eq!(entities.len(), TokenPrefix::COUNT); + } + + #[test] + pub fn all_prefixes_end_with_underscore() { + let entities = TokenPrefix::iter() + .map(|entity| entity.prefix()) + .filter(|prefix| prefix.ends_with("_")) + .collect::>(); + + assert_eq!(entities.len(), TokenPrefix::COUNT); + } + } + + mod legacy_token_prefixes { + use crate::prefixes::LegacyTokenPrefix; + + use super::*; + + #[test] + pub fn no_duplicate_prefixes() { + let entities = LegacyTokenPrefix::iter() + .map(|entity| entity.prefix()) + .collect::>(); + assert_eq!(entities.len(), LegacyTokenPrefix::COUNT); + } + + #[test] + fn all_prefixes_are_unique_regardless_of_case_and_suffix() { + let entities = LegacyTokenPrefix::iter() + .map(|entity| entity.prefix()) + .map(|prefix| prefix.to_lowercase()) + .map(|prefix| prefix.replace("-", "")) + .map(|prefix| prefix.replace(":", "")) + .map(|prefix| prefix.replace("_", "")) + .collect::>(); + + assert_eq!(entities.len(), LegacyTokenPrefix::COUNT); + } + + #[test] + pub fn all_prefixes_end_with_colon() { + let entities = LegacyTokenPrefix::iter() + .map(|entity| entity.prefix()) + .filter(|prefix| prefix.ends_with(":")) + .collect::>(); + assert_eq!(entities.len(), LegacyTokenPrefix::COUNT); + } + + #[test] + pub fn do_not_add_new_legacy_token_prefixes() { + const DO_NOT_INCREASE_THIS_COUNT : usize = 17; + assert_eq!(LegacyTokenPrefix::COUNT, DO_NOT_INCREASE_THIS_COUNT); + } + } + + mod all_prefixes { + use crate::prefixes::{LegacyTokenPrefix, RetiredTokenPrefix, TokenPrefix}; + + use super::*; + + fn get_all_prefixes() -> HashSet<&'static str> { + TokenPrefix::iter() + .map(|entity| entity.prefix()) + .chain(LegacyTokenPrefix::iter() + .map(|entity| entity.prefix())) + .chain(RetiredTokenPrefix::iter() + .map(|entity| entity.prefix())) + .collect::>() + } + + #[test] + fn test_all_prefixes_are_unique_across_all_token_generations() { + let entities = get_all_prefixes().iter() + .map(|s| s.to_string()) + .collect::>(); + + const ALL_TOKEN_PREFIX_COUNT: usize = TokenPrefix::COUNT + + LegacyTokenPrefix::COUNT + + RetiredTokenPrefix::COUNT; + + assert_eq!(entities.len(), ALL_TOKEN_PREFIX_COUNT); + } + + #[test] + fn test_all_prefixes_are_unique_across_all_token_generations_regardless_of_case_and_suffix() { + let entities = get_all_prefixes().iter() + .map(|prefix| prefix.to_lowercase()) + .map(|prefix| prefix.replace("-", "")) + .map(|prefix| prefix.replace(":", "")) + .map(|prefix| prefix.replace("_", "")) + .collect::>(); + + // NB: We're accounting for collision in a few new/legacy token prefixes: + // - `SESSION:` vs `session_` (the same table) + // - `VCR:` vs `vcr_` (two actually separate tables!) + // Don't let this happen anymore! + const ALL_TOKEN_PREFIX_COUNT: usize = TokenPrefix::COUNT + + LegacyTokenPrefix::COUNT + + RetiredTokenPrefix::COUNT + - 2; + + assert_eq!(entities.len(), ALL_TOKEN_PREFIX_COUNT); + } + + #[test] + fn test_all_prefixes_end_with_separator() { + assert!(get_all_prefixes() + .iter() + .all(|prefix| prefix.ends_with(":") || prefix.ends_with("_"))); + } + + // TODO: Just kill this test + #[test] + fn test_all_prefixes_end_with_separator_length_one() { + for prefix in get_all_prefixes().iter().map(|s| *s) { + if prefix == "news_story_" + || prefix == "INT_API:" + || prefix == "batch_g_" + || prefix == "beta_key_" + || prefix == "api_key_" + || prefix == "email_job_" + || prefix == "pw_reset_" + || prefix == "tts_task_" + || prefix == "app_session_" + || prefix == "mcp_session_" + || prefix == "mcp_refresh_" + { + // TODO/FIXME: I'm too tired at 5AM to replacen from the left. Make this test valid. + // These tokens are from the AIChatBot sidecar, so asserting their validity is less important. + continue; + } + assert_eq!(prefix.len() - 1, prefix.replace(":", "").replace("_", "").len()); + } + } + } +} diff --git a/crates/schema/public/tokens/src/safe_entropy.rs b/crates/schema/public/tokens/src/safe_entropy.rs new file mode 100644 index 0000000000..d5d1d6e466 --- /dev/null +++ b/crates/schema/public/tokens/src/safe_entropy.rs @@ -0,0 +1,127 @@ + + +// Don't generate slurs or dangerous terms in the token generator +// Beware, this is an n*m*k operation. +// see: https://news.ycombinator.com/item?id=35337210 +// see: https://www.reddit.com/r/ProgrammerHumor/comments/3ov56n/randomly_generating_ids_can_be_dangerous/ +// see: https://stackoverflow.com/a/36294356 +#[inline] +pub fn entropy_is_safe(entropic_string: &str) -> bool { + let entropic_string = entropic_string.to_lowercase(); + for bad in BAD_LIST.iter() { + if entropic_string.contains(bad) { + return false; + } + } + true +} + +/// This list of atoms should be kept *short* and the atoms themselves should +/// be short as long strings are improbable. +/// Some of this list came via ChatGPT. +/// Note: Crockford does not have `i`, `l`, `o`, `u`, ... +const BAD_LIST : [&str; 64] = [ + "000", + "111", + "1s15", // ISIS + "1s1s", // ISIS + "1sd", + "455", // ass + "4s5", // ass + "4ss", // ass + "53x", + "5ex", // sex + "5h1t", + "a1ep", // https://news.ycombinator.com/item?id=35337210 + "a55", // ass + "a5s", // ass + "as5", // ass + "ass", + "b0mb", + "bmb", // bomb + "crp", + "cvm", // cum + "d1c", // dick + "d1e", // die + "d1k", // dick + "dyk", // dyk[e] / ~dick + "f3c", + "f4ck", // fuck + "f4g", // fag + "fag", + "fec", + "fvc", // fvc[k] + "fvk", // fuck + "g0d", + "g4y", // gay + "gay", + "gvn", // gun + "jew", + "k11", // kill + "mvs", + "n1g", + "p00", + "p0t", + "p15", // piss + "p1s", // piss + "p33", // pee + "p3e", // pee + "p3n", // p3n[1s] + "pcp", + "pe3", // pee + "pee", + "pen", // pen[1s] + "pn15", // penis + "pn5", // penis + "pns", // penis + "pvs", // pus + "r1p", + "s3x", // sex + "sex", + "sh1t", // shit + "sht", // shit + "t1t", + "tw4t", // twat + "twt", // twat + "war", + "xxx", +]; + +#[cfg(test)] +mod tests { + use crate::CROCKFORD_LOWERCASE_CHARSET; + use super::BAD_LIST; + + mod do_not_waste_compute { + use std::collections::HashSet; + use super::*; + + #[test] + fn no_duplicates() { + let set = HashSet::from(BAD_LIST); + assert_eq!(set.len(), BAD_LIST.len()); + } + + #[test] + fn every_bad_word_is_crockford() { + for bad in BAD_LIST.iter() { + for c_test in bad.chars() { + if !CROCKFORD_LOWERCASE_CHARSET.contains(&(c_test as u8)) { + panic!("Bad word '{}' contains non-crockford character '{}'", bad, c_test); + } + } + } + } + + #[test] + fn no_bad_word_is_a_subset_of_another_bad_word() { + for bad_large in BAD_LIST.iter() { + for bad_small in BAD_LIST.iter() { + if bad_large != bad_small && bad_large.contains(bad_small) { + panic!("Bad word '{}' contains another bad word '{}'", bad_large, bad_small); + } + } + } + } + } +} diff --git a/crates/schema/public/tokens/src/tokens/anonymous_visitor_tracking.rs b/crates/schema/public/tokens/src/tokens/anonymous_visitor_tracking.rs new file mode 100644 index 0000000000..1a34eafde8 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/anonymous_visitor_tracking.rs @@ -0,0 +1,18 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// A rare instance that is *not* a primary key. +/// These are generated as cookies to track users for a better logged out experience and for analytics. +/// We use these as indices into several columns (ML result types, uploads, etc.) +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct AnonymousVisitorTrackingToken(pub String); + +impl_crockford_generator!(AnonymousVisitorTrackingToken, 32usize, TokenPrefix::AnonymousVisitorTracking, CrockfordLower); +impl_mysql_token_from_row!(AnonymousVisitorTrackingToken); +impl_string_token!(AnonymousVisitorTrackingToken); diff --git a/crates/schema/public/tokens/src/tokens/api_keys.rs b/crates/schema/public/tokens/src/tokens/api_keys.rs new file mode 100644 index 0000000000..2125b8dc8e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/api_keys.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for api_keys. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ApiKeyToken(pub String); + +impl_mysql_token_from_row!(ApiKeyToken); +impl_string_token!(ApiKeyToken); +impl_crockford_generator!(ApiKeyToken, 32usize, TokenPrefix::ApiKey, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/api_tokens_external.rs b/crates/schema/public/tokens/src/tokens/api_tokens_external.rs new file mode 100644 index 0000000000..d4fa9495d9 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/api_tokens_external.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// External-facing key for the `api_tokens` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ApiTokenExternal(pub String); + +impl_string_token!(ApiTokenExternal); +impl_crockford_generator!(ApiTokenExternal, 32usize, LegacyTokenPrefix::ApiTokenExternal, CrockfordUpper); diff --git a/crates/schema/public/tokens/src/tokens/api_tokens_internal.rs b/crates/schema/public/tokens/src/tokens/api_tokens_internal.rs new file mode 100644 index 0000000000..04b0bf3ae5 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/api_tokens_internal.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Internal token for the `api_tokens` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ApiTokenInternal(pub String); + +impl_string_token!(ApiTokenInternal); +impl_crockford_generator!(ApiTokenInternal, 32usize, LegacyTokenPrefix::ApiTokenInternal, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/app_session.rs b/crates/schema/public/tokens/src/tokens/app_session.rs new file mode 100644 index 0000000000..bf2a7c5a38 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/app_session.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for users. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct AppSessionToken(pub String); + +impl_mysql_token_from_row!(AppSessionToken); +impl_string_token!(AppSessionToken); +impl_crockford_generator!(AppSessionToken, 32usize, TokenPrefix::AppSession, CrockfordMixed); diff --git a/crates/schema/public/tokens/src/tokens/audit_logs.rs b/crates/schema/public/tokens/src/tokens/audit_logs.rs new file mode 100644 index 0000000000..3bac6e396f --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/audit_logs.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct AuditLogToken(pub String); + +impl_string_token!(AuditLogToken); +impl_crockford_generator!(AuditLogToken, 32usize, TokenPrefix::AuditLog, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/batch_generations.rs b/crates/schema/public/tokens/src/tokens/batch_generations.rs new file mode 100644 index 0000000000..e6e9405ff1 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/batch_generations.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct BatchGenerationToken(pub String); + +impl_crockford_generator!(BatchGenerationToken, 32usize, TokenPrefix::BatchGeneration, CrockfordLower); +impl_mysql_token_from_row!(BatchGenerationToken); +impl_string_token!(BatchGenerationToken); diff --git a/crates/schema/public/tokens/src/tokens/beta_keys.rs b/crates/schema/public/tokens/src/tokens/beta_keys.rs new file mode 100644 index 0000000000..83bc2c4882 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/beta_keys.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct BetaKeyToken(pub String); + +impl_crockford_generator!(BetaKeyToken, 32usize, TokenPrefix::BetaKey, CrockfordLower); +impl_mysql_token_from_row!(BetaKeyToken); +impl_string_token!(BetaKeyToken); diff --git a/crates/schema/public/tokens/src/tokens/browser_session_logs.rs b/crates/schema/public/tokens/src/tokens/browser_session_logs.rs new file mode 100644 index 0000000000..d7728a1973 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/browser_session_logs.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct BrowserSessionLogToken(pub String); + +impl_crockford_generator!(BrowserSessionLogToken, 32usize, TokenPrefix::BrowserSessionLog, CrockfordLower); +impl_mysql_token_from_row!(BrowserSessionLogToken); +impl_string_token!(BrowserSessionLogToken); diff --git a/crates/schema/public/tokens/src/tokens/characters.rs b/crates/schema/public/tokens/src/tokens/characters.rs new file mode 100644 index 0000000000..de5208a2ef --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/characters.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Characters +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct CharacterToken(pub String); + +impl_string_token!(CharacterToken); +impl_mysql_token_from_row!(CharacterToken); +impl_crockford_generator!(CharacterToken, 32usize, TokenPrefix::Character, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/comments.rs b/crates/schema/public/tokens/src/tokens/comments.rs new file mode 100644 index 0000000000..3fa27761d5 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/comments.rs @@ -0,0 +1,16 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct CommentToken(pub String); + +impl_string_token!(CommentToken); +impl_crockford_generator!(CommentToken, 32usize, TokenPrefix::Comment, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/email_sender_job_token.rs b/crates/schema/public/tokens/src/tokens/email_sender_job_token.rs new file mode 100644 index 0000000000..f17ad20e5f --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/email_sender_job_token.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for email_sender_jobs +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct EmailSenderJobToken(pub String); + +impl_string_token!(EmailSenderJobToken); +impl_crockford_generator!(EmailSenderJobToken, 32usize, TokenPrefix::EmailSenderJob, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/firehose_entries.rs b/crates/schema/public/tokens/src/tokens/firehose_entries.rs new file mode 100644 index 0000000000..af8484c458 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/firehose_entries.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `firehose_entries` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct FirehoseEntryToken(pub String); + +impl_string_token!(FirehoseEntryToken); +impl_crockford_generator!(FirehoseEntryToken, 32usize, LegacyTokenPrefix::FirehoseEntry, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/folders.rs b/crates/schema/public/tokens/src/tokens/folders.rs new file mode 100644 index 0000000000..41a69e30eb --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/folders.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for folders. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct FolderToken(pub String); + +impl_mysql_token_from_row!(FolderToken); +impl_string_token!(FolderToken); +impl_crockford_generator!(FolderToken, 32usize, TokenPrefix::Folder, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/generic_download_jobs.rs b/crates/schema/public/tokens/src/tokens/generic_download_jobs.rs new file mode 100644 index 0000000000..e50f4ca097 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/generic_download_jobs.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for "generic" inference jobs. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct DownloadJobToken(String); + +impl_string_token!(DownloadJobToken); +impl_crockford_generator!(DownloadJobToken, 32usize, TokenPrefix::DownloadJob, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/generic_inference_jobs.rs b/crates/schema/public/tokens/src/tokens/generic_inference_jobs.rs new file mode 100644 index 0000000000..38f39c3196 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/generic_inference_jobs.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for "generic" inference jobs. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct InferenceJobToken(String); + +impl_crockford_generator!(InferenceJobToken, 32usize, TokenPrefix::InferenceJob, CrockfordLower); +impl_mysql_token_from_row!(InferenceJobToken); +impl_string_token!(InferenceJobToken); diff --git a/crates/schema/public/tokens/src/tokens/google_sign_in_accounts.rs b/crates/schema/public/tokens/src/tokens/google_sign_in_accounts.rs new file mode 100644 index 0000000000..3b81833b61 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/google_sign_in_accounts.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for google_sign_in_accounts +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct GoogleSignInAccountToken(String); + +impl_crockford_generator!(GoogleSignInAccountToken, 32usize, TokenPrefix::GoogleSignInAccount, CrockfordLower); +impl_mysql_token_from_row!(GoogleSignInAccountToken); +impl_string_token!(GoogleSignInAccountToken); diff --git a/crates/schema/public/tokens/src/tokens/mcp_session_private.rs b/crates/schema/public/tokens/src/tokens/mcp_session_private.rs new file mode 100644 index 0000000000..cb5a316d1b --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_session_private.rs @@ -0,0 +1,22 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The private credential for an `mcp_sessions` row (the `private_session_token` column). +/// +/// This is the secret an MCP client presents in the `Authorization` header to authenticate as a +/// live session, so it carries more entropy than an ordinary token. Never list or display it +/// after creation. The non-private management handle is `McpSessionToken` (see +/// `mcp_sessions.rs`). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct McpSessionPrivateToken(pub String); + +impl_string_token!(McpSessionPrivateToken); +impl_mysql_token_from_row!(McpSessionPrivateToken); +impl_crockford_generator!(McpSessionPrivateToken, 64usize, TokenPrefix::McpSessionPrivate, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs b/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs new file mode 100644 index 0000000000..9cffcdbf37 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_session_refresh.rs @@ -0,0 +1,24 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The rotating refresh credential for an `mcp_sessions` row (the `private_refresh_token` +/// column). +/// +/// An MCP client presents this on the session-refresh endpoint to extend its session; each +/// successful refresh ROTATES the value, invalidating the previous one. Like the session +/// credential, it is a secret with extra entropy — never list or display it after it is handed +/// out. The session's other credentials are `McpSessionToken` (management handle) and +/// `McpSessionPrivateToken` (the session secret). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct McpSessionRefreshToken(pub String); + +impl_string_token!(McpSessionRefreshToken); +impl_mysql_token_from_row!(McpSessionRefreshToken); +impl_crockford_generator!(McpSessionRefreshToken, 64usize, TokenPrefix::McpSessionRefresh, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/mcp_sessions.rs b/crates/schema/public/tokens/src/tokens/mcp_sessions.rs new file mode 100644 index 0000000000..ff3a30a109 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mcp_sessions.rs @@ -0,0 +1,21 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for `mcp_sessions`. +/// +/// This is the NON-PRIVATE handle for a session — safe to list and display, and used to +/// delete/manage sessions in the dashboard. It never authenticates anything; that is the job of +/// `McpSessionPrivateToken` (see `mcp_session_private.rs`). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct McpSessionToken(pub String); + +impl_string_token!(McpSessionToken); +impl_mysql_token_from_row!(McpSessionToken); +impl_crockford_generator!(McpSessionToken, 32usize, TokenPrefix::McpSession, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/media_files.rs b/crates/schema/public/tokens/src/tokens/media_files.rs new file mode 100644 index 0000000000..a445ea7cdd --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/media_files.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Media Files +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct MediaFileToken(pub String); + +impl_string_token!(MediaFileToken); +impl_mysql_token_from_row!(MediaFileToken); +impl_crockford_generator!(MediaFileToken, 32usize, TokenPrefix::MediaFile, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/media_uploads.rs b/crates/schema/public/tokens/src/tokens/media_uploads.rs new file mode 100644 index 0000000000..be178e329c --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/media_uploads.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for user media uploads (images, video, etc.) +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct MediaUploadToken(pub String); + +impl_string_token!(MediaUploadToken); +impl_crockford_generator!(MediaUploadToken, 32usize, TokenPrefix::MediaUpload, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs new file mode 100644 index 0000000000..30992bc518 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -0,0 +1,61 @@ +pub mod anonymous_visitor_tracking; +pub mod api_keys; +pub mod api_tokens_external; +pub mod api_tokens_internal; +pub mod app_session; +pub mod audit_logs; +pub mod batch_generations; +pub mod beta_keys; +pub mod browser_session_logs; +pub mod characters; +pub mod comments; +pub mod email_sender_job_token; +pub mod firehose_entries; +pub mod folders; +pub mod generic_download_jobs; +pub mod generic_inference_jobs; +pub mod google_sign_in_accounts; +pub mod mcp_session_private; +pub mod mcp_session_refresh; +pub mod mcp_sessions; +pub mod media_files; +pub mod media_uploads; +pub mod model_categories; +pub mod model_weights; +pub mod password_reset; +pub mod prompts; +pub mod sqlite; +pub mod staff_audit_logs; +pub mod tags; +pub mod tts_inference_jobs; +pub mod tts_model_upload_jobs; +pub mod tts_models; +pub mod tts_results; +pub mod twitch_event_rule; +pub mod twitch_oauth_tokens_grouping; +pub mod twitch_oauth_tokens_internal; +pub mod uploaded_video_notes; +pub mod uploaded_videos; +pub mod user_bookmarks; +pub mod user_impersonation_requests; +pub mod user_referral_codes; +pub mod user_sessions; +pub mod user_spend_events; +pub mod user_subscriptions; +pub mod users; +pub mod vocoder_models; +pub mod voice_clone_requests; +pub mod voice_conversion_models; +pub mod voice_conversion_results; +pub mod w2l_inference_jobs; +pub mod w2l_results; +pub mod w2l_template_upload_jobs; +pub mod w2l_templates; +pub mod wallet_ledger_entries; +pub mod wallets; +pub mod zs_voice_dataset_samples; +pub mod zs_voice_datasets; +pub mod zs_voices; + +// Non-unique tokens (used as indices, not primary keys) +pub mod non_unique; diff --git a/crates/schema/public/tokens/src/tokens/model_categories.rs b/crates/schema/public/tokens/src/tokens/model_categories.rs new file mode 100644 index 0000000000..adf43ec914 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/model_categories.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// The primary key for model categories. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ModelCategoryToken(pub String); + +impl_string_token!(ModelCategoryToken); +impl_crockford_generator!(ModelCategoryToken, 15usize, LegacyTokenPrefix::ModelCategory, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/model_weights.rs b/crates/schema/public/tokens/src/tokens/model_weights.rs new file mode 100644 index 0000000000..8c22bc4c22 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/model_weights.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for the "model_weights" table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ModelWeightToken(pub String); + +impl_crockford_generator!(ModelWeightToken, 32usize, TokenPrefix::ModelWeight, CrockfordLower); +impl_mysql_token_from_row!(ModelWeightToken); +impl_string_token!(ModelWeightToken); diff --git a/crates/schema/public/tokens/src/tokens/non_unique/debug_logs_event_token.rs b/crates/schema/public/tokens/src/tokens/non_unique/debug_logs_event_token.rs new file mode 100644 index 0000000000..a7428599c6 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/non_unique/debug_logs_event_token.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// A non-unique event token for the `debug_logs` table. +/// Multiple log rows can share the same event token. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct DebugLogEventToken(pub String); + +impl_string_token!(DebugLogEventToken); +impl_crockford_generator!(DebugLogEventToken, 32usize, TokenPrefix::DebugLogEvent, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/non_unique/mod.rs b/crates/schema/public/tokens/src/tokens/non_unique/mod.rs new file mode 100644 index 0000000000..382dc89d87 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/non_unique/mod.rs @@ -0,0 +1 @@ +pub mod debug_logs_event_token; diff --git a/crates/schema/public/tokens/src/tokens/password_reset.rs b/crates/schema/public/tokens/src/tokens/password_reset.rs new file mode 100644 index 0000000000..aeacdd80c0 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/password_reset.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for `password_reset`s +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct PasswordResetToken(pub String); + +impl_string_token!(PasswordResetToken); +impl_crockford_generator!(PasswordResetToken, 32usize, TokenPrefix::PasswordReset, CrockfordMixed); diff --git a/crates/schema/public/tokens/src/tokens/prompts.rs b/crates/schema/public/tokens/src/tokens/prompts.rs new file mode 100644 index 0000000000..c1c3dd542c --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/prompts.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Prompts +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct PromptToken(pub String); + +impl_crockford_generator!(PromptToken, 32usize, TokenPrefix::Prompt, CrockfordLower); +impl_mysql_token_from_row!(PromptToken); +impl_string_token!(PromptToken); diff --git a/crates/schema/public/tokens/src/tokens/sqlite/mod.rs b/crates/schema/public/tokens/src/tokens/sqlite/mod.rs new file mode 100644 index 0000000000..e639c06e93 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/sqlite/mod.rs @@ -0,0 +1,3 @@ +pub mod news_stories; +pub mod tasks; +pub mod tts_render_tasks; diff --git a/crates/schema/public/tokens/src/tokens/sqlite/news_stories.rs b/crates/schema/public/tokens/src/tokens/sqlite/news_stories.rs new file mode 100644 index 0000000000..bd24aee09b --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/sqlite/news_stories.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for news stories (Sqlite) +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct NewsStoryToken(pub String); + +impl_string_token!(NewsStoryToken); +impl_crockford_generator!(NewsStoryToken, 32usize, TokenPrefix::NewsStory, CrockfordMixed); diff --git a/crates/schema/public/tokens/src/tokens/sqlite/tasks.rs b/crates/schema/public/tokens/src/tokens/sqlite/tasks.rs new file mode 100644 index 0000000000..1a42d6a536 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/sqlite/tasks.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TauriTokenPrefix; + +/// The primary key for tasks (Tauri / Sqlite) +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TaskId(pub String); + +impl_string_token!(TaskId); +impl_crockford_generator!(TaskId, 32usize, TauriTokenPrefix::Task, CrockfordMixed); diff --git a/crates/schema/public/tokens/src/tokens/sqlite/tts_render_tasks.rs b/crates/schema/public/tokens/src/tokens/sqlite/tts_render_tasks.rs new file mode 100644 index 0000000000..df2f211f17 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/sqlite/tts_render_tasks.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for TTS render tasks (Sqlite / AiChatBotSidecar) +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TtsRenderTaskToken(pub String); + +impl_string_token!(TtsRenderTaskToken); +impl_crockford_generator!(TtsRenderTaskToken, 32usize, TokenPrefix::TtsRenderTask, CrockfordMixed); diff --git a/crates/schema/public/tokens/src/tokens/staff_audit_logs.rs b/crates/schema/public/tokens/src/tokens/staff_audit_logs.rs new file mode 100644 index 0000000000..49d1446c68 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/staff_audit_logs.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Staff Audit Logs +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct StaffAuditLogToken(pub String); + +impl_string_token!(StaffAuditLogToken); +impl_mysql_token_from_row!(StaffAuditLogToken); +impl_crockford_generator!(StaffAuditLogToken, 32usize, TokenPrefix::StaffAuditLog, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/tags.rs b/crates/schema/public/tokens/src/tokens/tags.rs new file mode 100644 index 0000000000..a544edabed --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/tags.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Prompts +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TagToken(pub String); + +impl_crockford_generator!(TagToken, 32usize, TokenPrefix::Tag, CrockfordLower); +impl_mysql_token_from_row!(TagToken); +impl_string_token!(TagToken); diff --git a/crates/schema/public/tokens/src/tokens/tts_inference_jobs.rs b/crates/schema/public/tokens/src/tokens/tts_inference_jobs.rs new file mode 100644 index 0000000000..8ff79d7434 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/tts_inference_jobs.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `tts_inference_jobs` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TtsInferenceJobToken(pub String); + +impl_string_token!(TtsInferenceJobToken); +impl_crockford_generator!(TtsInferenceJobToken, 32usize, LegacyTokenPrefix::TtsInferenceJob, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/tts_model_upload_jobs.rs b/crates/schema/public/tokens/src/tokens/tts_model_upload_jobs.rs new file mode 100644 index 0000000000..8063c27669 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/tts_model_upload_jobs.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `tts_model_upload_jobs` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TtsModelUploadJobToken(pub String); + +impl_string_token!(TtsModelUploadJobToken); +impl_crockford_generator!(TtsModelUploadJobToken, 32usize, LegacyTokenPrefix::TtsModelUploadJob, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/tts_models.rs b/crates/schema/public/tokens/src/tokens/tts_models.rs new file mode 100644 index 0000000000..e755376d94 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/tts_models.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// The primary key for TTS models. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TtsModelToken(pub String); + +impl_string_token!(TtsModelToken); +impl_crockford_generator!(TtsModelToken, 15usize, LegacyTokenPrefix::TtsModel, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/tts_results.rs b/crates/schema/public/tokens/src/tokens/tts_results.rs new file mode 100644 index 0000000000..9461a42581 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/tts_results.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `tts_results` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TtsResultToken(pub String); + +impl_string_token!(TtsResultToken); +impl_crockford_generator!(TtsResultToken, 32usize, LegacyTokenPrefix::TtsResult, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/twitch_event_rule.rs b/crates/schema/public/tokens/src/tokens/twitch_event_rule.rs new file mode 100644 index 0000000000..375a3cd0cc --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/twitch_event_rule.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// primary key token for the `twitch_event_rules` table (this is deprecated) +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TwitchEventRuleToken(pub String); + +impl_string_token!(TwitchEventRuleToken); +impl_crockford_generator!(TwitchEventRuleToken, 32usize, LegacyTokenPrefix::TwitchEventRule, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/twitch_oauth_tokens_grouping.rs b/crates/schema/public/tokens/src/tokens/twitch_oauth_tokens_grouping.rs new file mode 100644 index 0000000000..548d162aae --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/twitch_oauth_tokens_grouping.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// "grouping token" for the `twitch_oauth_tokens` table (this is deprecated) +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TwitchOauthGroupingToken(pub String); + +impl_string_token!(TwitchOauthGroupingToken); +impl_crockford_generator!(TwitchOauthGroupingToken, 32usize, LegacyTokenPrefix::TwitchOauthGrouping, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/twitch_oauth_tokens_internal.rs b/crates/schema/public/tokens/src/tokens/twitch_oauth_tokens_internal.rs new file mode 100644 index 0000000000..9848b95d91 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/twitch_oauth_tokens_internal.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// "internal token" for the `twitch_oauth_tokens` table (this is deprecated) +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct TwitchOauthInternalToken(pub String); + +impl_string_token!(TwitchOauthInternalToken); +impl_crockford_generator!(TwitchOauthInternalToken, 32usize, LegacyTokenPrefix::TwitchOauthInternal, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/uploaded_video_notes.rs b/crates/schema/public/tokens/src/tokens/uploaded_video_notes.rs new file mode 100644 index 0000000000..93095d675e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/uploaded_video_notes.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for uploaded_video_notes. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UploadVideoNoteToken(pub String); + +impl_string_token!(UploadVideoNoteToken); +impl_mysql_token_from_row!(UploadVideoNoteToken); +impl_crockford_generator!(UploadVideoNoteToken, 32usize, TokenPrefix::UploadedVideoNote, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/uploaded_videos.rs b/crates/schema/public/tokens/src/tokens/uploaded_videos.rs new file mode 100644 index 0000000000..80a36c6af2 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/uploaded_videos.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for uploaded_videos. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UploadedVideoToken(pub String); + +impl_string_token!(UploadedVideoToken); +impl_mysql_token_from_row!(UploadedVideoToken); +impl_crockford_generator!(UploadedVideoToken, 32usize, TokenPrefix::UploadedVideo, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_bookmarks.rs b/crates/schema/public/tokens/src/tokens/user_bookmarks.rs new file mode 100644 index 0000000000..6c292c1696 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_bookmarks.rs @@ -0,0 +1,16 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserBookmarkToken(pub String); + +impl_string_token!(UserBookmarkToken); +impl_crockford_generator!(UserBookmarkToken, 32usize, TokenPrefix::UserBookmark, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_impersonation_requests.rs b/crates/schema/public/tokens/src/tokens/user_impersonation_requests.rs new file mode 100644 index 0000000000..f7535ccfc0 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_impersonation_requests.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for User Impersonation Requests +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserImpersonationRequestToken(pub String); + +impl_string_token!(UserImpersonationRequestToken); +impl_mysql_token_from_row!(UserImpersonationRequestToken); +impl_crockford_generator!(UserImpersonationRequestToken, 32usize, TokenPrefix::UserImpersonationRequest, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_referral_codes.rs b/crates/schema/public/tokens/src/tokens/user_referral_codes.rs new file mode 100644 index 0000000000..4e7815bc99 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_referral_codes.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for user referral codes. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserReferralCodeToken(pub String); + +impl_mysql_token_from_row!(UserReferralCodeToken); +impl_string_token!(UserReferralCodeToken); +impl_crockford_generator!(UserReferralCodeToken, 18usize, TokenPrefix::UserReferralCode, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_sessions.rs b/crates/schema/public/tokens/src/tokens/user_sessions.rs new file mode 100644 index 0000000000..69e6f376d0 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_sessions.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// Primary key for the `user_sessions` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserSessionToken(pub String); + +impl_string_token!(UserSessionToken); +impl_crockford_generator!(UserSessionToken, 32usize, TokenPrefix::UserSession, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_spend_events.rs b/crates/schema/public/tokens/src/tokens/user_spend_events.rs new file mode 100644 index 0000000000..2a32b7dd62 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_spend_events.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for user_spend_events +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserSpendEventToken(pub String); + +impl_string_token!(UserSpendEventToken); +impl_mysql_token_from_row!(UserSpendEventToken); +impl_crockford_generator!(UserSpendEventToken, 32usize, TokenPrefix::UserSpendEvent, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_subscriptions.rs b/crates/schema/public/tokens/src/tokens/user_subscriptions.rs new file mode 100644 index 0000000000..4930b62ad7 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_subscriptions.rs @@ -0,0 +1,16 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// Primary key for the `user_subscriptions` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserSubscriptionToken(pub String); + +impl_string_token!(UserSubscriptionToken); +impl_crockford_generator!(UserSubscriptionToken, 32usize, TokenPrefix::UserSubscription, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/users.rs b/crates/schema/public/tokens/src/tokens/users.rs new file mode 100644 index 0000000000..93308d8527 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/users.rs @@ -0,0 +1,18 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for users. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserToken(pub String); + +impl_mysql_token_from_row!(UserToken); +impl_string_token!(UserToken); +// NB: Older user tokens were under this regime: 15 characters, "U:" prefix, Crockford Upper. +impl_crockford_generator!(UserToken, 18usize, TokenPrefix::User, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/vocoder_models.rs b/crates/schema/public/tokens/src/tokens/vocoder_models.rs new file mode 100644 index 0000000000..c258d8db77 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/vocoder_models.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// The primary key for the `vocoder_models` table +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct VocoderModelToken(pub String); + +impl_string_token!(VocoderModelToken); +impl_crockford_generator!(VocoderModelToken, 15usize, LegacyTokenPrefix::VocoderModel, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/voice_clone_requests.rs b/crates/schema/public/tokens/src/tokens/voice_clone_requests.rs new file mode 100644 index 0000000000..47430a7c0e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/voice_clone_requests.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `voice_clone_requests` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct VoiceCloneRequestToken(pub String); + +impl_string_token!(VoiceCloneRequestToken); +impl_crockford_generator!(VoiceCloneRequestToken, 32usize, LegacyTokenPrefix::VoiceCloneRequest, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/voice_conversion_models.rs b/crates/schema/public/tokens/src/tokens/voice_conversion_models.rs new file mode 100644 index 0000000000..7f82d2128c --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/voice_conversion_models.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for "generic" inference jobs. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct VoiceConversionModelToken(pub String); + +impl_string_token!(VoiceConversionModelToken); +impl_crockford_generator!(VoiceConversionModelToken, 16usize, TokenPrefix::VoiceConversionModel, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/voice_conversion_results.rs b/crates/schema/public/tokens/src/tokens/voice_conversion_results.rs new file mode 100644 index 0000000000..6441e48d7c --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/voice_conversion_results.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for model categories. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct VoiceConversionResultToken(pub String); + +impl_string_token!(VoiceConversionResultToken); +impl_crockford_generator!(VoiceConversionResultToken, 32usize, TokenPrefix::VoiceConversionResult, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/w2l_inference_jobs.rs b/crates/schema/public/tokens/src/tokens/w2l_inference_jobs.rs new file mode 100644 index 0000000000..1b5fa9c257 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/w2l_inference_jobs.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `w2l_inference_jobs` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct W2lInferenceJobToken(pub String); + +impl_string_token!(W2lInferenceJobToken); +impl_crockford_generator!(W2lInferenceJobToken, 32usize, LegacyTokenPrefix::W2lInferenceJob, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/w2l_results.rs b/crates/schema/public/tokens/src/tokens/w2l_results.rs new file mode 100644 index 0000000000..9af33d573e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/w2l_results.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `w2l_results` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct W2lResultToken(pub String); + +impl_string_token!(W2lResultToken); +impl_crockford_generator!(W2lResultToken, 32usize, LegacyTokenPrefix::W2lResult, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/w2l_template_upload_jobs.rs b/crates/schema/public/tokens/src/tokens/w2l_template_upload_jobs.rs new file mode 100644 index 0000000000..603c04da8e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/w2l_template_upload_jobs.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// Primary key for the `w2l_template_upload_jobs` table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct W2lTemplateUploadJobToken(pub String); + +impl_string_token!(W2lTemplateUploadJobToken); +impl_crockford_generator!(W2lTemplateUploadJobToken, 32usize, LegacyTokenPrefix::W2lTemplateUploadJob, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/w2l_templates.rs b/crates/schema/public/tokens/src/tokens/w2l_templates.rs new file mode 100644 index 0000000000..c2cc800525 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/w2l_templates.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::LegacyTokenPrefix; + +/// The primary key for W2L templates. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct W2lTemplateToken(pub String); + +impl_string_token!(W2lTemplateToken); +impl_crockford_generator!(W2lTemplateToken, 32usize, LegacyTokenPrefix::W2lTemplate, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/wallet_ledger_entries.rs b/crates/schema/public/tokens/src/tokens/wallet_ledger_entries.rs new file mode 100644 index 0000000000..93267ec5e3 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/wallet_ledger_entries.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for wallet_ledger_entries +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct WalletLedgerEntryToken(pub String); + +impl_string_token!(WalletLedgerEntryToken); +impl_mysql_token_from_row!(WalletLedgerEntryToken); +impl_crockford_generator!(WalletLedgerEntryToken, 32usize, TokenPrefix::WalletLedgerEntry, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/wallets.rs b/crates/schema/public/tokens/src/tokens/wallets.rs new file mode 100644 index 0000000000..1f80dc7384 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/wallets.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for wallets +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct WalletToken(pub String); + +impl_string_token!(WalletToken); +impl_mysql_token_from_row!(WalletToken); +impl_crockford_generator!(WalletToken, 32usize, TokenPrefix::Wallet, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/zs_voice_dataset_samples.rs b/crates/schema/public/tokens/src/tokens/zs_voice_dataset_samples.rs new file mode 100644 index 0000000000..9fb73f2254 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/zs_voice_dataset_samples.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for the "zs_voice_dataset_samples" table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ZsVoiceDatasetSampleToken(pub String); + +impl_string_token!(ZsVoiceDatasetSampleToken); +impl_crockford_generator!(ZsVoiceDatasetSampleToken, 32usize, TokenPrefix::ZsVoiceDatasetSample, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/zs_voice_datasets.rs b/crates/schema/public/tokens/src/tokens/zs_voice_datasets.rs new file mode 100644 index 0000000000..e7d2942029 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/zs_voice_datasets.rs @@ -0,0 +1,16 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for the "zs_voice_datasets" table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ZsVoiceDatasetToken(pub String); + +impl_string_token!(ZsVoiceDatasetToken); +impl_crockford_generator!(ZsVoiceDatasetToken, 32usize, TokenPrefix::ZsVoiceDataset, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/zs_voices.rs b/crates/schema/public/tokens/src/tokens/zs_voices.rs new file mode 100644 index 0000000000..b887a3e597 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/zs_voices.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TokenPrefix; + +/// The primary key for the "zs_voices" table. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ZsVoiceToken(pub String); + +impl_string_token!(ZsVoiceToken); +impl_crockford_generator!(ZsVoiceToken, 32usize, TokenPrefix::ZsVoice, CrockfordLower); diff --git a/crates/schema/public/tokens/src/traits/mod.rs b/crates/schema/public/tokens/src/traits/mod.rs new file mode 100644 index 0000000000..c16e33fde8 --- /dev/null +++ b/crates/schema/public/tokens/src/traits/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "database")] +pub mod mysql_token_from_row; \ No newline at end of file diff --git a/crates/schema/public/tokens/src/traits/mysql_token_from_row.rs b/crates/schema/public/tokens/src/traits/mysql_token_from_row.rs new file mode 100644 index 0000000000..e0d62c225e --- /dev/null +++ b/crates/schema/public/tokens/src/traits/mysql_token_from_row.rs @@ -0,0 +1,11 @@ +use sqlx_mysql::MySqlRow; + +/// This is a trait (in conjunction with the macro implementation) that makes up for sqlx's +/// QueryBuilder not playing well with FromRow implementation or derivation of named types. +pub trait MySqlTokenFromRow { + + /// Attempt to parse a named column from a MySqlRow into the type. + fn try_from_mysql_row(row: &MySqlRow, field_name: &str) -> Result; + + fn try_from_mysql_row_nullable(row: &MySqlRow, field_name: &str) -> Result, sqlx::Error>; +} diff --git a/crates/schema/public/tokens/tests/media_file_token_tests.rs b/crates/schema/public/tokens/tests/media_file_token_tests.rs new file mode 100644 index 0000000000..b3e6090c10 --- /dev/null +++ b/crates/schema/public/tokens/tests/media_file_token_tests.rs @@ -0,0 +1,201 @@ +/// Test MediaFileToken in isolation, just in case our macro-derived tests break. + +use serde::Deserialize; +use serde::Serialize; + +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +struct CompositeType { + media_file_token: MediaFileToken, + string: String, +} + +mod interface { + use tokens::tokens::media_files::MediaFileToken; + + #[test] + fn generate() { + let token = MediaFileToken::generate(); + assert!(!token.to_string().is_empty()); + assert!(token.to_string().starts_with("m_")); + } + + #[test] + fn generate_for_testing_and_dev_seeding_never_use_in_production_seriously_1() { + // NB: Using the same reset seed will produce the same results each time + MediaFileToken::reset_rng_for_testing_and_dev_seeding_never_use_in_production_seriously(0); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_q8sz47gmfw2zx02snrbz88ns9m16ab"); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_ma1xetxrwbh39vg639a9zrq8b9wk6h"); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_4tswec8z27wnm01njypx4vmfhgj41e"); + // NB: Same seed -> same tokens generated + MediaFileToken::reset_rng_for_testing_and_dev_seeding_never_use_in_production_seriously(0); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_q8sz47gmfw2zx02snrbz88ns9m16ab"); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_ma1xetxrwbh39vg639a9zrq8b9wk6h"); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_4tswec8z27wnm01njypx4vmfhgj41e"); + // Once more... + MediaFileToken::reset_rng_for_testing_and_dev_seeding_never_use_in_production_seriously(0); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_q8sz47gmfw2zx02snrbz88ns9m16ab"); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_ma1xetxrwbh39vg639a9zrq8b9wk6h"); + assert_eq!(MediaFileToken::generate_for_testing_and_dev_seeding_never_use_in_production_seriously().as_str(), "m_4tswec8z27wnm01njypx4vmfhgj41e"); + } + + #[test] + fn new() { + let token = MediaFileToken::new("m_foo".to_string()); + assert_eq!(token, MediaFileToken("m_foo".to_string())); + } + + #[test] + fn new_from_str() { + let token = MediaFileToken::new_from_str("m_foo"); + assert_eq!(token, MediaFileToken("m_foo".to_string())); + } + + #[test] + fn as_str() { + let token = MediaFileToken("m_foo".to_string()); + assert_eq!(token.as_str(), "m_foo"); + } + + #[test] + fn to_string() { + let token = MediaFileToken("m_foo".to_string()); + assert_eq!(token.to_string(), "m_foo".to_string()); + } +} + +mod traits { + use tokens::tokens::media_files::MediaFileToken; + + #[test] + fn display() { + let token = MediaFileToken("m_foo".to_string()); + assert_eq!(format!("{}", token), "m_foo".to_string()); + } + + #[test] + fn debug() { + let token = MediaFileToken("m_foo".to_string()); + assert_eq!(format!("{:?}", token), "MediaFileToken(\"m_foo\")".to_string()); + } +} + +mod serialization { + use tokens::tokens::media_files::MediaFileToken; + + use crate::CompositeType; + +// NB(bt,2024-09-25): These tests are broken with a recent upgrade +// #[test] +// fn serialize() { +// let expected = "\"m_foo\"".to_string(); // NB: Quoted +// +// let token = MediaFileToken("m_foo".to_string()); +// assert_eq!(expected, toml::to_string(&token).unwrap()); +// +// // Just to show this serializes the same as a string +// assert_eq!(expected, toml::to_string("m_foo").unwrap()); +// } + + #[test] + fn nested_serialize() { + let value = CompositeType { media_file_token: MediaFileToken("m_foo".to_string()), string: "bar".to_string() }; + let expected = r#"{"media_file_token":"m_foo","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } +} + +mod deserialization { + use tokens::tokens::media_files::MediaFileToken; + + use crate::CompositeType; + + #[test] + fn deserialize() { + let payload = "\"m_foo\""; // NB: Quoted + let expected = "m_foo".to_string(); + + let value: MediaFileToken = serde_json::from_str(payload).unwrap(); + assert_eq!(value, MediaFileToken(expected.clone())); + + // Just to show this deserializes the same way as a string + let value: String = serde_json::from_str(payload).unwrap(); + assert_eq!(value, expected.clone()); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"media_file_token":"m_foo","string":"bar"}"#.to_string(); + let expected = CompositeType { + media_file_token: MediaFileToken("m_foo".to_string()), + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } +} + +// These traits should be tested by the macro, but we duplicate them in case that breaks +mod crockford_traits { + use tokens::tokens::media_files::MediaFileToken; + + const ENTROPIC_CHARACTERS_MINIMUM : usize = 8; + + #[test] + fn entropy_is_sufficient() { + assert!(MediaFileToken::entropic_character_len() > ENTROPIC_CHARACTERS_MINIMUM); + } + + #[test] + fn token_length() { + assert_eq!(MediaFileToken::generate().as_str().len(), 32); + } + + #[test] + fn tokens_are_random() { + let mut tokens = std::collections::HashSet::new(); + for _ in 0..100 { + tokens.insert(MediaFileToken::generate().to_string()); + } + assert_eq!(tokens.len(), 100); + } + + #[test] + fn character_set() { + let token_string = MediaFileToken::generate().to_string(); + let prefix = MediaFileToken::token_prefix(); + let random_part = token_string.replace(prefix, ""); + + assert!(random_part.len() > ENTROPIC_CHARACTERS_MINIMUM); + assert!(random_part.chars().all(|c| c.is_numeric() || c.is_lowercase())); + } + + #[test] + fn prefix_ends_with_separator() { + let prefix = MediaFileToken::token_prefix(); + assert!(prefix.ends_with("_")); + + let token_string = MediaFileToken::generate().to_string(); + assert!(token_string.contains(":") || token_string.contains("_")); + } + + #[test] + fn token_begins_with_prefix() { + let prefix = MediaFileToken::token_prefix(); + let token_string = MediaFileToken::generate().to_string(); + assert!(token_string.starts_with(prefix)); + } + + #[test] + fn entropy_suffix() { + let token = MediaFileToken::new_from_str("m_foo"); + assert_eq!(token.entropy_suffix(), "foo"); + + let token = MediaFileToken::new_from_str("bar"); + assert_eq!(token.entropy_suffix(), "bar"); + + let token = MediaFileToken::generate(); + assert_eq!(token.entropy_suffix().len(), 30); + } +} diff --git a/crates/schema/public/tokens/tests/media_upload_token_tests.rs b/crates/schema/public/tokens/tests/media_upload_token_tests.rs new file mode 100644 index 0000000000..5e93342f9e --- /dev/null +++ b/crates/schema/public/tokens/tests/media_upload_token_tests.rs @@ -0,0 +1,182 @@ +/// Test MediaUploadToken in isolation, just in case our macro-derived tests break. + +use serde::Deserialize; +use serde::Serialize; + +use tokens::tokens::media_uploads::MediaUploadToken; + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +struct CompositeType { + media_upload_token: MediaUploadToken, + string: String, +} + +mod interface { + use tokens::tokens::media_uploads::MediaUploadToken; + + #[test] + fn generate() { + let token = MediaUploadToken::generate(); + assert!(!token.to_string().is_empty()); + assert!(token.to_string().starts_with("mu_")); + } + + #[test] + fn new() { + let token = MediaUploadToken::new("mu_foo".to_string()); + assert_eq!(token, MediaUploadToken("mu_foo".to_string())); + } + + #[test] + fn new_from_str() { + let token = MediaUploadToken::new_from_str("mu_foo"); + assert_eq!(token, MediaUploadToken("mu_foo".to_string())); + } + + #[test] + fn as_str() { + let token = MediaUploadToken("mu_foo".to_string()); + assert_eq!(token.as_str(), "mu_foo"); + } + + #[test] + fn to_string() { + let token = MediaUploadToken("mu_foo".to_string()); + assert_eq!(token.to_string(), "mu_foo".to_string()); + } +} + +mod traits { + use tokens::tokens::media_uploads::MediaUploadToken; + + #[test] + fn display() { + let token = MediaUploadToken("mu_foo".to_string()); + assert_eq!(format!("{}", token), "mu_foo".to_string()); + } + + #[test] + fn debug() { + let token = MediaUploadToken("mu_foo".to_string()); + assert_eq!(format!("{:?}", token), "MediaUploadToken(\"mu_foo\")".to_string()); + } +} + +mod serialization { + use tokens::tokens::media_uploads::MediaUploadToken; + + use crate::CompositeType; + +// NB(bt,2024-09-25): These tests are broken with a recent upgrade +// #[test] +// fn serialize() { +// let expected = "\"mu_foo\"".to_string(); // NB: Quoted +// +// let token = MediaUploadToken("mu_foo".to_string()); +// assert_eq!(expected, toml::to_string(&token).unwrap()); +// +// // Just to show this serializes the same as a string +// assert_eq!(expected, toml::to_string("mu_foo").unwrap()); +// } + + #[test] + fn nested_serialize() { + let value = CompositeType { media_upload_token: MediaUploadToken("mu_foo".to_string()), string: "bar".to_string() }; + let expected = r#"{"media_upload_token":"mu_foo","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } +} + +mod deserialization { + use tokens::tokens::media_uploads::MediaUploadToken; + + use crate::CompositeType; + + #[test] + fn deserialize() { + let payload = "\"mu_foo\""; // NB: Quoted + let expected = "mu_foo".to_string(); + + let value: MediaUploadToken = serde_json::from_str(payload).unwrap(); + assert_eq!(value, MediaUploadToken(expected.clone())); + + // Just to show this deserializes the same way as a string + let value: String = serde_json::from_str(payload).unwrap(); + assert_eq!(value, expected.clone()); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"media_upload_token":"mu_foo","string":"bar"}"#.to_string(); + let expected = CompositeType { + media_upload_token: MediaUploadToken("mu_foo".to_string()), + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } +} + +// These traits should be tested by the macro, but we duplicate them in case that breaks +mod crockford_traits { + use tokens::tokens::media_uploads::MediaUploadToken; + + const ENTROPIC_CHARACTERS_MINIMUM : usize = 8; + + #[test] + fn entropy_is_sufficient() { + assert!(MediaUploadToken::entropic_character_len() > ENTROPIC_CHARACTERS_MINIMUM); + } + + #[test] + fn token_length() { + assert_eq!(MediaUploadToken::generate().as_str().len(), 32); + } + + #[test] + fn tokens_are_random() { + let mut tokens = std::collections::HashSet::new(); + for _ in 0..100 { + tokens.insert(MediaUploadToken::generate().to_string()); + } + assert_eq!(tokens.len(), 100); + } + + #[test] + fn character_set() { + let token_string = MediaUploadToken::generate().to_string(); + let prefix = MediaUploadToken::token_prefix(); + let random_part = token_string.replace(prefix, ""); + + assert!(random_part.len() > ENTROPIC_CHARACTERS_MINIMUM); + assert!(random_part.chars().all(|c| c.is_numeric() || c.is_lowercase())); + } + + #[test] + fn prefix_ends_with_separator() { + let prefix = MediaUploadToken::token_prefix(); + assert!(prefix.ends_with(":") || prefix.ends_with("_")); + + let token_string = MediaUploadToken::generate().to_string(); + assert!(token_string.contains(":") || token_string.contains("_")); + } + + #[test] + fn token_begins_with_prefix() { + let prefix = MediaUploadToken::token_prefix(); + let token_string = MediaUploadToken::generate().to_string(); + assert!(token_string.starts_with(prefix)); + } + + #[test] + fn entropy_suffix() { + let token = MediaUploadToken::new_from_str("mu_foo"); + assert_eq!(token.entropy_suffix(), "foo"); + + let token = MediaUploadToken::new_from_str("bar"); + assert_eq!(token.entropy_suffix(), "bar"); + + let token = MediaUploadToken::generate(); + assert_eq!(token.entropy_suffix().len(), 29); + } +} diff --git a/crates/schema/public/tokens/tests/user_token_tests.rs b/crates/schema/public/tokens/tests/user_token_tests.rs new file mode 100644 index 0000000000..9fae084e28 --- /dev/null +++ b/crates/schema/public/tokens/tests/user_token_tests.rs @@ -0,0 +1,182 @@ +/// Test UserToken in isolation, just in case our macro-derived tests break. + +use serde::Deserialize; +use serde::Serialize; + +use tokens::tokens::users::UserToken; + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +struct CompositeType { + user_token: UserToken, + string: String, +} + +mod interface { + use tokens::tokens::users::UserToken; + + #[test] + fn generate() { + let token = UserToken::generate(); + assert!(!token.to_string().is_empty()); + assert!(token.to_string().starts_with("user_")); + } + + #[test] + fn new() { + let token = UserToken::new("user_foo".to_string()); + assert_eq!(token, UserToken("user_foo".to_string())); + } + + #[test] + fn new_from_str() { + let token = UserToken::new_from_str("user_foo"); + assert_eq!(token, UserToken("user_foo".to_string())); + } + + #[test] + fn as_str() { + let token = UserToken("user_foo".to_string()); + assert_eq!(token.as_str(), "user_foo"); + } + + #[test] + fn to_string() { + let token = UserToken("user_foo".to_string()); + assert_eq!(token.to_string(), "user_foo".to_string()); + } +} + +mod traits { + use tokens::tokens::users::UserToken; + + #[test] + fn display() { + let token = UserToken("user_foo".to_string()); + assert_eq!(format!("{}", token), "user_foo".to_string()); + } + + #[test] + fn debug() { + let token = UserToken("user_foo".to_string()); + assert_eq!(format!("{:?}", token), "UserToken(\"user_foo\")".to_string()); + } +} + +mod serialization { + use tokens::tokens::users::UserToken; + + use crate::CompositeType; + +// NB(bt,2024-09-25): These tests are broken with a recent upgrade +// #[test] +// fn serialize() { +// let expected = "\"user_foo\"".to_string(); // NB: Quoted +// +// let token = UserToken("user_foo".to_string()); +// assert_eq!(expected, toml::to_string(&token).unwrap()); +// +// // Just to show this serializes the same as a string +// assert_eq!(expected, toml::to_string("user_foo").unwrap()); +// } + + #[test] + fn nested_serialize() { + let value = CompositeType { user_token: UserToken("user_foo".to_string()), string: "bar".to_string() }; + let expected = r#"{"user_token":"user_foo","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } +} + +mod deserialization { + use tokens::tokens::users::UserToken; + + use crate::CompositeType; + + #[test] + fn deserialize() { + let payload = "\"user_foo\""; // NB: Quoted + let expected = "user_foo".to_string(); + + let value: UserToken = serde_json::from_str(payload).unwrap(); + assert_eq!(value, UserToken(expected.clone())); + + // Just to show this deserializes the same way as a string + let value: String = serde_json::from_str(payload).unwrap(); + assert_eq!(value, expected.clone()); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"user_token":"user_foo","string":"bar"}"#.to_string(); + let expected = CompositeType { + user_token: UserToken("user_foo".to_string()), + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } +} + +// These traits should be tested by the macro, but we duplicate them in case that breaks +mod crockford_traits { + use tokens::tokens::users::UserToken; + + const ENTROPIC_CHARACTERS_MINIMUM : usize = 8; + + #[test] + fn entropy_is_sufficient() { + assert!(UserToken::entropic_character_len() > ENTROPIC_CHARACTERS_MINIMUM); + } + + #[test] + fn token_length() { + assert_eq!(UserToken::generate().as_str().len(), 18); + } + + #[test] + fn tokens_are_random() { + let mut tokens = std::collections::HashSet::new(); + for _ in 0..100 { + tokens.insert(UserToken::generate().to_string()); + } + assert_eq!(tokens.len(), 100); + } + + #[test] + fn character_set() { + let token_string = UserToken::generate().to_string(); + let prefix = UserToken::token_prefix(); + let random_part = token_string.replace(prefix, ""); + + assert!(random_part.len() > ENTROPIC_CHARACTERS_MINIMUM); + assert!(random_part.chars().all(|c| c.is_numeric() || c.is_lowercase())); + } + + #[test] + fn prefix_ends_with_separator() { + let prefix = UserToken::token_prefix(); + assert!(prefix.ends_with(":") || prefix.ends_with("_")); + + let token_string = UserToken::generate().to_string(); + assert!(token_string.contains(":") || token_string.contains("_")); + } + + #[test] + fn token_begins_with_prefix() { + let prefix = UserToken::token_prefix(); + let token_string = UserToken::generate().to_string(); + assert!(token_string.starts_with(prefix)); + } + + #[test] + fn entropy_suffix() { + let token = UserToken::new_from_str("user_foo"); + assert_eq!(token.entropy_suffix(), "foo"); + + let token = UserToken::new_from_str("bar"); + assert_eq!(token.entropy_suffix(), "bar"); + + let token = UserToken::generate(); + assert_eq!(token.entropy_suffix().len(), 13); + } +} diff --git a/crates/testing/test_data/Cargo.toml b/crates/testing/test_data/Cargo.toml new file mode 100644 index 0000000000..b5b3d1a9c0 --- /dev/null +++ b/crates/testing/test_data/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "test_data" +edition = "2021" +version = "0.0.1" +publish = false + +[lib] +# NB: Did not look for public-facing crate with same name. +name = "test_data" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors.workspace = true + +# External +anyhow.workspace = true + +[dev-dependencies] +# None yet diff --git a/crates/testing/test_data/src/lib.rs b/crates/testing/test_data/src/lib.rs new file mode 100644 index 0000000000..a64a6b6121 --- /dev/null +++ b/crates/testing/test_data/src/lib.rs @@ -0,0 +1,18 @@ +//! A library with test data for our test cases. + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod web; diff --git a/crates/testing/test_data/src/web/character_tokens.rs b/crates/testing/test_data/src/web/character_tokens.rs new file mode 100644 index 0000000000..b000479d9e --- /dev/null +++ b/crates/testing/test_data/src/web/character_tokens.rs @@ -0,0 +1,25 @@ +//! Production character test data from the test account. + +pub struct TestCharacter { + pub token: &'static str, + pub name: &'static str, + pub avatar_cdn_url: &'static str, +} + +pub const JIM: TestCharacter = TestCharacter { + token: "character_9f3sa1qatp1nr3876srm0b", + name: "Jim", + avatar_cdn_url: "https://cdn-2.fakeyou.com/media/b/b/w/7/3/bbw7329f04h0zcsyvh5tb908htfvq9fy/artcraft_bbw7329f04h0zcsyvh5tb908htfvq9fy.jpg", +}; + +pub const FANTASY_RAPTOR: TestCharacter = TestCharacter { + token: "character_6rhsk8a7twjdnykhw4kr4n", + name: "Fantasy Raptor", + avatar_cdn_url: "https://cdn-2.fakeyou.com/media/p/1/q/v/m/p1qvmnh5z637cdpc5m3wr6qmjr77q48f/artcraft_p1qvmnh5z637cdpc5m3wr6qmjr77q48f.png", +}; + +pub const KNIGHT: TestCharacter = TestCharacter { + token: "character_6xvgbmm9q79c5ygsj0y4fb", + name: "Knight", + avatar_cdn_url: "https://cdn-2.fakeyou.com/media/w/k/j/5/q/wkj5qwnpg9dm5969jbz4vs49xncypq2y/artcraft_wkj5qwnpg9dm5969jbz4vs49xncypq2y.png", +}; diff --git a/crates/testing/test_data/src/web/image_media_tokens.rs b/crates/testing/test_data/src/web/image_media_tokens.rs new file mode 100644 index 0000000000..5334117228 --- /dev/null +++ b/crates/testing/test_data/src/web/image_media_tokens.rs @@ -0,0 +1,6 @@ +//! Production media file tokens corresponding to the image URLs in `image_urls.rs`. + +pub const JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN: &str = "m_m1bz02z1kkzanxy6rb4vk1kvq9de9g"; +pub const FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN: &str = "m_f5yy0fdmkwrf2hxaq4yw4sd95tvvrf"; +pub const WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN: &str = "m_xmzdeqhjpxs5418kvepcrj5a3vkfb1"; +pub const TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN: &str = "m_5y81rcnpnrbey55da6cc2s9z4d2f45"; \ No newline at end of file diff --git a/crates/testing/test_data/src/web/image_urls.rs b/crates/testing/test_data/src/web/image_urls.rs new file mode 100644 index 0000000000..074f3d8f60 --- /dev/null +++ b/crates/testing/test_data/src/web/image_urls.rs @@ -0,0 +1,46 @@ +//! Links to images that should remain available for tests to download. + +// media token: m_g978j1h93fatjastnv43bcwnem667p +pub const ERNEST_GHOST_TREX_IMAGE_URL :&str = "https://cdn-2.fakeyou.com/media/h/p/v/2/n/hpv2ncy2d3wcy02mr2kh9e807z8whrm8/image_hpv2ncy2d3wcy02mr2kh9e807z8whrm8.png"; + +// media token: m_1997908e4tcbaj34wa0s5bvr4y4szt +pub const ERNEST_SCARED_STUPID_IMAGE_URL :&str = "https://cdn-2.fakeyou.com/media/t/8/r/q/3/t8rq3b8tvabpyrmp9c0tw9t612bjq4rm/asset_t8rq3b8tvabpyrmp9c0tw9t612bjq4rm.jpg"; + +// media token: m_f5yy0fdmkwrf2hxaq4yw4sd95tvvrf +pub const FOREST_BACKDROP_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/0/p/0/s/9/0p0s9brjd8k3xr6qgahmkkhs5d4kga30/asset_0p0s9brjd8k3xr6qgahmkkhs5d4kga30.jpg"; + +// media token: m_deprc7e171byqqe0z8n9f7tjqxvdvj +pub const GHOST_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/g/r/m/e/t/grmetywfpztmxcn2f796ambvm3hdhrt3/asset_grmetywfpztmxcn2f796ambvm3hdhrt3.jpg"; + +// media token: m_6mhxhv9wat9p63nc6w502zxrz31t3a +pub const GRASSY_HILL_TRANSPARENT_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/y/4/n/w/7/y4nw7rebh54j4cnw7nmcy81v2nsjkpxg/asset_y4nw7rebh54j4cnw7nmcy81v2nsjkpxg.png"; + +// media token: m_43vfk9rc35yt2rxb1tgcsx11wedc01 +pub const MOUNTAIN_TREE_IMAGE_URL :&str = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png"; + +// media token: m_xmzdeqhjpxs5418kvepcrj5a3vkfb1 +pub const WHITE_HOUSE_SUNSET_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/q/t/z/w/k/qtzwka8dd0g2nfyk94k1qh3zzfwqtfhn/asset_qtzwka8dd0g2nfyk94k1qh3zzfwqtfhn.jpg"; + +// media token: m_z61yg5w7dnnvhb1f9sqt8jf02y61bv +pub const SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL: &str = "https://cdn-2.fakeyou.com/media/w/6/m/j/p/w6mjpcqkky7f2f3xkyvkcv3qeedkmejs/asset_w6mjpcqkky7f2f3xkyvkcv3qeedkmejs.jpg"; + +// media token: m_m1bz02z1kkzanxy6rb4vk1kvq9de9g +pub const JUNO_AT_LAKE_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/s/v/c/p/p/svcpp441gyb6mjtt8413kf5zxw1ehy4t/upload_svcpp441gyb6mjtt8413kf5zxw1ehy4t.jpg"; + +// media token: m_5y81rcnpnrbey55da6cc2s9z4d2f45 +pub const TALL_MOCHI_WITH_GLASSES_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/n/m/f/h/a/nmfhaz9h5wsgzfyz7nh402wxrfwa7c2y/upload_nmfhaz9h5wsgzfyz7nh402wxrfwa7c2y.jpg"; + +// media token: m_7x18h6qnyay4a42kyyyw2xctasx5k9 +pub const TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/r/e/j/b/p/rejbp61m56hqx4a498xh7j47vkjd7jnh/upload_rejbp61m56hqx4a498xh7j47vkjd7jnh.png"; + +// media token: m_77cpdyaby60z0qv9g6tv67gw2a1337 +pub const TREX_SKELETON_IMAGE_URL : &str = "https://cdn-2.fakeyou.com/media/f/4/j/5/3/f4j53296sc35wqs3csgw4j4vvjf5yyxe/image_f4j53296sc35wqs3csgw4j4vvjf5yyxe.png"; + +// media token: m_zyh17gn6prfp4xs6z76mqkz9k0sr0j +pub const TALL_CORGI_SHIBA_TREASURE_OCEAN_URL: &str = "https://cdn-2.fakeyou.com/media/j/z/7/2/p/jz72pgwb6fegem74pr36yy88hsshwx4m/artcraft_jz72pgwb6fegem74pr36yy88hsshwx4m.png"; + +// media token: m_a9c4g43nw87y6mebjammrqz849c5xe +pub const WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL: &str = "https://cdn-2.fakeyou.com/media/v/a/g/g/a/vaggan4137tqsz173432cjjg33xkrxpc/artcraft_vaggan4137tqsz173432cjjg33xkrxpc.png"; + +// media token: m_ytkp3tk2ptvr03p75fwqvend0m3qsg +pub const TALL_CORGI_SHIBA_TREASURE_SKY_URL: &str = "https://cdn-2.fakeyou.com/media/a/4/s/x/x/a4sxxecwenrbta5kx5jk34dp2z9apxqv/artcraft_a4sxxecwenrbta5kx5jk34dp2z9apxqv.png"; diff --git a/crates/testing/test_data/src/web/mod.rs b/crates/testing/test_data/src/web/mod.rs new file mode 100644 index 0000000000..6321d56724 --- /dev/null +++ b/crates/testing/test_data/src/web/mod.rs @@ -0,0 +1,4 @@ +pub mod character_tokens; +pub mod image_media_tokens; +pub mod image_urls; +pub mod video_urls; \ No newline at end of file diff --git a/crates/testing/test_data/src/web/video_urls.rs b/crates/testing/test_data/src/web/video_urls.rs new file mode 100644 index 0000000000..93583b2b2e --- /dev/null +++ b/crates/testing/test_data/src/web/video_urls.rs @@ -0,0 +1,5 @@ + + +// media token: m_fz9r14yhfe05ffg9wqda5291p7mp75 +pub const ANGRY_SHIBA_VIDEO_URL : &str = "https://cdn-2.fakeyou.com/media/q/0/1/e/9/q01e9xsye0scamh5mrskr2emsv401x14/artcraft_q01e9xsye0scamh5mrskr2emsv401x14.mp4"; + diff --git a/crates/testing/test_utils/Cargo.toml b/crates/testing/test_utils/Cargo.toml new file mode 100644 index 0000000000..6e03326b65 --- /dev/null +++ b/crates/testing/test_utils/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "test_utils" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "testing" crate is used by the "swc" project as an internal project. +# It isn't a crate meant to be consumed by others, so we'll use it internally. +name = "test_utils" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors = { path = "../../lib/errors" } + + +# External +# None yet + +[dev-dependencies] +# None yet diff --git a/crates/testing/test_utils/src/lib.rs b/crates/testing/test_utils/src/lib.rs new file mode 100644 index 0000000000..51da30fce7 --- /dev/null +++ b/crates/testing/test_utils/src/lib.rs @@ -0,0 +1,27 @@ +//! testing +//! +//! This crate collects some of our common code around test setup and execution. +//! In the future we may have several such crates that deal with specific functions (http serving, +//! mocks, database, etc.). This crate is meant for general testing concerns. It will also import +//! and re-export the most common testing libraries we use. +//! +//! (The public "testing" crate is used for another project's internal tooling and isn't meant for +//! public consumption.) +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod test_file_path; \ No newline at end of file diff --git a/crates/testing/test_utils/src/test_file_path.rs b/crates/testing/test_utils/src/test_file_path.rs new file mode 100644 index 0000000000..1804a16fdb --- /dev/null +++ b/crates/testing/test_utils/src/test_file_path.rs @@ -0,0 +1,41 @@ +use std::path::{Path, PathBuf}; + +use errors::{anyhow, AnyhowResult}; + +/// Refer to test files using the path from repo root and return a canonical path. +/// +/// NB: The path must exist or the canonicalization will return an error! +/// +/// (If each library had to deal with this problem separately, they would have to concern +/// themselves with the number of directories between the Cargo.toml project declaration +/// and the test directory, ie. this differs based on code nestedness.) +pub fn test_file_path>(path_from_repo_root: P) -> AnyhowResult { + // https://doc.rust-lang.org/cargo/reference/environment-variables.html + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let file = path_from_repo_root + .as_ref() + .to_str() + .ok_or(anyhow!("path cannot convert to string"))?; + path.push(format!("../../../{}", file)); + println!(" >>> path = {:?}", path); + Ok(path.canonicalize()?) + +} + +#[cfg(test)] +mod tests { + use super::test_file_path; + + #[test] + fn assert_canonicalized() { + // NB: We can't assert the exact paths since each machine (dev, CI) differs, + // but we can make assertions + let str_path = "test_data/audio/flac/zelda_ocarina_small_item.flac"; + let path = test_file_path(str_path).unwrap(); + + assert!(path.is_absolute()); + assert!(path.exists()); + assert!(path.ends_with(str_path)); + assert_eq!(path.canonicalize().unwrap(), path); + } +} diff --git a/crates/vendor/tauri-plugin-http/CHANGELOG.md b/crates/vendor/tauri-plugin-http/CHANGELOG.md new file mode 100644 index 0000000000..4e1fb76b87 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/CHANGELOG.md @@ -0,0 +1,249 @@ +# Changelog + +## \[2.4.3] + +- [`37c0477a`](https://github.com/tauri-apps/plugins-workspace/commit/37c0477afe926d326573f1827045875ce8bf8187) ([#2561](https://github.com/tauri-apps/plugins-workspace/pull/2561)) Add `zstd` cargo feature flag to enable `reqwest/zstd` flag. +- [`9ebbfb2e`](https://github.com/tauri-apps/plugins-workspace/commit/9ebbfb2e3ccef8e0f277a0c02fe6b399b41feeb6) ([#1978](https://github.com/tauri-apps/plugins-workspace/pull/1978)) Persist cookies to disk and load it on next app start. + +### Dependencies + +- Upgraded to `fs-js@2.2.1` + +## \[2.4.2] + +- [`a15eedf3`](https://github.com/tauri-apps/plugins-workspace/commit/a15eedf37854344f7ffbcb0d373d848563817011) ([#2535](https://github.com/tauri-apps/plugins-workspace/pull/2535) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix `fetch` occasionally throwing an error due to trying to close the underline stream twice. + +## \[2.4.1] + +- [`d3183aa9`](https://github.com/tauri-apps/plugins-workspace/commit/d3183aa99da7ca67e627394132ddeb3b85ccef06) ([#2522](https://github.com/tauri-apps/plugins-workspace/pull/2522) by [@adrieljss](https://github.com/tauri-apps/plugins-workspace/../../adrieljss)) Fix `fetch` blocking until the whole response is read even if it was a streaming response. + +## \[2.4.0] + +- [`cb38f54f`](https://github.com/tauri-apps/plugins-workspace/commit/cb38f54f4a4ef30995283cd82166c62da17bac44) ([#2479](https://github.com/tauri-apps/plugins-workspace/pull/2479) by [@adrieljss](https://github.com/tauri-apps/plugins-workspace/../../adrieljss)) Add stream support for HTTP stream responses. + +## \[2.3.0] + +- [`10513649`](https://github.com/tauri-apps/plugins-workspace/commit/105136494c5a5bf4b1f1cc06cc71815412d17ec8) ([#2204](https://github.com/tauri-apps/plugins-workspace/pull/2204) by [@RickeyWard](https://github.com/tauri-apps/plugins-workspace/../../RickeyWard)) Add `dangerous-settings` feature flag and new JS `danger` option to disable tls hostname/certificate validation. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +### Dependencies + +- Upgraded to `fs@2.2.0` + +## \[2.0.2] + +### Dependencies + +- Upgraded to `fs-js@2.0.4` + +## \[2.0.4] + +- [`a3b553dd`](https://github.com/tauri-apps/plugins-workspace/commit/a3b553ddb403771aa699362c4e69a064b7731da5) ([#2079](https://github.com/tauri-apps/plugins-workspace/pull/2079) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add tracing logs for requestes and responses behind `tracing` feature flag. + +### Dependencies + +- Upgraded to `fs@2.1.0` + +## \[2.0.3] + +### Dependencies + +- Upgraded to `fs@2.0.3` + +## \[2.0.1] + +- [`cfd48b3b`](https://github.com/tauri-apps/plugins-workspace/commit/cfd48b3b2ec0fccfc162197518694ed59ceda22c) ([#1941](https://github.com/tauri-apps/plugins-workspace/pull/1941) by [@Nipsuli](https://github.com/tauri-apps/plugins-workspace/../../Nipsuli)) Allow skipping sending `Origin` header in HTTP requests by setting `Origin` header to an empty string when calling `fetch`. +- [`9b2840db`](https://github.com/tauri-apps/plugins-workspace/commit/9b2840db9464cf08db75806270e4441f0af81e5d) ([#1884](https://github.com/tauri-apps/plugins-workspace/pull/1884) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Retain headers order. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +### Dependencies + +- Upgraded to `fs@2.0.1` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `fs@2.0.0` + +## \[2.0.0-rc.6] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.6` + +## \[2.0.0-rc.5] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.5` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.4` + +## \[2.0.0-rc.3] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.3` + +## \[2.0.0-rc.2] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.2` + +## \[2.0.0-rc.2] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.1] + +- [`84f8bd5e`](https://github.com/tauri-apps/plugins-workspace/commit/84f8bd5e1ef22e664267380b5bbf756ebc5389c3) ([#1662](https://github.com/tauri-apps/plugins-workspace/pull/1662) by [@twlite](https://github.com/tauri-apps/plugins-workspace/../../twlite)) Fixed an issue with abort signal not aborting the fetch request. + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.0` + +## \[2.0.0-beta.9] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.8] + +- [`ac9a25cc`](https://github.com/tauri-apps/plugins-workspace/commit/ac9a25cc12ee2b325f00212ba74316da3369bde5) ([#1395](https://github.com/tauri-apps/plugins-workspace/pull/1395) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix cancelling requests using `AbortSignal`. +- [`a6654932`](https://github.com/tauri-apps/plugins-workspace/commit/a66549329c60dea35e3a06a38c357e368c9053a1) ([#1526](https://github.com/tauri-apps/plugins-workspace/pull/1526) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix missing `Set-Cookie` headers in the response which meant `request.headers.getSetCookie()` always returned empty array. +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.7] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.6] + +- [`0f739dbc`](https://github.com/tauri-apps/plugins-workspace/commit/0f739dbc483a1f091977cbe575c3862fd39f8cf1) ([#1392](https://github.com/tauri-apps/plugins-workspace/pull/1392) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Allow setting `Origin` header when `unsafe-headers` feature flag is active. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`9d7ae45b`](https://github.com/tauri-apps/plugins-workspace/commit/9d7ae45b0edf9b22c73e7d7c413a784bb35c3d77)([#1354](https://github.com/tauri-apps/plugins-workspace/pull/1354)) Include headers created by browser if not declared by user, which fixes missing headers like `Content-Type` when using `FormData`. +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.6] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.6` + +## \[2.0.0-beta.5] + +- [`500ff10`](https://github.com/tauri-apps/plugins-workspace/commit/500ff10fbd89fdfc73caf9d153029dad567b4ff1)([#1166](https://github.com/tauri-apps/plugins-workspace/pull/1166)) **Breaking change:** Removed the `default-tls` feature flag. The `rustls-tls`, `http2`, `macos-system-configuration`, and `charset` feature flags are now enabled by default. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Internally use the webview scoped resources table instead of the app one, so other webviews can't access other webviews resources. +- [`7e2fcc5`](https://github.com/tauri-apps/plugins-workspace/commit/7e2fcc5e74df7c3c718e40f75bfb0eafc7d69d8d)([#1146](https://github.com/tauri-apps/plugins-workspace/pull/1146)) Update dependencies to align with tauri 2.0.0-beta.14. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Update for tauri 2.0.0-beta.15. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.5` + +## \[2.0.0-beta.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.4` + +## \[2.0.0-beta.3] + +- [`c873e4d`](https://github.com/tauri-apps/plugins-workspace/commit/c873e4d6c74e759742f7c9a88e35cff10a75122a)([#1059](https://github.com/tauri-apps/plugins-workspace/pull/1059)) Fixes scope not allowing subpaths, query parameters and hash when those values are empty. +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. +- [`753c7be`](https://github.com/tauri-apps/plugins-workspace/commit/753c7be0a6a78121d2e88ea0efc3040580c885b4)([#1050](https://github.com/tauri-apps/plugins-workspace/pull/1050)) Add `unsafe-headers` cargo feature flag to allow using [forbidden headers](https://fetch.spec.whatwg.org/#terminology-headers). + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.3` + +## \[2.0.0-beta.2] + +- [`ae56b13`](https://github.com/tauri-apps/plugins-workspace/commit/ae56b13a4d49dbf922b8a0fbb0d557bb63c1d72b)([#983](https://github.com/tauri-apps/plugins-workspace/pull/983)) Allow `User-Agent` header to be set. +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`1a34720`](https://github.com/tauri-apps/plugins-workspace/commit/1a347203a54eccc954749d11c4ee81fdd9a0cde7)([#858](https://github.com/tauri-apps/plugins-workspace/pull/858)) Fix http fetch client option init with parameter `connectTimeout` + +## \[2.0.0-alpha.9] + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.7` + +## \[2.0.0-alpha.6] + +- [`bfa87da`](https://github.com/tauri-apps/plugins-workspace/commit/bfa87da848f9f1da2abae3354eed632881eddf11)([#824](https://github.com/tauri-apps/plugins-workspace/pull/824)) Add `proxy` field to `fetch` options to configure proxy. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.3] + +- [`2cb0fa7`](https://github.com/tauri-apps/plugins-workspace/commit/2cb0fa719b8b1f5ac07dada93520dbbcf637d64c)([#587](https://github.com/tauri-apps/plugins-workspace/pull/587)) Remove `cmd` property which breaks invoke call. +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.2` + +## \[2.0.0-alpha.2] + +- [`aec17a9`](https://github.com/tauri-apps/plugins-workspace/commit/aec17a90fc365774c70c4876b94a899416120e26)([#558](https://github.com/tauri-apps/plugins-workspace/pull/558)) Improve response performance by using the new IPC streaming data. + +## \[2.0.0-alpha.1] + +- [`7d9df72`](https://github.com/tauri-apps/plugins-workspace/commit/7d9df7297a221a64d9de945ffc2cd8313d3104dc)([#428](https://github.com/tauri-apps/plugins-workspace/pull/428)) Multipart requests are now handled in JavaScript by the `Request` JavaScript class so you just need to use a `FormData` body and not set the content-type header to `multipart/form-data`. `application/x-www-form-urlencoded` requests must be done manually. +- [`7d9df72`](https://github.com/tauri-apps/plugins-workspace/commit/7d9df7297a221a64d9de945ffc2cd8313d3104dc)([#428](https://github.com/tauri-apps/plugins-workspace/pull/428)) The http plugin has been rewritten from scratch and now only exposes a `fetch` function in Javascript and Re-exports `reqwest` crate in Rust. The new `fetch` method tries to be as close and compliant to the `fetch` Web API as possible. +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/crates/vendor/tauri-plugin-http/Cargo.toml b/crates/vendor/tauri-plugin-http/Cargo.toml new file mode 100644 index 0000000000..fd5fb7c577 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/Cargo.toml @@ -0,0 +1,88 @@ +# NB(bt): We've made some changes to the packages and directives. +# The original values are commented out. + +[package] +name = "tauri-plugin-http" +version = "2.4.3" +description = "Access an HTTP client written in Rust." +#edition = { workspace = true } +edition = "2021" +#authors = { workspace = true } +#license = { workspace = true } +#rust-version = { workspace = true } +rust-version = "1.77.2" +#repository = { workspace = true } +links = "tauri-plugin-http" + +[package.metadata.docs.rs] +rustc-args = ["--cfg", "docsrs"] +rustdoc-args = ["--cfg", "docsrs"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +#tauri-plugin = { workspace = true, features = ["build"] } +tauri-plugin = { version = "2.6.2", features = ["build"] } +#schemars = { workspace = true } +schemars = { version = "0.8" } +serde = { workspace = true } +url = { workspace = true } +urlpattern = "0.3" +regex = "1" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +thiserror = { workspace = true } +tokio = { version = "1", features = ["sync", "macros"] } +#tauri-plugin-fs = { path = "../fs", version = "2.2.1" } +tauri-plugin-fs = { workspace = true } +urlpattern = "0.3" +regex = "1" +http = "1" +reqwest = { version = "0.12", default-features = false } +url = { workspace = true } +data-url = "0.3" +cookie_store = { version = "0.21.1", optional = true, features = ["serde"] } +bytes = { version = "1.9", optional = true } +#tracing = { workspace = true, optional = true } +tracing = { version = "0.1", optional = true } + +[features] +default = [ + "rustls-tls", + "http2", + "charset", + "macos-system-configuration", + "cookies", +] +multipart = ["reqwest/multipart"] +json = ["reqwest/json"] +stream = ["reqwest/stream"] +native-tls = ["reqwest/native-tls"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +native-tls-alpn = ["reqwest/native-tls-alpn"] +rustls-tls = ["reqwest/rustls-tls"] +rustls-tls-manual-roots = ["reqwest/rustls-tls-manual-roots"] +rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] +rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] +blocking = ["reqwest/blocking"] +cookies = ["reqwest/cookies", "dep:cookie_store", "dep:bytes"] +gzip = ["reqwest/gzip"] +brotli = ["reqwest/brotli"] +deflate = ["reqwest/deflate"] +zstd = ["reqwest/zstd"] +trust-dns = ["reqwest/trust-dns"] +socks = ["reqwest/socks"] +http2 = ["reqwest/http2"] +charset = ["reqwest/charset"] +macos-system-configuration = ["reqwest/macos-system-configuration"] +unsafe-headers = [] +tracing = ["dep:tracing"] +dangerous-settings = [] diff --git a/crates/vendor/tauri-plugin-http/LICENSE.spdx b/crates/vendor/tauri-plugin-http/LICENSE.spdx new file mode 100644 index 0000000000..cdd0df5ad7 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/crates/vendor/tauri-plugin-http/LICENSE_APACHE-2.0 b/crates/vendor/tauri-plugin-http/LICENSE_APACHE-2.0 new file mode 100644 index 0000000000..4947287f7b --- /dev/null +++ b/crates/vendor/tauri-plugin-http/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/crates/vendor/tauri-plugin-http/LICENSE_MIT b/crates/vendor/tauri-plugin-http/LICENSE_MIT new file mode 100644 index 0000000000..4d75472566 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/crates/vendor/tauri-plugin-http/README.md b/crates/vendor/tauri-plugin-http/README.md new file mode 100644 index 0000000000..5c3cd9c2e2 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/README.md @@ -0,0 +1,102 @@ +![plugin-http](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/http/banner.png) + +Access the HTTP client written in Rust. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-http = "2.0.0" +# alternatively with Git: +tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +> Note: Since most JavaScript package managers are unable to install packages from git monorepos we provide read-only mirrors of each plugin. This makes installation option 2 more ergonomic to use. + +```sh +pnpm add @tauri-apps/plugin-http +# or +npm add @tauri-apps/plugin-http +# or +yarn add @tauri-apps/plugin-http + +# alternatively with Git: +pnpm add https://github.com/tauri-apps/tauri-plugin-http#v2 +# or +npm add https://github.com/tauri-apps/tauri-plugin-http#v2 +# or +yarn add https://github.com/tauri-apps/tauri-plugin-http#v2 +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { fetch } from '@tauri-apps/plugin-http' +const response = await fetch('http://localhost:3003/users/2', { + method: 'GET', + timeout: 30 +}) +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/crates/vendor/tauri-plugin-http/SECURITY.md b/crates/vendor/tauri-plugin-http/SECURITY.md new file mode 100644 index 0000000000..4f09bbacd4 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/crates/vendor/tauri-plugin-http/api-iife.js b/crates/vendor/tauri-plugin-http/api-iife.js new file mode 100644 index 0000000000..70b99c24e6 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e){"use strict";function t(e,t,r,n){if("function"==typeof t||!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===r?n:"a"===r?n.call(e):n?n.value:t.get(e)}function r(e,t,r,n,s){if("function"==typeof t||!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");return t.set(e,r),r}var n,s,i,a;"function"==typeof SuppressedError&&SuppressedError;const o="__TAURI_TO_IPC_KEY__";class c{constructor(e){n.set(this,void 0),s.set(this,0),i.set(this,[]),a.set(this,void 0),r(this,n,e||(()=>{})),this.id=function(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}((e=>{const o=e.index;if("end"in e)return void(o==t(this,s,"f")?this.cleanupCallback():r(this,a,o));const c=e.message;if(o==t(this,s,"f")){for(t(this,n,"f").call(this,c),r(this,s,t(this,s,"f")+1);t(this,s,"f")in t(this,i,"f");){const e=t(this,i,"f")[t(this,s,"f")];t(this,n,"f").call(this,e),delete t(this,i,"f")[t(this,s,"f")],r(this,s,t(this,s,"f")+1)}t(this,s,"f")===t(this,a,"f")&&this.cleanupCallback()}else t(this,i,"f")[o]=c}))}cleanupCallback(){Reflect.deleteProperty(window,`_${this.id}`)}set onmessage(e){r(this,n,e)}get onmessage(){return t(this,n,"f")}[(n=new WeakMap,s=new WeakMap,i=new WeakMap,a=new WeakMap,o)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[o]()}}async function d(e,t={},r){return window.__TAURI_INTERNALS__.invoke(e,t,r)}const h="Request cancelled";return e.fetch=async function(e,t){const r=t?.signal;if(r?.aborted)throw new Error(h);const n=t?.maxRedirections,s=t?.connectTimeout,i=t?.proxy,a=t?.danger;t&&(delete t.maxRedirections,delete t.connectTimeout,delete t.proxy,delete t.danger);const o=t?.headers?t.headers instanceof Headers?t.headers:new Headers(t.headers):new Headers,f=new Request(e,t),l=await f.arrayBuffer(),u=0!==l.byteLength?Array.from(new Uint8Array(l)):null;for(const[e,t]of f.headers)o.get(e)||o.set(e,t);const _=(o instanceof Headers?Array.from(o.entries()):Array.isArray(o)?o:Object.entries(o)).map((([e,t])=>[e,"string"==typeof t?t:t.toString()]));if(r?.aborted)throw new Error(h);const w=await d("plugin:http|fetch",{clientConfig:{method:f.method,url:f.url,headers:_,data:u,maxRedirections:n,connectTimeout:s,proxy:i,danger:a}}),p=()=>d("plugin:http|fetch_cancel",{rid:w});if(r?.aborted)throw p(),new Error(h);r?.addEventListener("abort",(()=>{p()}));const{status:y,statusText:m,url:b,headers:g,rid:T}=await d("plugin:http|fetch_send",{rid:w}),R=[101,103,204,205,304].includes(y)?null:new ReadableStream({start:e=>{const t=new c;t.onmessage=t=>{if(r?.aborted)return void e.error(h);const n=new Uint8Array(t),s=n[n.byteLength-1],i=n.slice(0,n.byteLength-1);1!=s?e.enqueue(i):e.close()},d("plugin:http|fetch_read_body",{rid:T,streamChannel:t}).catch((t=>{e.error(t)}))}}),A=new Response(R,{status:y,statusText:m});return Object.defineProperty(A,"url",{value:b}),Object.defineProperty(A,"headers",{value:new Headers(g)}),A},e}({});Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} diff --git a/crates/vendor/tauri-plugin-http/banner.png b/crates/vendor/tauri-plugin-http/banner.png new file mode 100644 index 0000000000..121741e51b Binary files /dev/null and b/crates/vendor/tauri-plugin-http/banner.png differ diff --git a/crates/vendor/tauri-plugin-http/build.rs b/crates/vendor/tauri-plugin-http/build.rs new file mode 100644 index 0000000000..a4b802adf5 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/build.rs @@ -0,0 +1,67 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[path = "src/scope.rs"] +#[allow(dead_code)] +mod scope; + +const COMMANDS: &[&str] = &["fetch", "fetch_cancel", "fetch_send", "fetch_read_body"]; + +/// HTTP scope entry. +#[derive(schemars::JsonSchema)] +#[serde(untagged)] +#[allow(unused)] +enum HttpScopeEntry { + /// A URL that can be accessed by the webview when using the HTTP APIs. + /// Wildcards can be used following the URL pattern standard. + /// + /// See [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information. + /// + /// Examples: + /// + /// - "https://*" : allows all HTTPS origin on port 443 + /// + /// - "https://*:*" : allows all HTTPS origin on any port + /// + /// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path + /// + /// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" + Value(String), + Object { + /// A URL that can be accessed by the webview when using the HTTP APIs. + /// Wildcards can be used following the URL pattern standard. + /// + /// See [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information. + /// + /// Examples: + /// + /// - "https://*" : allows all HTTPS origin on port 443 + /// + /// - "https://*:*" : allows all HTTPS origin on any port + /// + /// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path + /// + /// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" + url: String, + }, +} + +// Ensure `HttpScopeEntry` and `scope::EntryRaw` is kept in sync +fn _f() { + match scope::EntryRaw::Value(String::new()) { + scope::EntryRaw::Value(url) => HttpScopeEntry::Value(url), + scope::EntryRaw::Object { url } => HttpScopeEntry::Object { url }, + }; + match HttpScopeEntry::Value(String::new()) { + HttpScopeEntry::Value(url) => scope::EntryRaw::Value(url), + HttpScopeEntry::Object { url } => scope::EntryRaw::Object { url }, + }; +} + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .global_scope_schema(schemars::schema_for!(HttpScopeEntry)) + .build(); +} diff --git a/crates/vendor/tauri-plugin-http/dist-js/index.cjs b/crates/vendor/tauri-plugin-http/dist-js/index.cjs new file mode 100644 index 0000000000..93f343848c --- /dev/null +++ b/crates/vendor/tauri-plugin-http/dist-js/index.cjs @@ -0,0 +1,166 @@ +'use strict'; + +var core = require('@tauri-apps/api/core'); + +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT +/** + * Make HTTP requests with the Rust backend. + * + * ## Security + * + * This API has a scope configuration that forces you to restrict the URLs that can be accessed using glob patterns. + * + * For instance, this scope configuration only allows making HTTP requests to all subdomains for `tauri.app` except for `https://private.tauri.app`: + * ```json + * { + * "permissions": [ + * { + * "identifier": "http:default", + * "allow": [{ "url": "https://*.tauri.app" }], + * "deny": [{ "url": "https://private.tauri.app" }] + * } + * ] + * } + * ``` + * Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access. + * + * @module + */ +const ERROR_REQUEST_CANCELLED = 'Request cancelled'; +/** + * Fetch a resource from the network. It returns a `Promise` that resolves to the + * `Response` to that `Request`, whether it is successful or not. + * + * @example + * ```typescript + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + * ``` + * + * @since 2.0.0 + */ +async function fetch(input, init) { + // abort early here if needed + const signal = init?.signal; + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED); + } + const maxRedirections = init?.maxRedirections; + const connectTimeout = init?.connectTimeout; + const proxy = init?.proxy; + const danger = init?.danger; + // Remove these fields before creating the request + if (init) { + delete init.maxRedirections; + delete init.connectTimeout; + delete init.proxy; + delete init.danger; + } + const headers = init?.headers + ? init.headers instanceof Headers + ? init.headers + : new Headers(init.headers) + : new Headers(); + const req = new Request(input, init); + const buffer = await req.arrayBuffer(); + const data = buffer.byteLength !== 0 ? Array.from(new Uint8Array(buffer)) : null; + // append new headers created by the browser `Request` implementation, + // if not already declared by the caller of this function + for (const [key, value] of req.headers) { + if (!headers.get(key)) { + headers.set(key, value); + } + } + const headersArray = headers instanceof Headers + ? Array.from(headers.entries()) + : Array.isArray(headers) + ? headers + : Object.entries(headers); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mappedHeaders = headersArray.map(([name, val]) => [ + name, + // we need to ensure we have all header values as strings + // eslint-disable-next-line + typeof val === 'string' ? val : val.toString() + ]); + // abort early here if needed + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED); + } + const rid = await core.invoke('plugin:http|fetch', { + clientConfig: { + method: req.method, + url: req.url, + headers: mappedHeaders, + data, + maxRedirections, + connectTimeout, + proxy, + danger + } + }); + const abort = () => core.invoke('plugin:http|fetch_cancel', { rid }); + // abort early here if needed + if (signal?.aborted) { + // we don't care about the result of this proimse + // eslint-disable-next-line @typescript-eslint/no-floating-promises + abort(); + throw new Error(ERROR_REQUEST_CANCELLED); + } + signal?.addEventListener('abort', () => void abort()); + const { status, statusText, url, headers: responseHeaders, rid: responseRid } = await core.invoke('plugin:http|fetch_send', { + rid + }); + // no body for 101, 103, 204, 205 and 304 + // see https://fetch.spec.whatwg.org/#null-body-status + const body = [101, 103, 204, 205, 304].includes(status) + ? null + : new ReadableStream({ + start: (controller) => { + const streamChannel = new core.Channel(); + streamChannel.onmessage = (res) => { + // close early if aborted + if (signal?.aborted) { + controller.error(ERROR_REQUEST_CANCELLED); + return; + } + const resUint8 = new Uint8Array(res); + const lastByte = resUint8[resUint8.byteLength - 1]; + const actualRes = resUint8.slice(0, resUint8.byteLength - 1); + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte == 1) { + controller.close(); + return; + } + controller.enqueue(actualRes); + }; + // run a non-blocking body stream fetch + core.invoke('plugin:http|fetch_read_body', { + rid: responseRid, + streamChannel + }).catch((e) => { + controller.error(e); + }); + } + }); + const res = new Response(body, { + status, + statusText + }); + // Set `Response` properties that are ignored by the + // constructor, like url and some headers + // + // Since url and headers are read only properties + // this is the only way to set them. + Object.defineProperty(res, 'url', { value: url }); + Object.defineProperty(res, 'headers', { + value: new Headers(responseHeaders) + }); + return res; +} + +exports.fetch = fetch; diff --git a/crates/vendor/tauri-plugin-http/dist-js/index.d.ts b/crates/vendor/tauri-plugin-http/dist-js/index.d.ts new file mode 100644 index 0000000000..b030d62112 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/dist-js/index.d.ts @@ -0,0 +1,89 @@ +/** + * Configuration of a proxy that a Client should pass requests to. + * + * @since 2.0.0 + */ +export interface Proxy { + /** + * Proxy all traffic to the passed URL. + */ + all?: string | ProxyConfig; + /** + * Proxy all HTTP traffic to the passed URL. + */ + http?: string | ProxyConfig; + /** + * Proxy all HTTPS traffic to the passed URL. + */ + https?: string | ProxyConfig; +} +export interface ProxyConfig { + /** + * The URL of the proxy server. + */ + url: string; + /** + * Set the `Proxy-Authorization` header using Basic auth. + */ + basicAuth?: { + username: string; + password: string; + }; + /** + * A configuration for filtering out requests that shouldn't be proxied. + * Entries are expected to be comma-separated (whitespace between entries is ignored) + */ + noProxy?: string; +} +/** + * Options to configure the Rust client used to make fetch requests + * + * @since 2.0.0 + */ +export interface ClientOptions { + /** + * Defines the maximum number of redirects the client should follow. + * If set to 0, no redirects will be followed. + */ + maxRedirections?: number; + /** Timeout in milliseconds */ + connectTimeout?: number; + /** + * Configuration of a proxy that a Client should pass requests to. + */ + proxy?: Proxy; + /** + * Configuration for dangerous settings on the client such as disabling SSL verification. + */ + danger?: DangerousSettings; +} +/** + * Configuration for dangerous settings on the client such as disabling SSL verification. + * + * @since 2.3.0 + */ +export interface DangerousSettings { + /** + * Disables SSL verification. + */ + acceptInvalidCerts?: boolean; + /** + * Disables hostname verification. + */ + acceptInvalidHostnames?: boolean; +} +/** + * Fetch a resource from the network. It returns a `Promise` that resolves to the + * `Response` to that `Request`, whether it is successful or not. + * + * @example + * ```typescript + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + * ``` + * + * @since 2.0.0 + */ +export declare function fetch(input: URL | Request | string, init?: RequestInit & ClientOptions): Promise; diff --git a/crates/vendor/tauri-plugin-http/dist-js/index.js b/crates/vendor/tauri-plugin-http/dist-js/index.js new file mode 100644 index 0000000000..f4db76b005 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/dist-js/index.js @@ -0,0 +1,164 @@ +import { invoke, Channel } from '@tauri-apps/api/core'; + +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT +/** + * Make HTTP requests with the Rust backend. + * + * ## Security + * + * This API has a scope configuration that forces you to restrict the URLs that can be accessed using glob patterns. + * + * For instance, this scope configuration only allows making HTTP requests to all subdomains for `tauri.app` except for `https://private.tauri.app`: + * ```json + * { + * "permissions": [ + * { + * "identifier": "http:default", + * "allow": [{ "url": "https://*.tauri.app" }], + * "deny": [{ "url": "https://private.tauri.app" }] + * } + * ] + * } + * ``` + * Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access. + * + * @module + */ +const ERROR_REQUEST_CANCELLED = 'Request cancelled'; +/** + * Fetch a resource from the network. It returns a `Promise` that resolves to the + * `Response` to that `Request`, whether it is successful or not. + * + * @example + * ```typescript + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + * ``` + * + * @since 2.0.0 + */ +async function fetch(input, init) { + // abort early here if needed + const signal = init?.signal; + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED); + } + const maxRedirections = init?.maxRedirections; + const connectTimeout = init?.connectTimeout; + const proxy = init?.proxy; + const danger = init?.danger; + // Remove these fields before creating the request + if (init) { + delete init.maxRedirections; + delete init.connectTimeout; + delete init.proxy; + delete init.danger; + } + const headers = init?.headers + ? init.headers instanceof Headers + ? init.headers + : new Headers(init.headers) + : new Headers(); + const req = new Request(input, init); + const buffer = await req.arrayBuffer(); + const data = buffer.byteLength !== 0 ? Array.from(new Uint8Array(buffer)) : null; + // append new headers created by the browser `Request` implementation, + // if not already declared by the caller of this function + for (const [key, value] of req.headers) { + if (!headers.get(key)) { + headers.set(key, value); + } + } + const headersArray = headers instanceof Headers + ? Array.from(headers.entries()) + : Array.isArray(headers) + ? headers + : Object.entries(headers); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mappedHeaders = headersArray.map(([name, val]) => [ + name, + // we need to ensure we have all header values as strings + // eslint-disable-next-line + typeof val === 'string' ? val : val.toString() + ]); + // abort early here if needed + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED); + } + const rid = await invoke('plugin:http|fetch', { + clientConfig: { + method: req.method, + url: req.url, + headers: mappedHeaders, + data, + maxRedirections, + connectTimeout, + proxy, + danger + } + }); + const abort = () => invoke('plugin:http|fetch_cancel', { rid }); + // abort early here if needed + if (signal?.aborted) { + // we don't care about the result of this proimse + // eslint-disable-next-line @typescript-eslint/no-floating-promises + abort(); + throw new Error(ERROR_REQUEST_CANCELLED); + } + signal?.addEventListener('abort', () => void abort()); + const { status, statusText, url, headers: responseHeaders, rid: responseRid } = await invoke('plugin:http|fetch_send', { + rid + }); + // no body for 101, 103, 204, 205 and 304 + // see https://fetch.spec.whatwg.org/#null-body-status + const body = [101, 103, 204, 205, 304].includes(status) + ? null + : new ReadableStream({ + start: (controller) => { + const streamChannel = new Channel(); + streamChannel.onmessage = (res) => { + // close early if aborted + if (signal?.aborted) { + controller.error(ERROR_REQUEST_CANCELLED); + return; + } + const resUint8 = new Uint8Array(res); + const lastByte = resUint8[resUint8.byteLength - 1]; + const actualRes = resUint8.slice(0, resUint8.byteLength - 1); + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte == 1) { + controller.close(); + return; + } + controller.enqueue(actualRes); + }; + // run a non-blocking body stream fetch + invoke('plugin:http|fetch_read_body', { + rid: responseRid, + streamChannel + }).catch((e) => { + controller.error(e); + }); + } + }); + const res = new Response(body, { + status, + statusText + }); + // Set `Response` properties that are ignored by the + // constructor, like url and some headers + // + // Since url and headers are read only properties + // this is the only way to set them. + Object.defineProperty(res, 'url', { value: url }); + Object.defineProperty(res, 'headers', { + value: new Headers(responseHeaders) + }); + return res; +} + +export { fetch }; diff --git a/crates/vendor/tauri-plugin-http/guest-js/index.ts b/crates/vendor/tauri-plugin-http/guest-js/index.ts new file mode 100644 index 0000000000..4b38f8bfaa --- /dev/null +++ b/crates/vendor/tauri-plugin-http/guest-js/index.ts @@ -0,0 +1,285 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Make HTTP requests with the Rust backend. + * + * ## Security + * + * This API has a scope configuration that forces you to restrict the URLs that can be accessed using glob patterns. + * + * For instance, this scope configuration only allows making HTTP requests to all subdomains for `tauri.app` except for `https://private.tauri.app`: + * ```json + * { + * "permissions": [ + * { + * "identifier": "http:default", + * "allow": [{ "url": "https://*.tauri.app" }], + * "deny": [{ "url": "https://private.tauri.app" }] + * } + * ] + * } + * ``` + * Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access. + * + * @module + */ + +import { Channel, invoke } from '@tauri-apps/api/core' + +/** + * Configuration of a proxy that a Client should pass requests to. + * + * @since 2.0.0 + */ +export interface Proxy { + /** + * Proxy all traffic to the passed URL. + */ + all?: string | ProxyConfig + /** + * Proxy all HTTP traffic to the passed URL. + */ + http?: string | ProxyConfig + /** + * Proxy all HTTPS traffic to the passed URL. + */ + https?: string | ProxyConfig +} + +export interface ProxyConfig { + /** + * The URL of the proxy server. + */ + url: string + /** + * Set the `Proxy-Authorization` header using Basic auth. + */ + basicAuth?: { + username: string + password: string + } + /** + * A configuration for filtering out requests that shouldn't be proxied. + * Entries are expected to be comma-separated (whitespace between entries is ignored) + */ + noProxy?: string +} + +/** + * Options to configure the Rust client used to make fetch requests + * + * @since 2.0.0 + */ +export interface ClientOptions { + /** + * Defines the maximum number of redirects the client should follow. + * If set to 0, no redirects will be followed. + */ + maxRedirections?: number + /** Timeout in milliseconds */ + connectTimeout?: number + /** + * Configuration of a proxy that a Client should pass requests to. + */ + proxy?: Proxy + /** + * Configuration for dangerous settings on the client such as disabling SSL verification. + */ + danger?: DangerousSettings +} + +/** + * Configuration for dangerous settings on the client such as disabling SSL verification. + * + * @since 2.3.0 + */ +export interface DangerousSettings { + /** + * Disables SSL verification. + */ + acceptInvalidCerts?: boolean + /** + * Disables hostname verification. + */ + acceptInvalidHostnames?: boolean +} + +const ERROR_REQUEST_CANCELLED = 'Request cancelled' + +/** + * Fetch a resource from the network. It returns a `Promise` that resolves to the + * `Response` to that `Request`, whether it is successful or not. + * + * @example + * ```typescript + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + * ``` + * + * @since 2.0.0 + */ +export async function fetch( + input: URL | Request | string, + init?: RequestInit & ClientOptions +): Promise { + // abort early here if needed + const signal = init?.signal + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED) + } + + const maxRedirections = init?.maxRedirections + const connectTimeout = init?.connectTimeout + const proxy = init?.proxy + const danger = init?.danger + + // Remove these fields before creating the request + if (init) { + delete init.maxRedirections + delete init.connectTimeout + delete init.proxy + delete init.danger + } + + const headers = init?.headers + ? init.headers instanceof Headers + ? init.headers + : new Headers(init.headers) + : new Headers() + + const req = new Request(input, init) + const buffer = await req.arrayBuffer() + const data = + buffer.byteLength !== 0 ? Array.from(new Uint8Array(buffer)) : null + + // append new headers created by the browser `Request` implementation, + // if not already declared by the caller of this function + for (const [key, value] of req.headers) { + if (!headers.get(key)) { + headers.set(key, value) + } + } + + const headersArray = + headers instanceof Headers + ? Array.from(headers.entries()) + : Array.isArray(headers) + ? headers + : Object.entries(headers) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mappedHeaders: Array<[string, string]> = headersArray.map( + ([name, val]) => [ + name, + // we need to ensure we have all header values as strings + // eslint-disable-next-line + typeof val === 'string' ? val : (val as any).toString() + ] + ) + + // abort early here if needed + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED) + } + + const rid = await invoke('plugin:http|fetch', { + clientConfig: { + method: req.method, + url: req.url, + headers: mappedHeaders, + data, + maxRedirections, + connectTimeout, + proxy, + danger + } + }) + + const abort = () => invoke('plugin:http|fetch_cancel', { rid }) + + // abort early here if needed + if (signal?.aborted) { + // we don't care about the result of this proimse + // eslint-disable-next-line @typescript-eslint/no-floating-promises + abort() + throw new Error(ERROR_REQUEST_CANCELLED) + } + + signal?.addEventListener('abort', () => void abort()) + + interface FetchSendResponse { + status: number + statusText: string + headers: [[string, string]] + url: string + rid: number + } + + const { + status, + statusText, + url, + headers: responseHeaders, + rid: responseRid + } = await invoke('plugin:http|fetch_send', { + rid + }) + + // no body for 101, 103, 204, 205 and 304 + // see https://fetch.spec.whatwg.org/#null-body-status + const body = [101, 103, 204, 205, 304].includes(status) + ? null + : new ReadableStream({ + start: (controller) => { + const streamChannel = new Channel() + streamChannel.onmessage = (res: ArrayBuffer | number[]) => { + // close early if aborted + if (signal?.aborted) { + controller.error(ERROR_REQUEST_CANCELLED) + return + } + + const resUint8 = new Uint8Array(res) + const lastByte = resUint8[resUint8.byteLength - 1] + const actualRes = resUint8.slice(0, resUint8.byteLength - 1) + + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte == 1) { + controller.close() + return + } + + controller.enqueue(actualRes) + } + + // run a non-blocking body stream fetch + invoke('plugin:http|fetch_read_body', { + rid: responseRid, + streamChannel + }).catch((e) => { + controller.error(e) + }) + } + }) + + const res = new Response(body, { + status, + statusText + }) + + // Set `Response` properties that are ignored by the + // constructor, like url and some headers + // + // Since url and headers are read only properties + // this is the only way to set them. + Object.defineProperty(res, 'url', { value: url }) + Object.defineProperty(res, 'headers', { + value: new Headers(responseHeaders) + }) + + return res +} diff --git a/crates/vendor/tauri-plugin-http/package.json b/crates/vendor/tauri-plugin-http/package.json new file mode 100644 index 0000000000..7439c46167 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-http", + "version": "2.4.3", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } +} diff --git a/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch.toml b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch.toml new file mode 100644 index 0000000000..c4e068a7ce --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch" +description = "Enables the fetch command without any pre-configured scope." +commands.allow = ["fetch"] + +[[permission]] +identifier = "deny-fetch" +description = "Denies the fetch command without any pre-configured scope." +commands.deny = ["fetch"] diff --git a/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_cancel.toml b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_cancel.toml new file mode 100644 index 0000000000..70c1139b51 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_cancel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-cancel" +description = "Enables the fetch_cancel command without any pre-configured scope." +commands.allow = ["fetch_cancel"] + +[[permission]] +identifier = "deny-fetch-cancel" +description = "Denies the fetch_cancel command without any pre-configured scope." +commands.deny = ["fetch_cancel"] diff --git a/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_read_body.toml b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_read_body.toml new file mode 100644 index 0000000000..b5867e99fa --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_read_body.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-read-body" +description = "Enables the fetch_read_body command without any pre-configured scope." +commands.allow = ["fetch_read_body"] + +[[permission]] +identifier = "deny-fetch-read-body" +description = "Denies the fetch_read_body command without any pre-configured scope." +commands.deny = ["fetch_read_body"] diff --git a/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_send.toml b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_send.toml new file mode 100644 index 0000000000..f6a55d2808 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/autogenerated/commands/fetch_send.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-send" +description = "Enables the fetch_send command without any pre-configured scope." +commands.allow = ["fetch_send"] + +[[permission]] +identifier = "deny-fetch-send" +description = "Denies the fetch_send command without any pre-configured scope." +commands.deny = ["fetch_send"] diff --git a/crates/vendor/tauri-plugin-http/permissions/autogenerated/reference.md b/crates/vendor/tauri-plugin-http/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..40c41cac16 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/autogenerated/reference.md @@ -0,0 +1,133 @@ +## Default Permission + +This permission set configures what kind of +fetch operations are available from the http plugin. + +This enables all fetch operations but does not +allow explicitly any origins to be fetched. This needs to +be manually configured before usage. + +#### Granted Permissions + +All fetch operations are enabled. + +#### This default permission set includes the following: + +- `allow-fetch` +- `allow-fetch-cancel` +- `allow-fetch-read-body` +- `allow-fetch-send` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`http:allow-fetch` + + + +Enables the fetch command without any pre-configured scope. + +
+ +`http:deny-fetch` + + + +Denies the fetch command without any pre-configured scope. + +
+ +`http:allow-fetch-cancel` + + + +Enables the fetch_cancel command without any pre-configured scope. + +
+ +`http:deny-fetch-cancel` + + + +Denies the fetch_cancel command without any pre-configured scope. + +
+ +`http:allow-fetch-read-body` + + + +Enables the fetch_read_body command without any pre-configured scope. + +
+ +`http:deny-fetch-read-body` + + + +Denies the fetch_read_body command without any pre-configured scope. + +
+ +`http:allow-fetch-send` + + + +Enables the fetch_send command without any pre-configured scope. + +
+ +`http:deny-fetch-send` + + + +Denies the fetch_send command without any pre-configured scope. + +
diff --git a/crates/vendor/tauri-plugin-http/permissions/default.toml b/crates/vendor/tauri-plugin-http/permissions/default.toml new file mode 100644 index 0000000000..b469536ddc --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/default.toml @@ -0,0 +1,22 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures what kind of +fetch operations are available from the http plugin. + +This enables all fetch operations but does not +allow explicitly any origins to be fetched. This needs to +be manually configured before usage. + +#### Granted Permissions + +All fetch operations are enabled. + +""" +permissions = [ + "allow-fetch", + "allow-fetch-cancel", + "allow-fetch-read-body", + "allow-fetch-send", +] diff --git a/crates/vendor/tauri-plugin-http/permissions/schemas/schema.json b/crates/vendor/tauri-plugin-http/permissions/schemas/schema.json new file mode 100644 index 0000000000..ea774399d5 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/permissions/schemas/schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the fetch command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch", + "markdownDescription": "Enables the fetch command without any pre-configured scope." + }, + { + "description": "Denies the fetch command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch", + "markdownDescription": "Denies the fetch command without any pre-configured scope." + }, + { + "description": "Enables the fetch_cancel command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-cancel", + "markdownDescription": "Enables the fetch_cancel command without any pre-configured scope." + }, + { + "description": "Denies the fetch_cancel command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-cancel", + "markdownDescription": "Denies the fetch_cancel command without any pre-configured scope." + }, + { + "description": "Enables the fetch_read_body command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-read-body", + "markdownDescription": "Enables the fetch_read_body command without any pre-configured scope." + }, + { + "description": "Denies the fetch_read_body command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-read-body", + "markdownDescription": "Denies the fetch_read_body command without any pre-configured scope." + }, + { + "description": "Enables the fetch_send command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-send", + "markdownDescription": "Enables the fetch_send command without any pre-configured scope." + }, + { + "description": "Denies the fetch_send command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-send", + "markdownDescription": "Denies the fetch_send command without any pre-configured scope." + }, + { + "description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`" + } + ] + } + } +} \ No newline at end of file diff --git a/crates/vendor/tauri-plugin-http/rollup.config.js b/crates/vendor/tauri-plugin-http/rollup.config.js new file mode 100644 index 0000000000..1f349ec8a2 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/crates/vendor/tauri-plugin-http/src/commands.rs b/crates/vendor/tauri-plugin-http/src/commands.rs new file mode 100644 index 0000000000..7093a7270b --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/commands.rs @@ -0,0 +1,490 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration}; + +use http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; +use reqwest::{redirect::Policy, NoProxy}; +use serde::{Deserialize, Serialize}; +use tauri::{ + async_runtime::Mutex, + command, + ipc::{Channel, CommandScope, GlobalScope}, + Manager, ResourceId, ResourceTable, Runtime, State, Webview, +}; +use tokio::sync::oneshot::{channel, Receiver, Sender}; + +use crate::{ + scope::{Entry, Scope}, + Error, Http, Result, +}; + +const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +struct ReqwestResponse(reqwest::Response); +impl tauri::Resource for ReqwestResponse {} + +type CancelableResponseResult = Result; +type CancelableResponseFuture = + Pin + Send + Sync>>; + +struct FetchRequest { + fut: Mutex, + abort_tx_rid: ResourceId, + abort_rx_rid: ResourceId, +} +impl tauri::Resource for FetchRequest {} + +struct AbortSender(Sender<()>); +impl tauri::Resource for AbortRecveiver {} + +impl AbortSender { + fn abort(self) { + let _ = self.0.send(()); + } +} + +struct AbortRecveiver(Receiver<()>); +impl tauri::Resource for AbortSender {} + +trait AddRequest { + fn add_request(&mut self, fut: CancelableResponseFuture) -> ResourceId; +} + +impl AddRequest for ResourceTable { + fn add_request(&mut self, fut: CancelableResponseFuture) -> ResourceId { + let (tx, rx) = channel::<()>(); + let (tx, rx) = (AbortSender(tx), AbortRecveiver(rx)); + let req = FetchRequest { + fut: Mutex::new(fut), + abort_tx_rid: self.add(tx), + abort_rx_rid: self.add(rx), + }; + self.add(req) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchResponse { + status: u16, + status_text: String, + headers: Vec<(String, String)>, + url: String, + rid: ResourceId, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] //feature flags shoudln't affect api +pub struct DangerousSettings { + accept_invalid_certs: bool, + accept_invalid_hostnames: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientConfig { + method: String, + url: url::Url, + headers: Vec<(String, String)>, + data: Option>, + connect_timeout: Option, + max_redirections: Option, + proxy: Option, + danger: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Proxy { + all: Option, + http: Option, + https: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(untagged)] +pub enum UrlOrConfig { + Url(String), + Config(ProxyConfig), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyConfig { + url: String, + basic_auth: Option, + no_proxy: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BasicAuth { + username: String, + password: String, +} + +#[inline] +fn proxy_creator( + url_or_config: UrlOrConfig, + proxy_fn: fn(String) -> reqwest::Result, +) -> reqwest::Result { + match url_or_config { + UrlOrConfig::Url(url) => Ok(proxy_fn(url)?), + UrlOrConfig::Config(ProxyConfig { + url, + basic_auth, + no_proxy, + }) => { + let mut proxy = proxy_fn(url)?; + if let Some(basic_auth) = basic_auth { + proxy = proxy.basic_auth(&basic_auth.username, &basic_auth.password); + } + if let Some(no_proxy) = no_proxy { + proxy = proxy.no_proxy(NoProxy::from_string(&no_proxy)); + } + Ok(proxy) + } + } +} + +fn attach_proxy( + proxy: Proxy, + mut builder: reqwest::ClientBuilder, +) -> crate::Result { + let Proxy { all, http, https } = proxy; + + if let Some(all) = all { + let proxy = proxy_creator(all, reqwest::Proxy::all)?; + builder = builder.proxy(proxy); + } + + if let Some(http) = http { + let proxy = proxy_creator(http, reqwest::Proxy::http)?; + builder = builder.proxy(proxy); + } + + if let Some(https) = https { + let proxy = proxy_creator(https, reqwest::Proxy::https)?; + builder = builder.proxy(proxy); + } + + Ok(builder) +} + +#[command] +pub async fn fetch( + webview: Webview, + state: State<'_, Http>, + client_config: ClientConfig, + command_scope: CommandScope, + global_scope: GlobalScope, +) -> crate::Result { + let ClientConfig { + method, + url, + headers: headers_raw, + data, + connect_timeout, + max_redirections, + proxy, + danger, + } = client_config; + + let scheme = url.scheme(); + let method = Method::from_bytes(method.as_bytes())?; + + let mut headers = HeaderMap::new(); + for (h, v) in headers_raw { + let name = HeaderName::from_str(&h)?; + #[cfg(not(feature = "unsafe-headers"))] + if is_unsafe_header(&name) { + #[cfg(debug_assertions)] + { + eprintln!("[\x1b[33mWARNING\x1b[0m] Skipping {name} header as it is a forbidden header per fetch spec https://fetch.spec.whatwg.org/#terminology-headers"); + eprintln!("[\x1b[33mWARNING\x1b[0m] if keeping the header is a desired behavior, you can enable `unsafe-headers` feature flag in your Cargo.toml"); + } + continue; + } + + headers.append(name, HeaderValue::from_str(&v)?); + } + + match scheme { + "http" | "https" => { + if Scope::new( + command_scope + .allows() + .iter() + .chain(global_scope.allows()) + .collect(), + command_scope + .denies() + .iter() + .chain(global_scope.denies()) + .collect(), + ) + .is_allowed(&url) + { + let mut builder = reqwest::ClientBuilder::new(); + + if let Some(danger_config) = danger { + #[cfg(not(feature = "dangerous-settings"))] + { + #[cfg(debug_assertions)] + { + eprintln!("[\x1b[33mWARNING\x1b[0m] using dangerous settings requires `dangerous-settings` feature flag in your Cargo.toml"); + } + let _ = danger_config; + return Err(Error::DangerousSettings); + } + #[cfg(feature = "dangerous-settings")] + { + builder = builder + .danger_accept_invalid_certs(danger_config.accept_invalid_certs) + .danger_accept_invalid_hostnames(danger_config.accept_invalid_hostnames) + } + } + + if let Some(timeout) = connect_timeout { + builder = builder.connect_timeout(Duration::from_millis(timeout)); + } + + if let Some(max_redirections) = max_redirections { + builder = builder.redirect(if max_redirections == 0 { + Policy::none() + } else { + Policy::limited(max_redirections) + }); + } + + if let Some(proxy_config) = proxy { + builder = attach_proxy(proxy_config, builder)?; + } + + #[cfg(feature = "cookies")] + { + builder = builder.cookie_provider(state.cookies_jar.clone()); + } + + let mut request = builder.build()?.request(method.clone(), url); + + // POST and PUT requests should always have a 0 length content-length, + // if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch + if data.is_none() && matches!(method, Method::POST | Method::PUT) { + headers.append(header::CONTENT_LENGTH, HeaderValue::from_str("0")?); + } + + if headers.contains_key(header::RANGE) { + // https://fetch.spec.whatwg.org/#http-network-or-cache-fetch step 18 + // If httpRequest’s header list contains `Range`, then append (`Accept-Encoding`, `identity`) + headers.append(header::ACCEPT_ENCODING, HeaderValue::from_str("identity")?); + } + + if !headers.contains_key(header::USER_AGENT) { + headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?); + } + + // ensure we have an Origin header set + if cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN) { + if let Ok(url) = webview.url() { + headers.append( + header::ORIGIN, + HeaderValue::from_str(&url.origin().ascii_serialization())?, + ); + } + } + + // In case empty origin is passed, remove it. Some services do not like Origin header + // so this way we can remove it in explicit way. The default behaviour is still to set it + if cfg!(feature = "unsafe-headers") + && headers.get(header::ORIGIN) == Some(&HeaderValue::from_static("")) + { + headers.remove(header::ORIGIN); + }; + + // NB(bt): In production builds, the origin seems to be "null". This is what our server CORS filter sees: + // [2025-05-01T23:42:35Z WARN actix_cors_configs::configs::fakeyou] Invalid origin: "null" + // [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null" + // [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null" + // ... + // [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null" + // [2025-05-01T23:42:35Z INFO actix_web::middleware::logger] [storyteller-web-85f9b4974-ddvq7] IP=[redact] + // 939322e1095ee5e4-IAD "POST /v1/login HTTP/1.1" 400 42 "-" "tauri-plugin-http/2.4.3" 0.000076 + let mut maybe_origin_header = headers.get(header::ORIGIN); + + let replace_origin_header = maybe_origin_header.is_none() + || maybe_origin_header == Some(&HeaderValue::from_static("null")) // Above error (Mac) + || maybe_origin_header == Some(&HeaderValue::from_static("http://tauri.localhost")) // Windows + || maybe_origin_header == Some(&HeaderValue::from_static("tauri://localhost")); // Mac plausibly causes this + + if replace_origin_header { + // TODO(bt): Make this a sentinel origin, eg. sentinel.storyteller.ai. + // NB: HeadersMap is a multimap, but this should ensure we just have one value. + headers.insert(header::ORIGIN, HeaderValue::from_static("https://studio.storyteller.ai")); + } + + if let Some(data) = data { + request = request.body(data); + } + + request = request.headers(headers); + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", request); + + let fut = async move { request.send().await.map_err(Into::into) }; + + let mut resources_table = webview.resources_table(); + let rid = resources_table.add_request(Box::pin(fut)); + + Ok(rid) + } else { + Err(Error::UrlNotAllowed(url)) + } + } + "data" => { + let data_url = + data_url::DataUrl::process(url.as_str()).map_err(|_| Error::DataUrlError)?; + let (body, _) = data_url + .decode_to_vec() + .map_err(|_| Error::DataUrlDecodeError)?; + + let response = http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, data_url.mime_type().to_string()) + .body(reqwest::Body::from(body))?; + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", response); + + let fut = async move { Ok(reqwest::Response::from(response)) }; + let mut resources_table = webview.resources_table(); + let rid = resources_table.add_request(Box::pin(fut)); + Ok(rid) + } + _ => Err(Error::SchemeNotSupport(scheme.to_string())), + } +} + +#[command] +pub fn fetch_cancel(webview: Webview, rid: ResourceId) -> crate::Result<()> { + let mut resources_table = webview.resources_table(); + let req = resources_table.get::(rid)?; + let abort_tx = resources_table.take::(req.abort_tx_rid)?; + if let Some(abort_tx) = Arc::into_inner(abort_tx) { + abort_tx.abort(); + } + Ok(()) +} + +#[command] +pub async fn fetch_send( + webview: Webview, + rid: ResourceId, +) -> crate::Result { + let (req, abort_rx) = { + let mut resources_table = webview.resources_table(); + let req = resources_table.get::(rid)?; + let abort_rx = resources_table.take::(req.abort_rx_rid)?; + (req, abort_rx) + }; + + let Some(abort_rx) = Arc::into_inner(abort_rx) else { + return Err(Error::RequestCanceled); + }; + + let mut fut = req.fut.lock().await; + + let res = tokio::select! { + res = fut.as_mut() => res?, + _ = abort_rx.0 => { + let mut resources_table = webview.resources_table(); + resources_table.close(rid)?; + return Err(Error::RequestCanceled); + } + }; + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", res); + + let status = res.status(); + let url = res.url().to_string(); + let mut headers = Vec::new(); + for (key, val) in res.headers().iter() { + headers.push(( + key.as_str().into(), + String::from_utf8(val.as_bytes().to_vec())?, + )); + } + + let mut resources_table = webview.resources_table(); + let rid = resources_table.add(ReqwestResponse(res)); + + Ok(FetchResponse { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or_default().to_string(), + headers, + url, + rid, + }) +} + +#[command] +pub async fn fetch_read_body( + webview: Webview, + rid: ResourceId, + stream_channel: Channel, +) -> crate::Result<()> { + let res = { + let mut resources_table = webview.resources_table(); + resources_table.take::(rid)? + }; + + let mut res = Arc::into_inner(res).unwrap().0; + + // send response through IPC channel + while let Some(chunk) = res.chunk().await? { + let mut chunk = chunk.to_vec(); + // append 0 to indicate we are not done yet + chunk.push(0); + stream_channel.send(tauri::ipc::InvokeResponseBody::Raw(chunk))?; + } + + // send 1 to indicate we are done + stream_channel.send(tauri::ipc::InvokeResponseBody::Raw(vec![1]))?; + + Ok(()) +} + +// forbidden headers per fetch spec https://fetch.spec.whatwg.org/#terminology-headers +#[cfg(not(feature = "unsafe-headers"))] +fn is_unsafe_header(header: &HeaderName) -> bool { + matches!( + *header, + header::ACCEPT_CHARSET + | header::ACCEPT_ENCODING + | header::ACCESS_CONTROL_REQUEST_HEADERS + | header::ACCESS_CONTROL_REQUEST_METHOD + | header::CONNECTION + | header::CONTENT_LENGTH + | header::COOKIE + | header::DATE + | header::DNT + | header::EXPECT + | header::HOST + | header::ORIGIN + | header::REFERER + | header::SET_COOKIE + | header::TE + | header::TRAILER + | header::TRANSFER_ENCODING + | header::UPGRADE + | header::VIA + ) || { + let lower = header.as_str().to_lowercase(); + lower.starts_with("proxy-") || lower.starts_with("sec-") + } +} diff --git a/crates/vendor/tauri-plugin-http/src/error.rs b/crates/vendor/tauri-plugin-http/src/error.rs new file mode 100644 index 0000000000..ef8de0c526 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/error.rs @@ -0,0 +1,57 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Serialize, Serializer}; +use url::Url; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Network(#[from] reqwest::Error), + #[error(transparent)] + Http(#[from] http::Error), + #[error(transparent)] + HttpInvalidHeaderName(#[from] http::header::InvalidHeaderName), + #[error(transparent)] + HttpInvalidHeaderValue(#[from] http::header::InvalidHeaderValue), + /// URL not allowed by the scope. + #[error("url not allowed on the configured scope: {0}")] + UrlNotAllowed(Url), + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + /// HTTP method error. + #[error(transparent)] + HttpMethod(#[from] http::method::InvalidMethod), + #[error("scheme {0} not supported")] + SchemeNotSupport(String), + #[error("Request canceled")] + RequestCanceled, + #[error(transparent)] + FsError(#[from] tauri_plugin_fs::Error), + #[error("failed to process data url")] + DataUrlError, + #[error("failed to decode data url into bytes")] + DataUrlDecodeError, + #[error(transparent)] + Tauri(#[from] tauri::Error), + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), + #[error("dangerous settings used but are not enabled")] + DangerousSettings, +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +pub type Result = std::result::Result; diff --git a/crates/vendor/tauri-plugin-http/src/lib.rs b/crates/vendor/tauri-plugin-http/src/lib.rs new file mode 100644 index 0000000000..1da8993fba --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/lib.rs @@ -0,0 +1,96 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Access the HTTP client written in Rust. + +pub use reqwest; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use error::{Error, Result}; + +// NB(bt): We're making everything public so we can access it. +pub mod commands; +pub mod error; +#[cfg(feature = "cookies")] +pub mod reqwest_cookie_store; +pub mod scope; + +#[cfg(feature = "cookies")] +const COOKIES_FILENAME: &str = ".cookies"; + +// NB(bt): We've vendored in this library so we can poke at the cookie data store. +pub struct Http { + #[cfg(feature = "cookies")] + pub cookies_jar: std::sync::Arc, +} + +pub fn init() -> TauriPlugin { + Builder::::new("http") + .setup(|app, _| { + #[cfg(feature = "cookies")] + let cookies_jar = { + use crate::reqwest_cookie_store::*; + use std::fs::File; + use std::io::BufReader; + + let cache_dir = app.path().app_cache_dir()?; + std::fs::create_dir_all(&cache_dir)?; + + let path = cache_dir.join(COOKIES_FILENAME); + + // NB(bt): Let's always log this. + println!("Tauri plugin http: using cookie store at {}", path.display()); + + let file = File::options() + .create(true) + .append(true) + .read(true) + .open(&path)?; + + let reader = BufReader::new(file); + CookieStoreMutex::load(path.clone(), reader).unwrap_or_else(|_e| { + #[cfg(feature = "tracing")] + tracing::warn!( + "failed to load cookie store: {_e}, falling back to empty store" + ); + CookieStoreMutex::new(path, Default::default()) + }) + }; + + let state = Http { + #[cfg(feature = "cookies")] + cookies_jar: std::sync::Arc::new(cookies_jar), + }; + + app.manage(state); + + Ok(()) + }) + .on_event(|app, event| { + #[cfg(feature = "cookies")] + if let tauri::RunEvent::Exit = event { + let state = app.state::(); + + match state.cookies_jar.request_save() { + Ok(rx) => { + let _ = rx.recv(); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + } + }) + .invoke_handler(tauri::generate_handler![ + commands::fetch, + commands::fetch_cancel, + commands::fetch_send, + commands::fetch_read_body + ]) + .build() +} diff --git a/crates/vendor/tauri-plugin-http/src/reqwest_cookie_store.rs b/crates/vendor/tauri-plugin-http/src/reqwest_cookie_store.rs new file mode 100644 index 0000000000..be4215ca1a --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/reqwest_cookie_store.rs @@ -0,0 +1,135 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// taken from https://github.com/pfernie/reqwest_cookie_store/blob/2ec4afabcd55e24d3afe3f0626ee6dc97bed938d/src/lib.rs + +use std::{ + path::PathBuf, + sync::{mpsc::Receiver, Mutex}, +}; + +// NB(bt): Re-export these for reuse +pub use cookie_store::{CookieStore, RawCookie, RawCookieParseError}; +use reqwest::header::HeaderValue; + +fn set_cookies( + cookie_store: &mut CookieStore, + cookie_headers: &mut dyn Iterator, + url: &url::Url, +) { + let cookies = cookie_headers.filter_map(|val| { + std::str::from_utf8(val.as_bytes()) + .map_err(RawCookieParseError::from) + .and_then(RawCookie::parse) + .map(|c| c.into_owned()) + .ok() + }); + cookie_store.store_response_cookies(cookies, url); +} + +fn cookies(cookie_store: &CookieStore, url: &url::Url) -> Option { + let s = cookie_store + .get_request_values(url) + .map(|(name, value)| format!("{}={}", name, value)) + .collect::>() + .join("; "); + + if s.is_empty() { + return None; + } + + HeaderValue::from_maybe_shared(bytes::Bytes::from(s)).ok() +} + +/// A [`cookie_store::CookieStore`] wrapped internally by a [`std::sync::Mutex`], suitable for use in +/// async/concurrent contexts. +#[derive(Debug)] +pub struct CookieStoreMutex { + pub path: PathBuf, + // NB(bt): We've made both of these public so we can access them. + pub store: Mutex, + pub save_task: Mutex>, +} + +impl CookieStoreMutex { + /// Create a new [`CookieStoreMutex`] from an existing [`cookie_store::CookieStore`]. + pub fn new(path: PathBuf, cookie_store: CookieStore) -> CookieStoreMutex { + CookieStoreMutex { + path, + store: Mutex::new(cookie_store), + save_task: Default::default(), + } + } + + pub fn load( + path: PathBuf, + reader: R, + ) -> cookie_store::Result { + cookie_store::serde::load(reader, |c| serde_json::from_str(c)) + .map(|store| CookieStoreMutex::new(path, store)) + } + + fn cookies_to_str(&self) -> Result { + let mut cookies = Vec::new(); + for cookie in self + .store + .lock() + .expect("poisoned cookie jar mutex") + .iter_unexpired() + { + if cookie.is_persistent() { + cookies.push(cookie.clone()); + } + } + serde_json::to_string(&cookies) + } + + pub fn request_save(&self) -> cookie_store::Result> { + let cookie_str = self.cookies_to_str()?; + let path = self.path.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let task = tauri::async_runtime::spawn(async move { + match tokio::fs::write(&path, &cookie_str).await { + Ok(()) => { + let _ = tx.send(()); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + }); + self.save_task + .lock() + .unwrap() + .replace(CancellableTask(task)); + Ok(rx) + } +} + +impl reqwest::cookie::CookieStore for CookieStoreMutex { + fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { + set_cookies(&mut self.store.lock().unwrap(), cookie_headers, url); + + // try to persist cookies immediately asynchronously + if let Err(_e) = self.request_save() { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + + fn cookies(&self, url: &url::Url) -> Option { + let store = self.store.lock().unwrap(); + cookies(&store, url) + } +} + +#[derive(Debug)] +struct CancellableTask(tauri::async_runtime::JoinHandle<()>); + +impl Drop for CancellableTask { + fn drop(&mut self) { + self.0.abort(); + } +} diff --git a/crates/vendor/tauri-plugin-http/src/scope.rs b/crates/vendor/tauri-plugin-http/src/scope.rs new file mode 100644 index 0000000000..2123f215ad --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/scope.rs @@ -0,0 +1,231 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::Arc; + +use serde::{Deserialize, Deserializer}; +use url::Url; +use urlpattern::{UrlPattern, UrlPatternMatchInput}; + +#[allow(rustdoc::bare_urls)] +#[derive(Debug)] +pub struct Entry { + pub url: UrlPattern, +} + +fn parse_url_pattern(s: &str) -> Result { + let mut init = urlpattern::UrlPatternInit::parse_constructor_string::(s, None)?; + if init.search.as_ref().map(|p| p.is_empty()).unwrap_or(true) { + init.search.replace("*".to_string()); + } + if init.hash.as_ref().map(|p| p.is_empty()).unwrap_or(true) { + init.hash.replace("*".to_string()); + } + if init + .pathname + .as_ref() + .map(|p| p.is_empty() || p == "/") + .unwrap_or(true) + { + init.pathname.replace("*".to_string()); + } + UrlPattern::parse(init, Default::default()) +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum EntryRaw { + Value(String), + Object { url: String }, +} + +impl<'de> Deserialize<'de> for Entry { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + EntryRaw::deserialize(deserializer).and_then(|raw| { + let url = match raw { + EntryRaw::Value(url) => url, + EntryRaw::Object { url } => url, + }; + Ok(Entry { + url: parse_url_pattern(&url).map_err(|e| { + serde::de::Error::custom(format!("`{}` is not a valid URL pattern: {e}", url)) + })?, + }) + }) + } +} + +/// Scope for filesystem access. +#[derive(Debug)] +pub struct Scope<'a> { + allowed: Vec<&'a Arc>, + denied: Vec<&'a Arc>, +} + +impl<'a> Scope<'a> { + /// Creates a new scope from the scope configuration. + pub(crate) fn new(allowed: Vec<&'a Arc>, denied: Vec<&'a Arc>) -> Self { + Self { allowed, denied } + } + + /// Determines if the given URL is allowed on this scope. + pub fn is_allowed(&self, url: &Url) -> bool { + let denied = self.denied.iter().any(|entry| { + entry + .url + .test(UrlPatternMatchInput::Url(url.clone())) + .unwrap_or_default() + }); + if denied { + false + } else { + self.allowed.iter().any(|entry| { + entry + .url + .test(UrlPatternMatchInput::Url(url.clone())) + .unwrap_or_default() + }) + } + } +} + +#[cfg(test)] +mod tests { + use std::{str::FromStr, sync::Arc}; + + use super::Entry; + + impl FromStr for Entry { + type Err = urlpattern::quirks::Error; + + fn from_str(s: &str) -> Result { + let pattern = super::parse_url_pattern(s)?; + Ok(Self { url: pattern }) + } + } + + #[test] + fn denied_takes_precedence() { + let allow = Arc::new("http://localhost:8080/file.png".parse().unwrap()); + let deny = Arc::new("http://localhost:8080/*".parse().unwrap()); + let scope = super::Scope::new(vec![&allow], vec![&deny]); + assert!(!scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8080?framework=tauri".parse().unwrap())); + } + + #[test] + fn fixed_url() { + // plain URL + let entry = Arc::new("http://localhost:8080".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + assert!(scope.is_allowed(&"http://localhost:8080".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/file".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/path/to/asset.png".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/path/list?limit=50".parse().unwrap())); + + assert!(!scope.is_allowed(&"https://localhost:8080".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8081".parse().unwrap())); + assert!(!scope.is_allowed(&"http://local:8080".parse().unwrap())); + } + + #[test] + fn fixed_path() { + // URL with fixed path + let entry = Arc::new("http://localhost:8080/file.png".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/file.png?q=1".parse().unwrap())); + + assert!(!scope.is_allowed(&"http://localhost:8080".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8080/file".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8080/file.png/other.jpg".parse().unwrap())); + } + + #[test] + fn pattern_wildcard() { + let entry = Arc::new("http://localhost:8080/*.png".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/file.png#head".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/assets/file.png".parse().unwrap())); + assert!(scope.is_allowed( + &"http://localhost:8080/assets/file.png?width=100&height=200" + .parse() + .unwrap() + )); + + assert!(!scope.is_allowed(&"http://localhost:8080/file.jpeg".parse().unwrap())); + } + + #[test] + fn domain_wildcard() { + let entry = Arc::new("http://*".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else#tauri".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else?rel=tauri".parse().unwrap())); + assert!(scope.is_allowed( + &"http://something.else/path/to/file.mp4?start=500" + .parse() + .unwrap() + )); + + assert!(!scope.is_allowed(&"https://something.else".parse().unwrap())); + + let entry = Arc::new("http://*/*".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + } + + #[test] + fn scheme_wildcard() { + let entry = Arc::new("*://*".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"file://path".parse().unwrap())); + assert!(scope.is_allowed(&"file://path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"https://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"https://something.else?x=1#frag".parse().unwrap())); + + let entry = Arc::new("*://*/*".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"file://path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"https://something.else".parse().unwrap())); + } + + #[test] + fn validate_query() { + let entry = Arc::new("https://tauri.app/path?x=*".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"https://tauri.app/path?x=5".parse().unwrap())); + + assert!(!scope.is_allowed(&"https://tauri.app/path?y=5".parse().unwrap())); + } + + #[test] + fn validate_hash() { + let entry = Arc::new("https://tauri.app/path#frame*".parse().unwrap()); + let scope = super::Scope::new(vec![&entry], Vec::new()); + + assert!(scope.is_allowed(&"https://tauri.app/path#frame".parse().unwrap())); + + assert!(!scope.is_allowed(&"https://tauri.app/path#work".parse().unwrap())); + } +} diff --git a/crates/vendor/tauri-plugin-http/tsconfig.json b/crates/vendor/tauri-plugin-http/tsconfig.json new file mode 100644 index 0000000000..5098169aa9 --- /dev/null +++ b/crates/vendor/tauri-plugin-http/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["guest-js/*.ts"] +} diff --git a/docs/moodboard/DESIGN_AND_FLOW.md b/docs/moodboard/DESIGN_AND_FLOW.md new file mode 100644 index 0000000000..23cd769ee8 --- /dev/null +++ b/docs/moodboard/DESIGN_AND_FLOW.md @@ -0,0 +1,227 @@ +# Artcraft Moodboard — Design & Flow + +> How the moodboard looks, feels, and flows. Written through a high-end product-design lens +> (Linear/Apple-tier restraint: haptic depth, spring-physics motion, calm surfaces) and grounded +> in the shipped implementation (`@storyteller/ui-moodboard`). Companion to `DESIGN_SYSTEM.md` +> (tokens/components), `FEATURE_PLAN.md` (roadmap), and `FEATURE_BACKLOG.md` (what's next). + +--- + +## 1. Design philosophy + +Three convictions drive every decision: + +1. **The board is an input to generation, not a graveyard.** Generic moodboards (Pinterest, + Milanote) are dead ends — references go in, nothing comes out. Artcraft owns the engine, so the + board is one gesture away from a generation and vice-versa. Every surface keeps that loop in + reach without ever shouting about it. +2. **Native, then elevated.** The moodboard lives *inside* Artcraft, so it speaks the app's visual + language (the `--st-*` theme tokens, Fira Sans, the `glass` material, the brand blue). Elevation + comes from *composition* — nested "double-bezel" enclosures, floating glass islands, motion with + mass — never from a louder palette. The board should feel like it was always part of the app. +3. **The imagery is the interface.** Chrome recedes; references dominate. No like-counts, no + notification noise, no decorative gradients competing with the user's work. Calm by default + (the Cosmos/Are.na lesson), dense where it counts. + +The felt quality we're after: **a precise, quiet instrument** — fast, tactile, and a little bit +joyful, where heavy operations feel weightless and nothing ever teleports. + +--- + +## 2. Visual language (the short version) + +The full token/component spec is in `DESIGN_SYSTEM.md`; the design *intent* behind it: + +- **Double-bezel enclosures.** Cards, the inspector, and toolbars are never flat on the canvas. + A subtle outer shell (plane-1, hairline ring, `rounded-[18px]`, `p-1`) cradles an inner core + (plane-2, concentric `rounded-[14px]`, an inset top-highlight). It reads like a glass plate + seated in a machined tray — physical, considered, expensive. +- **Floating glass islands.** The toolbar and the view switch are detached pills, not edge-glued + bars (`glass` + hairline + ambient shadow, `backdrop-blur` only on these fixed elements — never + on the scrolling grid, where blur would torch scroll performance). +- **Spring-physics motion.** Custom cubic-beziers, never `linear`/`ease-in-out`. A hover lift, a + card landing on drop with a touch of overshoot, a palette fading up. Animate only `transform`, + `opacity`, `filter` — GPU-safe. `prefers-reduced-motion` collapses springs to fades. +- **Concentric squircle radii** and **ambient, layered shadows** (never a harsh `shadow-md`). +- **One display moment.** Body/UI is Fira Sans (the app font, for zero context-switch); the only + "display" flourish is the editorial empty-state heading and presentation mode. + +--- + +## 3. Information architecture + +``` +Board (durable model, localStorage today → backend later) +├── items: image · video · text · link · color (each: tags, rating, deterministic aspect) +├── sections (model-ready; UI is a near-term add) +└── rendered three ways — ALL views are projections of the same board: + ├── GRID virtualized masonry — the default; scan / search / triage at volume + ├── CANVAS freeform Konva stage — arrange / compare / plan spatially + └── PRESENT full-bleed deck — review & share +``` + +**Why grid-first.** 90% of moodboard time is scanning, searching, and triaging — the grid wins +those high-frequency jobs and stays at 60fps (canvas-only tools like Milanote lag at a few hundred +items; we virtualize from item aspect ratios so only the viewport mounts). The freeform canvas is +the *delighter* for spatial thinking, not the foundation. **The board, not the view, is what plugs +into generation** — "use as reference" works regardless of how you're looking at it. + +--- + +## 4. Core flows + +Each flow below lists the **gesture**, then the **design rationale**. + +### 4.1 Capture — get references in with zero friction +The fastest path wins, so we offer every path and make the empty state *say so*. + +- **Drag images** from the desktop onto the board → a dashed primary-tinted drop overlay confirms + the target; files land at the cursor. +- **Paste** — an image from the clipboard drops instantly; a **URL** becomes a link card + (hostname title today; rich OG preview is a backlog item). +- **Upload** (toolbar `↑` / empty-state CTA) → the image appears immediately from a blob, then + uploads in the background to capture a durable media token (so it becomes reference-capable). +- **From library** → the shared gallery picker (Artcraft's own `GalleryModal`), so muscle memory + transfers from the rest of the app. Not signed in? The picker now shows a **Log in** button + (not a dead "Retry"), routed per-platform via the adapter. +- **Note / Color** → a sticky note or an OS-picked swatch, one click each. + +*Rationale:* friction is the enemy of collection. The empty state is an **editorial hero** +("Start collecting ideas") that doubles as the drop affordance — it teaches the gestures while +looking like a finished product, not a blank slate. + +### 4.2 Organize — keep a growing board legible +- **Density** — a segmented control (Compact / Cozy / Comfortable) re-flows the masonry; the + layout is target-width-driven, so it adapts to any container (Savee's adjustable-density bar). +- **Search + tag chips** — text query over captions/tags/links; click a chip to filter. +- **Multi-select** — click, shift/⌘-click, or marquee-drag on bare canvas; a floating glass + **selection bar** rises from the bottom with the count + actions. + +### 4.3 Triage — sift keepers from a batch, fast +Lightroom is the gold standard for high-volume culling, so we borrow its muscle memory: + +- With items selected, **`1`–`5`** rate, **`0`** clears, **`P`** picks (5 stars). A rating badge + persists on the tile. +- The toolbar **star filter** cycles "All → 1+ … → 5+ → All" to winnow to keepers. +- The selection bar surfaces the shortcuts inline (`1–5 rate · 0 clear`) so they're *discoverable*, + not hidden lore. +- **Delete is deliberate** — `Delete`/`Backspace` only (we removed `x` as a delete alias: it sits + in the number row and the board has no undo, so a stray press must not destroy work). + +*Rationale:* triage is keyboard-first because it's repetitive and high-volume; every key saves a +trip to the mouse. Destructive actions get friction; constructive ones get none. + +### 4.4 The board → generate loop — the differentiator +This is the move no generic moodboard can make. + +- **"Use as reference"** — on a card's hover rail, in the selection bar, or in the inspector. + Selected board images are pushed into the image-generation prompt as style references (via their + media tokens) and the user lands on the Create-Image surface, refs pre-filled. Krea/Firefly + proved board→generate; Artcraft closes it natively. +- The seam is the **adapter** (§6): desktop seeds `usePromptImageStore` + switches to the Image + tab; web seeds `useCreateImageStore` + navigates to `/create-image`. Same gesture, same result, + two platforms. + +*Rationale:* the highest-leverage feature is the one that turns a passive collection into momentum. +It's always one click from any view, and it never blocks — it sends and gets out of the way. + +### 4.5 Inspect — the lightbox as a workbench +Double-click or the rail's expand icon opens a full-screen **inspector**: + +- Large media on a glass-blurred backdrop (blur is fine here — it's a fixed overlay), a + double-bezel side panel, `←/→` to walk the board, `Esc` to close, background scroll locked, + `role="dialog"`. +- The panel is a small workbench: **rating** stars, a **tag editor**, an **extracted palette** + (client-side color quantization — click a swatch to copy, or "add swatches to board"), and + **Use as reference**. The palette is keyed on the image src, so editing tags never re-decodes + the image or flickers. + +### 4.6 Canvas — think spatially +The freeform Konva stage for arranging, comparing, and planning: + +- Pan/zoom, marquee + lasso select, 8-handle transform, grouping, undo/redo, a shortcut cheatsheet, + and auto-layout helpers (pack / fit-to-grid / cluster-by-proximity) that already exist and want + better surfacing. Alignment/snapping + a background grid are the next polish (backlog). +- Drop a generation onto the canvas, set images side by side, group a coherent set, then send that + set to generation. It's the planning room; the grid is the library. + +### 4.7 Present — review & share +A distraction-free, full-bleed deck: one item at a time, `←/→/space` to navigate, `Esc` to exit, +launched from the view switch as a transient overlay (never persisted, so a reload never reopens +into a slideshow). For client/team review and screen-sharing. + +--- + +## 5. Micro-UX niceties (the details that read as "expensive") + +- **Hover action rail** — a glass pill on each card fades *up* on hover (translate + opacity), with + Open / Use-as-reference / Delete. Buttons stop propagation so they never double as a select. +- **Card states choreographed** — hover lifts the shell 2px and deepens the shadow; selected swaps + to a 2px brand ring; drag scales to 1.03; drop lands with a snap-overshoot. +- **Selection check + rating** — solid scrims (not blur) inside the scrolling grid for legibility + without GPU cost. +- **Focus-visible rings everywhere** — keyboard users see where they are, not just hover users. +- **The Login button** — the gallery picker now invites action ("Log in to browse your library" + + a real primary button) instead of a confusing "Retry" link. +- **Stagger on entry** — grid items and presentation reveals come in with a short staggered + fade-up, so the board feels assembled, not dumped. + +--- + +## 6. Cross-platform via the adapter + +One library, two hosts, identical experience. The lib owns everything platform-agnostic; each app +injects a `MoodboardAdapter` for its three seams: + +```ts +interface MoodboardAdapter { + uploadImage?: (file) => Promise; // capture a durable token + sendToGeneration: (refs) => void; // board → prompt + renderLibraryPicker?: (props) => ReactNode; // the gallery picker (render-prop) +} +``` + +- **Desktop (Tauri):** upload via `@storyteller/api`, picker via `GalleryModal`, send → prompt + store + tab switch, login → the login modal. +- **Web (artcraft-webapp):** upload via the webapp uploader, picker via the same `GalleryModal`, + send → create-image store + `/create-image`, login → navigate `/login`. + +*Rationale:* the adapter is the seam where platform reality lives, so the *experience* stays a +single, consistent design across surfaces — and the same polish ships everywhere at once. + +--- + +## 7. Motion & performance contract + +- **Curves:** spring (`cubic-bezier(0.32,0.72,0,1)`) for settle/zoom; smooth + (`cubic-bezier(0.4,0,0.2,1)`, ~200ms) for hover/selection; snap with slight overshoot on drop. +- **Only `transform`/`opacity`/`filter`** animate — never layout properties. +- **`backdrop-blur` only on fixed chrome** (toolbar, view switch, inspector/present backdrops); + the virtualized scrolling grid uses solid scrims so scrolling stays at 60fps. +- **Virtualize aggressively** — deterministic masonry from item aspects means only viewport-visible + cards mount; the grid holds thousands of items without lag (the competitor failure mode). +- **Respect `prefers-reduced-motion`** — springs become instant, fades remain. + +--- + +## 8. Accessibility & polish checklist + +- [x] Theme-correct: consumes `--st-*` only, so light/dark/gray/black/aurora themes work for free. +- [x] Focus-visible rings on interactive controls; inspector is a labelled `role="dialog"` with + scroll-lock and keyboard nav. +- [x] Color is never the only signal (drop state pairs a tint with a dashed outline + icon). +- [x] Destructive delete is deliberate (no single-printable-key delete); no silent data loss. +- [ ] Cards are not yet keyboard-focusable/selectable (pointer-first) — a known a11y gap. +- [ ] Toolbar/inspector don't yet collapse gracefully below ~768px (desktop-first) — responsive + pass pending. + +--- + +## 9. Where the design goes next + +The research-backed backlog (`FEATURE_BACKLOG.md`) extends this flow without changing its spine — +client-side ML (in-browser captioning, auto-tagging, find-similar, palette/color search) makes the +board *understand itself*; canvas snapping/alignment and a region→palette tool deepen planning; and +the generate-into-a-board / remix loop tightens the differentiator. Collaboration & sharing are +deferred behind a flag (they need the backend). The throughline stays constant: **collect with no +friction, organize effortlessly, and turn the board into the next generation — calmly.** diff --git a/docs/moodboard/UX_CHANGE_PLAN.md b/docs/moodboard/UX_CHANGE_PLAN.md new file mode 100644 index 0000000000..407493db6d --- /dev/null +++ b/docs/moodboard/UX_CHANGE_PLAN.md @@ -0,0 +1,101 @@ +# Moodboard — UX Change Plan + +> Derived from the recommendations in `DESIGN_AND_FLOW.md` (the high-end design/UX doc), checked +> against the **actual implementation**. This is a *design-and-UX polish* plan — closing the gap +> between the experience the doc describes and what's shipped. (AI/ML features live separately in +> `FEATURE_BACKLOG.md`; backend-dependent work like sharing stays deferred.) + +## Audit — doc says vs. reality + +| Recommendation (DESIGN_AND_FLOW.md) | Status today | Gap | +|---|---|---| +| §5 "Stagger on entry — items fade-up assembled" | **Not built** — grid renders the virtualized slice with no entry motion | Yes | +| §8 "Focus-visible rings on interactive controls" | **Partial** — only card action-rail + toolbar add-buttons | Yes | +| §8 "Cards not yet keyboard-focusable/selectable" | **Not built** — pointer-only | Yes (acknowledged) | +| §8 "Toolbar/inspector don't collapse < ~768px" | **Not built** — fixed horizontal island; inspector is a fixed 2-pane | Yes (acknowledged) | +| §4.2 "Sections" | Model-ready, **no UI** | Yes | +| §4.1 "Paste URL → link card" | Hostname-only, **no OG preview** | Yes (needs proxy) | +| §4.6 "Auto-layout helpers want better surfacing" | Helpers exist (`pack`/`fitToGrid`/`cluster`), **under-exposed** | Yes | +| §5 double-bezel, glass islands, spring motion, hover rail, card states, scrims, login button, triage hint, inspector dialog | **Built** ✓ | — | + +Everything in the bottom row is genuinely shipped; the rows above are the work. + +--- + +## The plan (prioritized by impact ÷ effort) + +Each item: **what + UX**, **why** (design rationale), **effort (S/M/L)**, **acceptance**. + +### P1 — Motion & accessibility foundation (do first) + +**1.1 Entry motion — staggered fade-up on the grid.** *(the doc claims it; it isn't there)* +- **What:** as cards mount into the viewport, fade-up from `translate-y-2 opacity-0 blur-[2px]` → rest over ~320ms on `--mb-ease-smooth`, with an index-based delay capped at ~12 items so a full screen feels *assembled*, not dumped. Must be virtualization-safe: animate only on first mount, key the animation so re-renders/scroll don't re-trigger; respect `prefers-reduced-motion`. +- **Why:** the single biggest "expensive feel" lever the doc names; the board currently pops in flatly. +- **Effort:** **S–M.** **Acceptance:** opening a board / switching density plays one clean stagger; scrolling does **not** re-animate; reduced-motion shows instant. + +**1.2 Keyboard-navigable cards.** +- **What:** card root becomes focusable (`tabIndex=0`, `role="button"`, `aria-label`); `Enter`/`Space` opens the inspector, `X`/`Delete` deletes the focused card (with the same deliberate-delete rule), and arrow keys move focus across the masonry (geometry-aware: left/right within a row, up/down to the nearest card). Selection follows focus with `Space`; `Shift`+arrows extends. +- **Why:** the doc's headline a11y gap; also a power-user speed win (no mouse round-trips during triage). +- **Effort:** **M.** **Acceptance:** a keyboard-only user can tab into the grid, move with arrows, open/rate/delete, and select — with a visible focus ring on each card. + +**1.3 Complete focus-visible coverage.** +- **What:** add focus-visible rings to every remaining interactive control — `SelectionBar` buttons, `SmartSearchBar` input + clear + tag chips, density segmented control, rating-filter toggle, the view switch, and all `ItemInspector` controls (rating stars, tag input, palette swatches, actions). +- **Why:** §8 says "everywhere"; today it's two components. +- **Effort:** **S.** **Acceptance:** tabbing through the whole moodboard always shows where focus is. + +### P2 — Responsive & micro-UX completeness + +**2.1 Responsive toolbar.** +- **What:** below `md`, the floating island stops being one wide row — it becomes a compact glass bar: board name + a `+`-menu (collapsing the 4 add-buttons), density, rating, and a search **icon** that expands into a sheet; the whole thing `overflow-x-auto` as a last resort so it never breaks the layout. +- **Why:** §8 gap; the webapp can be viewed narrow, and today the island overflows. +- **Effort:** **M.** **Acceptance:** at 375px wide the toolbar is usable and doesn't clip; at ≥`md` it's unchanged. + +**2.2 Responsive inspector.** +- **What:** below `md`, the inspector stacks (media on top, the panel as a bottom sheet) instead of the fixed media-left / 320px-panel split; `h-[100dvh]`; nav arrows move to the corners. +- **Why:** §8 gap; the current side-by-side is unusable on phones. +- **Effort:** **S–M.** **Acceptance:** inspector is fully usable at 375px. + +**2.3 Sections UI.** +- **What:** group items into named, collapsible sections in the grid (the model already supports `sections` + `sectionId`). A section header row with a name (inline-rename), count, collapse toggle, and "new section"; drag a selection into a section; an "Ungrouped" default lane. +- **Why:** §4.2 calls it a near-term add; big boards spiral without it (Pinterest Sections / MJ folder-groups). +- **Effort:** **M.** **Acceptance:** create/rename/collapse sections; assign items; persists. + +**2.4 Surface canvas auto-layout helpers.** +- **What:** a one-click **Tidy / Arrange** menu on the canvas toolbar exposing the already-built `pack` (justified rows), `fitToGrid`, and `cluster-by-proximity`, plus "align selection." +- **Why:** §4.6 — the logic exists but is hidden; cheap, high-delight. +- **Effort:** **S.** **Acceptance:** the three layouts run from a visible menu; undoable. + +**2.5 "Button-in-button" primary CTAs.** +- **What:** apply the doc's nested trailing-icon pattern (already in the empty-state CTA) to the primary actions — "Use as reference" and the inspector's primary button — with the magnetic hover physics (icon translates diagonally + scales on hover, button `active:scale-[0.98]`). +- **Why:** §5 haptic consistency; makes the differentiator action feel premium. +- **Effort:** **S.** **Acceptance:** primary CTAs share one polished button anatomy. + +### P3 — Larger canvas & capture polish + +**2.6 Canvas alignment + snapping + background grid.** +- **What:** snap a dragged node to other nodes' edges/centers with guide lines (DIY on Konva `dragmove`), plus a toggleable background grid + snap-to-grid. +- **Why:** §4.6/§9 — makes the freeform canvas feel professional (PureRef 2.1). +- **Effort:** **M.** (also tracked in `FEATURE_BACKLOG.md` 3.1/3.2.) **Acceptance:** dragging shows guides + snaps; grid toggles. + +**2.7 Rich URL preview (link cards).** +- **What:** paste a URL → fetch OG title + image for a real preview card (vs hostname today). +- **Why:** §4.1 — link cards look unfinished without a thumbnail. +- **Effort:** **S (client) / M (proxy).** **Dependency:** OG fetch needs a CORS unfurl proxy → **flag as backend-dependent / defer the image; ship title now.** + +**2.8 Drag-from-library onto the board.** +- **What:** let a gallery item be dragged directly onto the grid/canvas (not only picked via the modal), reusing Artcraft's existing gallery-drag event. +- **Why:** §4.1 — the lowest-friction capture path for already-owned media. +- **Effort:** **M.** **Acceptance:** drag a library thumbnail onto the board → it lands as a tokened item. + +--- + +## Sequencing + +- **Sprint 1 (feel + a11y):** 1.1 entry motion, 1.2 keyboard cards, 1.3 focus coverage. *One cohesive "the board feels finished and is fully keyboard-able" release.* +- **Sprint 2 (responsive + organize):** 2.1 toolbar, 2.2 inspector, 2.3 sections, 2.5 CTAs. +- **Sprint 3 (canvas + capture):** 2.4 surface auto-layout, 2.6 snapping/grid, 2.8 drag-from-library, 2.7 link previews (title now, image when a proxy exists). + +## Recommended first move +**Sprint 1** — it's the highest "expensive feel ÷ effort" and closes the doc's two explicit a11y +gaps + the one motion claim that isn't yet true. All client-side, no backend, fits the shared lib +cleanly, and verifiable headlessly (focus order, reduced-motion, DOM-on-mount). diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000000..6e87a003da --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +node_modules diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000000..c21b3ce943 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,56 @@ +{ + "root": true, + "ignorePatterns": [ + "**/*" + ], + "plugins": [ + "@nx" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "enforceBuildableLibDependency": true, + "allow": [], + "depConstraints": [ + { + "sourceTag": "*", + "onlyDependOnLibsWithTags": [ + "*" + ] + } + ] + } + ] + } + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "extends": [ + "plugin:@nx/typescript" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "extends": [ + "plugin:@nx/javascript" + ], + "rules": {} + } + ] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..cbe39ccc36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,52 @@ +# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# compiled output +dist +tmp +out-tsc +*.tsbuildinfo + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.nx/cache +.nx/workspace-data + +vite.config.*.timestamp* +vitest.config.*.timestamp* +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md + +# Environment files +.env.local +.env*.local diff --git a/frontend/.nxignore b/frontend/.nxignore new file mode 100644 index 0000000000..9b6ca4c783 --- /dev/null +++ b/frontend/.nxignore @@ -0,0 +1,8 @@ +# The admin dashboard is intentionally isolated from the Nx/npm workspace. +# +# It pins its own incompatible toolchain (React 19, Vite 7, Tailwind 4) and +# installs with pnpm into its own node_modules. Nx must NOT infer it as a +# project — otherwise `nx run-many`/`nx affected` would try to build it with the +# workspace's React 18 toolchain and root node_modules. It builds standalone +# (see apps/artcraft-dashboard/netlify.toml). +apps/artcraft-dashboard diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000000..462e29bbf4 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "nrwl.angular-console", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint" + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000..5e72ef568a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,52 @@ +# Artcraft frontend + +This Nx workspace contains the Tauri desktop frontend in `apps/artcraft` and its +shared libraries in `libs`. Website and Netlify builds live in `artcraft-services`. + +Use Node.js 20 or newer and npm. Run the following commands from `frontend/`: + +```bash +npm ci +npx nx dev artcraft +npx nx build artcraft +``` + +The frontend development server uses port 5173. Start the Rust app separately +from the repository root: + +```bash +./script/artcraft/unix_rust_dev.sh +``` + +The repository-root frontend launcher installs dependencies and starts the same +server: + +```bash +./script/artcraft/unix_frontend_dev.sh +``` + +On Windows, use `script/artcraft/windows_frontend_dev.ps1` and +`script/artcraft/windows_rust_dev.ps1` in separate terminals. See +[development setup](../_docs/dev_setup.md) for Rust and Tauri prerequisites. + +The Vite build writes `apps/artcraft/dist`, which Tauri bundles. Public resources +come from `apps/artcraft/app/public`. The app also needs its retained libraries, +root package lock, TypeScript project references, and Nx/Vite configuration. +The aliases in `tsconfig.base.json` resolve shared libraries to their source for +both development and production. The desktop build does not require library +`dist/` outputs or a separate library build. + +Import shared libraries by the names declared in their `package.json` files: + +```ts +import { Button } from "@storyteller/ui-button"; +import { Modal } from "@storyteller/ui-modal"; +``` + +Use the workspace-local Nx version through `npx nx`. `npm ci` and the release +workflows use the checked-in lockfile; do not reinstall different Nx versions +inside CI. + +`clean_modules.sh` is an explicit cleanup command that removes build outputs, +Nx caches, and frontend dependencies. Do not run it when preserving local build +outputs during repository pruning. diff --git a/frontend/api_docs.html b/frontend/api_docs.html new file mode 100644 index 0000000000..a5c0ebb87b --- /dev/null +++ b/frontend/api_docs.html @@ -0,0 +1,34 @@ + + + + + + + Storyteller API + + + + +
+ + + + + + diff --git a/frontend/apps/artcraft/.env-local-proxy b/frontend/apps/artcraft/.env-local-proxy new file mode 100644 index 0000000000..6366906887 --- /dev/null +++ b/frontend/apps/artcraft/.env-local-proxy @@ -0,0 +1,9 @@ +BASE_API="http://localhost:3000" +GOOGLE_API="http://localhost:3000/google" +FUNNEL_API="http://localhost:3000/funnel" +CDN_API="http://localhost:3000/cdn" +GRAVATAR_API="https://www.gravatar.com" +DEPLOY_PRIME_URL="http://localhost:5173" +REACT_APP_PUBLIC_POSTHOG_KEY="phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb" +REACT_APP_PUBLIC_POSTHOG_UI="https://us.i.posthog.com" +CONTEXT="DEVELOPMENT" \ No newline at end of file diff --git a/frontend/apps/artcraft/.env-no-proxy b/frontend/apps/artcraft/.env-no-proxy new file mode 100644 index 0000000000..466e082fed --- /dev/null +++ b/frontend/apps/artcraft/.env-no-proxy @@ -0,0 +1,9 @@ +BASE_API="https://localhost:12345" +GOOGLE_API="https://storage.googleapis.com" +FUNNEL_API="https://style.storyteller.ai" +CDN_API="https://cdn.storyteller.ai" +GRAVATAR_API="https://www.gravatar.com" +DEPLOY_PRIME_URL="https://studio.storyteller.ai" +REACT_APP_PUBLIC_POSTHOG_KEY="phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb" +REACT_APP_PUBLIC_POSTHOG_UI="https://us.i.posthog.com" +CONTEXT="DEVELOPMENT" \ No newline at end of file diff --git a/frontend/apps/artcraft/.eslintrc.cjs b/frontend/apps/artcraft/.eslintrc.cjs new file mode 100644 index 0000000000..b96f4b9848 --- /dev/null +++ b/frontend/apps/artcraft/.eslintrc.cjs @@ -0,0 +1,95 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ +const SEVERITY = { + OFF: 0, + WARN: 1, + ERROR: 2, +}; + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + rules: { + // we are incapable to provide support for blind people atm + "jsx-a11y/click-events-have-key-events": SEVERITY.OFF, + "jsx-a11y/no-static-element-interactions": SEVERITY.OFF, + "jsx-a11y/media-has-caption": SEVERITY.OFF, + "jsx-a11y/no-autofocus": SEVERITY.OFF, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.js"], + env: { + node: true, + }, + }, + ], +}; diff --git a/frontend/apps/artcraft/.github/workflows/sync-studio-staging-branch.yml b/frontend/apps/artcraft/.github/workflows/sync-studio-staging-branch.yml new file mode 100644 index 0000000000..4bc7a27fba --- /dev/null +++ b/frontend/apps/artcraft/.github/workflows/sync-studio-staging-branch.yml @@ -0,0 +1,33 @@ +# This action syncs "studio-staging" with "main" by force pushing every time it is run. +# Adapted from: +# https://github.community/t/user-github-pages-site-auto-deploy-from-another-branch-on-push/17912/2 +on: + push: + branches: + - main + +jobs: + sync_studio_staging_branch: + runs-on: [ubuntu-latest] + steps: + - name: checkout main branch + uses: actions/checkout@v2 + with: + persist-credentials: false + - name: sync main to studio-staging + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git checkout -b studio-staging + - name: push changes + uses: ad-m/github-push-action@0fafdd62b84042d49ec0cb92d9cac7f7ce4ec79e + with: + # Note: Github does not allow the default access token to push to + # protected branches. Using a PAT with permissions can bypass this, + # but it triggers recursive Github Actions calls on branch updates + # (which the default access token does not). Ultimately, it's better + # to leave the studio-staging branch unprotected. + github_token: ${{ secrets.GITHUB_TOKEN }} + branch: studio-staging + force: true + diff --git a/frontend/apps/artcraft/.gitignore b/frontend/apps/artcraft/.gitignore new file mode 100644 index 0000000000..1d7b8398d9 --- /dev/null +++ b/frontend/apps/artcraft/.gitignore @@ -0,0 +1,30 @@ +.env +.eslintcache +.vite +.notes +node_modules +/~/ +proxy/~/ +/.cache +/public/build +/build +/backup +.idea +$ + +# VS Code Setting +.vscode + +# Local Netlify folder +.netlify +# Mac +.DS_Store + +# Assets to ignore +public/resources/movie/Big_Buck_Bunny_180_10s.webm +public/resources/models/room/room_with_girl.glb + +*storybook.log + +# VIM +*~ diff --git a/frontend/apps/artcraft/.prettierrc b/frontend/apps/artcraft/.prettierrc new file mode 100644 index 0000000000..7ff0eaa385 --- /dev/null +++ b/frontend/apps/artcraft/.prettierrc @@ -0,0 +1,11 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"], + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "useTabs": false, + "tabWidth": 2 +} diff --git a/frontend/apps/artcraft/README.md b/frontend/apps/artcraft/README.md new file mode 100644 index 0000000000..f341c53f7c --- /dev/null +++ b/frontend/apps/artcraft/README.md @@ -0,0 +1,130 @@ +# Storyteller GGF + +## Coding Rules and Styles + +``` +Short Style Guide TS React and Engine: + +No use of lets + +No basic for loop operators. +Use the collection operators and array operators. + +React Side / No modules over 200 lines. ( loose rule ) + +Don't do: +if (condition) return; + +Do: +if (condition) { + return; +} + +No use of any type. +If you can use chat gpt to create the interface. +``` + +## Branches + +- `main` - The main branch is the production source of truth. + + Changes to main will automatically build, but will not auto-deploy to production. You'll have to promote changes to production using the Netlify control panel. + + Merge your code to main frequently. If you have to have a long-lived set of changes, ask yourself whether it might be better to feature flag your code off and + land it to main rather than fight rebasing against the whole team. + +- `studio-staging` - The staging auto-deploy branch. + + You do not ever need to interact with this branch. Changes to `main` will automatically be pushed to `studio-staging` and auto-deployed to + https://studio-staging.studio.storyteller.ai . You can test changes that have landed in `main` here before promoting them to production. + +- `studio-testing` - The testing branch. + +Anyone can push their changes to this branch for testing in a production-like environment. Since it is built and hosted on https://studio-testing.studio.storyteller.ai , +you won't face CORS issues, same-site cookie issues, or have to use a local development proxy. (This doesn't obviate the need for a development +proxy for fast development iteration, but is a fantastic way to test your changes in a "production-like" environment.) + +Force push to this branch by using `git push -f origin studio-testing` + +## API Docs + +Server API docs live here: https://storyteller-docs.netlify.app/ + +## Local Testing & Development + +### Apple Silicon package installation issues + +Installation of some dependencies via npm may fail on M-series macs (like node-canvas) and may require additional setup including python3 and some brew dependencies: + +``` +brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman python-setuptools +``` + +### Set up `.env` and the Proxy Server + +Copy & paste the env settings from `.env-local-proxy` to `.env`, (if the file `.env` do not exist, simply create one at the root of this project's folder beside the other env settings files). + +The settings from `.env-local-proxy` directs all requests to `localhost:3000`, this will relieve us from having to deal with CORS between various services. + +Then, start the proxy server in the folder `/proxy`. Before running the proxy for the first time, run: + +``` +npm i +``` + +To start the proxy run: + +``` +npm run dev +``` + +If the proxy fails, make sure your node version is 18.18. If you need to change the node version +run `npm rebuild` once before re-running `npm run dev` + +### Run the Code Locally + +To setup and run this project's code locally: + +``` +npm install +npm run dev +``` + +## PostHog + +PostHog is installed and working. To create a feature flag go to https://us.posthog.com/project/75284/feature_flags +Add the feature flag for all users. + +To check the feature flag in the app use code similar to this + +```ts +import { usePosthogFeatureFlag } from "~/hooks/usePosthogFeatureflag"; +import { FeatureFlags } from "~/enums"; + +const Component = () => { + const flag = usePosthogFeatureFlag(FeaturesFlags.FLAG_NAME); + // if flag is true, the feature shoud be executed & showed; + // if flag is false, the feature should be disabled & hid; + // for example + return( + <> + {flag && } + + ); +}; +``` + +## Other Documentation + +Here is the Swagger documentation for the backend endpoints + +- https://storyteller-docs.netlify.app/ + +This project uses Remix for routing and Tailwind for styling: + +- [Remix Docs](https://remix.run/docs) +- [Tailwind Docs](https://tailwindcss.com/docs) + +On Netlify, we use functions: + +- [Netlify Functions Overview](https://docs.netlify.com/functions/overview) diff --git a/frontend/apps/artcraft/_headers b/frontend/apps/artcraft/_headers new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/apps/artcraft/app/index.html b/frontend/apps/artcraft/app/index.html new file mode 100644 index 0000000000..166218c75f --- /dev/null +++ b/frontend/apps/artcraft/app/index.html @@ -0,0 +1,44 @@ + + + + + ArtCraft + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/frontend/apps/artcraft/app/public/android-chrome-192x192.png b/frontend/apps/artcraft/app/public/android-chrome-192x192.png new file mode 100644 index 0000000000..fb053ce95d Binary files /dev/null and b/frontend/apps/artcraft/app/public/android-chrome-192x192.png differ diff --git a/frontend/apps/artcraft/app/public/android-chrome-512x512.png b/frontend/apps/artcraft/app/public/android-chrome-512x512.png new file mode 100644 index 0000000000..a471729992 Binary files /dev/null and b/frontend/apps/artcraft/app/public/android-chrome-512x512.png differ diff --git a/frontend/apps/artcraft/app/public/apple-touch-icon.png b/frontend/apps/artcraft/app/public/apple-touch-icon.png new file mode 100644 index 0000000000..e22536b60d Binary files /dev/null and b/frontend/apps/artcraft/app/public/apple-touch-icon.png differ diff --git a/frontend/apps/artcraft/app/public/favicon-16x16.png b/frontend/apps/artcraft/app/public/favicon-16x16.png new file mode 100644 index 0000000000..bd6bec4d5c Binary files /dev/null and b/frontend/apps/artcraft/app/public/favicon-16x16.png differ diff --git a/frontend/apps/artcraft/app/public/favicon-32x32.png b/frontend/apps/artcraft/app/public/favicon-32x32.png new file mode 100644 index 0000000000..6deb0f9af9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/favicon-32x32.png differ diff --git a/frontend/apps/artcraft/app/public/favicon.ico b/frontend/apps/artcraft/app/public/favicon.ico new file mode 100644 index 0000000000..df243d83f7 Binary files /dev/null and b/frontend/apps/artcraft/app/public/favicon.ico differ diff --git a/frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.js b/frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.js new file mode 100644 index 0000000000..46ab297aee --- /dev/null +++ b/frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.js @@ -0,0 +1,921 @@ +// This is ammo.js, a port of Bullet Physics to JavaScript. zlib licensed. + +var Ammo = (() => { + var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined; + if (typeof __filename !== 'undefined') _scriptDir = _scriptDir || __filename; + return ( +function(Ammo = {}) { + +var b;b||(b=typeof Ammo !== 'undefined' ? Ammo : {});var aa,ba;b.ready=new Promise(function(a,c){aa=a;ba=c});var ca=Object.assign({},b),da="object"==typeof window,ea="function"==typeof importScripts,fa="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,ha="",ia,ja,ka; +if(fa){var fs=require("fs"),la=require("path");ha=ea?la.dirname(ha)+"/":__dirname+"/";ia=(a,c)=>{a=a.startsWith("file://")?new URL(a):la.normalize(a);return fs.readFileSync(a,c?void 0:"utf8")};ka=a=>{a=ia(a,!0);a.buffer||(a=new Uint8Array(a));return a};ja=(a,c,d)=>{a=a.startsWith("file://")?new URL(a):la.normalize(a);fs.readFile(a,function(e,g){e?d(e):c(g.buffer)})};1{var c=new XMLHttpRequest;c.open("GET",a,!1);c.send(null);return c.responseText},ea&&(ka=a=>{var c=new XMLHttpRequest;c.open("GET",a,!1);c.responseType="arraybuffer";c.send(null);return new Uint8Array(c.response)}),ja=(a,c,d)=>{var e=new XMLHttpRequest;e.open("GET",a, +!0);e.responseType="arraybuffer";e.onload=()=>{200==e.status||0==e.status&&e.response?c(e.response):d()};e.onerror=d;e.send(null)};b.print||console.log.bind(console);var ma=b.printErr||console.warn.bind(console);Object.assign(b,ca);ca=null;var na;b.wasmBinary&&(na=b.wasmBinary);var noExitRuntime=b.noExitRuntime||!0;"object"!=typeof WebAssembly&&oa("no native wasm support detected");var pa,qa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0; +function sa(a,c){if(a){var d=ta,e=a+c;for(c=a;d[c]&&!(c>=e);)++c;if(16g?e+=String.fromCharCode(g):(g-=65536,e+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else e+=String.fromCharCode(g)}a=e}}else a="";return a} +var ua,ta,va,wa,xa,ya,za=[],Aa=[],Ba=[],Ca=!1;function Ea(){var a=b.preRun.shift();za.unshift(a)}var Fa=0,Ga=null,Ha=null;function oa(a){if(b.onAbort)b.onAbort(a);a="Aborted("+a+")";ma(a);qa=!0;a=new WebAssembly.RuntimeError(a+". Build with -sASSERTIONS for more info.");ba(a);throw a;}function Ia(a){return a.startsWith("data:application/octet-stream;base64,")}var Ja;Ja="ammo.wasm.wasm";if(!Ia(Ja)){var Ka=Ja;Ja=b.locateFile?b.locateFile(Ka,ha):ha+Ka} +function La(a){try{if(a==Ja&&na)return new Uint8Array(na);if(ka)return ka(a);throw"both async and sync fetching of the wasm failed";}catch(c){oa(c)}} +function Ma(a){if(!na&&(da||ea)){if("function"==typeof fetch&&!a.startsWith("file://"))return fetch(a,{credentials:"same-origin"}).then(function(c){if(!c.ok)throw"failed to load wasm binary file at '"+a+"'";return c.arrayBuffer()}).catch(function(){return La(a)});if(ja)return new Promise(function(c,d){ja(a,function(e){c(new Uint8Array(e))},d)})}return Promise.resolve().then(function(){return La(a)})} +function Na(a,c,d){return Ma(a).then(function(e){return WebAssembly.instantiate(e,c)}).then(function(e){return e}).then(d,function(e){ma("failed to asynchronously prepare wasm: "+e);oa(e)})} +function Oa(a,c){var d=Ja;return na||"function"!=typeof WebAssembly.instantiateStreaming||Ia(d)||d.startsWith("file://")||fa||"function"!=typeof fetch?Na(d,a,c):fetch(d,{credentials:"same-origin"}).then(function(e){return WebAssembly.instantiateStreaming(e,a).then(c,function(g){ma("wasm streaming compile failed: "+g);ma("falling back to ArrayBuffer instantiation");return Na(d,a,c)})})} +var Pa={27338:(a,c,d,e)=>{a=b.getCache(b.DebugDrawer)[a];if(!a.hasOwnProperty("drawLine"))throw"a JSImplementation must implement all functions, you forgot DebugDrawer::drawLine.";a.drawLine(c,d,e)},27558:(a,c,d,e,g,n)=>{a=b.getCache(b.DebugDrawer)[a];if(!a.hasOwnProperty("drawContactPoint"))throw"a JSImplementation must implement all functions, you forgot DebugDrawer::drawContactPoint.";a.drawContactPoint(c,d,e,g,n)},27808:(a,c)=>{a=b.getCache(b.DebugDrawer)[a];if(!a.hasOwnProperty("reportErrorWarning"))throw"a JSImplementation must implement all functions, you forgot DebugDrawer::reportErrorWarning."; +a.reportErrorWarning(c)},28052:(a,c,d)=>{a=b.getCache(b.DebugDrawer)[a];if(!a.hasOwnProperty("draw3dText"))throw"a JSImplementation must implement all functions, you forgot DebugDrawer::draw3dText.";a.draw3dText(c,d)},28275:(a,c)=>{a=b.getCache(b.DebugDrawer)[a];if(!a.hasOwnProperty("setDebugMode"))throw"a JSImplementation must implement all functions, you forgot DebugDrawer::setDebugMode.";a.setDebugMode(c)},28501:a=>{a=b.getCache(b.DebugDrawer)[a];if(!a.hasOwnProperty("getDebugMode"))throw"a JSImplementation must implement all functions, you forgot DebugDrawer::getDebugMode."; +return a.getDebugMode()},28732:(a,c)=>{a=b.getCache(b.MotionState)[a];if(!a.hasOwnProperty("getWorldTransform"))throw"a JSImplementation must implement all functions, you forgot MotionState::getWorldTransform.";a.getWorldTransform(c)},28973:(a,c)=>{a=b.getCache(b.MotionState)[a];if(!a.hasOwnProperty("setWorldTransform"))throw"a JSImplementation must implement all functions, you forgot MotionState::setWorldTransform.";a.setWorldTransform(c)},29214:(a,c,d,e,g,n,z,T)=>{a=b.getCache(b.ConcreteContactResultCallback)[a]; +if(!a.hasOwnProperty("addSingleResult"))throw"a JSImplementation must implement all functions, you forgot ConcreteContactResultCallback::addSingleResult.";return a.addSingleResult(c,d,e,g,n,z,T)}};function Qa(a){for(;0>=2;e=ta[c++];)d+=105!=e&d,Ra.push(105==e?va[d]:xa[d++>>1]),++d;return Pa[a].apply(null,Ra)} +var Ta=[],Ua=void 0,Va=[],Wa={b:function(){oa("")},f:function(a,c,d){return Sa(a,c,d)},a:function(a,c,d){return Sa(a,c,d)},d:function(){return Date.now()},e:function(a,c,d){ta.copyWithin(a,c,c+d)},c:function(){oa("OOM")}}; +(function(){function a(d){d=d.exports;b.asm=d;pa=b.asm.g;var e=pa.buffer;b.HEAP8=ua=new Int8Array(e);b.HEAP16=new Int16Array(e);b.HEAP32=va=new Int32Array(e);b.HEAPU8=ta=new Uint8Array(e);b.HEAPU16=new Uint16Array(e);b.HEAPU32=new Uint32Array(e);b.HEAPF32=wa=new Float32Array(e);b.HEAPF64=xa=new Float64Array(e);ya=b.asm.iB;Aa.unshift(b.asm.h);Fa--;b.monitorRunDependencies&&b.monitorRunDependencies(Fa);0==Fa&&(null!==Ga&&(clearInterval(Ga),Ga=null),Ha&&(e=Ha,Ha=null,e()));return d}var c={a:Wa};Fa++; +b.monitorRunDependencies&&b.monitorRunDependencies(Fa);if(b.instantiateWasm)try{return b.instantiateWasm(c,a)}catch(d){ma("Module.instantiateWasm callback failed with error: "+d),ba(d)}Oa(c,function(d){a(d.instance)}).catch(ba);return{}})(); +var Xa=b._emscripten_bind_btCollisionShape_setLocalScaling_1=function(){return(Xa=b._emscripten_bind_btCollisionShape_setLocalScaling_1=b.asm.i).apply(null,arguments)},Ya=b._emscripten_bind_btCollisionShape_getLocalScaling_0=function(){return(Ya=b._emscripten_bind_btCollisionShape_getLocalScaling_0=b.asm.j).apply(null,arguments)},Za=b._emscripten_bind_btCollisionShape_calculateLocalInertia_2=function(){return(Za=b._emscripten_bind_btCollisionShape_calculateLocalInertia_2=b.asm.k).apply(null,arguments)}, +$a=b._emscripten_bind_btCollisionShape_setMargin_1=function(){return($a=b._emscripten_bind_btCollisionShape_setMargin_1=b.asm.l).apply(null,arguments)},ab=b._emscripten_bind_btCollisionShape_getMargin_0=function(){return(ab=b._emscripten_bind_btCollisionShape_getMargin_0=b.asm.m).apply(null,arguments)},bb=b._emscripten_bind_btCollisionShape___destroy___0=function(){return(bb=b._emscripten_bind_btCollisionShape___destroy___0=b.asm.n).apply(null,arguments)},cb=b._emscripten_bind_btCollisionWorld_getDispatcher_0= +function(){return(cb=b._emscripten_bind_btCollisionWorld_getDispatcher_0=b.asm.o).apply(null,arguments)},db=b._emscripten_bind_btCollisionWorld_rayTest_3=function(){return(db=b._emscripten_bind_btCollisionWorld_rayTest_3=b.asm.p).apply(null,arguments)},eb=b._emscripten_bind_btCollisionWorld_getPairCache_0=function(){return(eb=b._emscripten_bind_btCollisionWorld_getPairCache_0=b.asm.q).apply(null,arguments)},fb=b._emscripten_bind_btCollisionWorld_getDispatchInfo_0=function(){return(fb=b._emscripten_bind_btCollisionWorld_getDispatchInfo_0= +b.asm.r).apply(null,arguments)},gb=b._emscripten_bind_btCollisionWorld_addCollisionObject_1=function(){return(gb=b._emscripten_bind_btCollisionWorld_addCollisionObject_1=b.asm.s).apply(null,arguments)},hb=b._emscripten_bind_btCollisionWorld_addCollisionObject_2=function(){return(hb=b._emscripten_bind_btCollisionWorld_addCollisionObject_2=b.asm.t).apply(null,arguments)},ib=b._emscripten_bind_btCollisionWorld_addCollisionObject_3=function(){return(ib=b._emscripten_bind_btCollisionWorld_addCollisionObject_3= +b.asm.u).apply(null,arguments)},jb=b._emscripten_bind_btCollisionWorld_removeCollisionObject_1=function(){return(jb=b._emscripten_bind_btCollisionWorld_removeCollisionObject_1=b.asm.v).apply(null,arguments)},kb=b._emscripten_bind_btCollisionWorld_getBroadphase_0=function(){return(kb=b._emscripten_bind_btCollisionWorld_getBroadphase_0=b.asm.w).apply(null,arguments)},lb=b._emscripten_bind_btCollisionWorld_convexSweepTest_5=function(){return(lb=b._emscripten_bind_btCollisionWorld_convexSweepTest_5=b.asm.x).apply(null, +arguments)},mb=b._emscripten_bind_btCollisionWorld_contactPairTest_3=function(){return(mb=b._emscripten_bind_btCollisionWorld_contactPairTest_3=b.asm.y).apply(null,arguments)},nb=b._emscripten_bind_btCollisionWorld_contactTest_2=function(){return(nb=b._emscripten_bind_btCollisionWorld_contactTest_2=b.asm.z).apply(null,arguments)},ob=b._emscripten_bind_btCollisionWorld_updateSingleAabb_1=function(){return(ob=b._emscripten_bind_btCollisionWorld_updateSingleAabb_1=b.asm.A).apply(null,arguments)},pb= +b._emscripten_bind_btCollisionWorld_setDebugDrawer_1=function(){return(pb=b._emscripten_bind_btCollisionWorld_setDebugDrawer_1=b.asm.B).apply(null,arguments)},qb=b._emscripten_bind_btCollisionWorld_getDebugDrawer_0=function(){return(qb=b._emscripten_bind_btCollisionWorld_getDebugDrawer_0=b.asm.C).apply(null,arguments)},rb=b._emscripten_bind_btCollisionWorld_debugDrawWorld_0=function(){return(rb=b._emscripten_bind_btCollisionWorld_debugDrawWorld_0=b.asm.D).apply(null,arguments)},sb=b._emscripten_bind_btCollisionWorld_debugDrawObject_3= +function(){return(sb=b._emscripten_bind_btCollisionWorld_debugDrawObject_3=b.asm.E).apply(null,arguments)},tb=b._emscripten_bind_btCollisionWorld___destroy___0=function(){return(tb=b._emscripten_bind_btCollisionWorld___destroy___0=b.asm.F).apply(null,arguments)},ub=b._emscripten_bind_btCollisionObject_setAnisotropicFriction_2=function(){return(ub=b._emscripten_bind_btCollisionObject_setAnisotropicFriction_2=b.asm.G).apply(null,arguments)},vb=b._emscripten_bind_btCollisionObject_getCollisionShape_0= +function(){return(vb=b._emscripten_bind_btCollisionObject_getCollisionShape_0=b.asm.H).apply(null,arguments)},wb=b._emscripten_bind_btCollisionObject_setContactProcessingThreshold_1=function(){return(wb=b._emscripten_bind_btCollisionObject_setContactProcessingThreshold_1=b.asm.I).apply(null,arguments)},xb=b._emscripten_bind_btCollisionObject_setActivationState_1=function(){return(xb=b._emscripten_bind_btCollisionObject_setActivationState_1=b.asm.J).apply(null,arguments)},yb=b._emscripten_bind_btCollisionObject_forceActivationState_1= +function(){return(yb=b._emscripten_bind_btCollisionObject_forceActivationState_1=b.asm.K).apply(null,arguments)},zb=b._emscripten_bind_btCollisionObject_activate_0=function(){return(zb=b._emscripten_bind_btCollisionObject_activate_0=b.asm.L).apply(null,arguments)},Ab=b._emscripten_bind_btCollisionObject_activate_1=function(){return(Ab=b._emscripten_bind_btCollisionObject_activate_1=b.asm.M).apply(null,arguments)},Bb=b._emscripten_bind_btCollisionObject_isActive_0=function(){return(Bb=b._emscripten_bind_btCollisionObject_isActive_0= +b.asm.N).apply(null,arguments)},Cb=b._emscripten_bind_btCollisionObject_isKinematicObject_0=function(){return(Cb=b._emscripten_bind_btCollisionObject_isKinematicObject_0=b.asm.O).apply(null,arguments)},Db=b._emscripten_bind_btCollisionObject_isStaticObject_0=function(){return(Db=b._emscripten_bind_btCollisionObject_isStaticObject_0=b.asm.P).apply(null,arguments)},Eb=b._emscripten_bind_btCollisionObject_isStaticOrKinematicObject_0=function(){return(Eb=b._emscripten_bind_btCollisionObject_isStaticOrKinematicObject_0= +b.asm.Q).apply(null,arguments)},Fb=b._emscripten_bind_btCollisionObject_getRestitution_0=function(){return(Fb=b._emscripten_bind_btCollisionObject_getRestitution_0=b.asm.R).apply(null,arguments)},Gb=b._emscripten_bind_btCollisionObject_getFriction_0=function(){return(Gb=b._emscripten_bind_btCollisionObject_getFriction_0=b.asm.S).apply(null,arguments)},Hb=b._emscripten_bind_btCollisionObject_getRollingFriction_0=function(){return(Hb=b._emscripten_bind_btCollisionObject_getRollingFriction_0=b.asm.T).apply(null, +arguments)},Ib=b._emscripten_bind_btCollisionObject_setRestitution_1=function(){return(Ib=b._emscripten_bind_btCollisionObject_setRestitution_1=b.asm.U).apply(null,arguments)},Jb=b._emscripten_bind_btCollisionObject_setFriction_1=function(){return(Jb=b._emscripten_bind_btCollisionObject_setFriction_1=b.asm.V).apply(null,arguments)},Kb=b._emscripten_bind_btCollisionObject_setRollingFriction_1=function(){return(Kb=b._emscripten_bind_btCollisionObject_setRollingFriction_1=b.asm.W).apply(null,arguments)}, +Lb=b._emscripten_bind_btCollisionObject_getWorldTransform_0=function(){return(Lb=b._emscripten_bind_btCollisionObject_getWorldTransform_0=b.asm.X).apply(null,arguments)},Mb=b._emscripten_bind_btCollisionObject_getCollisionFlags_0=function(){return(Mb=b._emscripten_bind_btCollisionObject_getCollisionFlags_0=b.asm.Y).apply(null,arguments)},Nb=b._emscripten_bind_btCollisionObject_setCollisionFlags_1=function(){return(Nb=b._emscripten_bind_btCollisionObject_setCollisionFlags_1=b.asm.Z).apply(null,arguments)}, +Ob=b._emscripten_bind_btCollisionObject_setWorldTransform_1=function(){return(Ob=b._emscripten_bind_btCollisionObject_setWorldTransform_1=b.asm._).apply(null,arguments)},Pb=b._emscripten_bind_btCollisionObject_setCollisionShape_1=function(){return(Pb=b._emscripten_bind_btCollisionObject_setCollisionShape_1=b.asm.$).apply(null,arguments)},Qb=b._emscripten_bind_btCollisionObject_setCcdMotionThreshold_1=function(){return(Qb=b._emscripten_bind_btCollisionObject_setCcdMotionThreshold_1=b.asm.aa).apply(null, +arguments)},Rb=b._emscripten_bind_btCollisionObject_setCcdSweptSphereRadius_1=function(){return(Rb=b._emscripten_bind_btCollisionObject_setCcdSweptSphereRadius_1=b.asm.ba).apply(null,arguments)},Sb=b._emscripten_bind_btCollisionObject_getUserIndex_0=function(){return(Sb=b._emscripten_bind_btCollisionObject_getUserIndex_0=b.asm.ca).apply(null,arguments)},Tb=b._emscripten_bind_btCollisionObject_setUserIndex_1=function(){return(Tb=b._emscripten_bind_btCollisionObject_setUserIndex_1=b.asm.da).apply(null, +arguments)},Ub=b._emscripten_bind_btCollisionObject_getUserPointer_0=function(){return(Ub=b._emscripten_bind_btCollisionObject_getUserPointer_0=b.asm.ea).apply(null,arguments)},Vb=b._emscripten_bind_btCollisionObject_setUserPointer_1=function(){return(Vb=b._emscripten_bind_btCollisionObject_setUserPointer_1=b.asm.fa).apply(null,arguments)},Wb=b._emscripten_bind_btCollisionObject_getBroadphaseHandle_0=function(){return(Wb=b._emscripten_bind_btCollisionObject_getBroadphaseHandle_0=b.asm.ga).apply(null, +arguments)},Xb=b._emscripten_bind_btCollisionObject___destroy___0=function(){return(Xb=b._emscripten_bind_btCollisionObject___destroy___0=b.asm.ha).apply(null,arguments)},Yb=b._emscripten_bind_btConcaveShape_setLocalScaling_1=function(){return(Yb=b._emscripten_bind_btConcaveShape_setLocalScaling_1=b.asm.ia).apply(null,arguments)},Zb=b._emscripten_bind_btConcaveShape_getLocalScaling_0=function(){return(Zb=b._emscripten_bind_btConcaveShape_getLocalScaling_0=b.asm.ja).apply(null,arguments)},$b=b._emscripten_bind_btConcaveShape_calculateLocalInertia_2= +function(){return($b=b._emscripten_bind_btConcaveShape_calculateLocalInertia_2=b.asm.ka).apply(null,arguments)},ac=b._emscripten_bind_btConcaveShape___destroy___0=function(){return(ac=b._emscripten_bind_btConcaveShape___destroy___0=b.asm.la).apply(null,arguments)},bc=b._emscripten_bind_btCollisionAlgorithm___destroy___0=function(){return(bc=b._emscripten_bind_btCollisionAlgorithm___destroy___0=b.asm.ma).apply(null,arguments)},cc=b._emscripten_bind_btTypedConstraint_enableFeedback_1=function(){return(cc= +b._emscripten_bind_btTypedConstraint_enableFeedback_1=b.asm.na).apply(null,arguments)},ec=b._emscripten_bind_btTypedConstraint_getBreakingImpulseThreshold_0=function(){return(ec=b._emscripten_bind_btTypedConstraint_getBreakingImpulseThreshold_0=b.asm.oa).apply(null,arguments)},fc=b._emscripten_bind_btTypedConstraint_setBreakingImpulseThreshold_1=function(){return(fc=b._emscripten_bind_btTypedConstraint_setBreakingImpulseThreshold_1=b.asm.pa).apply(null,arguments)},gc=b._emscripten_bind_btTypedConstraint_getParam_2= +function(){return(gc=b._emscripten_bind_btTypedConstraint_getParam_2=b.asm.qa).apply(null,arguments)},hc=b._emscripten_bind_btTypedConstraint_setParam_3=function(){return(hc=b._emscripten_bind_btTypedConstraint_setParam_3=b.asm.ra).apply(null,arguments)},ic=b._emscripten_bind_btTypedConstraint___destroy___0=function(){return(ic=b._emscripten_bind_btTypedConstraint___destroy___0=b.asm.sa).apply(null,arguments)},jc=b._emscripten_bind_btDynamicsWorld_addAction_1=function(){return(jc=b._emscripten_bind_btDynamicsWorld_addAction_1= +b.asm.ta).apply(null,arguments)},kc=b._emscripten_bind_btDynamicsWorld_removeAction_1=function(){return(kc=b._emscripten_bind_btDynamicsWorld_removeAction_1=b.asm.ua).apply(null,arguments)},lc=b._emscripten_bind_btDynamicsWorld_getSolverInfo_0=function(){return(lc=b._emscripten_bind_btDynamicsWorld_getSolverInfo_0=b.asm.va).apply(null,arguments)},mc=b._emscripten_bind_btDynamicsWorld_setInternalTickCallback_1=function(){return(mc=b._emscripten_bind_btDynamicsWorld_setInternalTickCallback_1=b.asm.wa).apply(null, +arguments)},nc=b._emscripten_bind_btDynamicsWorld_setInternalTickCallback_2=function(){return(nc=b._emscripten_bind_btDynamicsWorld_setInternalTickCallback_2=b.asm.xa).apply(null,arguments)},oc=b._emscripten_bind_btDynamicsWorld_setInternalTickCallback_3=function(){return(oc=b._emscripten_bind_btDynamicsWorld_setInternalTickCallback_3=b.asm.ya).apply(null,arguments)},pc=b._emscripten_bind_btDynamicsWorld_getDispatcher_0=function(){return(pc=b._emscripten_bind_btDynamicsWorld_getDispatcher_0=b.asm.za).apply(null, +arguments)},qc=b._emscripten_bind_btDynamicsWorld_rayTest_3=function(){return(qc=b._emscripten_bind_btDynamicsWorld_rayTest_3=b.asm.Aa).apply(null,arguments)},rc=b._emscripten_bind_btDynamicsWorld_getPairCache_0=function(){return(rc=b._emscripten_bind_btDynamicsWorld_getPairCache_0=b.asm.Ba).apply(null,arguments)},sc=b._emscripten_bind_btDynamicsWorld_getDispatchInfo_0=function(){return(sc=b._emscripten_bind_btDynamicsWorld_getDispatchInfo_0=b.asm.Ca).apply(null,arguments)},tc=b._emscripten_bind_btDynamicsWorld_addCollisionObject_1= +function(){return(tc=b._emscripten_bind_btDynamicsWorld_addCollisionObject_1=b.asm.Da).apply(null,arguments)},uc=b._emscripten_bind_btDynamicsWorld_addCollisionObject_2=function(){return(uc=b._emscripten_bind_btDynamicsWorld_addCollisionObject_2=b.asm.Ea).apply(null,arguments)},vc=b._emscripten_bind_btDynamicsWorld_addCollisionObject_3=function(){return(vc=b._emscripten_bind_btDynamicsWorld_addCollisionObject_3=b.asm.Fa).apply(null,arguments)},wc=b._emscripten_bind_btDynamicsWorld_removeCollisionObject_1= +function(){return(wc=b._emscripten_bind_btDynamicsWorld_removeCollisionObject_1=b.asm.Ga).apply(null,arguments)},xc=b._emscripten_bind_btDynamicsWorld_getBroadphase_0=function(){return(xc=b._emscripten_bind_btDynamicsWorld_getBroadphase_0=b.asm.Ha).apply(null,arguments)},yc=b._emscripten_bind_btDynamicsWorld_convexSweepTest_5=function(){return(yc=b._emscripten_bind_btDynamicsWorld_convexSweepTest_5=b.asm.Ia).apply(null,arguments)},zc=b._emscripten_bind_btDynamicsWorld_contactPairTest_3=function(){return(zc= +b._emscripten_bind_btDynamicsWorld_contactPairTest_3=b.asm.Ja).apply(null,arguments)},Ac=b._emscripten_bind_btDynamicsWorld_contactTest_2=function(){return(Ac=b._emscripten_bind_btDynamicsWorld_contactTest_2=b.asm.Ka).apply(null,arguments)},Bc=b._emscripten_bind_btDynamicsWorld_updateSingleAabb_1=function(){return(Bc=b._emscripten_bind_btDynamicsWorld_updateSingleAabb_1=b.asm.La).apply(null,arguments)},Cc=b._emscripten_bind_btDynamicsWorld_setDebugDrawer_1=function(){return(Cc=b._emscripten_bind_btDynamicsWorld_setDebugDrawer_1= +b.asm.Ma).apply(null,arguments)},Dc=b._emscripten_bind_btDynamicsWorld_getDebugDrawer_0=function(){return(Dc=b._emscripten_bind_btDynamicsWorld_getDebugDrawer_0=b.asm.Na).apply(null,arguments)},Ec=b._emscripten_bind_btDynamicsWorld_debugDrawWorld_0=function(){return(Ec=b._emscripten_bind_btDynamicsWorld_debugDrawWorld_0=b.asm.Oa).apply(null,arguments)},Fc=b._emscripten_bind_btDynamicsWorld_debugDrawObject_3=function(){return(Fc=b._emscripten_bind_btDynamicsWorld_debugDrawObject_3=b.asm.Pa).apply(null, +arguments)},Gc=b._emscripten_bind_btDynamicsWorld___destroy___0=function(){return(Gc=b._emscripten_bind_btDynamicsWorld___destroy___0=b.asm.Qa).apply(null,arguments)},Hc=b._emscripten_bind_btIDebugDraw_drawLine_3=function(){return(Hc=b._emscripten_bind_btIDebugDraw_drawLine_3=b.asm.Ra).apply(null,arguments)},Ic=b._emscripten_bind_btIDebugDraw_drawContactPoint_5=function(){return(Ic=b._emscripten_bind_btIDebugDraw_drawContactPoint_5=b.asm.Sa).apply(null,arguments)},Jc=b._emscripten_bind_btIDebugDraw_reportErrorWarning_1= +function(){return(Jc=b._emscripten_bind_btIDebugDraw_reportErrorWarning_1=b.asm.Ta).apply(null,arguments)},Kc=b._emscripten_bind_btIDebugDraw_draw3dText_2=function(){return(Kc=b._emscripten_bind_btIDebugDraw_draw3dText_2=b.asm.Ua).apply(null,arguments)},Lc=b._emscripten_bind_btIDebugDraw_setDebugMode_1=function(){return(Lc=b._emscripten_bind_btIDebugDraw_setDebugMode_1=b.asm.Va).apply(null,arguments)},Mc=b._emscripten_bind_btIDebugDraw_getDebugMode_0=function(){return(Mc=b._emscripten_bind_btIDebugDraw_getDebugMode_0= +b.asm.Wa).apply(null,arguments)},Nc=b._emscripten_bind_btIDebugDraw___destroy___0=function(){return(Nc=b._emscripten_bind_btIDebugDraw___destroy___0=b.asm.Xa).apply(null,arguments)},Oc=b._emscripten_bind_btVector3_btVector3_0=function(){return(Oc=b._emscripten_bind_btVector3_btVector3_0=b.asm.Ya).apply(null,arguments)},Pc=b._emscripten_bind_btVector3_btVector3_3=function(){return(Pc=b._emscripten_bind_btVector3_btVector3_3=b.asm.Za).apply(null,arguments)},Qc=b._emscripten_bind_btVector3_length_0= +function(){return(Qc=b._emscripten_bind_btVector3_length_0=b.asm._a).apply(null,arguments)},Rc=b._emscripten_bind_btVector3_x_0=function(){return(Rc=b._emscripten_bind_btVector3_x_0=b.asm.$a).apply(null,arguments)},Sc=b._emscripten_bind_btVector3_y_0=function(){return(Sc=b._emscripten_bind_btVector3_y_0=b.asm.ab).apply(null,arguments)},Tc=b._emscripten_bind_btVector3_z_0=function(){return(Tc=b._emscripten_bind_btVector3_z_0=b.asm.bb).apply(null,arguments)},Uc=b._emscripten_bind_btVector3_setX_1=function(){return(Uc= +b._emscripten_bind_btVector3_setX_1=b.asm.cb).apply(null,arguments)},Vc=b._emscripten_bind_btVector3_setY_1=function(){return(Vc=b._emscripten_bind_btVector3_setY_1=b.asm.db).apply(null,arguments)},Wc=b._emscripten_bind_btVector3_setZ_1=function(){return(Wc=b._emscripten_bind_btVector3_setZ_1=b.asm.eb).apply(null,arguments)},Xc=b._emscripten_bind_btVector3_setValue_3=function(){return(Xc=b._emscripten_bind_btVector3_setValue_3=b.asm.fb).apply(null,arguments)},Yc=b._emscripten_bind_btVector3_normalize_0= +function(){return(Yc=b._emscripten_bind_btVector3_normalize_0=b.asm.gb).apply(null,arguments)},Zc=b._emscripten_bind_btVector3_rotate_2=function(){return(Zc=b._emscripten_bind_btVector3_rotate_2=b.asm.hb).apply(null,arguments)},$c=b._emscripten_bind_btVector3_dot_1=function(){return($c=b._emscripten_bind_btVector3_dot_1=b.asm.ib).apply(null,arguments)},ad=b._emscripten_bind_btVector3_op_mul_1=function(){return(ad=b._emscripten_bind_btVector3_op_mul_1=b.asm.jb).apply(null,arguments)},bd=b._emscripten_bind_btVector3_op_add_1= +function(){return(bd=b._emscripten_bind_btVector3_op_add_1=b.asm.kb).apply(null,arguments)},cd=b._emscripten_bind_btVector3_op_sub_1=function(){return(cd=b._emscripten_bind_btVector3_op_sub_1=b.asm.lb).apply(null,arguments)},dd=b._emscripten_bind_btVector3___destroy___0=function(){return(dd=b._emscripten_bind_btVector3___destroy___0=b.asm.mb).apply(null,arguments)},ed=b._emscripten_bind_btQuadWord_x_0=function(){return(ed=b._emscripten_bind_btQuadWord_x_0=b.asm.nb).apply(null,arguments)},fd=b._emscripten_bind_btQuadWord_y_0= +function(){return(fd=b._emscripten_bind_btQuadWord_y_0=b.asm.ob).apply(null,arguments)},gd=b._emscripten_bind_btQuadWord_z_0=function(){return(gd=b._emscripten_bind_btQuadWord_z_0=b.asm.pb).apply(null,arguments)},hd=b._emscripten_bind_btQuadWord_w_0=function(){return(hd=b._emscripten_bind_btQuadWord_w_0=b.asm.qb).apply(null,arguments)},jd=b._emscripten_bind_btQuadWord_setX_1=function(){return(jd=b._emscripten_bind_btQuadWord_setX_1=b.asm.rb).apply(null,arguments)},kd=b._emscripten_bind_btQuadWord_setY_1= +function(){return(kd=b._emscripten_bind_btQuadWord_setY_1=b.asm.sb).apply(null,arguments)},ld=b._emscripten_bind_btQuadWord_setZ_1=function(){return(ld=b._emscripten_bind_btQuadWord_setZ_1=b.asm.tb).apply(null,arguments)},md=b._emscripten_bind_btQuadWord_setW_1=function(){return(md=b._emscripten_bind_btQuadWord_setW_1=b.asm.ub).apply(null,arguments)},nd=b._emscripten_bind_btQuadWord___destroy___0=function(){return(nd=b._emscripten_bind_btQuadWord___destroy___0=b.asm.vb).apply(null,arguments)},od= +b._emscripten_bind_btMotionState_getWorldTransform_1=function(){return(od=b._emscripten_bind_btMotionState_getWorldTransform_1=b.asm.wb).apply(null,arguments)},pd=b._emscripten_bind_btMotionState_setWorldTransform_1=function(){return(pd=b._emscripten_bind_btMotionState_setWorldTransform_1=b.asm.xb).apply(null,arguments)},qd=b._emscripten_bind_btMotionState___destroy___0=function(){return(qd=b._emscripten_bind_btMotionState___destroy___0=b.asm.yb).apply(null,arguments)},rd=b._emscripten_bind_RayResultCallback_hasHit_0= +function(){return(rd=b._emscripten_bind_RayResultCallback_hasHit_0=b.asm.zb).apply(null,arguments)},sd=b._emscripten_bind_RayResultCallback_get_m_collisionFilterGroup_0=function(){return(sd=b._emscripten_bind_RayResultCallback_get_m_collisionFilterGroup_0=b.asm.Ab).apply(null,arguments)},td=b._emscripten_bind_RayResultCallback_set_m_collisionFilterGroup_1=function(){return(td=b._emscripten_bind_RayResultCallback_set_m_collisionFilterGroup_1=b.asm.Bb).apply(null,arguments)},ud=b._emscripten_bind_RayResultCallback_get_m_collisionFilterMask_0= +function(){return(ud=b._emscripten_bind_RayResultCallback_get_m_collisionFilterMask_0=b.asm.Cb).apply(null,arguments)},vd=b._emscripten_bind_RayResultCallback_set_m_collisionFilterMask_1=function(){return(vd=b._emscripten_bind_RayResultCallback_set_m_collisionFilterMask_1=b.asm.Db).apply(null,arguments)},wd=b._emscripten_bind_RayResultCallback_get_m_closestHitFraction_0=function(){return(wd=b._emscripten_bind_RayResultCallback_get_m_closestHitFraction_0=b.asm.Eb).apply(null,arguments)},xd=b._emscripten_bind_RayResultCallback_set_m_closestHitFraction_1= +function(){return(xd=b._emscripten_bind_RayResultCallback_set_m_closestHitFraction_1=b.asm.Fb).apply(null,arguments)},yd=b._emscripten_bind_RayResultCallback_get_m_collisionObject_0=function(){return(yd=b._emscripten_bind_RayResultCallback_get_m_collisionObject_0=b.asm.Gb).apply(null,arguments)},zd=b._emscripten_bind_RayResultCallback_set_m_collisionObject_1=function(){return(zd=b._emscripten_bind_RayResultCallback_set_m_collisionObject_1=b.asm.Hb).apply(null,arguments)},Ad=b._emscripten_bind_RayResultCallback_get_m_flags_0= +function(){return(Ad=b._emscripten_bind_RayResultCallback_get_m_flags_0=b.asm.Ib).apply(null,arguments)},Bd=b._emscripten_bind_RayResultCallback_set_m_flags_1=function(){return(Bd=b._emscripten_bind_RayResultCallback_set_m_flags_1=b.asm.Jb).apply(null,arguments)},Cd=b._emscripten_bind_RayResultCallback___destroy___0=function(){return(Cd=b._emscripten_bind_RayResultCallback___destroy___0=b.asm.Kb).apply(null,arguments)},Dd=b._emscripten_bind_ContactResultCallback_addSingleResult_7=function(){return(Dd= +b._emscripten_bind_ContactResultCallback_addSingleResult_7=b.asm.Lb).apply(null,arguments)},Ed=b._emscripten_bind_ContactResultCallback___destroy___0=function(){return(Ed=b._emscripten_bind_ContactResultCallback___destroy___0=b.asm.Mb).apply(null,arguments)},Fd=b._emscripten_bind_ConvexResultCallback_hasHit_0=function(){return(Fd=b._emscripten_bind_ConvexResultCallback_hasHit_0=b.asm.Nb).apply(null,arguments)},Gd=b._emscripten_bind_ConvexResultCallback_get_m_collisionFilterGroup_0=function(){return(Gd= +b._emscripten_bind_ConvexResultCallback_get_m_collisionFilterGroup_0=b.asm.Ob).apply(null,arguments)},Hd=b._emscripten_bind_ConvexResultCallback_set_m_collisionFilterGroup_1=function(){return(Hd=b._emscripten_bind_ConvexResultCallback_set_m_collisionFilterGroup_1=b.asm.Pb).apply(null,arguments)},Id=b._emscripten_bind_ConvexResultCallback_get_m_collisionFilterMask_0=function(){return(Id=b._emscripten_bind_ConvexResultCallback_get_m_collisionFilterMask_0=b.asm.Qb).apply(null,arguments)},Jd=b._emscripten_bind_ConvexResultCallback_set_m_collisionFilterMask_1= +function(){return(Jd=b._emscripten_bind_ConvexResultCallback_set_m_collisionFilterMask_1=b.asm.Rb).apply(null,arguments)},Kd=b._emscripten_bind_ConvexResultCallback_get_m_closestHitFraction_0=function(){return(Kd=b._emscripten_bind_ConvexResultCallback_get_m_closestHitFraction_0=b.asm.Sb).apply(null,arguments)},Ld=b._emscripten_bind_ConvexResultCallback_set_m_closestHitFraction_1=function(){return(Ld=b._emscripten_bind_ConvexResultCallback_set_m_closestHitFraction_1=b.asm.Tb).apply(null,arguments)}, +Md=b._emscripten_bind_ConvexResultCallback___destroy___0=function(){return(Md=b._emscripten_bind_ConvexResultCallback___destroy___0=b.asm.Ub).apply(null,arguments)},Nd=b._emscripten_bind_btConvexShape_setLocalScaling_1=function(){return(Nd=b._emscripten_bind_btConvexShape_setLocalScaling_1=b.asm.Vb).apply(null,arguments)},Od=b._emscripten_bind_btConvexShape_getLocalScaling_0=function(){return(Od=b._emscripten_bind_btConvexShape_getLocalScaling_0=b.asm.Wb).apply(null,arguments)},Pd=b._emscripten_bind_btConvexShape_calculateLocalInertia_2= +function(){return(Pd=b._emscripten_bind_btConvexShape_calculateLocalInertia_2=b.asm.Xb).apply(null,arguments)},Qd=b._emscripten_bind_btConvexShape_setMargin_1=function(){return(Qd=b._emscripten_bind_btConvexShape_setMargin_1=b.asm.Yb).apply(null,arguments)},Rd=b._emscripten_bind_btConvexShape_getMargin_0=function(){return(Rd=b._emscripten_bind_btConvexShape_getMargin_0=b.asm.Zb).apply(null,arguments)},Sd=b._emscripten_bind_btConvexShape___destroy___0=function(){return(Sd=b._emscripten_bind_btConvexShape___destroy___0= +b.asm._b).apply(null,arguments)},Td=b._emscripten_bind_btCapsuleShape_btCapsuleShape_2=function(){return(Td=b._emscripten_bind_btCapsuleShape_btCapsuleShape_2=b.asm.$b).apply(null,arguments)},Ud=b._emscripten_bind_btCapsuleShape_setMargin_1=function(){return(Ud=b._emscripten_bind_btCapsuleShape_setMargin_1=b.asm.ac).apply(null,arguments)},Vd=b._emscripten_bind_btCapsuleShape_getMargin_0=function(){return(Vd=b._emscripten_bind_btCapsuleShape_getMargin_0=b.asm.bc).apply(null,arguments)},Wd=b._emscripten_bind_btCapsuleShape_getUpAxis_0= +function(){return(Wd=b._emscripten_bind_btCapsuleShape_getUpAxis_0=b.asm.cc).apply(null,arguments)},Xd=b._emscripten_bind_btCapsuleShape_getRadius_0=function(){return(Xd=b._emscripten_bind_btCapsuleShape_getRadius_0=b.asm.dc).apply(null,arguments)},Yd=b._emscripten_bind_btCapsuleShape_getHalfHeight_0=function(){return(Yd=b._emscripten_bind_btCapsuleShape_getHalfHeight_0=b.asm.ec).apply(null,arguments)},Zd=b._emscripten_bind_btCapsuleShape_setLocalScaling_1=function(){return(Zd=b._emscripten_bind_btCapsuleShape_setLocalScaling_1= +b.asm.fc).apply(null,arguments)},$d=b._emscripten_bind_btCapsuleShape_getLocalScaling_0=function(){return($d=b._emscripten_bind_btCapsuleShape_getLocalScaling_0=b.asm.gc).apply(null,arguments)},ae=b._emscripten_bind_btCapsuleShape_calculateLocalInertia_2=function(){return(ae=b._emscripten_bind_btCapsuleShape_calculateLocalInertia_2=b.asm.hc).apply(null,arguments)},be=b._emscripten_bind_btCapsuleShape___destroy___0=function(){return(be=b._emscripten_bind_btCapsuleShape___destroy___0=b.asm.ic).apply(null, +arguments)},ce=b._emscripten_bind_btCylinderShape_btCylinderShape_1=function(){return(ce=b._emscripten_bind_btCylinderShape_btCylinderShape_1=b.asm.jc).apply(null,arguments)},de=b._emscripten_bind_btCylinderShape_setMargin_1=function(){return(de=b._emscripten_bind_btCylinderShape_setMargin_1=b.asm.kc).apply(null,arguments)},ee=b._emscripten_bind_btCylinderShape_getMargin_0=function(){return(ee=b._emscripten_bind_btCylinderShape_getMargin_0=b.asm.lc).apply(null,arguments)},fe=b._emscripten_bind_btCylinderShape_setLocalScaling_1= +function(){return(fe=b._emscripten_bind_btCylinderShape_setLocalScaling_1=b.asm.mc).apply(null,arguments)},ge=b._emscripten_bind_btCylinderShape_getLocalScaling_0=function(){return(ge=b._emscripten_bind_btCylinderShape_getLocalScaling_0=b.asm.nc).apply(null,arguments)},he=b._emscripten_bind_btCylinderShape_calculateLocalInertia_2=function(){return(he=b._emscripten_bind_btCylinderShape_calculateLocalInertia_2=b.asm.oc).apply(null,arguments)},ie=b._emscripten_bind_btCylinderShape___destroy___0=function(){return(ie= +b._emscripten_bind_btCylinderShape___destroy___0=b.asm.pc).apply(null,arguments)},je=b._emscripten_bind_btConeShape_btConeShape_2=function(){return(je=b._emscripten_bind_btConeShape_btConeShape_2=b.asm.qc).apply(null,arguments)},ke=b._emscripten_bind_btConeShape_setLocalScaling_1=function(){return(ke=b._emscripten_bind_btConeShape_setLocalScaling_1=b.asm.rc).apply(null,arguments)},le=b._emscripten_bind_btConeShape_getLocalScaling_0=function(){return(le=b._emscripten_bind_btConeShape_getLocalScaling_0= +b.asm.sc).apply(null,arguments)},me=b._emscripten_bind_btConeShape_calculateLocalInertia_2=function(){return(me=b._emscripten_bind_btConeShape_calculateLocalInertia_2=b.asm.tc).apply(null,arguments)},ne=b._emscripten_bind_btConeShape___destroy___0=function(){return(ne=b._emscripten_bind_btConeShape___destroy___0=b.asm.uc).apply(null,arguments)},oe=b._emscripten_bind_btStridingMeshInterface_setScaling_1=function(){return(oe=b._emscripten_bind_btStridingMeshInterface_setScaling_1=b.asm.vc).apply(null, +arguments)},pe=b._emscripten_bind_btStridingMeshInterface___destroy___0=function(){return(pe=b._emscripten_bind_btStridingMeshInterface___destroy___0=b.asm.wc).apply(null,arguments)},qe=b._emscripten_bind_btTriangleMeshShape_setLocalScaling_1=function(){return(qe=b._emscripten_bind_btTriangleMeshShape_setLocalScaling_1=b.asm.xc).apply(null,arguments)},re=b._emscripten_bind_btTriangleMeshShape_getLocalScaling_0=function(){return(re=b._emscripten_bind_btTriangleMeshShape_getLocalScaling_0=b.asm.yc).apply(null, +arguments)},se=b._emscripten_bind_btTriangleMeshShape_calculateLocalInertia_2=function(){return(se=b._emscripten_bind_btTriangleMeshShape_calculateLocalInertia_2=b.asm.zc).apply(null,arguments)},te=b._emscripten_bind_btTriangleMeshShape___destroy___0=function(){return(te=b._emscripten_bind_btTriangleMeshShape___destroy___0=b.asm.Ac).apply(null,arguments)},ue=b._emscripten_bind_btPrimitiveManagerBase_is_trimesh_0=function(){return(ue=b._emscripten_bind_btPrimitiveManagerBase_is_trimesh_0=b.asm.Bc).apply(null, +arguments)},ve=b._emscripten_bind_btPrimitiveManagerBase_get_primitive_count_0=function(){return(ve=b._emscripten_bind_btPrimitiveManagerBase_get_primitive_count_0=b.asm.Cc).apply(null,arguments)},we=b._emscripten_bind_btPrimitiveManagerBase_get_primitive_box_2=function(){return(we=b._emscripten_bind_btPrimitiveManagerBase_get_primitive_box_2=b.asm.Dc).apply(null,arguments)},xe=b._emscripten_bind_btPrimitiveManagerBase_get_primitive_triangle_2=function(){return(xe=b._emscripten_bind_btPrimitiveManagerBase_get_primitive_triangle_2= +b.asm.Ec).apply(null,arguments)},ye=b._emscripten_bind_btPrimitiveManagerBase___destroy___0=function(){return(ye=b._emscripten_bind_btPrimitiveManagerBase___destroy___0=b.asm.Fc).apply(null,arguments)},ze=b._emscripten_bind_btGImpactShapeInterface_updateBound_0=function(){return(ze=b._emscripten_bind_btGImpactShapeInterface_updateBound_0=b.asm.Gc).apply(null,arguments)},Ae=b._emscripten_bind_btGImpactShapeInterface_postUpdate_0=function(){return(Ae=b._emscripten_bind_btGImpactShapeInterface_postUpdate_0= +b.asm.Hc).apply(null,arguments)},Be=b._emscripten_bind_btGImpactShapeInterface_getShapeType_0=function(){return(Be=b._emscripten_bind_btGImpactShapeInterface_getShapeType_0=b.asm.Ic).apply(null,arguments)},Ce=b._emscripten_bind_btGImpactShapeInterface_getName_0=function(){return(Ce=b._emscripten_bind_btGImpactShapeInterface_getName_0=b.asm.Jc).apply(null,arguments)},De=b._emscripten_bind_btGImpactShapeInterface_getGImpactShapeType_0=function(){return(De=b._emscripten_bind_btGImpactShapeInterface_getGImpactShapeType_0= +b.asm.Kc).apply(null,arguments)},Ee=b._emscripten_bind_btGImpactShapeInterface_getPrimitiveManager_0=function(){return(Ee=b._emscripten_bind_btGImpactShapeInterface_getPrimitiveManager_0=b.asm.Lc).apply(null,arguments)},Fe=b._emscripten_bind_btGImpactShapeInterface_getNumChildShapes_0=function(){return(Fe=b._emscripten_bind_btGImpactShapeInterface_getNumChildShapes_0=b.asm.Mc).apply(null,arguments)},Ge=b._emscripten_bind_btGImpactShapeInterface_childrenHasTransform_0=function(){return(Ge=b._emscripten_bind_btGImpactShapeInterface_childrenHasTransform_0= +b.asm.Nc).apply(null,arguments)},He=b._emscripten_bind_btGImpactShapeInterface_needsRetrieveTriangles_0=function(){return(He=b._emscripten_bind_btGImpactShapeInterface_needsRetrieveTriangles_0=b.asm.Oc).apply(null,arguments)},Ie=b._emscripten_bind_btGImpactShapeInterface_needsRetrieveTetrahedrons_0=function(){return(Ie=b._emscripten_bind_btGImpactShapeInterface_needsRetrieveTetrahedrons_0=b.asm.Pc).apply(null,arguments)},Je=b._emscripten_bind_btGImpactShapeInterface_getBulletTriangle_2=function(){return(Je= +b._emscripten_bind_btGImpactShapeInterface_getBulletTriangle_2=b.asm.Qc).apply(null,arguments)},Ke=b._emscripten_bind_btGImpactShapeInterface_getBulletTetrahedron_2=function(){return(Ke=b._emscripten_bind_btGImpactShapeInterface_getBulletTetrahedron_2=b.asm.Rc).apply(null,arguments)},Le=b._emscripten_bind_btGImpactShapeInterface_getChildShape_1=function(){return(Le=b._emscripten_bind_btGImpactShapeInterface_getChildShape_1=b.asm.Sc).apply(null,arguments)},Me=b._emscripten_bind_btGImpactShapeInterface_getChildTransform_1= +function(){return(Me=b._emscripten_bind_btGImpactShapeInterface_getChildTransform_1=b.asm.Tc).apply(null,arguments)},Ne=b._emscripten_bind_btGImpactShapeInterface_setChildTransform_2=function(){return(Ne=b._emscripten_bind_btGImpactShapeInterface_setChildTransform_2=b.asm.Uc).apply(null,arguments)},Oe=b._emscripten_bind_btGImpactShapeInterface_setLocalScaling_1=function(){return(Oe=b._emscripten_bind_btGImpactShapeInterface_setLocalScaling_1=b.asm.Vc).apply(null,arguments)},Pe=b._emscripten_bind_btGImpactShapeInterface_getLocalScaling_0= +function(){return(Pe=b._emscripten_bind_btGImpactShapeInterface_getLocalScaling_0=b.asm.Wc).apply(null,arguments)},Qe=b._emscripten_bind_btGImpactShapeInterface_calculateLocalInertia_2=function(){return(Qe=b._emscripten_bind_btGImpactShapeInterface_calculateLocalInertia_2=b.asm.Xc).apply(null,arguments)},Re=b._emscripten_bind_btGImpactShapeInterface___destroy___0=function(){return(Re=b._emscripten_bind_btGImpactShapeInterface___destroy___0=b.asm.Yc).apply(null,arguments)},Se=b._emscripten_bind_btActivatingCollisionAlgorithm___destroy___0= +function(){return(Se=b._emscripten_bind_btActivatingCollisionAlgorithm___destroy___0=b.asm.Zc).apply(null,arguments)},Te=b._emscripten_bind_btDefaultCollisionConfiguration_btDefaultCollisionConfiguration_0=function(){return(Te=b._emscripten_bind_btDefaultCollisionConfiguration_btDefaultCollisionConfiguration_0=b.asm._c).apply(null,arguments)},Ue=b._emscripten_bind_btDefaultCollisionConfiguration_btDefaultCollisionConfiguration_1=function(){return(Ue=b._emscripten_bind_btDefaultCollisionConfiguration_btDefaultCollisionConfiguration_1= +b.asm.$c).apply(null,arguments)},Ve=b._emscripten_bind_btDefaultCollisionConfiguration___destroy___0=function(){return(Ve=b._emscripten_bind_btDefaultCollisionConfiguration___destroy___0=b.asm.ad).apply(null,arguments)},We=b._emscripten_bind_btDispatcher_getNumManifolds_0=function(){return(We=b._emscripten_bind_btDispatcher_getNumManifolds_0=b.asm.bd).apply(null,arguments)},Xe=b._emscripten_bind_btDispatcher_getManifoldByIndexInternal_1=function(){return(Xe=b._emscripten_bind_btDispatcher_getManifoldByIndexInternal_1= +b.asm.cd).apply(null,arguments)},Ye=b._emscripten_bind_btDispatcher___destroy___0=function(){return(Ye=b._emscripten_bind_btDispatcher___destroy___0=b.asm.dd).apply(null,arguments)},Ze=b._emscripten_bind_btGeneric6DofConstraint_btGeneric6DofConstraint_3=function(){return(Ze=b._emscripten_bind_btGeneric6DofConstraint_btGeneric6DofConstraint_3=b.asm.ed).apply(null,arguments)},$e=b._emscripten_bind_btGeneric6DofConstraint_btGeneric6DofConstraint_5=function(){return($e=b._emscripten_bind_btGeneric6DofConstraint_btGeneric6DofConstraint_5= +b.asm.fd).apply(null,arguments)},af=b._emscripten_bind_btGeneric6DofConstraint_setLinearLowerLimit_1=function(){return(af=b._emscripten_bind_btGeneric6DofConstraint_setLinearLowerLimit_1=b.asm.gd).apply(null,arguments)},bf=b._emscripten_bind_btGeneric6DofConstraint_setLinearUpperLimit_1=function(){return(bf=b._emscripten_bind_btGeneric6DofConstraint_setLinearUpperLimit_1=b.asm.hd).apply(null,arguments)},cf=b._emscripten_bind_btGeneric6DofConstraint_setAngularLowerLimit_1=function(){return(cf=b._emscripten_bind_btGeneric6DofConstraint_setAngularLowerLimit_1= +b.asm.id).apply(null,arguments)},df=b._emscripten_bind_btGeneric6DofConstraint_setAngularUpperLimit_1=function(){return(df=b._emscripten_bind_btGeneric6DofConstraint_setAngularUpperLimit_1=b.asm.jd).apply(null,arguments)},ef=b._emscripten_bind_btGeneric6DofConstraint_getFrameOffsetA_0=function(){return(ef=b._emscripten_bind_btGeneric6DofConstraint_getFrameOffsetA_0=b.asm.kd).apply(null,arguments)},ff=b._emscripten_bind_btGeneric6DofConstraint_enableFeedback_1=function(){return(ff=b._emscripten_bind_btGeneric6DofConstraint_enableFeedback_1= +b.asm.ld).apply(null,arguments)},gf=b._emscripten_bind_btGeneric6DofConstraint_getBreakingImpulseThreshold_0=function(){return(gf=b._emscripten_bind_btGeneric6DofConstraint_getBreakingImpulseThreshold_0=b.asm.md).apply(null,arguments)},hf=b._emscripten_bind_btGeneric6DofConstraint_setBreakingImpulseThreshold_1=function(){return(hf=b._emscripten_bind_btGeneric6DofConstraint_setBreakingImpulseThreshold_1=b.asm.nd).apply(null,arguments)},jf=b._emscripten_bind_btGeneric6DofConstraint_getParam_2=function(){return(jf= +b._emscripten_bind_btGeneric6DofConstraint_getParam_2=b.asm.od).apply(null,arguments)},kf=b._emscripten_bind_btGeneric6DofConstraint_setParam_3=function(){return(kf=b._emscripten_bind_btGeneric6DofConstraint_setParam_3=b.asm.pd).apply(null,arguments)},lf=b._emscripten_bind_btGeneric6DofConstraint___destroy___0=function(){return(lf=b._emscripten_bind_btGeneric6DofConstraint___destroy___0=b.asm.qd).apply(null,arguments)},mf=b._emscripten_bind_btDiscreteDynamicsWorld_btDiscreteDynamicsWorld_4=function(){return(mf= +b._emscripten_bind_btDiscreteDynamicsWorld_btDiscreteDynamicsWorld_4=b.asm.rd).apply(null,arguments)},nf=b._emscripten_bind_btDiscreteDynamicsWorld_setGravity_1=function(){return(nf=b._emscripten_bind_btDiscreteDynamicsWorld_setGravity_1=b.asm.sd).apply(null,arguments)},of=b._emscripten_bind_btDiscreteDynamicsWorld_getGravity_0=function(){return(of=b._emscripten_bind_btDiscreteDynamicsWorld_getGravity_0=b.asm.td).apply(null,arguments)},pf=b._emscripten_bind_btDiscreteDynamicsWorld_addRigidBody_1= +function(){return(pf=b._emscripten_bind_btDiscreteDynamicsWorld_addRigidBody_1=b.asm.ud).apply(null,arguments)},qf=b._emscripten_bind_btDiscreteDynamicsWorld_addRigidBody_3=function(){return(qf=b._emscripten_bind_btDiscreteDynamicsWorld_addRigidBody_3=b.asm.vd).apply(null,arguments)},rf=b._emscripten_bind_btDiscreteDynamicsWorld_removeRigidBody_1=function(){return(rf=b._emscripten_bind_btDiscreteDynamicsWorld_removeRigidBody_1=b.asm.wd).apply(null,arguments)},sf=b._emscripten_bind_btDiscreteDynamicsWorld_addConstraint_1= +function(){return(sf=b._emscripten_bind_btDiscreteDynamicsWorld_addConstraint_1=b.asm.xd).apply(null,arguments)},tf=b._emscripten_bind_btDiscreteDynamicsWorld_addConstraint_2=function(){return(tf=b._emscripten_bind_btDiscreteDynamicsWorld_addConstraint_2=b.asm.yd).apply(null,arguments)},uf=b._emscripten_bind_btDiscreteDynamicsWorld_removeConstraint_1=function(){return(uf=b._emscripten_bind_btDiscreteDynamicsWorld_removeConstraint_1=b.asm.zd).apply(null,arguments)},vf=b._emscripten_bind_btDiscreteDynamicsWorld_stepSimulation_1= +function(){return(vf=b._emscripten_bind_btDiscreteDynamicsWorld_stepSimulation_1=b.asm.Ad).apply(null,arguments)},wf=b._emscripten_bind_btDiscreteDynamicsWorld_stepSimulation_2=function(){return(wf=b._emscripten_bind_btDiscreteDynamicsWorld_stepSimulation_2=b.asm.Bd).apply(null,arguments)},xf=b._emscripten_bind_btDiscreteDynamicsWorld_stepSimulation_3=function(){return(xf=b._emscripten_bind_btDiscreteDynamicsWorld_stepSimulation_3=b.asm.Cd).apply(null,arguments)},yf=b._emscripten_bind_btDiscreteDynamicsWorld_setContactAddedCallback_1= +function(){return(yf=b._emscripten_bind_btDiscreteDynamicsWorld_setContactAddedCallback_1=b.asm.Dd).apply(null,arguments)},zf=b._emscripten_bind_btDiscreteDynamicsWorld_setContactProcessedCallback_1=function(){return(zf=b._emscripten_bind_btDiscreteDynamicsWorld_setContactProcessedCallback_1=b.asm.Ed).apply(null,arguments)},Af=b._emscripten_bind_btDiscreteDynamicsWorld_setContactDestroyedCallback_1=function(){return(Af=b._emscripten_bind_btDiscreteDynamicsWorld_setContactDestroyedCallback_1=b.asm.Fd).apply(null, +arguments)},Bf=b._emscripten_bind_btDiscreteDynamicsWorld_getDispatcher_0=function(){return(Bf=b._emscripten_bind_btDiscreteDynamicsWorld_getDispatcher_0=b.asm.Gd).apply(null,arguments)},Cf=b._emscripten_bind_btDiscreteDynamicsWorld_rayTest_3=function(){return(Cf=b._emscripten_bind_btDiscreteDynamicsWorld_rayTest_3=b.asm.Hd).apply(null,arguments)},Df=b._emscripten_bind_btDiscreteDynamicsWorld_getPairCache_0=function(){return(Df=b._emscripten_bind_btDiscreteDynamicsWorld_getPairCache_0=b.asm.Id).apply(null, +arguments)},Ef=b._emscripten_bind_btDiscreteDynamicsWorld_getDispatchInfo_0=function(){return(Ef=b._emscripten_bind_btDiscreteDynamicsWorld_getDispatchInfo_0=b.asm.Jd).apply(null,arguments)},Ff=b._emscripten_bind_btDiscreteDynamicsWorld_addCollisionObject_1=function(){return(Ff=b._emscripten_bind_btDiscreteDynamicsWorld_addCollisionObject_1=b.asm.Kd).apply(null,arguments)},Gf=b._emscripten_bind_btDiscreteDynamicsWorld_addCollisionObject_2=function(){return(Gf=b._emscripten_bind_btDiscreteDynamicsWorld_addCollisionObject_2= +b.asm.Ld).apply(null,arguments)},Hf=b._emscripten_bind_btDiscreteDynamicsWorld_addCollisionObject_3=function(){return(Hf=b._emscripten_bind_btDiscreteDynamicsWorld_addCollisionObject_3=b.asm.Md).apply(null,arguments)},If=b._emscripten_bind_btDiscreteDynamicsWorld_removeCollisionObject_1=function(){return(If=b._emscripten_bind_btDiscreteDynamicsWorld_removeCollisionObject_1=b.asm.Nd).apply(null,arguments)},Jf=b._emscripten_bind_btDiscreteDynamicsWorld_getBroadphase_0=function(){return(Jf=b._emscripten_bind_btDiscreteDynamicsWorld_getBroadphase_0= +b.asm.Od).apply(null,arguments)},Kf=b._emscripten_bind_btDiscreteDynamicsWorld_convexSweepTest_5=function(){return(Kf=b._emscripten_bind_btDiscreteDynamicsWorld_convexSweepTest_5=b.asm.Pd).apply(null,arguments)},Lf=b._emscripten_bind_btDiscreteDynamicsWorld_contactPairTest_3=function(){return(Lf=b._emscripten_bind_btDiscreteDynamicsWorld_contactPairTest_3=b.asm.Qd).apply(null,arguments)},Mf=b._emscripten_bind_btDiscreteDynamicsWorld_contactTest_2=function(){return(Mf=b._emscripten_bind_btDiscreteDynamicsWorld_contactTest_2= +b.asm.Rd).apply(null,arguments)},Nf=b._emscripten_bind_btDiscreteDynamicsWorld_updateSingleAabb_1=function(){return(Nf=b._emscripten_bind_btDiscreteDynamicsWorld_updateSingleAabb_1=b.asm.Sd).apply(null,arguments)},Of=b._emscripten_bind_btDiscreteDynamicsWorld_setDebugDrawer_1=function(){return(Of=b._emscripten_bind_btDiscreteDynamicsWorld_setDebugDrawer_1=b.asm.Td).apply(null,arguments)},Pf=b._emscripten_bind_btDiscreteDynamicsWorld_getDebugDrawer_0=function(){return(Pf=b._emscripten_bind_btDiscreteDynamicsWorld_getDebugDrawer_0= +b.asm.Ud).apply(null,arguments)},Qf=b._emscripten_bind_btDiscreteDynamicsWorld_debugDrawWorld_0=function(){return(Qf=b._emscripten_bind_btDiscreteDynamicsWorld_debugDrawWorld_0=b.asm.Vd).apply(null,arguments)},Rf=b._emscripten_bind_btDiscreteDynamicsWorld_debugDrawObject_3=function(){return(Rf=b._emscripten_bind_btDiscreteDynamicsWorld_debugDrawObject_3=b.asm.Wd).apply(null,arguments)},Sf=b._emscripten_bind_btDiscreteDynamicsWorld_addAction_1=function(){return(Sf=b._emscripten_bind_btDiscreteDynamicsWorld_addAction_1= +b.asm.Xd).apply(null,arguments)},Tf=b._emscripten_bind_btDiscreteDynamicsWorld_removeAction_1=function(){return(Tf=b._emscripten_bind_btDiscreteDynamicsWorld_removeAction_1=b.asm.Yd).apply(null,arguments)},Uf=b._emscripten_bind_btDiscreteDynamicsWorld_getSolverInfo_0=function(){return(Uf=b._emscripten_bind_btDiscreteDynamicsWorld_getSolverInfo_0=b.asm.Zd).apply(null,arguments)},Vf=b._emscripten_bind_btDiscreteDynamicsWorld_setInternalTickCallback_1=function(){return(Vf=b._emscripten_bind_btDiscreteDynamicsWorld_setInternalTickCallback_1= +b.asm._d).apply(null,arguments)},Wf=b._emscripten_bind_btDiscreteDynamicsWorld_setInternalTickCallback_2=function(){return(Wf=b._emscripten_bind_btDiscreteDynamicsWorld_setInternalTickCallback_2=b.asm.$d).apply(null,arguments)},Xf=b._emscripten_bind_btDiscreteDynamicsWorld_setInternalTickCallback_3=function(){return(Xf=b._emscripten_bind_btDiscreteDynamicsWorld_setInternalTickCallback_3=b.asm.ae).apply(null,arguments)},Yf=b._emscripten_bind_btDiscreteDynamicsWorld___destroy___0=function(){return(Yf= +b._emscripten_bind_btDiscreteDynamicsWorld___destroy___0=b.asm.be).apply(null,arguments)},Zf=b._emscripten_bind_btVehicleRaycaster_castRay_3=function(){return(Zf=b._emscripten_bind_btVehicleRaycaster_castRay_3=b.asm.ce).apply(null,arguments)},$f=b._emscripten_bind_btVehicleRaycaster___destroy___0=function(){return($f=b._emscripten_bind_btVehicleRaycaster___destroy___0=b.asm.de).apply(null,arguments)},ag=b._emscripten_bind_btActionInterface_updateAction_2=function(){return(ag=b._emscripten_bind_btActionInterface_updateAction_2= +b.asm.ee).apply(null,arguments)},bg=b._emscripten_bind_btActionInterface___destroy___0=function(){return(bg=b._emscripten_bind_btActionInterface___destroy___0=b.asm.fe).apply(null,arguments)},cg=b._emscripten_bind_btGhostObject_btGhostObject_0=function(){return(cg=b._emscripten_bind_btGhostObject_btGhostObject_0=b.asm.ge).apply(null,arguments)},dg=b._emscripten_bind_btGhostObject_getNumOverlappingObjects_0=function(){return(dg=b._emscripten_bind_btGhostObject_getNumOverlappingObjects_0=b.asm.he).apply(null, +arguments)},eg=b._emscripten_bind_btGhostObject_getOverlappingObject_1=function(){return(eg=b._emscripten_bind_btGhostObject_getOverlappingObject_1=b.asm.ie).apply(null,arguments)},fg=b._emscripten_bind_btGhostObject_setAnisotropicFriction_2=function(){return(fg=b._emscripten_bind_btGhostObject_setAnisotropicFriction_2=b.asm.je).apply(null,arguments)},gg=b._emscripten_bind_btGhostObject_getCollisionShape_0=function(){return(gg=b._emscripten_bind_btGhostObject_getCollisionShape_0=b.asm.ke).apply(null, +arguments)},hg=b._emscripten_bind_btGhostObject_setContactProcessingThreshold_1=function(){return(hg=b._emscripten_bind_btGhostObject_setContactProcessingThreshold_1=b.asm.le).apply(null,arguments)},ig=b._emscripten_bind_btGhostObject_setActivationState_1=function(){return(ig=b._emscripten_bind_btGhostObject_setActivationState_1=b.asm.me).apply(null,arguments)},jg=b._emscripten_bind_btGhostObject_forceActivationState_1=function(){return(jg=b._emscripten_bind_btGhostObject_forceActivationState_1=b.asm.ne).apply(null, +arguments)},kg=b._emscripten_bind_btGhostObject_activate_0=function(){return(kg=b._emscripten_bind_btGhostObject_activate_0=b.asm.oe).apply(null,arguments)},lg=b._emscripten_bind_btGhostObject_activate_1=function(){return(lg=b._emscripten_bind_btGhostObject_activate_1=b.asm.pe).apply(null,arguments)},mg=b._emscripten_bind_btGhostObject_isActive_0=function(){return(mg=b._emscripten_bind_btGhostObject_isActive_0=b.asm.qe).apply(null,arguments)},ng=b._emscripten_bind_btGhostObject_isKinematicObject_0= +function(){return(ng=b._emscripten_bind_btGhostObject_isKinematicObject_0=b.asm.re).apply(null,arguments)},og=b._emscripten_bind_btGhostObject_isStaticObject_0=function(){return(og=b._emscripten_bind_btGhostObject_isStaticObject_0=b.asm.se).apply(null,arguments)},pg=b._emscripten_bind_btGhostObject_isStaticOrKinematicObject_0=function(){return(pg=b._emscripten_bind_btGhostObject_isStaticOrKinematicObject_0=b.asm.te).apply(null,arguments)},qg=b._emscripten_bind_btGhostObject_getRestitution_0=function(){return(qg= +b._emscripten_bind_btGhostObject_getRestitution_0=b.asm.ue).apply(null,arguments)},rg=b._emscripten_bind_btGhostObject_getFriction_0=function(){return(rg=b._emscripten_bind_btGhostObject_getFriction_0=b.asm.ve).apply(null,arguments)},sg=b._emscripten_bind_btGhostObject_getRollingFriction_0=function(){return(sg=b._emscripten_bind_btGhostObject_getRollingFriction_0=b.asm.we).apply(null,arguments)},tg=b._emscripten_bind_btGhostObject_setRestitution_1=function(){return(tg=b._emscripten_bind_btGhostObject_setRestitution_1= +b.asm.xe).apply(null,arguments)},ug=b._emscripten_bind_btGhostObject_setFriction_1=function(){return(ug=b._emscripten_bind_btGhostObject_setFriction_1=b.asm.ye).apply(null,arguments)},vg=b._emscripten_bind_btGhostObject_setRollingFriction_1=function(){return(vg=b._emscripten_bind_btGhostObject_setRollingFriction_1=b.asm.ze).apply(null,arguments)},wg=b._emscripten_bind_btGhostObject_getWorldTransform_0=function(){return(wg=b._emscripten_bind_btGhostObject_getWorldTransform_0=b.asm.Ae).apply(null,arguments)}, +xg=b._emscripten_bind_btGhostObject_getCollisionFlags_0=function(){return(xg=b._emscripten_bind_btGhostObject_getCollisionFlags_0=b.asm.Be).apply(null,arguments)},yg=b._emscripten_bind_btGhostObject_setCollisionFlags_1=function(){return(yg=b._emscripten_bind_btGhostObject_setCollisionFlags_1=b.asm.Ce).apply(null,arguments)},zg=b._emscripten_bind_btGhostObject_setWorldTransform_1=function(){return(zg=b._emscripten_bind_btGhostObject_setWorldTransform_1=b.asm.De).apply(null,arguments)},Ag=b._emscripten_bind_btGhostObject_setCollisionShape_1= +function(){return(Ag=b._emscripten_bind_btGhostObject_setCollisionShape_1=b.asm.Ee).apply(null,arguments)},Bg=b._emscripten_bind_btGhostObject_setCcdMotionThreshold_1=function(){return(Bg=b._emscripten_bind_btGhostObject_setCcdMotionThreshold_1=b.asm.Fe).apply(null,arguments)},Cg=b._emscripten_bind_btGhostObject_setCcdSweptSphereRadius_1=function(){return(Cg=b._emscripten_bind_btGhostObject_setCcdSweptSphereRadius_1=b.asm.Ge).apply(null,arguments)},Dg=b._emscripten_bind_btGhostObject_getUserIndex_0= +function(){return(Dg=b._emscripten_bind_btGhostObject_getUserIndex_0=b.asm.He).apply(null,arguments)},Eg=b._emscripten_bind_btGhostObject_setUserIndex_1=function(){return(Eg=b._emscripten_bind_btGhostObject_setUserIndex_1=b.asm.Ie).apply(null,arguments)},Fg=b._emscripten_bind_btGhostObject_getUserPointer_0=function(){return(Fg=b._emscripten_bind_btGhostObject_getUserPointer_0=b.asm.Je).apply(null,arguments)},Gg=b._emscripten_bind_btGhostObject_setUserPointer_1=function(){return(Gg=b._emscripten_bind_btGhostObject_setUserPointer_1= +b.asm.Ke).apply(null,arguments)},Hg=b._emscripten_bind_btGhostObject_getBroadphaseHandle_0=function(){return(Hg=b._emscripten_bind_btGhostObject_getBroadphaseHandle_0=b.asm.Le).apply(null,arguments)},Ig=b._emscripten_bind_btGhostObject___destroy___0=function(){return(Ig=b._emscripten_bind_btGhostObject___destroy___0=b.asm.Me).apply(null,arguments)},Jg=b._emscripten_bind_btSoftBodySolver___destroy___0=function(){return(Jg=b._emscripten_bind_btSoftBodySolver___destroy___0=b.asm.Ne).apply(null,arguments)}, +Kg=b._emscripten_bind_VoidPtr___destroy___0=function(){return(Kg=b._emscripten_bind_VoidPtr___destroy___0=b.asm.Oe).apply(null,arguments)},Lg=b._emscripten_bind_DebugDrawer_DebugDrawer_0=function(){return(Lg=b._emscripten_bind_DebugDrawer_DebugDrawer_0=b.asm.Pe).apply(null,arguments)},Mg=b._emscripten_bind_DebugDrawer_drawLine_3=function(){return(Mg=b._emscripten_bind_DebugDrawer_drawLine_3=b.asm.Qe).apply(null,arguments)},Ng=b._emscripten_bind_DebugDrawer_drawContactPoint_5=function(){return(Ng= +b._emscripten_bind_DebugDrawer_drawContactPoint_5=b.asm.Re).apply(null,arguments)},Og=b._emscripten_bind_DebugDrawer_reportErrorWarning_1=function(){return(Og=b._emscripten_bind_DebugDrawer_reportErrorWarning_1=b.asm.Se).apply(null,arguments)},Pg=b._emscripten_bind_DebugDrawer_draw3dText_2=function(){return(Pg=b._emscripten_bind_DebugDrawer_draw3dText_2=b.asm.Te).apply(null,arguments)},Qg=b._emscripten_bind_DebugDrawer_setDebugMode_1=function(){return(Qg=b._emscripten_bind_DebugDrawer_setDebugMode_1= +b.asm.Ue).apply(null,arguments)},Rg=b._emscripten_bind_DebugDrawer_getDebugMode_0=function(){return(Rg=b._emscripten_bind_DebugDrawer_getDebugMode_0=b.asm.Ve).apply(null,arguments)},Sg=b._emscripten_bind_DebugDrawer___destroy___0=function(){return(Sg=b._emscripten_bind_DebugDrawer___destroy___0=b.asm.We).apply(null,arguments)},Tg=b._emscripten_bind_btVector4_btVector4_0=function(){return(Tg=b._emscripten_bind_btVector4_btVector4_0=b.asm.Xe).apply(null,arguments)},Ug=b._emscripten_bind_btVector4_btVector4_4= +function(){return(Ug=b._emscripten_bind_btVector4_btVector4_4=b.asm.Ye).apply(null,arguments)},Vg=b._emscripten_bind_btVector4_w_0=function(){return(Vg=b._emscripten_bind_btVector4_w_0=b.asm.Ze).apply(null,arguments)},Wg=b._emscripten_bind_btVector4_setValue_4=function(){return(Wg=b._emscripten_bind_btVector4_setValue_4=b.asm._e).apply(null,arguments)},Xg=b._emscripten_bind_btVector4_length_0=function(){return(Xg=b._emscripten_bind_btVector4_length_0=b.asm.$e).apply(null,arguments)},Yg=b._emscripten_bind_btVector4_x_0= +function(){return(Yg=b._emscripten_bind_btVector4_x_0=b.asm.af).apply(null,arguments)},Zg=b._emscripten_bind_btVector4_y_0=function(){return(Zg=b._emscripten_bind_btVector4_y_0=b.asm.bf).apply(null,arguments)},$g=b._emscripten_bind_btVector4_z_0=function(){return($g=b._emscripten_bind_btVector4_z_0=b.asm.cf).apply(null,arguments)},ah=b._emscripten_bind_btVector4_setX_1=function(){return(ah=b._emscripten_bind_btVector4_setX_1=b.asm.df).apply(null,arguments)},bh=b._emscripten_bind_btVector4_setY_1= +function(){return(bh=b._emscripten_bind_btVector4_setY_1=b.asm.ef).apply(null,arguments)},ch=b._emscripten_bind_btVector4_setZ_1=function(){return(ch=b._emscripten_bind_btVector4_setZ_1=b.asm.ff).apply(null,arguments)},dh=b._emscripten_bind_btVector4_normalize_0=function(){return(dh=b._emscripten_bind_btVector4_normalize_0=b.asm.gf).apply(null,arguments)},eh=b._emscripten_bind_btVector4_rotate_2=function(){return(eh=b._emscripten_bind_btVector4_rotate_2=b.asm.hf).apply(null,arguments)},fh=b._emscripten_bind_btVector4_dot_1= +function(){return(fh=b._emscripten_bind_btVector4_dot_1=b.asm.jf).apply(null,arguments)},gh=b._emscripten_bind_btVector4_op_mul_1=function(){return(gh=b._emscripten_bind_btVector4_op_mul_1=b.asm.kf).apply(null,arguments)},hh=b._emscripten_bind_btVector4_op_add_1=function(){return(hh=b._emscripten_bind_btVector4_op_add_1=b.asm.lf).apply(null,arguments)},ih=b._emscripten_bind_btVector4_op_sub_1=function(){return(ih=b._emscripten_bind_btVector4_op_sub_1=b.asm.mf).apply(null,arguments)},jh=b._emscripten_bind_btVector4___destroy___0= +function(){return(jh=b._emscripten_bind_btVector4___destroy___0=b.asm.nf).apply(null,arguments)},kh=b._emscripten_bind_btQuaternion_btQuaternion_4=function(){return(kh=b._emscripten_bind_btQuaternion_btQuaternion_4=b.asm.of).apply(null,arguments)},lh=b._emscripten_bind_btQuaternion_setValue_4=function(){return(lh=b._emscripten_bind_btQuaternion_setValue_4=b.asm.pf).apply(null,arguments)},mh=b._emscripten_bind_btQuaternion_setEulerZYX_3=function(){return(mh=b._emscripten_bind_btQuaternion_setEulerZYX_3= +b.asm.qf).apply(null,arguments)},nh=b._emscripten_bind_btQuaternion_setRotation_2=function(){return(nh=b._emscripten_bind_btQuaternion_setRotation_2=b.asm.rf).apply(null,arguments)},oh=b._emscripten_bind_btQuaternion_normalize_0=function(){return(oh=b._emscripten_bind_btQuaternion_normalize_0=b.asm.sf).apply(null,arguments)},ph=b._emscripten_bind_btQuaternion_length2_0=function(){return(ph=b._emscripten_bind_btQuaternion_length2_0=b.asm.tf).apply(null,arguments)},qh=b._emscripten_bind_btQuaternion_length_0= +function(){return(qh=b._emscripten_bind_btQuaternion_length_0=b.asm.uf).apply(null,arguments)},rh=b._emscripten_bind_btQuaternion_dot_1=function(){return(rh=b._emscripten_bind_btQuaternion_dot_1=b.asm.vf).apply(null,arguments)},sh=b._emscripten_bind_btQuaternion_normalized_0=function(){return(sh=b._emscripten_bind_btQuaternion_normalized_0=b.asm.wf).apply(null,arguments)},th=b._emscripten_bind_btQuaternion_getAxis_0=function(){return(th=b._emscripten_bind_btQuaternion_getAxis_0=b.asm.xf).apply(null, +arguments)},uh=b._emscripten_bind_btQuaternion_inverse_0=function(){return(uh=b._emscripten_bind_btQuaternion_inverse_0=b.asm.yf).apply(null,arguments)},vh=b._emscripten_bind_btQuaternion_getAngle_0=function(){return(vh=b._emscripten_bind_btQuaternion_getAngle_0=b.asm.zf).apply(null,arguments)},wh=b._emscripten_bind_btQuaternion_getAngleShortestPath_0=function(){return(wh=b._emscripten_bind_btQuaternion_getAngleShortestPath_0=b.asm.Af).apply(null,arguments)},xh=b._emscripten_bind_btQuaternion_angle_1= +function(){return(xh=b._emscripten_bind_btQuaternion_angle_1=b.asm.Bf).apply(null,arguments)},yh=b._emscripten_bind_btQuaternion_angleShortestPath_1=function(){return(yh=b._emscripten_bind_btQuaternion_angleShortestPath_1=b.asm.Cf).apply(null,arguments)},zh=b._emscripten_bind_btQuaternion_op_add_1=function(){return(zh=b._emscripten_bind_btQuaternion_op_add_1=b.asm.Df).apply(null,arguments)},Ah=b._emscripten_bind_btQuaternion_op_sub_1=function(){return(Ah=b._emscripten_bind_btQuaternion_op_sub_1=b.asm.Ef).apply(null, +arguments)},Bh=b._emscripten_bind_btQuaternion_op_mul_1=function(){return(Bh=b._emscripten_bind_btQuaternion_op_mul_1=b.asm.Ff).apply(null,arguments)},Ch=b._emscripten_bind_btQuaternion_op_mulq_1=function(){return(Ch=b._emscripten_bind_btQuaternion_op_mulq_1=b.asm.Gf).apply(null,arguments)},Dh=b._emscripten_bind_btQuaternion_op_div_1=function(){return(Dh=b._emscripten_bind_btQuaternion_op_div_1=b.asm.Hf).apply(null,arguments)},Eh=b._emscripten_bind_btQuaternion_x_0=function(){return(Eh=b._emscripten_bind_btQuaternion_x_0= +b.asm.If).apply(null,arguments)},Fh=b._emscripten_bind_btQuaternion_y_0=function(){return(Fh=b._emscripten_bind_btQuaternion_y_0=b.asm.Jf).apply(null,arguments)},Gh=b._emscripten_bind_btQuaternion_z_0=function(){return(Gh=b._emscripten_bind_btQuaternion_z_0=b.asm.Kf).apply(null,arguments)},Hh=b._emscripten_bind_btQuaternion_w_0=function(){return(Hh=b._emscripten_bind_btQuaternion_w_0=b.asm.Lf).apply(null,arguments)},Ih=b._emscripten_bind_btQuaternion_setX_1=function(){return(Ih=b._emscripten_bind_btQuaternion_setX_1= +b.asm.Mf).apply(null,arguments)},Jh=b._emscripten_bind_btQuaternion_setY_1=function(){return(Jh=b._emscripten_bind_btQuaternion_setY_1=b.asm.Nf).apply(null,arguments)},Kh=b._emscripten_bind_btQuaternion_setZ_1=function(){return(Kh=b._emscripten_bind_btQuaternion_setZ_1=b.asm.Of).apply(null,arguments)},Lh=b._emscripten_bind_btQuaternion_setW_1=function(){return(Lh=b._emscripten_bind_btQuaternion_setW_1=b.asm.Pf).apply(null,arguments)},Mh=b._emscripten_bind_btQuaternion___destroy___0=function(){return(Mh= +b._emscripten_bind_btQuaternion___destroy___0=b.asm.Qf).apply(null,arguments)},Nh=b._emscripten_bind_btMatrix3x3_setEulerZYX_3=function(){return(Nh=b._emscripten_bind_btMatrix3x3_setEulerZYX_3=b.asm.Rf).apply(null,arguments)},Oh=b._emscripten_bind_btMatrix3x3_getRotation_1=function(){return(Oh=b._emscripten_bind_btMatrix3x3_getRotation_1=b.asm.Sf).apply(null,arguments)},Ph=b._emscripten_bind_btMatrix3x3_getRow_1=function(){return(Ph=b._emscripten_bind_btMatrix3x3_getRow_1=b.asm.Tf).apply(null,arguments)}, +Qh=b._emscripten_bind_btMatrix3x3___destroy___0=function(){return(Qh=b._emscripten_bind_btMatrix3x3___destroy___0=b.asm.Uf).apply(null,arguments)},Rh=b._emscripten_bind_btTransform_btTransform_0=function(){return(Rh=b._emscripten_bind_btTransform_btTransform_0=b.asm.Vf).apply(null,arguments)},Sh=b._emscripten_bind_btTransform_btTransform_2=function(){return(Sh=b._emscripten_bind_btTransform_btTransform_2=b.asm.Wf).apply(null,arguments)},Th=b._emscripten_bind_btTransform_setIdentity_0=function(){return(Th= +b._emscripten_bind_btTransform_setIdentity_0=b.asm.Xf).apply(null,arguments)},Uh=b._emscripten_bind_btTransform_setOrigin_1=function(){return(Uh=b._emscripten_bind_btTransform_setOrigin_1=b.asm.Yf).apply(null,arguments)},Vh=b._emscripten_bind_btTransform_setRotation_1=function(){return(Vh=b._emscripten_bind_btTransform_setRotation_1=b.asm.Zf).apply(null,arguments)},Wh=b._emscripten_bind_btTransform_getOrigin_0=function(){return(Wh=b._emscripten_bind_btTransform_getOrigin_0=b.asm._f).apply(null,arguments)}, +Xh=b._emscripten_bind_btTransform_getRotation_0=function(){return(Xh=b._emscripten_bind_btTransform_getRotation_0=b.asm.$f).apply(null,arguments)},Yh=b._emscripten_bind_btTransform_getBasis_0=function(){return(Yh=b._emscripten_bind_btTransform_getBasis_0=b.asm.ag).apply(null,arguments)},Zh=b._emscripten_bind_btTransform_setFromOpenGLMatrix_1=function(){return(Zh=b._emscripten_bind_btTransform_setFromOpenGLMatrix_1=b.asm.bg).apply(null,arguments)},$h=b._emscripten_bind_btTransform_inverse_0=function(){return($h= +b._emscripten_bind_btTransform_inverse_0=b.asm.cg).apply(null,arguments)},ai=b._emscripten_bind_btTransform_op_mul_1=function(){return(ai=b._emscripten_bind_btTransform_op_mul_1=b.asm.dg).apply(null,arguments)},bi=b._emscripten_bind_btTransform___destroy___0=function(){return(bi=b._emscripten_bind_btTransform___destroy___0=b.asm.eg).apply(null,arguments)},ci=b._emscripten_bind_MotionState_MotionState_0=function(){return(ci=b._emscripten_bind_MotionState_MotionState_0=b.asm.fg).apply(null,arguments)}, +di=b._emscripten_bind_MotionState_getWorldTransform_1=function(){return(di=b._emscripten_bind_MotionState_getWorldTransform_1=b.asm.gg).apply(null,arguments)},ei=b._emscripten_bind_MotionState_setWorldTransform_1=function(){return(ei=b._emscripten_bind_MotionState_setWorldTransform_1=b.asm.hg).apply(null,arguments)},fi=b._emscripten_bind_MotionState___destroy___0=function(){return(fi=b._emscripten_bind_MotionState___destroy___0=b.asm.ig).apply(null,arguments)},gi=b._emscripten_bind_btDefaultMotionState_btDefaultMotionState_0= +function(){return(gi=b._emscripten_bind_btDefaultMotionState_btDefaultMotionState_0=b.asm.jg).apply(null,arguments)},hi=b._emscripten_bind_btDefaultMotionState_btDefaultMotionState_1=function(){return(hi=b._emscripten_bind_btDefaultMotionState_btDefaultMotionState_1=b.asm.kg).apply(null,arguments)},ii=b._emscripten_bind_btDefaultMotionState_btDefaultMotionState_2=function(){return(ii=b._emscripten_bind_btDefaultMotionState_btDefaultMotionState_2=b.asm.lg).apply(null,arguments)},ji=b._emscripten_bind_btDefaultMotionState_getWorldTransform_1= +function(){return(ji=b._emscripten_bind_btDefaultMotionState_getWorldTransform_1=b.asm.mg).apply(null,arguments)},ki=b._emscripten_bind_btDefaultMotionState_setWorldTransform_1=function(){return(ki=b._emscripten_bind_btDefaultMotionState_setWorldTransform_1=b.asm.ng).apply(null,arguments)},li=b._emscripten_bind_btDefaultMotionState_get_m_graphicsWorldTrans_0=function(){return(li=b._emscripten_bind_btDefaultMotionState_get_m_graphicsWorldTrans_0=b.asm.og).apply(null,arguments)},mi=b._emscripten_bind_btDefaultMotionState_set_m_graphicsWorldTrans_1= +function(){return(mi=b._emscripten_bind_btDefaultMotionState_set_m_graphicsWorldTrans_1=b.asm.pg).apply(null,arguments)},ni=b._emscripten_bind_btDefaultMotionState___destroy___0=function(){return(ni=b._emscripten_bind_btDefaultMotionState___destroy___0=b.asm.qg).apply(null,arguments)},oi=b._emscripten_bind_btCollisionObjectWrapper_getWorldTransform_0=function(){return(oi=b._emscripten_bind_btCollisionObjectWrapper_getWorldTransform_0=b.asm.rg).apply(null,arguments)},pi=b._emscripten_bind_btCollisionObjectWrapper_getCollisionObject_0= +function(){return(pi=b._emscripten_bind_btCollisionObjectWrapper_getCollisionObject_0=b.asm.sg).apply(null,arguments)},qi=b._emscripten_bind_btCollisionObjectWrapper_getCollisionShape_0=function(){return(qi=b._emscripten_bind_btCollisionObjectWrapper_getCollisionShape_0=b.asm.tg).apply(null,arguments)},ri=b._emscripten_bind_ClosestRayResultCallback_ClosestRayResultCallback_2=function(){return(ri=b._emscripten_bind_ClosestRayResultCallback_ClosestRayResultCallback_2=b.asm.ug).apply(null,arguments)}, +si=b._emscripten_bind_ClosestRayResultCallback_hasHit_0=function(){return(si=b._emscripten_bind_ClosestRayResultCallback_hasHit_0=b.asm.vg).apply(null,arguments)},ti=b._emscripten_bind_ClosestRayResultCallback_get_m_rayFromWorld_0=function(){return(ti=b._emscripten_bind_ClosestRayResultCallback_get_m_rayFromWorld_0=b.asm.wg).apply(null,arguments)},ui=b._emscripten_bind_ClosestRayResultCallback_set_m_rayFromWorld_1=function(){return(ui=b._emscripten_bind_ClosestRayResultCallback_set_m_rayFromWorld_1= +b.asm.xg).apply(null,arguments)},vi=b._emscripten_bind_ClosestRayResultCallback_get_m_rayToWorld_0=function(){return(vi=b._emscripten_bind_ClosestRayResultCallback_get_m_rayToWorld_0=b.asm.yg).apply(null,arguments)},wi=b._emscripten_bind_ClosestRayResultCallback_set_m_rayToWorld_1=function(){return(wi=b._emscripten_bind_ClosestRayResultCallback_set_m_rayToWorld_1=b.asm.zg).apply(null,arguments)},xi=b._emscripten_bind_ClosestRayResultCallback_get_m_hitNormalWorld_0=function(){return(xi=b._emscripten_bind_ClosestRayResultCallback_get_m_hitNormalWorld_0= +b.asm.Ag).apply(null,arguments)},yi=b._emscripten_bind_ClosestRayResultCallback_set_m_hitNormalWorld_1=function(){return(yi=b._emscripten_bind_ClosestRayResultCallback_set_m_hitNormalWorld_1=b.asm.Bg).apply(null,arguments)},zi=b._emscripten_bind_ClosestRayResultCallback_get_m_hitPointWorld_0=function(){return(zi=b._emscripten_bind_ClosestRayResultCallback_get_m_hitPointWorld_0=b.asm.Cg).apply(null,arguments)},Ai=b._emscripten_bind_ClosestRayResultCallback_set_m_hitPointWorld_1=function(){return(Ai= +b._emscripten_bind_ClosestRayResultCallback_set_m_hitPointWorld_1=b.asm.Dg).apply(null,arguments)},Bi=b._emscripten_bind_ClosestRayResultCallback_get_m_collisionFilterGroup_0=function(){return(Bi=b._emscripten_bind_ClosestRayResultCallback_get_m_collisionFilterGroup_0=b.asm.Eg).apply(null,arguments)},Ci=b._emscripten_bind_ClosestRayResultCallback_set_m_collisionFilterGroup_1=function(){return(Ci=b._emscripten_bind_ClosestRayResultCallback_set_m_collisionFilterGroup_1=b.asm.Fg).apply(null,arguments)}, +Di=b._emscripten_bind_ClosestRayResultCallback_get_m_collisionFilterMask_0=function(){return(Di=b._emscripten_bind_ClosestRayResultCallback_get_m_collisionFilterMask_0=b.asm.Gg).apply(null,arguments)},Ei=b._emscripten_bind_ClosestRayResultCallback_set_m_collisionFilterMask_1=function(){return(Ei=b._emscripten_bind_ClosestRayResultCallback_set_m_collisionFilterMask_1=b.asm.Hg).apply(null,arguments)},Fi=b._emscripten_bind_ClosestRayResultCallback_get_m_closestHitFraction_0=function(){return(Fi=b._emscripten_bind_ClosestRayResultCallback_get_m_closestHitFraction_0= +b.asm.Ig).apply(null,arguments)},Gi=b._emscripten_bind_ClosestRayResultCallback_set_m_closestHitFraction_1=function(){return(Gi=b._emscripten_bind_ClosestRayResultCallback_set_m_closestHitFraction_1=b.asm.Jg).apply(null,arguments)},Hi=b._emscripten_bind_ClosestRayResultCallback_get_m_collisionObject_0=function(){return(Hi=b._emscripten_bind_ClosestRayResultCallback_get_m_collisionObject_0=b.asm.Kg).apply(null,arguments)},Ii=b._emscripten_bind_ClosestRayResultCallback_set_m_collisionObject_1=function(){return(Ii= +b._emscripten_bind_ClosestRayResultCallback_set_m_collisionObject_1=b.asm.Lg).apply(null,arguments)},Ji=b._emscripten_bind_ClosestRayResultCallback_get_m_flags_0=function(){return(Ji=b._emscripten_bind_ClosestRayResultCallback_get_m_flags_0=b.asm.Mg).apply(null,arguments)},Ki=b._emscripten_bind_ClosestRayResultCallback_set_m_flags_1=function(){return(Ki=b._emscripten_bind_ClosestRayResultCallback_set_m_flags_1=b.asm.Ng).apply(null,arguments)},Li=b._emscripten_bind_ClosestRayResultCallback___destroy___0= +function(){return(Li=b._emscripten_bind_ClosestRayResultCallback___destroy___0=b.asm.Og).apply(null,arguments)},Mi=b._emscripten_bind_btConstCollisionObjectArray_size_0=function(){return(Mi=b._emscripten_bind_btConstCollisionObjectArray_size_0=b.asm.Pg).apply(null,arguments)},Ni=b._emscripten_bind_btConstCollisionObjectArray_at_1=function(){return(Ni=b._emscripten_bind_btConstCollisionObjectArray_at_1=b.asm.Qg).apply(null,arguments)},Oi=b._emscripten_bind_btConstCollisionObjectArray___destroy___0= +function(){return(Oi=b._emscripten_bind_btConstCollisionObjectArray___destroy___0=b.asm.Rg).apply(null,arguments)},Pi=b._emscripten_bind_btScalarArray_size_0=function(){return(Pi=b._emscripten_bind_btScalarArray_size_0=b.asm.Sg).apply(null,arguments)},Qi=b._emscripten_bind_btScalarArray_at_1=function(){return(Qi=b._emscripten_bind_btScalarArray_at_1=b.asm.Tg).apply(null,arguments)},Ri=b._emscripten_bind_btScalarArray___destroy___0=function(){return(Ri=b._emscripten_bind_btScalarArray___destroy___0= +b.asm.Ug).apply(null,arguments)},Si=b._emscripten_bind_AllHitsRayResultCallback_AllHitsRayResultCallback_2=function(){return(Si=b._emscripten_bind_AllHitsRayResultCallback_AllHitsRayResultCallback_2=b.asm.Vg).apply(null,arguments)},Ti=b._emscripten_bind_AllHitsRayResultCallback_hasHit_0=function(){return(Ti=b._emscripten_bind_AllHitsRayResultCallback_hasHit_0=b.asm.Wg).apply(null,arguments)},Ui=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionObjects_0=function(){return(Ui=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionObjects_0= +b.asm.Xg).apply(null,arguments)},Vi=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionObjects_1=function(){return(Vi=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionObjects_1=b.asm.Yg).apply(null,arguments)},Wi=b._emscripten_bind_AllHitsRayResultCallback_get_m_rayFromWorld_0=function(){return(Wi=b._emscripten_bind_AllHitsRayResultCallback_get_m_rayFromWorld_0=b.asm.Zg).apply(null,arguments)},Xi=b._emscripten_bind_AllHitsRayResultCallback_set_m_rayFromWorld_1=function(){return(Xi= +b._emscripten_bind_AllHitsRayResultCallback_set_m_rayFromWorld_1=b.asm._g).apply(null,arguments)},Yi=b._emscripten_bind_AllHitsRayResultCallback_get_m_rayToWorld_0=function(){return(Yi=b._emscripten_bind_AllHitsRayResultCallback_get_m_rayToWorld_0=b.asm.$g).apply(null,arguments)},Zi=b._emscripten_bind_AllHitsRayResultCallback_set_m_rayToWorld_1=function(){return(Zi=b._emscripten_bind_AllHitsRayResultCallback_set_m_rayToWorld_1=b.asm.ah).apply(null,arguments)},$i=b._emscripten_bind_AllHitsRayResultCallback_get_m_hitNormalWorld_0= +function(){return($i=b._emscripten_bind_AllHitsRayResultCallback_get_m_hitNormalWorld_0=b.asm.bh).apply(null,arguments)},aj=b._emscripten_bind_AllHitsRayResultCallback_set_m_hitNormalWorld_1=function(){return(aj=b._emscripten_bind_AllHitsRayResultCallback_set_m_hitNormalWorld_1=b.asm.ch).apply(null,arguments)},bj=b._emscripten_bind_AllHitsRayResultCallback_get_m_hitPointWorld_0=function(){return(bj=b._emscripten_bind_AllHitsRayResultCallback_get_m_hitPointWorld_0=b.asm.dh).apply(null,arguments)}, +cj=b._emscripten_bind_AllHitsRayResultCallback_set_m_hitPointWorld_1=function(){return(cj=b._emscripten_bind_AllHitsRayResultCallback_set_m_hitPointWorld_1=b.asm.eh).apply(null,arguments)},dj=b._emscripten_bind_AllHitsRayResultCallback_get_m_hitFractions_0=function(){return(dj=b._emscripten_bind_AllHitsRayResultCallback_get_m_hitFractions_0=b.asm.fh).apply(null,arguments)},ej=b._emscripten_bind_AllHitsRayResultCallback_set_m_hitFractions_1=function(){return(ej=b._emscripten_bind_AllHitsRayResultCallback_set_m_hitFractions_1= +b.asm.gh).apply(null,arguments)},fj=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionFilterGroup_0=function(){return(fj=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionFilterGroup_0=b.asm.hh).apply(null,arguments)},gj=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionFilterGroup_1=function(){return(gj=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionFilterGroup_1=b.asm.ih).apply(null,arguments)},hj=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionFilterMask_0= +function(){return(hj=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionFilterMask_0=b.asm.jh).apply(null,arguments)},ij=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionFilterMask_1=function(){return(ij=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionFilterMask_1=b.asm.kh).apply(null,arguments)},jj=b._emscripten_bind_AllHitsRayResultCallback_get_m_closestHitFraction_0=function(){return(jj=b._emscripten_bind_AllHitsRayResultCallback_get_m_closestHitFraction_0=b.asm.lh).apply(null, +arguments)},kj=b._emscripten_bind_AllHitsRayResultCallback_set_m_closestHitFraction_1=function(){return(kj=b._emscripten_bind_AllHitsRayResultCallback_set_m_closestHitFraction_1=b.asm.mh).apply(null,arguments)},lj=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionObject_0=function(){return(lj=b._emscripten_bind_AllHitsRayResultCallback_get_m_collisionObject_0=b.asm.nh).apply(null,arguments)},mj=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionObject_1=function(){return(mj=b._emscripten_bind_AllHitsRayResultCallback_set_m_collisionObject_1= +b.asm.oh).apply(null,arguments)},nj=b._emscripten_bind_AllHitsRayResultCallback_get_m_flags_0=function(){return(nj=b._emscripten_bind_AllHitsRayResultCallback_get_m_flags_0=b.asm.ph).apply(null,arguments)},oj=b._emscripten_bind_AllHitsRayResultCallback_set_m_flags_1=function(){return(oj=b._emscripten_bind_AllHitsRayResultCallback_set_m_flags_1=b.asm.qh).apply(null,arguments)},pj=b._emscripten_bind_AllHitsRayResultCallback___destroy___0=function(){return(pj=b._emscripten_bind_AllHitsRayResultCallback___destroy___0= +b.asm.rh).apply(null,arguments)},qj=b._emscripten_bind_btManifoldPoint_getPositionWorldOnA_0=function(){return(qj=b._emscripten_bind_btManifoldPoint_getPositionWorldOnA_0=b.asm.sh).apply(null,arguments)},rj=b._emscripten_bind_btManifoldPoint_getPositionWorldOnB_0=function(){return(rj=b._emscripten_bind_btManifoldPoint_getPositionWorldOnB_0=b.asm.th).apply(null,arguments)},sj=b._emscripten_bind_btManifoldPoint_getAppliedImpulse_0=function(){return(sj=b._emscripten_bind_btManifoldPoint_getAppliedImpulse_0= +b.asm.uh).apply(null,arguments)},tj=b._emscripten_bind_btManifoldPoint_getDistance_0=function(){return(tj=b._emscripten_bind_btManifoldPoint_getDistance_0=b.asm.vh).apply(null,arguments)},uj=b._emscripten_bind_btManifoldPoint_get_m_localPointA_0=function(){return(uj=b._emscripten_bind_btManifoldPoint_get_m_localPointA_0=b.asm.wh).apply(null,arguments)},vj=b._emscripten_bind_btManifoldPoint_set_m_localPointA_1=function(){return(vj=b._emscripten_bind_btManifoldPoint_set_m_localPointA_1=b.asm.xh).apply(null, +arguments)},wj=b._emscripten_bind_btManifoldPoint_get_m_localPointB_0=function(){return(wj=b._emscripten_bind_btManifoldPoint_get_m_localPointB_0=b.asm.yh).apply(null,arguments)},xj=b._emscripten_bind_btManifoldPoint_set_m_localPointB_1=function(){return(xj=b._emscripten_bind_btManifoldPoint_set_m_localPointB_1=b.asm.zh).apply(null,arguments)},yj=b._emscripten_bind_btManifoldPoint_get_m_positionWorldOnB_0=function(){return(yj=b._emscripten_bind_btManifoldPoint_get_m_positionWorldOnB_0=b.asm.Ah).apply(null, +arguments)},zj=b._emscripten_bind_btManifoldPoint_set_m_positionWorldOnB_1=function(){return(zj=b._emscripten_bind_btManifoldPoint_set_m_positionWorldOnB_1=b.asm.Bh).apply(null,arguments)},Aj=b._emscripten_bind_btManifoldPoint_get_m_positionWorldOnA_0=function(){return(Aj=b._emscripten_bind_btManifoldPoint_get_m_positionWorldOnA_0=b.asm.Ch).apply(null,arguments)},Bj=b._emscripten_bind_btManifoldPoint_set_m_positionWorldOnA_1=function(){return(Bj=b._emscripten_bind_btManifoldPoint_set_m_positionWorldOnA_1= +b.asm.Dh).apply(null,arguments)},Cj=b._emscripten_bind_btManifoldPoint_get_m_normalWorldOnB_0=function(){return(Cj=b._emscripten_bind_btManifoldPoint_get_m_normalWorldOnB_0=b.asm.Eh).apply(null,arguments)},Dj=b._emscripten_bind_btManifoldPoint_set_m_normalWorldOnB_1=function(){return(Dj=b._emscripten_bind_btManifoldPoint_set_m_normalWorldOnB_1=b.asm.Fh).apply(null,arguments)},Ej=b._emscripten_bind_btManifoldPoint_get_m_userPersistentData_0=function(){return(Ej=b._emscripten_bind_btManifoldPoint_get_m_userPersistentData_0= +b.asm.Gh).apply(null,arguments)},Fj=b._emscripten_bind_btManifoldPoint_set_m_userPersistentData_1=function(){return(Fj=b._emscripten_bind_btManifoldPoint_set_m_userPersistentData_1=b.asm.Hh).apply(null,arguments)},Gj=b._emscripten_bind_btManifoldPoint___destroy___0=function(){return(Gj=b._emscripten_bind_btManifoldPoint___destroy___0=b.asm.Ih).apply(null,arguments)},Hj=b._emscripten_bind_ConcreteContactResultCallback_ConcreteContactResultCallback_0=function(){return(Hj=b._emscripten_bind_ConcreteContactResultCallback_ConcreteContactResultCallback_0= +b.asm.Jh).apply(null,arguments)},Ij=b._emscripten_bind_ConcreteContactResultCallback_addSingleResult_7=function(){return(Ij=b._emscripten_bind_ConcreteContactResultCallback_addSingleResult_7=b.asm.Kh).apply(null,arguments)},Jj=b._emscripten_bind_ConcreteContactResultCallback___destroy___0=function(){return(Jj=b._emscripten_bind_ConcreteContactResultCallback___destroy___0=b.asm.Lh).apply(null,arguments)},Kj=b._emscripten_bind_LocalShapeInfo_get_m_shapePart_0=function(){return(Kj=b._emscripten_bind_LocalShapeInfo_get_m_shapePart_0= +b.asm.Mh).apply(null,arguments)},Lj=b._emscripten_bind_LocalShapeInfo_set_m_shapePart_1=function(){return(Lj=b._emscripten_bind_LocalShapeInfo_set_m_shapePart_1=b.asm.Nh).apply(null,arguments)},Mj=b._emscripten_bind_LocalShapeInfo_get_m_triangleIndex_0=function(){return(Mj=b._emscripten_bind_LocalShapeInfo_get_m_triangleIndex_0=b.asm.Oh).apply(null,arguments)},Nj=b._emscripten_bind_LocalShapeInfo_set_m_triangleIndex_1=function(){return(Nj=b._emscripten_bind_LocalShapeInfo_set_m_triangleIndex_1=b.asm.Ph).apply(null, +arguments)},Oj=b._emscripten_bind_LocalShapeInfo___destroy___0=function(){return(Oj=b._emscripten_bind_LocalShapeInfo___destroy___0=b.asm.Qh).apply(null,arguments)},Pj=b._emscripten_bind_LocalConvexResult_LocalConvexResult_5=function(){return(Pj=b._emscripten_bind_LocalConvexResult_LocalConvexResult_5=b.asm.Rh).apply(null,arguments)},Qj=b._emscripten_bind_LocalConvexResult_get_m_hitCollisionObject_0=function(){return(Qj=b._emscripten_bind_LocalConvexResult_get_m_hitCollisionObject_0=b.asm.Sh).apply(null, +arguments)},Rj=b._emscripten_bind_LocalConvexResult_set_m_hitCollisionObject_1=function(){return(Rj=b._emscripten_bind_LocalConvexResult_set_m_hitCollisionObject_1=b.asm.Th).apply(null,arguments)},Sj=b._emscripten_bind_LocalConvexResult_get_m_localShapeInfo_0=function(){return(Sj=b._emscripten_bind_LocalConvexResult_get_m_localShapeInfo_0=b.asm.Uh).apply(null,arguments)},Tj=b._emscripten_bind_LocalConvexResult_set_m_localShapeInfo_1=function(){return(Tj=b._emscripten_bind_LocalConvexResult_set_m_localShapeInfo_1= +b.asm.Vh).apply(null,arguments)},Uj=b._emscripten_bind_LocalConvexResult_get_m_hitNormalLocal_0=function(){return(Uj=b._emscripten_bind_LocalConvexResult_get_m_hitNormalLocal_0=b.asm.Wh).apply(null,arguments)},Vj=b._emscripten_bind_LocalConvexResult_set_m_hitNormalLocal_1=function(){return(Vj=b._emscripten_bind_LocalConvexResult_set_m_hitNormalLocal_1=b.asm.Xh).apply(null,arguments)},Wj=b._emscripten_bind_LocalConvexResult_get_m_hitPointLocal_0=function(){return(Wj=b._emscripten_bind_LocalConvexResult_get_m_hitPointLocal_0= +b.asm.Yh).apply(null,arguments)},Xj=b._emscripten_bind_LocalConvexResult_set_m_hitPointLocal_1=function(){return(Xj=b._emscripten_bind_LocalConvexResult_set_m_hitPointLocal_1=b.asm.Zh).apply(null,arguments)},Yj=b._emscripten_bind_LocalConvexResult_get_m_hitFraction_0=function(){return(Yj=b._emscripten_bind_LocalConvexResult_get_m_hitFraction_0=b.asm._h).apply(null,arguments)},Zj=b._emscripten_bind_LocalConvexResult_set_m_hitFraction_1=function(){return(Zj=b._emscripten_bind_LocalConvexResult_set_m_hitFraction_1= +b.asm.$h).apply(null,arguments)},ak=b._emscripten_bind_LocalConvexResult___destroy___0=function(){return(ak=b._emscripten_bind_LocalConvexResult___destroy___0=b.asm.ai).apply(null,arguments)},bk=b._emscripten_bind_ClosestConvexResultCallback_ClosestConvexResultCallback_2=function(){return(bk=b._emscripten_bind_ClosestConvexResultCallback_ClosestConvexResultCallback_2=b.asm.bi).apply(null,arguments)},ck=b._emscripten_bind_ClosestConvexResultCallback_hasHit_0=function(){return(ck=b._emscripten_bind_ClosestConvexResultCallback_hasHit_0= +b.asm.ci).apply(null,arguments)},dk=b._emscripten_bind_ClosestConvexResultCallback_get_m_hitCollisionObject_0=function(){return(dk=b._emscripten_bind_ClosestConvexResultCallback_get_m_hitCollisionObject_0=b.asm.di).apply(null,arguments)},ek=b._emscripten_bind_ClosestConvexResultCallback_set_m_hitCollisionObject_1=function(){return(ek=b._emscripten_bind_ClosestConvexResultCallback_set_m_hitCollisionObject_1=b.asm.ei).apply(null,arguments)},fk=b._emscripten_bind_ClosestConvexResultCallback_get_m_convexFromWorld_0= +function(){return(fk=b._emscripten_bind_ClosestConvexResultCallback_get_m_convexFromWorld_0=b.asm.fi).apply(null,arguments)},gk=b._emscripten_bind_ClosestConvexResultCallback_set_m_convexFromWorld_1=function(){return(gk=b._emscripten_bind_ClosestConvexResultCallback_set_m_convexFromWorld_1=b.asm.gi).apply(null,arguments)},hk=b._emscripten_bind_ClosestConvexResultCallback_get_m_convexToWorld_0=function(){return(hk=b._emscripten_bind_ClosestConvexResultCallback_get_m_convexToWorld_0=b.asm.hi).apply(null, +arguments)},ik=b._emscripten_bind_ClosestConvexResultCallback_set_m_convexToWorld_1=function(){return(ik=b._emscripten_bind_ClosestConvexResultCallback_set_m_convexToWorld_1=b.asm.ii).apply(null,arguments)},jk=b._emscripten_bind_ClosestConvexResultCallback_get_m_hitNormalWorld_0=function(){return(jk=b._emscripten_bind_ClosestConvexResultCallback_get_m_hitNormalWorld_0=b.asm.ji).apply(null,arguments)},kk=b._emscripten_bind_ClosestConvexResultCallback_set_m_hitNormalWorld_1=function(){return(kk=b._emscripten_bind_ClosestConvexResultCallback_set_m_hitNormalWorld_1= +b.asm.ki).apply(null,arguments)},lk=b._emscripten_bind_ClosestConvexResultCallback_get_m_hitPointWorld_0=function(){return(lk=b._emscripten_bind_ClosestConvexResultCallback_get_m_hitPointWorld_0=b.asm.li).apply(null,arguments)},mk=b._emscripten_bind_ClosestConvexResultCallback_set_m_hitPointWorld_1=function(){return(mk=b._emscripten_bind_ClosestConvexResultCallback_set_m_hitPointWorld_1=b.asm.mi).apply(null,arguments)},nk=b._emscripten_bind_ClosestConvexResultCallback_get_m_collisionFilterGroup_0= +function(){return(nk=b._emscripten_bind_ClosestConvexResultCallback_get_m_collisionFilterGroup_0=b.asm.ni).apply(null,arguments)},ok=b._emscripten_bind_ClosestConvexResultCallback_set_m_collisionFilterGroup_1=function(){return(ok=b._emscripten_bind_ClosestConvexResultCallback_set_m_collisionFilterGroup_1=b.asm.oi).apply(null,arguments)},pk=b._emscripten_bind_ClosestConvexResultCallback_get_m_collisionFilterMask_0=function(){return(pk=b._emscripten_bind_ClosestConvexResultCallback_get_m_collisionFilterMask_0= +b.asm.pi).apply(null,arguments)},qk=b._emscripten_bind_ClosestConvexResultCallback_set_m_collisionFilterMask_1=function(){return(qk=b._emscripten_bind_ClosestConvexResultCallback_set_m_collisionFilterMask_1=b.asm.qi).apply(null,arguments)},rk=b._emscripten_bind_ClosestConvexResultCallback_get_m_closestHitFraction_0=function(){return(rk=b._emscripten_bind_ClosestConvexResultCallback_get_m_closestHitFraction_0=b.asm.ri).apply(null,arguments)},sk=b._emscripten_bind_ClosestConvexResultCallback_set_m_closestHitFraction_1= +function(){return(sk=b._emscripten_bind_ClosestConvexResultCallback_set_m_closestHitFraction_1=b.asm.si).apply(null,arguments)},tk=b._emscripten_bind_ClosestConvexResultCallback___destroy___0=function(){return(tk=b._emscripten_bind_ClosestConvexResultCallback___destroy___0=b.asm.ti).apply(null,arguments)},uk=b._emscripten_bind_btConvexTriangleMeshShape_btConvexTriangleMeshShape_1=function(){return(uk=b._emscripten_bind_btConvexTriangleMeshShape_btConvexTriangleMeshShape_1=b.asm.ui).apply(null,arguments)}, +vk=b._emscripten_bind_btConvexTriangleMeshShape_btConvexTriangleMeshShape_2=function(){return(vk=b._emscripten_bind_btConvexTriangleMeshShape_btConvexTriangleMeshShape_2=b.asm.vi).apply(null,arguments)},wk=b._emscripten_bind_btConvexTriangleMeshShape_setLocalScaling_1=function(){return(wk=b._emscripten_bind_btConvexTriangleMeshShape_setLocalScaling_1=b.asm.wi).apply(null,arguments)},xk=b._emscripten_bind_btConvexTriangleMeshShape_getLocalScaling_0=function(){return(xk=b._emscripten_bind_btConvexTriangleMeshShape_getLocalScaling_0= +b.asm.xi).apply(null,arguments)},yk=b._emscripten_bind_btConvexTriangleMeshShape_calculateLocalInertia_2=function(){return(yk=b._emscripten_bind_btConvexTriangleMeshShape_calculateLocalInertia_2=b.asm.yi).apply(null,arguments)},zk=b._emscripten_bind_btConvexTriangleMeshShape_setMargin_1=function(){return(zk=b._emscripten_bind_btConvexTriangleMeshShape_setMargin_1=b.asm.zi).apply(null,arguments)},Ak=b._emscripten_bind_btConvexTriangleMeshShape_getMargin_0=function(){return(Ak=b._emscripten_bind_btConvexTriangleMeshShape_getMargin_0= +b.asm.Ai).apply(null,arguments)},Bk=b._emscripten_bind_btConvexTriangleMeshShape___destroy___0=function(){return(Bk=b._emscripten_bind_btConvexTriangleMeshShape___destroy___0=b.asm.Bi).apply(null,arguments)},Ck=b._emscripten_bind_btBoxShape_btBoxShape_1=function(){return(Ck=b._emscripten_bind_btBoxShape_btBoxShape_1=b.asm.Ci).apply(null,arguments)},Dk=b._emscripten_bind_btBoxShape_setMargin_1=function(){return(Dk=b._emscripten_bind_btBoxShape_setMargin_1=b.asm.Di).apply(null,arguments)},Ek=b._emscripten_bind_btBoxShape_getMargin_0= +function(){return(Ek=b._emscripten_bind_btBoxShape_getMargin_0=b.asm.Ei).apply(null,arguments)},Fk=b._emscripten_bind_btBoxShape_setLocalScaling_1=function(){return(Fk=b._emscripten_bind_btBoxShape_setLocalScaling_1=b.asm.Fi).apply(null,arguments)},Gk=b._emscripten_bind_btBoxShape_getLocalScaling_0=function(){return(Gk=b._emscripten_bind_btBoxShape_getLocalScaling_0=b.asm.Gi).apply(null,arguments)},Hk=b._emscripten_bind_btBoxShape_calculateLocalInertia_2=function(){return(Hk=b._emscripten_bind_btBoxShape_calculateLocalInertia_2= +b.asm.Hi).apply(null,arguments)},Ik=b._emscripten_bind_btBoxShape___destroy___0=function(){return(Ik=b._emscripten_bind_btBoxShape___destroy___0=b.asm.Ii).apply(null,arguments)},Jk=b._emscripten_bind_btCapsuleShapeX_btCapsuleShapeX_2=function(){return(Jk=b._emscripten_bind_btCapsuleShapeX_btCapsuleShapeX_2=b.asm.Ji).apply(null,arguments)},Kk=b._emscripten_bind_btCapsuleShapeX_setMargin_1=function(){return(Kk=b._emscripten_bind_btCapsuleShapeX_setMargin_1=b.asm.Ki).apply(null,arguments)},Lk=b._emscripten_bind_btCapsuleShapeX_getMargin_0= +function(){return(Lk=b._emscripten_bind_btCapsuleShapeX_getMargin_0=b.asm.Li).apply(null,arguments)},Mk=b._emscripten_bind_btCapsuleShapeX_getUpAxis_0=function(){return(Mk=b._emscripten_bind_btCapsuleShapeX_getUpAxis_0=b.asm.Mi).apply(null,arguments)},Nk=b._emscripten_bind_btCapsuleShapeX_getRadius_0=function(){return(Nk=b._emscripten_bind_btCapsuleShapeX_getRadius_0=b.asm.Ni).apply(null,arguments)},Ok=b._emscripten_bind_btCapsuleShapeX_getHalfHeight_0=function(){return(Ok=b._emscripten_bind_btCapsuleShapeX_getHalfHeight_0= +b.asm.Oi).apply(null,arguments)},Pk=b._emscripten_bind_btCapsuleShapeX_setLocalScaling_1=function(){return(Pk=b._emscripten_bind_btCapsuleShapeX_setLocalScaling_1=b.asm.Pi).apply(null,arguments)},Qk=b._emscripten_bind_btCapsuleShapeX_getLocalScaling_0=function(){return(Qk=b._emscripten_bind_btCapsuleShapeX_getLocalScaling_0=b.asm.Qi).apply(null,arguments)},Rk=b._emscripten_bind_btCapsuleShapeX_calculateLocalInertia_2=function(){return(Rk=b._emscripten_bind_btCapsuleShapeX_calculateLocalInertia_2= +b.asm.Ri).apply(null,arguments)},Sk=b._emscripten_bind_btCapsuleShapeX___destroy___0=function(){return(Sk=b._emscripten_bind_btCapsuleShapeX___destroy___0=b.asm.Si).apply(null,arguments)},Tk=b._emscripten_bind_btCapsuleShapeZ_btCapsuleShapeZ_2=function(){return(Tk=b._emscripten_bind_btCapsuleShapeZ_btCapsuleShapeZ_2=b.asm.Ti).apply(null,arguments)},Uk=b._emscripten_bind_btCapsuleShapeZ_setMargin_1=function(){return(Uk=b._emscripten_bind_btCapsuleShapeZ_setMargin_1=b.asm.Ui).apply(null,arguments)}, +Vk=b._emscripten_bind_btCapsuleShapeZ_getMargin_0=function(){return(Vk=b._emscripten_bind_btCapsuleShapeZ_getMargin_0=b.asm.Vi).apply(null,arguments)},Wk=b._emscripten_bind_btCapsuleShapeZ_getUpAxis_0=function(){return(Wk=b._emscripten_bind_btCapsuleShapeZ_getUpAxis_0=b.asm.Wi).apply(null,arguments)},Xk=b._emscripten_bind_btCapsuleShapeZ_getRadius_0=function(){return(Xk=b._emscripten_bind_btCapsuleShapeZ_getRadius_0=b.asm.Xi).apply(null,arguments)},Yk=b._emscripten_bind_btCapsuleShapeZ_getHalfHeight_0= +function(){return(Yk=b._emscripten_bind_btCapsuleShapeZ_getHalfHeight_0=b.asm.Yi).apply(null,arguments)},Zk=b._emscripten_bind_btCapsuleShapeZ_setLocalScaling_1=function(){return(Zk=b._emscripten_bind_btCapsuleShapeZ_setLocalScaling_1=b.asm.Zi).apply(null,arguments)},$k=b._emscripten_bind_btCapsuleShapeZ_getLocalScaling_0=function(){return($k=b._emscripten_bind_btCapsuleShapeZ_getLocalScaling_0=b.asm._i).apply(null,arguments)},al=b._emscripten_bind_btCapsuleShapeZ_calculateLocalInertia_2=function(){return(al= +b._emscripten_bind_btCapsuleShapeZ_calculateLocalInertia_2=b.asm.$i).apply(null,arguments)},bl=b._emscripten_bind_btCapsuleShapeZ___destroy___0=function(){return(bl=b._emscripten_bind_btCapsuleShapeZ___destroy___0=b.asm.aj).apply(null,arguments)},cl=b._emscripten_bind_btCylinderShapeX_btCylinderShapeX_1=function(){return(cl=b._emscripten_bind_btCylinderShapeX_btCylinderShapeX_1=b.asm.bj).apply(null,arguments)},dl=b._emscripten_bind_btCylinderShapeX_setMargin_1=function(){return(dl=b._emscripten_bind_btCylinderShapeX_setMargin_1= +b.asm.cj).apply(null,arguments)},el=b._emscripten_bind_btCylinderShapeX_getMargin_0=function(){return(el=b._emscripten_bind_btCylinderShapeX_getMargin_0=b.asm.dj).apply(null,arguments)},fl=b._emscripten_bind_btCylinderShapeX_setLocalScaling_1=function(){return(fl=b._emscripten_bind_btCylinderShapeX_setLocalScaling_1=b.asm.ej).apply(null,arguments)},gl=b._emscripten_bind_btCylinderShapeX_getLocalScaling_0=function(){return(gl=b._emscripten_bind_btCylinderShapeX_getLocalScaling_0=b.asm.fj).apply(null, +arguments)},hl=b._emscripten_bind_btCylinderShapeX_calculateLocalInertia_2=function(){return(hl=b._emscripten_bind_btCylinderShapeX_calculateLocalInertia_2=b.asm.gj).apply(null,arguments)},il=b._emscripten_bind_btCylinderShapeX___destroy___0=function(){return(il=b._emscripten_bind_btCylinderShapeX___destroy___0=b.asm.hj).apply(null,arguments)},jl=b._emscripten_bind_btCylinderShapeZ_btCylinderShapeZ_1=function(){return(jl=b._emscripten_bind_btCylinderShapeZ_btCylinderShapeZ_1=b.asm.ij).apply(null, +arguments)},kl=b._emscripten_bind_btCylinderShapeZ_setMargin_1=function(){return(kl=b._emscripten_bind_btCylinderShapeZ_setMargin_1=b.asm.jj).apply(null,arguments)},ll=b._emscripten_bind_btCylinderShapeZ_getMargin_0=function(){return(ll=b._emscripten_bind_btCylinderShapeZ_getMargin_0=b.asm.kj).apply(null,arguments)},ml=b._emscripten_bind_btCylinderShapeZ_setLocalScaling_1=function(){return(ml=b._emscripten_bind_btCylinderShapeZ_setLocalScaling_1=b.asm.lj).apply(null,arguments)},nl=b._emscripten_bind_btCylinderShapeZ_getLocalScaling_0= +function(){return(nl=b._emscripten_bind_btCylinderShapeZ_getLocalScaling_0=b.asm.mj).apply(null,arguments)},ol=b._emscripten_bind_btCylinderShapeZ_calculateLocalInertia_2=function(){return(ol=b._emscripten_bind_btCylinderShapeZ_calculateLocalInertia_2=b.asm.nj).apply(null,arguments)},pl=b._emscripten_bind_btCylinderShapeZ___destroy___0=function(){return(pl=b._emscripten_bind_btCylinderShapeZ___destroy___0=b.asm.oj).apply(null,arguments)},ql=b._emscripten_bind_btSphereShape_btSphereShape_1=function(){return(ql= +b._emscripten_bind_btSphereShape_btSphereShape_1=b.asm.pj).apply(null,arguments)},rl=b._emscripten_bind_btSphereShape_setMargin_1=function(){return(rl=b._emscripten_bind_btSphereShape_setMargin_1=b.asm.qj).apply(null,arguments)},sl=b._emscripten_bind_btSphereShape_getMargin_0=function(){return(sl=b._emscripten_bind_btSphereShape_getMargin_0=b.asm.rj).apply(null,arguments)},tl=b._emscripten_bind_btSphereShape_setLocalScaling_1=function(){return(tl=b._emscripten_bind_btSphereShape_setLocalScaling_1= +b.asm.sj).apply(null,arguments)},ul=b._emscripten_bind_btSphereShape_getLocalScaling_0=function(){return(ul=b._emscripten_bind_btSphereShape_getLocalScaling_0=b.asm.tj).apply(null,arguments)},vl=b._emscripten_bind_btSphereShape_calculateLocalInertia_2=function(){return(vl=b._emscripten_bind_btSphereShape_calculateLocalInertia_2=b.asm.uj).apply(null,arguments)},wl=b._emscripten_bind_btSphereShape___destroy___0=function(){return(wl=b._emscripten_bind_btSphereShape___destroy___0=b.asm.vj).apply(null, +arguments)},xl=b._emscripten_bind_btMultiSphereShape_btMultiSphereShape_3=function(){return(xl=b._emscripten_bind_btMultiSphereShape_btMultiSphereShape_3=b.asm.wj).apply(null,arguments)},yl=b._emscripten_bind_btMultiSphereShape_setLocalScaling_1=function(){return(yl=b._emscripten_bind_btMultiSphereShape_setLocalScaling_1=b.asm.xj).apply(null,arguments)},zl=b._emscripten_bind_btMultiSphereShape_getLocalScaling_0=function(){return(zl=b._emscripten_bind_btMultiSphereShape_getLocalScaling_0=b.asm.yj).apply(null, +arguments)},Al=b._emscripten_bind_btMultiSphereShape_calculateLocalInertia_2=function(){return(Al=b._emscripten_bind_btMultiSphereShape_calculateLocalInertia_2=b.asm.zj).apply(null,arguments)},Bl=b._emscripten_bind_btMultiSphereShape___destroy___0=function(){return(Bl=b._emscripten_bind_btMultiSphereShape___destroy___0=b.asm.Aj).apply(null,arguments)},Cl=b._emscripten_bind_btConeShapeX_btConeShapeX_2=function(){return(Cl=b._emscripten_bind_btConeShapeX_btConeShapeX_2=b.asm.Bj).apply(null,arguments)}, +Dl=b._emscripten_bind_btConeShapeX_setLocalScaling_1=function(){return(Dl=b._emscripten_bind_btConeShapeX_setLocalScaling_1=b.asm.Cj).apply(null,arguments)},El=b._emscripten_bind_btConeShapeX_getLocalScaling_0=function(){return(El=b._emscripten_bind_btConeShapeX_getLocalScaling_0=b.asm.Dj).apply(null,arguments)},Fl=b._emscripten_bind_btConeShapeX_calculateLocalInertia_2=function(){return(Fl=b._emscripten_bind_btConeShapeX_calculateLocalInertia_2=b.asm.Ej).apply(null,arguments)},Gl=b._emscripten_bind_btConeShapeX___destroy___0= +function(){return(Gl=b._emscripten_bind_btConeShapeX___destroy___0=b.asm.Fj).apply(null,arguments)},Hl=b._emscripten_bind_btConeShapeZ_btConeShapeZ_2=function(){return(Hl=b._emscripten_bind_btConeShapeZ_btConeShapeZ_2=b.asm.Gj).apply(null,arguments)},Il=b._emscripten_bind_btConeShapeZ_setLocalScaling_1=function(){return(Il=b._emscripten_bind_btConeShapeZ_setLocalScaling_1=b.asm.Hj).apply(null,arguments)},Jl=b._emscripten_bind_btConeShapeZ_getLocalScaling_0=function(){return(Jl=b._emscripten_bind_btConeShapeZ_getLocalScaling_0= +b.asm.Ij).apply(null,arguments)},Kl=b._emscripten_bind_btConeShapeZ_calculateLocalInertia_2=function(){return(Kl=b._emscripten_bind_btConeShapeZ_calculateLocalInertia_2=b.asm.Jj).apply(null,arguments)},Ll=b._emscripten_bind_btConeShapeZ___destroy___0=function(){return(Ll=b._emscripten_bind_btConeShapeZ___destroy___0=b.asm.Kj).apply(null,arguments)},Ml=b._emscripten_bind_btIntArray_size_0=function(){return(Ml=b._emscripten_bind_btIntArray_size_0=b.asm.Lj).apply(null,arguments)},Nl=b._emscripten_bind_btIntArray_at_1= +function(){return(Nl=b._emscripten_bind_btIntArray_at_1=b.asm.Mj).apply(null,arguments)},Ol=b._emscripten_bind_btIntArray___destroy___0=function(){return(Ol=b._emscripten_bind_btIntArray___destroy___0=b.asm.Nj).apply(null,arguments)},Pl=b._emscripten_bind_btFace_get_m_indices_0=function(){return(Pl=b._emscripten_bind_btFace_get_m_indices_0=b.asm.Oj).apply(null,arguments)},Ql=b._emscripten_bind_btFace_set_m_indices_1=function(){return(Ql=b._emscripten_bind_btFace_set_m_indices_1=b.asm.Pj).apply(null, +arguments)},Rl=b._emscripten_bind_btFace_get_m_plane_1=function(){return(Rl=b._emscripten_bind_btFace_get_m_plane_1=b.asm.Qj).apply(null,arguments)},Sl=b._emscripten_bind_btFace_set_m_plane_2=function(){return(Sl=b._emscripten_bind_btFace_set_m_plane_2=b.asm.Rj).apply(null,arguments)},Tl=b._emscripten_bind_btFace___destroy___0=function(){return(Tl=b._emscripten_bind_btFace___destroy___0=b.asm.Sj).apply(null,arguments)},Ul=b._emscripten_bind_btVector3Array_size_0=function(){return(Ul=b._emscripten_bind_btVector3Array_size_0= +b.asm.Tj).apply(null,arguments)},Vl=b._emscripten_bind_btVector3Array_at_1=function(){return(Vl=b._emscripten_bind_btVector3Array_at_1=b.asm.Uj).apply(null,arguments)},Wl=b._emscripten_bind_btVector3Array___destroy___0=function(){return(Wl=b._emscripten_bind_btVector3Array___destroy___0=b.asm.Vj).apply(null,arguments)},Xl=b._emscripten_bind_btFaceArray_size_0=function(){return(Xl=b._emscripten_bind_btFaceArray_size_0=b.asm.Wj).apply(null,arguments)},Yl=b._emscripten_bind_btFaceArray_at_1=function(){return(Yl= +b._emscripten_bind_btFaceArray_at_1=b.asm.Xj).apply(null,arguments)},Zl=b._emscripten_bind_btFaceArray___destroy___0=function(){return(Zl=b._emscripten_bind_btFaceArray___destroy___0=b.asm.Yj).apply(null,arguments)},$l=b._emscripten_bind_btConvexPolyhedron_get_m_vertices_0=function(){return($l=b._emscripten_bind_btConvexPolyhedron_get_m_vertices_0=b.asm.Zj).apply(null,arguments)},am=b._emscripten_bind_btConvexPolyhedron_set_m_vertices_1=function(){return(am=b._emscripten_bind_btConvexPolyhedron_set_m_vertices_1= +b.asm._j).apply(null,arguments)},bm=b._emscripten_bind_btConvexPolyhedron_get_m_faces_0=function(){return(bm=b._emscripten_bind_btConvexPolyhedron_get_m_faces_0=b.asm.$j).apply(null,arguments)},cm=b._emscripten_bind_btConvexPolyhedron_set_m_faces_1=function(){return(cm=b._emscripten_bind_btConvexPolyhedron_set_m_faces_1=b.asm.ak).apply(null,arguments)},dm=b._emscripten_bind_btConvexPolyhedron___destroy___0=function(){return(dm=b._emscripten_bind_btConvexPolyhedron___destroy___0=b.asm.bk).apply(null, +arguments)},em=b._emscripten_bind_btConvexHullShape_btConvexHullShape_0=function(){return(em=b._emscripten_bind_btConvexHullShape_btConvexHullShape_0=b.asm.ck).apply(null,arguments)},fm=b._emscripten_bind_btConvexHullShape_btConvexHullShape_1=function(){return(fm=b._emscripten_bind_btConvexHullShape_btConvexHullShape_1=b.asm.dk).apply(null,arguments)},gm=b._emscripten_bind_btConvexHullShape_btConvexHullShape_2=function(){return(gm=b._emscripten_bind_btConvexHullShape_btConvexHullShape_2=b.asm.ek).apply(null, +arguments)},hm=b._emscripten_bind_btConvexHullShape_addPoint_1=function(){return(hm=b._emscripten_bind_btConvexHullShape_addPoint_1=b.asm.fk).apply(null,arguments)},im=b._emscripten_bind_btConvexHullShape_addPoint_2=function(){return(im=b._emscripten_bind_btConvexHullShape_addPoint_2=b.asm.gk).apply(null,arguments)},jm=b._emscripten_bind_btConvexHullShape_setMargin_1=function(){return(jm=b._emscripten_bind_btConvexHullShape_setMargin_1=b.asm.hk).apply(null,arguments)},km=b._emscripten_bind_btConvexHullShape_getMargin_0= +function(){return(km=b._emscripten_bind_btConvexHullShape_getMargin_0=b.asm.ik).apply(null,arguments)},lm=b._emscripten_bind_btConvexHullShape_getNumVertices_0=function(){return(lm=b._emscripten_bind_btConvexHullShape_getNumVertices_0=b.asm.jk).apply(null,arguments)},mm=b._emscripten_bind_btConvexHullShape_initializePolyhedralFeatures_1=function(){return(mm=b._emscripten_bind_btConvexHullShape_initializePolyhedralFeatures_1=b.asm.kk).apply(null,arguments)},nm=b._emscripten_bind_btConvexHullShape_recalcLocalAabb_0= +function(){return(nm=b._emscripten_bind_btConvexHullShape_recalcLocalAabb_0=b.asm.lk).apply(null,arguments)},om=b._emscripten_bind_btConvexHullShape_getConvexPolyhedron_0=function(){return(om=b._emscripten_bind_btConvexHullShape_getConvexPolyhedron_0=b.asm.mk).apply(null,arguments)},pm=b._emscripten_bind_btConvexHullShape_setLocalScaling_1=function(){return(pm=b._emscripten_bind_btConvexHullShape_setLocalScaling_1=b.asm.nk).apply(null,arguments)},qm=b._emscripten_bind_btConvexHullShape_getLocalScaling_0= +function(){return(qm=b._emscripten_bind_btConvexHullShape_getLocalScaling_0=b.asm.ok).apply(null,arguments)},rm=b._emscripten_bind_btConvexHullShape_calculateLocalInertia_2=function(){return(rm=b._emscripten_bind_btConvexHullShape_calculateLocalInertia_2=b.asm.pk).apply(null,arguments)},sm=b._emscripten_bind_btConvexHullShape___destroy___0=function(){return(sm=b._emscripten_bind_btConvexHullShape___destroy___0=b.asm.qk).apply(null,arguments)},tm=b._emscripten_bind_btShapeHull_btShapeHull_1=function(){return(tm= +b._emscripten_bind_btShapeHull_btShapeHull_1=b.asm.rk).apply(null,arguments)},um=b._emscripten_bind_btShapeHull_buildHull_1=function(){return(um=b._emscripten_bind_btShapeHull_buildHull_1=b.asm.sk).apply(null,arguments)},wm=b._emscripten_bind_btShapeHull_numVertices_0=function(){return(wm=b._emscripten_bind_btShapeHull_numVertices_0=b.asm.tk).apply(null,arguments)},xm=b._emscripten_bind_btShapeHull_getVertexPointer_0=function(){return(xm=b._emscripten_bind_btShapeHull_getVertexPointer_0=b.asm.uk).apply(null, +arguments)},ym=b._emscripten_bind_btShapeHull___destroy___0=function(){return(ym=b._emscripten_bind_btShapeHull___destroy___0=b.asm.vk).apply(null,arguments)},zm=b._emscripten_bind_btCompoundShape_btCompoundShape_0=function(){return(zm=b._emscripten_bind_btCompoundShape_btCompoundShape_0=b.asm.wk).apply(null,arguments)},Am=b._emscripten_bind_btCompoundShape_btCompoundShape_1=function(){return(Am=b._emscripten_bind_btCompoundShape_btCompoundShape_1=b.asm.xk).apply(null,arguments)},Bm=b._emscripten_bind_btCompoundShape_addChildShape_2= +function(){return(Bm=b._emscripten_bind_btCompoundShape_addChildShape_2=b.asm.yk).apply(null,arguments)},Cm=b._emscripten_bind_btCompoundShape_removeChildShape_1=function(){return(Cm=b._emscripten_bind_btCompoundShape_removeChildShape_1=b.asm.zk).apply(null,arguments)},Dm=b._emscripten_bind_btCompoundShape_removeChildShapeByIndex_1=function(){return(Dm=b._emscripten_bind_btCompoundShape_removeChildShapeByIndex_1=b.asm.Ak).apply(null,arguments)},Em=b._emscripten_bind_btCompoundShape_getNumChildShapes_0= +function(){return(Em=b._emscripten_bind_btCompoundShape_getNumChildShapes_0=b.asm.Bk).apply(null,arguments)},Fm=b._emscripten_bind_btCompoundShape_getChildShape_1=function(){return(Fm=b._emscripten_bind_btCompoundShape_getChildShape_1=b.asm.Ck).apply(null,arguments)},Gm=b._emscripten_bind_btCompoundShape_updateChildTransform_2=function(){return(Gm=b._emscripten_bind_btCompoundShape_updateChildTransform_2=b.asm.Dk).apply(null,arguments)},Hm=b._emscripten_bind_btCompoundShape_updateChildTransform_3= +function(){return(Hm=b._emscripten_bind_btCompoundShape_updateChildTransform_3=b.asm.Ek).apply(null,arguments)},Im=b._emscripten_bind_btCompoundShape_setMargin_1=function(){return(Im=b._emscripten_bind_btCompoundShape_setMargin_1=b.asm.Fk).apply(null,arguments)},Jm=b._emscripten_bind_btCompoundShape_getMargin_0=function(){return(Jm=b._emscripten_bind_btCompoundShape_getMargin_0=b.asm.Gk).apply(null,arguments)},Km=b._emscripten_bind_btCompoundShape_setLocalScaling_1=function(){return(Km=b._emscripten_bind_btCompoundShape_setLocalScaling_1= +b.asm.Hk).apply(null,arguments)},Lm=b._emscripten_bind_btCompoundShape_getLocalScaling_0=function(){return(Lm=b._emscripten_bind_btCompoundShape_getLocalScaling_0=b.asm.Ik).apply(null,arguments)},Mm=b._emscripten_bind_btCompoundShape_calculateLocalInertia_2=function(){return(Mm=b._emscripten_bind_btCompoundShape_calculateLocalInertia_2=b.asm.Jk).apply(null,arguments)},Nm=b._emscripten_bind_btCompoundShape___destroy___0=function(){return(Nm=b._emscripten_bind_btCompoundShape___destroy___0=b.asm.Kk).apply(null, +arguments)},Om=b._emscripten_bind_btIndexedMesh_get_m_numTriangles_0=function(){return(Om=b._emscripten_bind_btIndexedMesh_get_m_numTriangles_0=b.asm.Lk).apply(null,arguments)},Pm=b._emscripten_bind_btIndexedMesh_set_m_numTriangles_1=function(){return(Pm=b._emscripten_bind_btIndexedMesh_set_m_numTriangles_1=b.asm.Mk).apply(null,arguments)},Qm=b._emscripten_bind_btIndexedMesh___destroy___0=function(){return(Qm=b._emscripten_bind_btIndexedMesh___destroy___0=b.asm.Nk).apply(null,arguments)},Rm=b._emscripten_bind_btIndexedMeshArray_size_0= +function(){return(Rm=b._emscripten_bind_btIndexedMeshArray_size_0=b.asm.Ok).apply(null,arguments)},Sm=b._emscripten_bind_btIndexedMeshArray_at_1=function(){return(Sm=b._emscripten_bind_btIndexedMeshArray_at_1=b.asm.Pk).apply(null,arguments)},Tm=b._emscripten_bind_btIndexedMeshArray___destroy___0=function(){return(Tm=b._emscripten_bind_btIndexedMeshArray___destroy___0=b.asm.Qk).apply(null,arguments)},Um=b._emscripten_bind_btTriangleMesh_btTriangleMesh_0=function(){return(Um=b._emscripten_bind_btTriangleMesh_btTriangleMesh_0= +b.asm.Rk).apply(null,arguments)},Vm=b._emscripten_bind_btTriangleMesh_btTriangleMesh_1=function(){return(Vm=b._emscripten_bind_btTriangleMesh_btTriangleMesh_1=b.asm.Sk).apply(null,arguments)},Wm=b._emscripten_bind_btTriangleMesh_btTriangleMesh_2=function(){return(Wm=b._emscripten_bind_btTriangleMesh_btTriangleMesh_2=b.asm.Tk).apply(null,arguments)},Xm=b._emscripten_bind_btTriangleMesh_addTriangle_3=function(){return(Xm=b._emscripten_bind_btTriangleMesh_addTriangle_3=b.asm.Uk).apply(null,arguments)}, +Ym=b._emscripten_bind_btTriangleMesh_addTriangle_4=function(){return(Ym=b._emscripten_bind_btTriangleMesh_addTriangle_4=b.asm.Vk).apply(null,arguments)},Zm=b._emscripten_bind_btTriangleMesh_findOrAddVertex_2=function(){return(Zm=b._emscripten_bind_btTriangleMesh_findOrAddVertex_2=b.asm.Wk).apply(null,arguments)},$m=b._emscripten_bind_btTriangleMesh_addIndex_1=function(){return($m=b._emscripten_bind_btTriangleMesh_addIndex_1=b.asm.Xk).apply(null,arguments)},an=b._emscripten_bind_btTriangleMesh_getIndexedMeshArray_0= +function(){return(an=b._emscripten_bind_btTriangleMesh_getIndexedMeshArray_0=b.asm.Yk).apply(null,arguments)},bn=b._emscripten_bind_btTriangleMesh_setScaling_1=function(){return(bn=b._emscripten_bind_btTriangleMesh_setScaling_1=b.asm.Zk).apply(null,arguments)},cn=b._emscripten_bind_btTriangleMesh___destroy___0=function(){return(cn=b._emscripten_bind_btTriangleMesh___destroy___0=b.asm._k).apply(null,arguments)},dn=b._emscripten_bind_btEmptyShape_btEmptyShape_0=function(){return(dn=b._emscripten_bind_btEmptyShape_btEmptyShape_0= +b.asm.$k).apply(null,arguments)},en=b._emscripten_bind_btEmptyShape_setLocalScaling_1=function(){return(en=b._emscripten_bind_btEmptyShape_setLocalScaling_1=b.asm.al).apply(null,arguments)},fn=b._emscripten_bind_btEmptyShape_getLocalScaling_0=function(){return(fn=b._emscripten_bind_btEmptyShape_getLocalScaling_0=b.asm.bl).apply(null,arguments)},gn=b._emscripten_bind_btEmptyShape_calculateLocalInertia_2=function(){return(gn=b._emscripten_bind_btEmptyShape_calculateLocalInertia_2=b.asm.cl).apply(null, +arguments)},hn=b._emscripten_bind_btEmptyShape___destroy___0=function(){return(hn=b._emscripten_bind_btEmptyShape___destroy___0=b.asm.dl).apply(null,arguments)},jn=b._emscripten_bind_btStaticPlaneShape_btStaticPlaneShape_2=function(){return(jn=b._emscripten_bind_btStaticPlaneShape_btStaticPlaneShape_2=b.asm.el).apply(null,arguments)},kn=b._emscripten_bind_btStaticPlaneShape_setLocalScaling_1=function(){return(kn=b._emscripten_bind_btStaticPlaneShape_setLocalScaling_1=b.asm.fl).apply(null,arguments)}, +ln=b._emscripten_bind_btStaticPlaneShape_getLocalScaling_0=function(){return(ln=b._emscripten_bind_btStaticPlaneShape_getLocalScaling_0=b.asm.gl).apply(null,arguments)},mn=b._emscripten_bind_btStaticPlaneShape_calculateLocalInertia_2=function(){return(mn=b._emscripten_bind_btStaticPlaneShape_calculateLocalInertia_2=b.asm.hl).apply(null,arguments)},nn=b._emscripten_bind_btStaticPlaneShape___destroy___0=function(){return(nn=b._emscripten_bind_btStaticPlaneShape___destroy___0=b.asm.il).apply(null,arguments)}, +on=b._emscripten_bind_btBvhTriangleMeshShape_btBvhTriangleMeshShape_2=function(){return(on=b._emscripten_bind_btBvhTriangleMeshShape_btBvhTriangleMeshShape_2=b.asm.jl).apply(null,arguments)},pn=b._emscripten_bind_btBvhTriangleMeshShape_btBvhTriangleMeshShape_3=function(){return(pn=b._emscripten_bind_btBvhTriangleMeshShape_btBvhTriangleMeshShape_3=b.asm.kl).apply(null,arguments)},qn=b._emscripten_bind_btBvhTriangleMeshShape_setLocalScaling_1=function(){return(qn=b._emscripten_bind_btBvhTriangleMeshShape_setLocalScaling_1= +b.asm.ll).apply(null,arguments)},rn=b._emscripten_bind_btBvhTriangleMeshShape_getLocalScaling_0=function(){return(rn=b._emscripten_bind_btBvhTriangleMeshShape_getLocalScaling_0=b.asm.ml).apply(null,arguments)},sn=b._emscripten_bind_btBvhTriangleMeshShape_calculateLocalInertia_2=function(){return(sn=b._emscripten_bind_btBvhTriangleMeshShape_calculateLocalInertia_2=b.asm.nl).apply(null,arguments)},tn=b._emscripten_bind_btBvhTriangleMeshShape___destroy___0=function(){return(tn=b._emscripten_bind_btBvhTriangleMeshShape___destroy___0= +b.asm.ol).apply(null,arguments)},un=b._emscripten_bind_btHeightfieldTerrainShape_btHeightfieldTerrainShape_9=function(){return(un=b._emscripten_bind_btHeightfieldTerrainShape_btHeightfieldTerrainShape_9=b.asm.pl).apply(null,arguments)},vn=b._emscripten_bind_btHeightfieldTerrainShape_setMargin_1=function(){return(vn=b._emscripten_bind_btHeightfieldTerrainShape_setMargin_1=b.asm.ql).apply(null,arguments)},wn=b._emscripten_bind_btHeightfieldTerrainShape_getMargin_0=function(){return(wn=b._emscripten_bind_btHeightfieldTerrainShape_getMargin_0= +b.asm.rl).apply(null,arguments)},xn=b._emscripten_bind_btHeightfieldTerrainShape_setLocalScaling_1=function(){return(xn=b._emscripten_bind_btHeightfieldTerrainShape_setLocalScaling_1=b.asm.sl).apply(null,arguments)},yn=b._emscripten_bind_btHeightfieldTerrainShape_getLocalScaling_0=function(){return(yn=b._emscripten_bind_btHeightfieldTerrainShape_getLocalScaling_0=b.asm.tl).apply(null,arguments)},zn=b._emscripten_bind_btHeightfieldTerrainShape_calculateLocalInertia_2=function(){return(zn=b._emscripten_bind_btHeightfieldTerrainShape_calculateLocalInertia_2= +b.asm.ul).apply(null,arguments)},An=b._emscripten_bind_btHeightfieldTerrainShape___destroy___0=function(){return(An=b._emscripten_bind_btHeightfieldTerrainShape___destroy___0=b.asm.vl).apply(null,arguments)},Bn=b._emscripten_bind_btAABB_btAABB_4=function(){return(Bn=b._emscripten_bind_btAABB_btAABB_4=b.asm.wl).apply(null,arguments)},Cn=b._emscripten_bind_btAABB_invalidate_0=function(){return(Cn=b._emscripten_bind_btAABB_invalidate_0=b.asm.xl).apply(null,arguments)},Dn=b._emscripten_bind_btAABB_increment_margin_1= +function(){return(Dn=b._emscripten_bind_btAABB_increment_margin_1=b.asm.yl).apply(null,arguments)},En=b._emscripten_bind_btAABB_copy_with_margin_2=function(){return(En=b._emscripten_bind_btAABB_copy_with_margin_2=b.asm.zl).apply(null,arguments)},Fn=b._emscripten_bind_btAABB___destroy___0=function(){return(Fn=b._emscripten_bind_btAABB___destroy___0=b.asm.Al).apply(null,arguments)},Gn=b._emscripten_bind_btPrimitiveTriangle_btPrimitiveTriangle_0=function(){return(Gn=b._emscripten_bind_btPrimitiveTriangle_btPrimitiveTriangle_0= +b.asm.Bl).apply(null,arguments)},Hn=b._emscripten_bind_btPrimitiveTriangle___destroy___0=function(){return(Hn=b._emscripten_bind_btPrimitiveTriangle___destroy___0=b.asm.Cl).apply(null,arguments)},In=b._emscripten_bind_btTriangleShapeEx_btTriangleShapeEx_3=function(){return(In=b._emscripten_bind_btTriangleShapeEx_btTriangleShapeEx_3=b.asm.Dl).apply(null,arguments)},Jn=b._emscripten_bind_btTriangleShapeEx_getAabb_3=function(){return(Jn=b._emscripten_bind_btTriangleShapeEx_getAabb_3=b.asm.El).apply(null, +arguments)},Kn=b._emscripten_bind_btTriangleShapeEx_applyTransform_1=function(){return(Kn=b._emscripten_bind_btTriangleShapeEx_applyTransform_1=b.asm.Fl).apply(null,arguments)},Ln=b._emscripten_bind_btTriangleShapeEx_buildTriPlane_1=function(){return(Ln=b._emscripten_bind_btTriangleShapeEx_buildTriPlane_1=b.asm.Gl).apply(null,arguments)},Mn=b._emscripten_bind_btTriangleShapeEx___destroy___0=function(){return(Mn=b._emscripten_bind_btTriangleShapeEx___destroy___0=b.asm.Hl).apply(null,arguments)},Nn= +b._emscripten_bind_btTetrahedronShapeEx_btTetrahedronShapeEx_0=function(){return(Nn=b._emscripten_bind_btTetrahedronShapeEx_btTetrahedronShapeEx_0=b.asm.Il).apply(null,arguments)},On=b._emscripten_bind_btTetrahedronShapeEx_setVertices_4=function(){return(On=b._emscripten_bind_btTetrahedronShapeEx_setVertices_4=b.asm.Jl).apply(null,arguments)},Pn=b._emscripten_bind_btTetrahedronShapeEx___destroy___0=function(){return(Pn=b._emscripten_bind_btTetrahedronShapeEx___destroy___0=b.asm.Kl).apply(null,arguments)}, +Qn=b._emscripten_bind_CompoundPrimitiveManager_get_primitive_count_0=function(){return(Qn=b._emscripten_bind_CompoundPrimitiveManager_get_primitive_count_0=b.asm.Ll).apply(null,arguments)},Rn=b._emscripten_bind_CompoundPrimitiveManager_get_primitive_box_2=function(){return(Rn=b._emscripten_bind_CompoundPrimitiveManager_get_primitive_box_2=b.asm.Ml).apply(null,arguments)},Sn=b._emscripten_bind_CompoundPrimitiveManager_get_primitive_triangle_2=function(){return(Sn=b._emscripten_bind_CompoundPrimitiveManager_get_primitive_triangle_2= +b.asm.Nl).apply(null,arguments)},Tn=b._emscripten_bind_CompoundPrimitiveManager_is_trimesh_0=function(){return(Tn=b._emscripten_bind_CompoundPrimitiveManager_is_trimesh_0=b.asm.Ol).apply(null,arguments)},Un=b._emscripten_bind_CompoundPrimitiveManager_get_m_compoundShape_0=function(){return(Un=b._emscripten_bind_CompoundPrimitiveManager_get_m_compoundShape_0=b.asm.Pl).apply(null,arguments)},Vn=b._emscripten_bind_CompoundPrimitiveManager_set_m_compoundShape_1=function(){return(Vn=b._emscripten_bind_CompoundPrimitiveManager_set_m_compoundShape_1= +b.asm.Ql).apply(null,arguments)},Wn=b._emscripten_bind_CompoundPrimitiveManager___destroy___0=function(){return(Wn=b._emscripten_bind_CompoundPrimitiveManager___destroy___0=b.asm.Rl).apply(null,arguments)},Xn=b._emscripten_bind_btGImpactCompoundShape_btGImpactCompoundShape_0=function(){return(Xn=b._emscripten_bind_btGImpactCompoundShape_btGImpactCompoundShape_0=b.asm.Sl).apply(null,arguments)},Yn=b._emscripten_bind_btGImpactCompoundShape_btGImpactCompoundShape_1=function(){return(Yn=b._emscripten_bind_btGImpactCompoundShape_btGImpactCompoundShape_1= +b.asm.Tl).apply(null,arguments)},Zn=b._emscripten_bind_btGImpactCompoundShape_childrenHasTransform_0=function(){return(Zn=b._emscripten_bind_btGImpactCompoundShape_childrenHasTransform_0=b.asm.Ul).apply(null,arguments)},$n=b._emscripten_bind_btGImpactCompoundShape_getPrimitiveManager_0=function(){return($n=b._emscripten_bind_btGImpactCompoundShape_getPrimitiveManager_0=b.asm.Vl).apply(null,arguments)},ao=b._emscripten_bind_btGImpactCompoundShape_getCompoundPrimitiveManager_0=function(){return(ao= +b._emscripten_bind_btGImpactCompoundShape_getCompoundPrimitiveManager_0=b.asm.Wl).apply(null,arguments)},bo=b._emscripten_bind_btGImpactCompoundShape_getNumChildShapes_0=function(){return(bo=b._emscripten_bind_btGImpactCompoundShape_getNumChildShapes_0=b.asm.Xl).apply(null,arguments)},co=b._emscripten_bind_btGImpactCompoundShape_addChildShape_2=function(){return(co=b._emscripten_bind_btGImpactCompoundShape_addChildShape_2=b.asm.Yl).apply(null,arguments)},eo=b._emscripten_bind_btGImpactCompoundShape_getChildShape_1= +function(){return(eo=b._emscripten_bind_btGImpactCompoundShape_getChildShape_1=b.asm.Zl).apply(null,arguments)},fo=b._emscripten_bind_btGImpactCompoundShape_getChildAabb_4=function(){return(fo=b._emscripten_bind_btGImpactCompoundShape_getChildAabb_4=b.asm._l).apply(null,arguments)},go=b._emscripten_bind_btGImpactCompoundShape_getChildTransform_1=function(){return(go=b._emscripten_bind_btGImpactCompoundShape_getChildTransform_1=b.asm.$l).apply(null,arguments)},ho=b._emscripten_bind_btGImpactCompoundShape_setChildTransform_2= +function(){return(ho=b._emscripten_bind_btGImpactCompoundShape_setChildTransform_2=b.asm.am).apply(null,arguments)},io=b._emscripten_bind_btGImpactCompoundShape_calculateLocalInertia_2=function(){return(io=b._emscripten_bind_btGImpactCompoundShape_calculateLocalInertia_2=b.asm.bm).apply(null,arguments)},jo=b._emscripten_bind_btGImpactCompoundShape_getName_0=function(){return(jo=b._emscripten_bind_btGImpactCompoundShape_getName_0=b.asm.cm).apply(null,arguments)},ko=b._emscripten_bind_btGImpactCompoundShape_getGImpactShapeType_0= +function(){return(ko=b._emscripten_bind_btGImpactCompoundShape_getGImpactShapeType_0=b.asm.dm).apply(null,arguments)},lo=b._emscripten_bind_btGImpactCompoundShape_setLocalScaling_1=function(){return(lo=b._emscripten_bind_btGImpactCompoundShape_setLocalScaling_1=b.asm.em).apply(null,arguments)},mo=b._emscripten_bind_btGImpactCompoundShape_getLocalScaling_0=function(){return(mo=b._emscripten_bind_btGImpactCompoundShape_getLocalScaling_0=b.asm.fm).apply(null,arguments)},no=b._emscripten_bind_btGImpactCompoundShape_updateBound_0= +function(){return(no=b._emscripten_bind_btGImpactCompoundShape_updateBound_0=b.asm.gm).apply(null,arguments)},oo=b._emscripten_bind_btGImpactCompoundShape_postUpdate_0=function(){return(oo=b._emscripten_bind_btGImpactCompoundShape_postUpdate_0=b.asm.hm).apply(null,arguments)},po=b._emscripten_bind_btGImpactCompoundShape_getShapeType_0=function(){return(po=b._emscripten_bind_btGImpactCompoundShape_getShapeType_0=b.asm.im).apply(null,arguments)},qo=b._emscripten_bind_btGImpactCompoundShape_needsRetrieveTriangles_0= +function(){return(qo=b._emscripten_bind_btGImpactCompoundShape_needsRetrieveTriangles_0=b.asm.jm).apply(null,arguments)},ro=b._emscripten_bind_btGImpactCompoundShape_needsRetrieveTetrahedrons_0=function(){return(ro=b._emscripten_bind_btGImpactCompoundShape_needsRetrieveTetrahedrons_0=b.asm.km).apply(null,arguments)},so=b._emscripten_bind_btGImpactCompoundShape_getBulletTriangle_2=function(){return(so=b._emscripten_bind_btGImpactCompoundShape_getBulletTriangle_2=b.asm.lm).apply(null,arguments)},to= +b._emscripten_bind_btGImpactCompoundShape_getBulletTetrahedron_2=function(){return(to=b._emscripten_bind_btGImpactCompoundShape_getBulletTetrahedron_2=b.asm.mm).apply(null,arguments)},uo=b._emscripten_bind_btGImpactCompoundShape___destroy___0=function(){return(uo=b._emscripten_bind_btGImpactCompoundShape___destroy___0=b.asm.nm).apply(null,arguments)},vo=b._emscripten_bind_TrimeshPrimitiveManager_TrimeshPrimitiveManager_0=function(){return(vo=b._emscripten_bind_TrimeshPrimitiveManager_TrimeshPrimitiveManager_0= +b.asm.om).apply(null,arguments)},wo=b._emscripten_bind_TrimeshPrimitiveManager_TrimeshPrimitiveManager_1=function(){return(wo=b._emscripten_bind_TrimeshPrimitiveManager_TrimeshPrimitiveManager_1=b.asm.pm).apply(null,arguments)},xo=b._emscripten_bind_TrimeshPrimitiveManager_lock_0=function(){return(xo=b._emscripten_bind_TrimeshPrimitiveManager_lock_0=b.asm.qm).apply(null,arguments)},yo=b._emscripten_bind_TrimeshPrimitiveManager_unlock_0=function(){return(yo=b._emscripten_bind_TrimeshPrimitiveManager_unlock_0= +b.asm.rm).apply(null,arguments)},zo=b._emscripten_bind_TrimeshPrimitiveManager_is_trimesh_0=function(){return(zo=b._emscripten_bind_TrimeshPrimitiveManager_is_trimesh_0=b.asm.sm).apply(null,arguments)},Ao=b._emscripten_bind_TrimeshPrimitiveManager_get_vertex_count_0=function(){return(Ao=b._emscripten_bind_TrimeshPrimitiveManager_get_vertex_count_0=b.asm.tm).apply(null,arguments)},Bo=b._emscripten_bind_TrimeshPrimitiveManager_get_indices_4=function(){return(Bo=b._emscripten_bind_TrimeshPrimitiveManager_get_indices_4= +b.asm.um).apply(null,arguments)},Co=b._emscripten_bind_TrimeshPrimitiveManager_get_vertex_2=function(){return(Co=b._emscripten_bind_TrimeshPrimitiveManager_get_vertex_2=b.asm.vm).apply(null,arguments)},Do=b._emscripten_bind_TrimeshPrimitiveManager_get_bullet_triangle_2=function(){return(Do=b._emscripten_bind_TrimeshPrimitiveManager_get_bullet_triangle_2=b.asm.wm).apply(null,arguments)},Eo=b._emscripten_bind_TrimeshPrimitiveManager_get_m_margin_0=function(){return(Eo=b._emscripten_bind_TrimeshPrimitiveManager_get_m_margin_0= +b.asm.xm).apply(null,arguments)},Fo=b._emscripten_bind_TrimeshPrimitiveManager_set_m_margin_1=function(){return(Fo=b._emscripten_bind_TrimeshPrimitiveManager_set_m_margin_1=b.asm.ym).apply(null,arguments)},Go=b._emscripten_bind_TrimeshPrimitiveManager_get_m_meshInterface_0=function(){return(Go=b._emscripten_bind_TrimeshPrimitiveManager_get_m_meshInterface_0=b.asm.zm).apply(null,arguments)},Ho=b._emscripten_bind_TrimeshPrimitiveManager_set_m_meshInterface_1=function(){return(Ho=b._emscripten_bind_TrimeshPrimitiveManager_set_m_meshInterface_1= +b.asm.Am).apply(null,arguments)},Io=b._emscripten_bind_TrimeshPrimitiveManager_get_m_part_0=function(){return(Io=b._emscripten_bind_TrimeshPrimitiveManager_get_m_part_0=b.asm.Bm).apply(null,arguments)},Jo=b._emscripten_bind_TrimeshPrimitiveManager_set_m_part_1=function(){return(Jo=b._emscripten_bind_TrimeshPrimitiveManager_set_m_part_1=b.asm.Cm).apply(null,arguments)},Ko=b._emscripten_bind_TrimeshPrimitiveManager_get_m_lock_count_0=function(){return(Ko=b._emscripten_bind_TrimeshPrimitiveManager_get_m_lock_count_0= +b.asm.Dm).apply(null,arguments)},Lo=b._emscripten_bind_TrimeshPrimitiveManager_set_m_lock_count_1=function(){return(Lo=b._emscripten_bind_TrimeshPrimitiveManager_set_m_lock_count_1=b.asm.Em).apply(null,arguments)},Mo=b._emscripten_bind_TrimeshPrimitiveManager_get_numverts_0=function(){return(Mo=b._emscripten_bind_TrimeshPrimitiveManager_get_numverts_0=b.asm.Fm).apply(null,arguments)},No=b._emscripten_bind_TrimeshPrimitiveManager_set_numverts_1=function(){return(No=b._emscripten_bind_TrimeshPrimitiveManager_set_numverts_1= +b.asm.Gm).apply(null,arguments)},Oo=b._emscripten_bind_TrimeshPrimitiveManager_get_type_0=function(){return(Oo=b._emscripten_bind_TrimeshPrimitiveManager_get_type_0=b.asm.Hm).apply(null,arguments)},Po=b._emscripten_bind_TrimeshPrimitiveManager_set_type_1=function(){return(Po=b._emscripten_bind_TrimeshPrimitiveManager_set_type_1=b.asm.Im).apply(null,arguments)},Qo=b._emscripten_bind_TrimeshPrimitiveManager_get_stride_0=function(){return(Qo=b._emscripten_bind_TrimeshPrimitiveManager_get_stride_0=b.asm.Jm).apply(null, +arguments)},Ro=b._emscripten_bind_TrimeshPrimitiveManager_set_stride_1=function(){return(Ro=b._emscripten_bind_TrimeshPrimitiveManager_set_stride_1=b.asm.Km).apply(null,arguments)},So=b._emscripten_bind_TrimeshPrimitiveManager_get_indexstride_0=function(){return(So=b._emscripten_bind_TrimeshPrimitiveManager_get_indexstride_0=b.asm.Lm).apply(null,arguments)},To=b._emscripten_bind_TrimeshPrimitiveManager_set_indexstride_1=function(){return(To=b._emscripten_bind_TrimeshPrimitiveManager_set_indexstride_1= +b.asm.Mm).apply(null,arguments)},Uo=b._emscripten_bind_TrimeshPrimitiveManager_get_numfaces_0=function(){return(Uo=b._emscripten_bind_TrimeshPrimitiveManager_get_numfaces_0=b.asm.Nm).apply(null,arguments)},Vo=b._emscripten_bind_TrimeshPrimitiveManager_set_numfaces_1=function(){return(Vo=b._emscripten_bind_TrimeshPrimitiveManager_set_numfaces_1=b.asm.Om).apply(null,arguments)},Wo=b._emscripten_bind_TrimeshPrimitiveManager_get_indicestype_0=function(){return(Wo=b._emscripten_bind_TrimeshPrimitiveManager_get_indicestype_0= +b.asm.Pm).apply(null,arguments)},Xo=b._emscripten_bind_TrimeshPrimitiveManager_set_indicestype_1=function(){return(Xo=b._emscripten_bind_TrimeshPrimitiveManager_set_indicestype_1=b.asm.Qm).apply(null,arguments)},Yo=b._emscripten_bind_TrimeshPrimitiveManager___destroy___0=function(){return(Yo=b._emscripten_bind_TrimeshPrimitiveManager___destroy___0=b.asm.Rm).apply(null,arguments)},Zo=b._emscripten_bind_btGImpactMeshShapePart_btGImpactMeshShapePart_2=function(){return(Zo=b._emscripten_bind_btGImpactMeshShapePart_btGImpactMeshShapePart_2= +b.asm.Sm).apply(null,arguments)},$o=b._emscripten_bind_btGImpactMeshShapePart_getTrimeshPrimitiveManager_0=function(){return($o=b._emscripten_bind_btGImpactMeshShapePart_getTrimeshPrimitiveManager_0=b.asm.Tm).apply(null,arguments)},ap=b._emscripten_bind_btGImpactMeshShapePart_getVertexCount_0=function(){return(ap=b._emscripten_bind_btGImpactMeshShapePart_getVertexCount_0=b.asm.Um).apply(null,arguments)},bp=b._emscripten_bind_btGImpactMeshShapePart_getVertex_2=function(){return(bp=b._emscripten_bind_btGImpactMeshShapePart_getVertex_2= +b.asm.Vm).apply(null,arguments)},cp=b._emscripten_bind_btGImpactMeshShapePart_getPart_0=function(){return(cp=b._emscripten_bind_btGImpactMeshShapePart_getPart_0=b.asm.Wm).apply(null,arguments)},dp=b._emscripten_bind_btGImpactMeshShapePart_setLocalScaling_1=function(){return(dp=b._emscripten_bind_btGImpactMeshShapePart_setLocalScaling_1=b.asm.Xm).apply(null,arguments)},ep=b._emscripten_bind_btGImpactMeshShapePart_getLocalScaling_0=function(){return(ep=b._emscripten_bind_btGImpactMeshShapePart_getLocalScaling_0= +b.asm.Ym).apply(null,arguments)},fp=b._emscripten_bind_btGImpactMeshShapePart_updateBound_0=function(){return(fp=b._emscripten_bind_btGImpactMeshShapePart_updateBound_0=b.asm.Zm).apply(null,arguments)},gp=b._emscripten_bind_btGImpactMeshShapePart_postUpdate_0=function(){return(gp=b._emscripten_bind_btGImpactMeshShapePart_postUpdate_0=b.asm._m).apply(null,arguments)},hp=b._emscripten_bind_btGImpactMeshShapePart_getShapeType_0=function(){return(hp=b._emscripten_bind_btGImpactMeshShapePart_getShapeType_0= +b.asm.$m).apply(null,arguments)},ip=b._emscripten_bind_btGImpactMeshShapePart_needsRetrieveTriangles_0=function(){return(ip=b._emscripten_bind_btGImpactMeshShapePart_needsRetrieveTriangles_0=b.asm.an).apply(null,arguments)},jp=b._emscripten_bind_btGImpactMeshShapePart_needsRetrieveTetrahedrons_0=function(){return(jp=b._emscripten_bind_btGImpactMeshShapePart_needsRetrieveTetrahedrons_0=b.asm.bn).apply(null,arguments)},kp=b._emscripten_bind_btGImpactMeshShapePart_getBulletTriangle_2=function(){return(kp= +b._emscripten_bind_btGImpactMeshShapePart_getBulletTriangle_2=b.asm.cn).apply(null,arguments)},lp=b._emscripten_bind_btGImpactMeshShapePart_getBulletTetrahedron_2=function(){return(lp=b._emscripten_bind_btGImpactMeshShapePart_getBulletTetrahedron_2=b.asm.dn).apply(null,arguments)},mp=b._emscripten_bind_btGImpactMeshShapePart___destroy___0=function(){return(mp=b._emscripten_bind_btGImpactMeshShapePart___destroy___0=b.asm.en).apply(null,arguments)},np=b._emscripten_bind_btGImpactMeshShape_btGImpactMeshShape_1= +function(){return(np=b._emscripten_bind_btGImpactMeshShape_btGImpactMeshShape_1=b.asm.fn).apply(null,arguments)},op=b._emscripten_bind_btGImpactMeshShape_getMeshInterface_0=function(){return(op=b._emscripten_bind_btGImpactMeshShape_getMeshInterface_0=b.asm.gn).apply(null,arguments)},pp=b._emscripten_bind_btGImpactMeshShape_getMeshPartCount_0=function(){return(pp=b._emscripten_bind_btGImpactMeshShape_getMeshPartCount_0=b.asm.hn).apply(null,arguments)},qp=b._emscripten_bind_btGImpactMeshShape_getMeshPart_1= +function(){return(qp=b._emscripten_bind_btGImpactMeshShape_getMeshPart_1=b.asm.jn).apply(null,arguments)},rp=b._emscripten_bind_btGImpactMeshShape_calculateSerializeBufferSize_0=function(){return(rp=b._emscripten_bind_btGImpactMeshShape_calculateSerializeBufferSize_0=b.asm.kn).apply(null,arguments)},sp=b._emscripten_bind_btGImpactMeshShape_setLocalScaling_1=function(){return(sp=b._emscripten_bind_btGImpactMeshShape_setLocalScaling_1=b.asm.ln).apply(null,arguments)},tp=b._emscripten_bind_btGImpactMeshShape_getLocalScaling_0= +function(){return(tp=b._emscripten_bind_btGImpactMeshShape_getLocalScaling_0=b.asm.mn).apply(null,arguments)},up=b._emscripten_bind_btGImpactMeshShape_updateBound_0=function(){return(up=b._emscripten_bind_btGImpactMeshShape_updateBound_0=b.asm.nn).apply(null,arguments)},vp=b._emscripten_bind_btGImpactMeshShape_postUpdate_0=function(){return(vp=b._emscripten_bind_btGImpactMeshShape_postUpdate_0=b.asm.on).apply(null,arguments)},wp=b._emscripten_bind_btGImpactMeshShape_getShapeType_0=function(){return(wp= +b._emscripten_bind_btGImpactMeshShape_getShapeType_0=b.asm.pn).apply(null,arguments)},xp=b._emscripten_bind_btGImpactMeshShape_needsRetrieveTriangles_0=function(){return(xp=b._emscripten_bind_btGImpactMeshShape_needsRetrieveTriangles_0=b.asm.qn).apply(null,arguments)},yp=b._emscripten_bind_btGImpactMeshShape_needsRetrieveTetrahedrons_0=function(){return(yp=b._emscripten_bind_btGImpactMeshShape_needsRetrieveTetrahedrons_0=b.asm.rn).apply(null,arguments)},zp=b._emscripten_bind_btGImpactMeshShape_getBulletTriangle_2= +function(){return(zp=b._emscripten_bind_btGImpactMeshShape_getBulletTriangle_2=b.asm.sn).apply(null,arguments)},Ap=b._emscripten_bind_btGImpactMeshShape_getBulletTetrahedron_2=function(){return(Ap=b._emscripten_bind_btGImpactMeshShape_getBulletTetrahedron_2=b.asm.tn).apply(null,arguments)},Bp=b._emscripten_bind_btGImpactMeshShape___destroy___0=function(){return(Bp=b._emscripten_bind_btGImpactMeshShape___destroy___0=b.asm.un).apply(null,arguments)},Cp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_btCollisionAlgorithmConstructionInfo_0= +function(){return(Cp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_btCollisionAlgorithmConstructionInfo_0=b.asm.vn).apply(null,arguments)},Dp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_btCollisionAlgorithmConstructionInfo_2=function(){return(Dp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_btCollisionAlgorithmConstructionInfo_2=b.asm.wn).apply(null,arguments)},Ep=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_get_m_dispatcher1_0=function(){return(Ep=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_get_m_dispatcher1_0= +b.asm.xn).apply(null,arguments)},Fp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_set_m_dispatcher1_1=function(){return(Fp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_set_m_dispatcher1_1=b.asm.yn).apply(null,arguments)},Gp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_get_m_manifold_0=function(){return(Gp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_get_m_manifold_0=b.asm.zn).apply(null,arguments)},Hp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_set_m_manifold_1= +function(){return(Hp=b._emscripten_bind_btCollisionAlgorithmConstructionInfo_set_m_manifold_1=b.asm.An).apply(null,arguments)},Ip=b._emscripten_bind_btCollisionAlgorithmConstructionInfo___destroy___0=function(){return(Ip=b._emscripten_bind_btCollisionAlgorithmConstructionInfo___destroy___0=b.asm.Bn).apply(null,arguments)},Jp=b._emscripten_bind_btGImpactCollisionAlgorithm_btGImpactCollisionAlgorithm_3=function(){return(Jp=b._emscripten_bind_btGImpactCollisionAlgorithm_btGImpactCollisionAlgorithm_3= +b.asm.Cn).apply(null,arguments)},Kp=b._emscripten_bind_btGImpactCollisionAlgorithm_registerAlgorithm_1=function(){return(Kp=b._emscripten_bind_btGImpactCollisionAlgorithm_registerAlgorithm_1=b.asm.Dn).apply(null,arguments)},Lp=b._emscripten_bind_btGImpactCollisionAlgorithm___destroy___0=function(){return(Lp=b._emscripten_bind_btGImpactCollisionAlgorithm___destroy___0=b.asm.En).apply(null,arguments)},Mp=b._emscripten_bind_btDefaultCollisionConstructionInfo_btDefaultCollisionConstructionInfo_0=function(){return(Mp= +b._emscripten_bind_btDefaultCollisionConstructionInfo_btDefaultCollisionConstructionInfo_0=b.asm.Fn).apply(null,arguments)},Np=b._emscripten_bind_btDefaultCollisionConstructionInfo___destroy___0=function(){return(Np=b._emscripten_bind_btDefaultCollisionConstructionInfo___destroy___0=b.asm.Gn).apply(null,arguments)},Op=b._emscripten_bind_btPersistentManifold_btPersistentManifold_0=function(){return(Op=b._emscripten_bind_btPersistentManifold_btPersistentManifold_0=b.asm.Hn).apply(null,arguments)},Pp= +b._emscripten_bind_btPersistentManifold_getBody0_0=function(){return(Pp=b._emscripten_bind_btPersistentManifold_getBody0_0=b.asm.In).apply(null,arguments)},Qp=b._emscripten_bind_btPersistentManifold_getBody1_0=function(){return(Qp=b._emscripten_bind_btPersistentManifold_getBody1_0=b.asm.Jn).apply(null,arguments)},Rp=b._emscripten_bind_btPersistentManifold_getNumContacts_0=function(){return(Rp=b._emscripten_bind_btPersistentManifold_getNumContacts_0=b.asm.Kn).apply(null,arguments)},Sp=b._emscripten_bind_btPersistentManifold_getContactPoint_1= +function(){return(Sp=b._emscripten_bind_btPersistentManifold_getContactPoint_1=b.asm.Ln).apply(null,arguments)},Tp=b._emscripten_bind_btPersistentManifold___destroy___0=function(){return(Tp=b._emscripten_bind_btPersistentManifold___destroy___0=b.asm.Mn).apply(null,arguments)},Up=b._emscripten_bind_btCollisionDispatcher_btCollisionDispatcher_1=function(){return(Up=b._emscripten_bind_btCollisionDispatcher_btCollisionDispatcher_1=b.asm.Nn).apply(null,arguments)},Vp=b._emscripten_bind_btCollisionDispatcher_getNumManifolds_0= +function(){return(Vp=b._emscripten_bind_btCollisionDispatcher_getNumManifolds_0=b.asm.On).apply(null,arguments)},Wp=b._emscripten_bind_btCollisionDispatcher_getManifoldByIndexInternal_1=function(){return(Wp=b._emscripten_bind_btCollisionDispatcher_getManifoldByIndexInternal_1=b.asm.Pn).apply(null,arguments)},Xp=b._emscripten_bind_btCollisionDispatcher___destroy___0=function(){return(Xp=b._emscripten_bind_btCollisionDispatcher___destroy___0=b.asm.Qn).apply(null,arguments)},Yp=b._emscripten_bind_btOverlappingPairCallback___destroy___0= +function(){return(Yp=b._emscripten_bind_btOverlappingPairCallback___destroy___0=b.asm.Rn).apply(null,arguments)},Zp=b._emscripten_bind_btOverlappingPairCache_setInternalGhostPairCallback_1=function(){return(Zp=b._emscripten_bind_btOverlappingPairCache_setInternalGhostPairCallback_1=b.asm.Sn).apply(null,arguments)},$p=b._emscripten_bind_btOverlappingPairCache_getNumOverlappingPairs_0=function(){return($p=b._emscripten_bind_btOverlappingPairCache_getNumOverlappingPairs_0=b.asm.Tn).apply(null,arguments)}, +aq=b._emscripten_bind_btOverlappingPairCache___destroy___0=function(){return(aq=b._emscripten_bind_btOverlappingPairCache___destroy___0=b.asm.Un).apply(null,arguments)},bq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_2=function(){return(bq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_2=b.asm.Vn).apply(null,arguments)},cq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_3=function(){return(cq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_3=b.asm.Wn).apply(null,arguments)},dq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_4= +function(){return(dq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_4=b.asm.Xn).apply(null,arguments)},eq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_5=function(){return(eq=b._emscripten_bind_btAxisSweep3_btAxisSweep3_5=b.asm.Yn).apply(null,arguments)},fq=b._emscripten_bind_btAxisSweep3___destroy___0=function(){return(fq=b._emscripten_bind_btAxisSweep3___destroy___0=b.asm.Zn).apply(null,arguments)},gq=b._emscripten_bind_btBroadphaseInterface_getOverlappingPairCache_0=function(){return(gq=b._emscripten_bind_btBroadphaseInterface_getOverlappingPairCache_0= +b.asm._n).apply(null,arguments)},hq=b._emscripten_bind_btBroadphaseInterface___destroy___0=function(){return(hq=b._emscripten_bind_btBroadphaseInterface___destroy___0=b.asm.$n).apply(null,arguments)},iq=b._emscripten_bind_btCollisionConfiguration___destroy___0=function(){return(iq=b._emscripten_bind_btCollisionConfiguration___destroy___0=b.asm.ao).apply(null,arguments)},jq=b._emscripten_bind_btDbvtBroadphase_btDbvtBroadphase_0=function(){return(jq=b._emscripten_bind_btDbvtBroadphase_btDbvtBroadphase_0= +b.asm.bo).apply(null,arguments)},kq=b._emscripten_bind_btDbvtBroadphase___destroy___0=function(){return(kq=b._emscripten_bind_btDbvtBroadphase___destroy___0=b.asm.co).apply(null,arguments)},lq=b._emscripten_bind_btBroadphaseProxy_get_m_collisionFilterGroup_0=function(){return(lq=b._emscripten_bind_btBroadphaseProxy_get_m_collisionFilterGroup_0=b.asm.eo).apply(null,arguments)},mq=b._emscripten_bind_btBroadphaseProxy_set_m_collisionFilterGroup_1=function(){return(mq=b._emscripten_bind_btBroadphaseProxy_set_m_collisionFilterGroup_1= +b.asm.fo).apply(null,arguments)},nq=b._emscripten_bind_btBroadphaseProxy_get_m_collisionFilterMask_0=function(){return(nq=b._emscripten_bind_btBroadphaseProxy_get_m_collisionFilterMask_0=b.asm.go).apply(null,arguments)},oq=b._emscripten_bind_btBroadphaseProxy_set_m_collisionFilterMask_1=function(){return(oq=b._emscripten_bind_btBroadphaseProxy_set_m_collisionFilterMask_1=b.asm.ho).apply(null,arguments)},pq=b._emscripten_bind_btBroadphaseProxy___destroy___0=function(){return(pq=b._emscripten_bind_btBroadphaseProxy___destroy___0= +b.asm.io).apply(null,arguments)},qq=b._emscripten_bind_btRigidBodyConstructionInfo_btRigidBodyConstructionInfo_3=function(){return(qq=b._emscripten_bind_btRigidBodyConstructionInfo_btRigidBodyConstructionInfo_3=b.asm.jo).apply(null,arguments)},rq=b._emscripten_bind_btRigidBodyConstructionInfo_btRigidBodyConstructionInfo_4=function(){return(rq=b._emscripten_bind_btRigidBodyConstructionInfo_btRigidBodyConstructionInfo_4=b.asm.ko).apply(null,arguments)},sq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_linearDamping_0= +function(){return(sq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_linearDamping_0=b.asm.lo).apply(null,arguments)},tq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_linearDamping_1=function(){return(tq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_linearDamping_1=b.asm.mo).apply(null,arguments)},uq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_angularDamping_0=function(){return(uq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_angularDamping_0=b.asm.no).apply(null, +arguments)},vq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_angularDamping_1=function(){return(vq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_angularDamping_1=b.asm.oo).apply(null,arguments)},wq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_friction_0=function(){return(wq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_friction_0=b.asm.po).apply(null,arguments)},xq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_friction_1=function(){return(xq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_friction_1= +b.asm.qo).apply(null,arguments)},yq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_rollingFriction_0=function(){return(yq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_rollingFriction_0=b.asm.ro).apply(null,arguments)},zq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_rollingFriction_1=function(){return(zq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_rollingFriction_1=b.asm.so).apply(null,arguments)},Aq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_restitution_0= +function(){return(Aq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_restitution_0=b.asm.to).apply(null,arguments)},Bq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_restitution_1=function(){return(Bq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_restitution_1=b.asm.uo).apply(null,arguments)},Cq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_linearSleepingThreshold_0=function(){return(Cq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_linearSleepingThreshold_0=b.asm.vo).apply(null, +arguments)},Dq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_linearSleepingThreshold_1=function(){return(Dq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_linearSleepingThreshold_1=b.asm.wo).apply(null,arguments)},Eq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_angularSleepingThreshold_0=function(){return(Eq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_angularSleepingThreshold_0=b.asm.xo).apply(null,arguments)},Fq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_angularSleepingThreshold_1= +function(){return(Fq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_angularSleepingThreshold_1=b.asm.yo).apply(null,arguments)},Gq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalDamping_0=function(){return(Gq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalDamping_0=b.asm.zo).apply(null,arguments)},Hq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalDamping_1=function(){return(Hq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalDamping_1= +b.asm.Ao).apply(null,arguments)},Iq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalDampingFactor_0=function(){return(Iq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalDampingFactor_0=b.asm.Bo).apply(null,arguments)},Jq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalDampingFactor_1=function(){return(Jq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalDampingFactor_1=b.asm.Co).apply(null,arguments)},Kq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalLinearDampingThresholdSqr_0= +function(){return(Kq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalLinearDampingThresholdSqr_0=b.asm.Do).apply(null,arguments)},Lq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalLinearDampingThresholdSqr_1=function(){return(Lq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalLinearDampingThresholdSqr_1=b.asm.Eo).apply(null,arguments)},Mq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalAngularDampingThresholdSqr_0=function(){return(Mq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalAngularDampingThresholdSqr_0= +b.asm.Fo).apply(null,arguments)},Nq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalAngularDampingThresholdSqr_1=function(){return(Nq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalAngularDampingThresholdSqr_1=b.asm.Go).apply(null,arguments)},Oq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalAngularDampingFactor_0=function(){return(Oq=b._emscripten_bind_btRigidBodyConstructionInfo_get_m_additionalAngularDampingFactor_0=b.asm.Ho).apply(null,arguments)}, +Pq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalAngularDampingFactor_1=function(){return(Pq=b._emscripten_bind_btRigidBodyConstructionInfo_set_m_additionalAngularDampingFactor_1=b.asm.Io).apply(null,arguments)},Qq=b._emscripten_bind_btRigidBodyConstructionInfo___destroy___0=function(){return(Qq=b._emscripten_bind_btRigidBodyConstructionInfo___destroy___0=b.asm.Jo).apply(null,arguments)},Rq=b._emscripten_bind_btRigidBody_btRigidBody_1=function(){return(Rq=b._emscripten_bind_btRigidBody_btRigidBody_1= +b.asm.Ko).apply(null,arguments)},Sq=b._emscripten_bind_btRigidBody_getCenterOfMassTransform_0=function(){return(Sq=b._emscripten_bind_btRigidBody_getCenterOfMassTransform_0=b.asm.Lo).apply(null,arguments)},Tq=b._emscripten_bind_btRigidBody_setCenterOfMassTransform_1=function(){return(Tq=b._emscripten_bind_btRigidBody_setCenterOfMassTransform_1=b.asm.Mo).apply(null,arguments)},Uq=b._emscripten_bind_btRigidBody_setSleepingThresholds_2=function(){return(Uq=b._emscripten_bind_btRigidBody_setSleepingThresholds_2= +b.asm.No).apply(null,arguments)},Vq=b._emscripten_bind_btRigidBody_getLinearDamping_0=function(){return(Vq=b._emscripten_bind_btRigidBody_getLinearDamping_0=b.asm.Oo).apply(null,arguments)},Wq=b._emscripten_bind_btRigidBody_getAngularDamping_0=function(){return(Wq=b._emscripten_bind_btRigidBody_getAngularDamping_0=b.asm.Po).apply(null,arguments)},Xq=b._emscripten_bind_btRigidBody_setDamping_2=function(){return(Xq=b._emscripten_bind_btRigidBody_setDamping_2=b.asm.Qo).apply(null,arguments)},Yq=b._emscripten_bind_btRigidBody_setMassProps_2= +function(){return(Yq=b._emscripten_bind_btRigidBody_setMassProps_2=b.asm.Ro).apply(null,arguments)},Zq=b._emscripten_bind_btRigidBody_getLinearFactor_0=function(){return(Zq=b._emscripten_bind_btRigidBody_getLinearFactor_0=b.asm.So).apply(null,arguments)},$q=b._emscripten_bind_btRigidBody_setLinearFactor_1=function(){return($q=b._emscripten_bind_btRigidBody_setLinearFactor_1=b.asm.To).apply(null,arguments)},ar=b._emscripten_bind_btRigidBody_applyTorque_1=function(){return(ar=b._emscripten_bind_btRigidBody_applyTorque_1= +b.asm.Uo).apply(null,arguments)},br=b._emscripten_bind_btRigidBody_applyLocalTorque_1=function(){return(br=b._emscripten_bind_btRigidBody_applyLocalTorque_1=b.asm.Vo).apply(null,arguments)},cr=b._emscripten_bind_btRigidBody_applyForce_2=function(){return(cr=b._emscripten_bind_btRigidBody_applyForce_2=b.asm.Wo).apply(null,arguments)},dr=b._emscripten_bind_btRigidBody_applyCentralForce_1=function(){return(dr=b._emscripten_bind_btRigidBody_applyCentralForce_1=b.asm.Xo).apply(null,arguments)},er=b._emscripten_bind_btRigidBody_applyCentralLocalForce_1= +function(){return(er=b._emscripten_bind_btRigidBody_applyCentralLocalForce_1=b.asm.Yo).apply(null,arguments)},fr=b._emscripten_bind_btRigidBody_applyTorqueImpulse_1=function(){return(fr=b._emscripten_bind_btRigidBody_applyTorqueImpulse_1=b.asm.Zo).apply(null,arguments)},gr=b._emscripten_bind_btRigidBody_applyImpulse_2=function(){return(gr=b._emscripten_bind_btRigidBody_applyImpulse_2=b.asm._o).apply(null,arguments)},hr=b._emscripten_bind_btRigidBody_applyCentralImpulse_1=function(){return(hr=b._emscripten_bind_btRigidBody_applyCentralImpulse_1= +b.asm.$o).apply(null,arguments)},ir=b._emscripten_bind_btRigidBody_updateInertiaTensor_0=function(){return(ir=b._emscripten_bind_btRigidBody_updateInertiaTensor_0=b.asm.ap).apply(null,arguments)},jr=b._emscripten_bind_btRigidBody_getLinearVelocity_0=function(){return(jr=b._emscripten_bind_btRigidBody_getLinearVelocity_0=b.asm.bp).apply(null,arguments)},kr=b._emscripten_bind_btRigidBody_getAngularVelocity_0=function(){return(kr=b._emscripten_bind_btRigidBody_getAngularVelocity_0=b.asm.cp).apply(null, +arguments)},lr=b._emscripten_bind_btRigidBody_setLinearVelocity_1=function(){return(lr=b._emscripten_bind_btRigidBody_setLinearVelocity_1=b.asm.dp).apply(null,arguments)},mr=b._emscripten_bind_btRigidBody_setAngularVelocity_1=function(){return(mr=b._emscripten_bind_btRigidBody_setAngularVelocity_1=b.asm.ep).apply(null,arguments)},nr=b._emscripten_bind_btRigidBody_getMotionState_0=function(){return(nr=b._emscripten_bind_btRigidBody_getMotionState_0=b.asm.fp).apply(null,arguments)},or=b._emscripten_bind_btRigidBody_setMotionState_1= +function(){return(or=b._emscripten_bind_btRigidBody_setMotionState_1=b.asm.gp).apply(null,arguments)},pr=b._emscripten_bind_btRigidBody_getAngularFactor_0=function(){return(pr=b._emscripten_bind_btRigidBody_getAngularFactor_0=b.asm.hp).apply(null,arguments)},qr=b._emscripten_bind_btRigidBody_setAngularFactor_1=function(){return(qr=b._emscripten_bind_btRigidBody_setAngularFactor_1=b.asm.ip).apply(null,arguments)},rr=b._emscripten_bind_btRigidBody_upcast_1=function(){return(rr=b._emscripten_bind_btRigidBody_upcast_1= +b.asm.jp).apply(null,arguments)},sr=b._emscripten_bind_btRigidBody_getAabb_2=function(){return(sr=b._emscripten_bind_btRigidBody_getAabb_2=b.asm.kp).apply(null,arguments)},tr=b._emscripten_bind_btRigidBody_applyGravity_0=function(){return(tr=b._emscripten_bind_btRigidBody_applyGravity_0=b.asm.lp).apply(null,arguments)},ur=b._emscripten_bind_btRigidBody_getGravity_0=function(){return(ur=b._emscripten_bind_btRigidBody_getGravity_0=b.asm.mp).apply(null,arguments)},vr=b._emscripten_bind_btRigidBody_setGravity_1= +function(){return(vr=b._emscripten_bind_btRigidBody_setGravity_1=b.asm.np).apply(null,arguments)},wr=b._emscripten_bind_btRigidBody_getBroadphaseProxy_0=function(){return(wr=b._emscripten_bind_btRigidBody_getBroadphaseProxy_0=b.asm.op).apply(null,arguments)},xr=b._emscripten_bind_btRigidBody_clearForces_0=function(){return(xr=b._emscripten_bind_btRigidBody_clearForces_0=b.asm.pp).apply(null,arguments)},yr=b._emscripten_bind_btRigidBody_setFlags_1=function(){return(yr=b._emscripten_bind_btRigidBody_setFlags_1= +b.asm.qp).apply(null,arguments)},zr=b._emscripten_bind_btRigidBody_getFlags_0=function(){return(zr=b._emscripten_bind_btRigidBody_getFlags_0=b.asm.rp).apply(null,arguments)},Ar=b._emscripten_bind_btRigidBody_setAnisotropicFriction_2=function(){return(Ar=b._emscripten_bind_btRigidBody_setAnisotropicFriction_2=b.asm.sp).apply(null,arguments)},Br=b._emscripten_bind_btRigidBody_getCollisionShape_0=function(){return(Br=b._emscripten_bind_btRigidBody_getCollisionShape_0=b.asm.tp).apply(null,arguments)}, +Cr=b._emscripten_bind_btRigidBody_setContactProcessingThreshold_1=function(){return(Cr=b._emscripten_bind_btRigidBody_setContactProcessingThreshold_1=b.asm.up).apply(null,arguments)},Dr=b._emscripten_bind_btRigidBody_setActivationState_1=function(){return(Dr=b._emscripten_bind_btRigidBody_setActivationState_1=b.asm.vp).apply(null,arguments)},Er=b._emscripten_bind_btRigidBody_forceActivationState_1=function(){return(Er=b._emscripten_bind_btRigidBody_forceActivationState_1=b.asm.wp).apply(null,arguments)}, +Fr=b._emscripten_bind_btRigidBody_activate_0=function(){return(Fr=b._emscripten_bind_btRigidBody_activate_0=b.asm.xp).apply(null,arguments)},Gr=b._emscripten_bind_btRigidBody_activate_1=function(){return(Gr=b._emscripten_bind_btRigidBody_activate_1=b.asm.yp).apply(null,arguments)},Hr=b._emscripten_bind_btRigidBody_isActive_0=function(){return(Hr=b._emscripten_bind_btRigidBody_isActive_0=b.asm.zp).apply(null,arguments)},Ir=b._emscripten_bind_btRigidBody_isKinematicObject_0=function(){return(Ir=b._emscripten_bind_btRigidBody_isKinematicObject_0= +b.asm.Ap).apply(null,arguments)},Jr=b._emscripten_bind_btRigidBody_isStaticObject_0=function(){return(Jr=b._emscripten_bind_btRigidBody_isStaticObject_0=b.asm.Bp).apply(null,arguments)},Kr=b._emscripten_bind_btRigidBody_isStaticOrKinematicObject_0=function(){return(Kr=b._emscripten_bind_btRigidBody_isStaticOrKinematicObject_0=b.asm.Cp).apply(null,arguments)},Lr=b._emscripten_bind_btRigidBody_getRestitution_0=function(){return(Lr=b._emscripten_bind_btRigidBody_getRestitution_0=b.asm.Dp).apply(null, +arguments)},Mr=b._emscripten_bind_btRigidBody_getFriction_0=function(){return(Mr=b._emscripten_bind_btRigidBody_getFriction_0=b.asm.Ep).apply(null,arguments)},Nr=b._emscripten_bind_btRigidBody_getRollingFriction_0=function(){return(Nr=b._emscripten_bind_btRigidBody_getRollingFriction_0=b.asm.Fp).apply(null,arguments)},Or=b._emscripten_bind_btRigidBody_setRestitution_1=function(){return(Or=b._emscripten_bind_btRigidBody_setRestitution_1=b.asm.Gp).apply(null,arguments)},Pr=b._emscripten_bind_btRigidBody_setFriction_1= +function(){return(Pr=b._emscripten_bind_btRigidBody_setFriction_1=b.asm.Hp).apply(null,arguments)},Qr=b._emscripten_bind_btRigidBody_setRollingFriction_1=function(){return(Qr=b._emscripten_bind_btRigidBody_setRollingFriction_1=b.asm.Ip).apply(null,arguments)},Rr=b._emscripten_bind_btRigidBody_getWorldTransform_0=function(){return(Rr=b._emscripten_bind_btRigidBody_getWorldTransform_0=b.asm.Jp).apply(null,arguments)},Sr=b._emscripten_bind_btRigidBody_getCollisionFlags_0=function(){return(Sr=b._emscripten_bind_btRigidBody_getCollisionFlags_0= +b.asm.Kp).apply(null,arguments)},Tr=b._emscripten_bind_btRigidBody_setCollisionFlags_1=function(){return(Tr=b._emscripten_bind_btRigidBody_setCollisionFlags_1=b.asm.Lp).apply(null,arguments)},Ur=b._emscripten_bind_btRigidBody_setWorldTransform_1=function(){return(Ur=b._emscripten_bind_btRigidBody_setWorldTransform_1=b.asm.Mp).apply(null,arguments)},Vr=b._emscripten_bind_btRigidBody_setCollisionShape_1=function(){return(Vr=b._emscripten_bind_btRigidBody_setCollisionShape_1=b.asm.Np).apply(null,arguments)}, +Wr=b._emscripten_bind_btRigidBody_setCcdMotionThreshold_1=function(){return(Wr=b._emscripten_bind_btRigidBody_setCcdMotionThreshold_1=b.asm.Op).apply(null,arguments)},Xr=b._emscripten_bind_btRigidBody_setCcdSweptSphereRadius_1=function(){return(Xr=b._emscripten_bind_btRigidBody_setCcdSweptSphereRadius_1=b.asm.Pp).apply(null,arguments)},Yr=b._emscripten_bind_btRigidBody_getUserIndex_0=function(){return(Yr=b._emscripten_bind_btRigidBody_getUserIndex_0=b.asm.Qp).apply(null,arguments)},Zr=b._emscripten_bind_btRigidBody_setUserIndex_1= +function(){return(Zr=b._emscripten_bind_btRigidBody_setUserIndex_1=b.asm.Rp).apply(null,arguments)},$r=b._emscripten_bind_btRigidBody_getUserPointer_0=function(){return($r=b._emscripten_bind_btRigidBody_getUserPointer_0=b.asm.Sp).apply(null,arguments)},as=b._emscripten_bind_btRigidBody_setUserPointer_1=function(){return(as=b._emscripten_bind_btRigidBody_setUserPointer_1=b.asm.Tp).apply(null,arguments)},bs=b._emscripten_bind_btRigidBody_getBroadphaseHandle_0=function(){return(bs=b._emscripten_bind_btRigidBody_getBroadphaseHandle_0= +b.asm.Up).apply(null,arguments)},cs=b._emscripten_bind_btRigidBody___destroy___0=function(){return(cs=b._emscripten_bind_btRigidBody___destroy___0=b.asm.Vp).apply(null,arguments)},ds=b._emscripten_bind_btConstraintSetting_btConstraintSetting_0=function(){return(ds=b._emscripten_bind_btConstraintSetting_btConstraintSetting_0=b.asm.Wp).apply(null,arguments)},es=b._emscripten_bind_btConstraintSetting_get_m_tau_0=function(){return(es=b._emscripten_bind_btConstraintSetting_get_m_tau_0=b.asm.Xp).apply(null, +arguments)},gs=b._emscripten_bind_btConstraintSetting_set_m_tau_1=function(){return(gs=b._emscripten_bind_btConstraintSetting_set_m_tau_1=b.asm.Yp).apply(null,arguments)},hs=b._emscripten_bind_btConstraintSetting_get_m_damping_0=function(){return(hs=b._emscripten_bind_btConstraintSetting_get_m_damping_0=b.asm.Zp).apply(null,arguments)},is=b._emscripten_bind_btConstraintSetting_set_m_damping_1=function(){return(is=b._emscripten_bind_btConstraintSetting_set_m_damping_1=b.asm._p).apply(null,arguments)}, +js=b._emscripten_bind_btConstraintSetting_get_m_impulseClamp_0=function(){return(js=b._emscripten_bind_btConstraintSetting_get_m_impulseClamp_0=b.asm.$p).apply(null,arguments)},ks=b._emscripten_bind_btConstraintSetting_set_m_impulseClamp_1=function(){return(ks=b._emscripten_bind_btConstraintSetting_set_m_impulseClamp_1=b.asm.aq).apply(null,arguments)},ls=b._emscripten_bind_btConstraintSetting___destroy___0=function(){return(ls=b._emscripten_bind_btConstraintSetting___destroy___0=b.asm.bq).apply(null, +arguments)},ms=b._emscripten_bind_btPoint2PointConstraint_btPoint2PointConstraint_2=function(){return(ms=b._emscripten_bind_btPoint2PointConstraint_btPoint2PointConstraint_2=b.asm.cq).apply(null,arguments)},ns=b._emscripten_bind_btPoint2PointConstraint_btPoint2PointConstraint_4=function(){return(ns=b._emscripten_bind_btPoint2PointConstraint_btPoint2PointConstraint_4=b.asm.dq).apply(null,arguments)},ps=b._emscripten_bind_btPoint2PointConstraint_setPivotA_1=function(){return(ps=b._emscripten_bind_btPoint2PointConstraint_setPivotA_1= +b.asm.eq).apply(null,arguments)},qs=b._emscripten_bind_btPoint2PointConstraint_setPivotB_1=function(){return(qs=b._emscripten_bind_btPoint2PointConstraint_setPivotB_1=b.asm.fq).apply(null,arguments)},rs=b._emscripten_bind_btPoint2PointConstraint_getPivotInA_0=function(){return(rs=b._emscripten_bind_btPoint2PointConstraint_getPivotInA_0=b.asm.gq).apply(null,arguments)},ss=b._emscripten_bind_btPoint2PointConstraint_getPivotInB_0=function(){return(ss=b._emscripten_bind_btPoint2PointConstraint_getPivotInB_0= +b.asm.hq).apply(null,arguments)},ts=b._emscripten_bind_btPoint2PointConstraint_enableFeedback_1=function(){return(ts=b._emscripten_bind_btPoint2PointConstraint_enableFeedback_1=b.asm.iq).apply(null,arguments)},us=b._emscripten_bind_btPoint2PointConstraint_getBreakingImpulseThreshold_0=function(){return(us=b._emscripten_bind_btPoint2PointConstraint_getBreakingImpulseThreshold_0=b.asm.jq).apply(null,arguments)},vs=b._emscripten_bind_btPoint2PointConstraint_setBreakingImpulseThreshold_1=function(){return(vs= +b._emscripten_bind_btPoint2PointConstraint_setBreakingImpulseThreshold_1=b.asm.kq).apply(null,arguments)},xs=b._emscripten_bind_btPoint2PointConstraint_getParam_2=function(){return(xs=b._emscripten_bind_btPoint2PointConstraint_getParam_2=b.asm.lq).apply(null,arguments)},ys=b._emscripten_bind_btPoint2PointConstraint_setParam_3=function(){return(ys=b._emscripten_bind_btPoint2PointConstraint_setParam_3=b.asm.mq).apply(null,arguments)},zs=b._emscripten_bind_btPoint2PointConstraint_get_m_setting_0=function(){return(zs= +b._emscripten_bind_btPoint2PointConstraint_get_m_setting_0=b.asm.nq).apply(null,arguments)},As=b._emscripten_bind_btPoint2PointConstraint_set_m_setting_1=function(){return(As=b._emscripten_bind_btPoint2PointConstraint_set_m_setting_1=b.asm.oq).apply(null,arguments)},Bs=b._emscripten_bind_btPoint2PointConstraint___destroy___0=function(){return(Bs=b._emscripten_bind_btPoint2PointConstraint___destroy___0=b.asm.pq).apply(null,arguments)},Cs=b._emscripten_bind_btGeneric6DofSpringConstraint_btGeneric6DofSpringConstraint_3= +function(){return(Cs=b._emscripten_bind_btGeneric6DofSpringConstraint_btGeneric6DofSpringConstraint_3=b.asm.qq).apply(null,arguments)},Ds=b._emscripten_bind_btGeneric6DofSpringConstraint_btGeneric6DofSpringConstraint_5=function(){return(Ds=b._emscripten_bind_btGeneric6DofSpringConstraint_btGeneric6DofSpringConstraint_5=b.asm.rq).apply(null,arguments)},Es=b._emscripten_bind_btGeneric6DofSpringConstraint_enableSpring_2=function(){return(Es=b._emscripten_bind_btGeneric6DofSpringConstraint_enableSpring_2= +b.asm.sq).apply(null,arguments)},Fs=b._emscripten_bind_btGeneric6DofSpringConstraint_setStiffness_2=function(){return(Fs=b._emscripten_bind_btGeneric6DofSpringConstraint_setStiffness_2=b.asm.tq).apply(null,arguments)},Gs=b._emscripten_bind_btGeneric6DofSpringConstraint_setDamping_2=function(){return(Gs=b._emscripten_bind_btGeneric6DofSpringConstraint_setDamping_2=b.asm.uq).apply(null,arguments)},Hs=b._emscripten_bind_btGeneric6DofSpringConstraint_setEquilibriumPoint_0=function(){return(Hs=b._emscripten_bind_btGeneric6DofSpringConstraint_setEquilibriumPoint_0= +b.asm.vq).apply(null,arguments)},Is=b._emscripten_bind_btGeneric6DofSpringConstraint_setEquilibriumPoint_1=function(){return(Is=b._emscripten_bind_btGeneric6DofSpringConstraint_setEquilibriumPoint_1=b.asm.wq).apply(null,arguments)},Js=b._emscripten_bind_btGeneric6DofSpringConstraint_setEquilibriumPoint_2=function(){return(Js=b._emscripten_bind_btGeneric6DofSpringConstraint_setEquilibriumPoint_2=b.asm.xq).apply(null,arguments)},Ks=b._emscripten_bind_btGeneric6DofSpringConstraint_setLinearLowerLimit_1= +function(){return(Ks=b._emscripten_bind_btGeneric6DofSpringConstraint_setLinearLowerLimit_1=b.asm.yq).apply(null,arguments)},Ls=b._emscripten_bind_btGeneric6DofSpringConstraint_setLinearUpperLimit_1=function(){return(Ls=b._emscripten_bind_btGeneric6DofSpringConstraint_setLinearUpperLimit_1=b.asm.zq).apply(null,arguments)},Ms=b._emscripten_bind_btGeneric6DofSpringConstraint_setAngularLowerLimit_1=function(){return(Ms=b._emscripten_bind_btGeneric6DofSpringConstraint_setAngularLowerLimit_1=b.asm.Aq).apply(null, +arguments)},Ns=b._emscripten_bind_btGeneric6DofSpringConstraint_setAngularUpperLimit_1=function(){return(Ns=b._emscripten_bind_btGeneric6DofSpringConstraint_setAngularUpperLimit_1=b.asm.Bq).apply(null,arguments)},Os=b._emscripten_bind_btGeneric6DofSpringConstraint_getFrameOffsetA_0=function(){return(Os=b._emscripten_bind_btGeneric6DofSpringConstraint_getFrameOffsetA_0=b.asm.Cq).apply(null,arguments)},Ps=b._emscripten_bind_btGeneric6DofSpringConstraint_enableFeedback_1=function(){return(Ps=b._emscripten_bind_btGeneric6DofSpringConstraint_enableFeedback_1= +b.asm.Dq).apply(null,arguments)},Qs=b._emscripten_bind_btGeneric6DofSpringConstraint_getBreakingImpulseThreshold_0=function(){return(Qs=b._emscripten_bind_btGeneric6DofSpringConstraint_getBreakingImpulseThreshold_0=b.asm.Eq).apply(null,arguments)},Rs=b._emscripten_bind_btGeneric6DofSpringConstraint_setBreakingImpulseThreshold_1=function(){return(Rs=b._emscripten_bind_btGeneric6DofSpringConstraint_setBreakingImpulseThreshold_1=b.asm.Fq).apply(null,arguments)},Ss=b._emscripten_bind_btGeneric6DofSpringConstraint_getParam_2= +function(){return(Ss=b._emscripten_bind_btGeneric6DofSpringConstraint_getParam_2=b.asm.Gq).apply(null,arguments)},Ts=b._emscripten_bind_btGeneric6DofSpringConstraint_setParam_3=function(){return(Ts=b._emscripten_bind_btGeneric6DofSpringConstraint_setParam_3=b.asm.Hq).apply(null,arguments)},Us=b._emscripten_bind_btGeneric6DofSpringConstraint___destroy___0=function(){return(Us=b._emscripten_bind_btGeneric6DofSpringConstraint___destroy___0=b.asm.Iq).apply(null,arguments)},Vs=b._emscripten_bind_btSequentialImpulseConstraintSolver_btSequentialImpulseConstraintSolver_0= +function(){return(Vs=b._emscripten_bind_btSequentialImpulseConstraintSolver_btSequentialImpulseConstraintSolver_0=b.asm.Jq).apply(null,arguments)},Ws=b._emscripten_bind_btSequentialImpulseConstraintSolver___destroy___0=function(){return(Ws=b._emscripten_bind_btSequentialImpulseConstraintSolver___destroy___0=b.asm.Kq).apply(null,arguments)},Xs=b._emscripten_bind_btConeTwistConstraint_btConeTwistConstraint_2=function(){return(Xs=b._emscripten_bind_btConeTwistConstraint_btConeTwistConstraint_2=b.asm.Lq).apply(null, +arguments)},Ys=b._emscripten_bind_btConeTwistConstraint_btConeTwistConstraint_4=function(){return(Ys=b._emscripten_bind_btConeTwistConstraint_btConeTwistConstraint_4=b.asm.Mq).apply(null,arguments)},Zs=b._emscripten_bind_btConeTwistConstraint_setLimit_2=function(){return(Zs=b._emscripten_bind_btConeTwistConstraint_setLimit_2=b.asm.Nq).apply(null,arguments)},$s=b._emscripten_bind_btConeTwistConstraint_setAngularOnly_1=function(){return($s=b._emscripten_bind_btConeTwistConstraint_setAngularOnly_1=b.asm.Oq).apply(null, +arguments)},at=b._emscripten_bind_btConeTwistConstraint_setDamping_1=function(){return(at=b._emscripten_bind_btConeTwistConstraint_setDamping_1=b.asm.Pq).apply(null,arguments)},bt=b._emscripten_bind_btConeTwistConstraint_enableMotor_1=function(){return(bt=b._emscripten_bind_btConeTwistConstraint_enableMotor_1=b.asm.Qq).apply(null,arguments)},ct=b._emscripten_bind_btConeTwistConstraint_setMaxMotorImpulse_1=function(){return(ct=b._emscripten_bind_btConeTwistConstraint_setMaxMotorImpulse_1=b.asm.Rq).apply(null, +arguments)},dt=b._emscripten_bind_btConeTwistConstraint_setMaxMotorImpulseNormalized_1=function(){return(dt=b._emscripten_bind_btConeTwistConstraint_setMaxMotorImpulseNormalized_1=b.asm.Sq).apply(null,arguments)},et=b._emscripten_bind_btConeTwistConstraint_setMotorTarget_1=function(){return(et=b._emscripten_bind_btConeTwistConstraint_setMotorTarget_1=b.asm.Tq).apply(null,arguments)},ft=b._emscripten_bind_btConeTwistConstraint_setMotorTargetInConstraintSpace_1=function(){return(ft=b._emscripten_bind_btConeTwistConstraint_setMotorTargetInConstraintSpace_1= +b.asm.Uq).apply(null,arguments)},gt=b._emscripten_bind_btConeTwistConstraint_enableFeedback_1=function(){return(gt=b._emscripten_bind_btConeTwistConstraint_enableFeedback_1=b.asm.Vq).apply(null,arguments)},ht=b._emscripten_bind_btConeTwistConstraint_getBreakingImpulseThreshold_0=function(){return(ht=b._emscripten_bind_btConeTwistConstraint_getBreakingImpulseThreshold_0=b.asm.Wq).apply(null,arguments)},it=b._emscripten_bind_btConeTwistConstraint_setBreakingImpulseThreshold_1=function(){return(it=b._emscripten_bind_btConeTwistConstraint_setBreakingImpulseThreshold_1= +b.asm.Xq).apply(null,arguments)},jt=b._emscripten_bind_btConeTwistConstraint_getParam_2=function(){return(jt=b._emscripten_bind_btConeTwistConstraint_getParam_2=b.asm.Yq).apply(null,arguments)},kt=b._emscripten_bind_btConeTwistConstraint_setParam_3=function(){return(kt=b._emscripten_bind_btConeTwistConstraint_setParam_3=b.asm.Zq).apply(null,arguments)},lt=b._emscripten_bind_btConeTwistConstraint___destroy___0=function(){return(lt=b._emscripten_bind_btConeTwistConstraint___destroy___0=b.asm._q).apply(null, +arguments)},mt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_2=function(){return(mt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_2=b.asm.$q).apply(null,arguments)},nt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_3=function(){return(nt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_3=b.asm.ar).apply(null,arguments)},ot=b._emscripten_bind_btHingeConstraint_btHingeConstraint_4=function(){return(ot=b._emscripten_bind_btHingeConstraint_btHingeConstraint_4=b.asm.br).apply(null, +arguments)},pt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_5=function(){return(pt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_5=b.asm.cr).apply(null,arguments)},qt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_6=function(){return(qt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_6=b.asm.dr).apply(null,arguments)},rt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_7=function(){return(rt=b._emscripten_bind_btHingeConstraint_btHingeConstraint_7=b.asm.er).apply(null, +arguments)},st=b._emscripten_bind_btHingeConstraint_getHingeAngle_0=function(){return(st=b._emscripten_bind_btHingeConstraint_getHingeAngle_0=b.asm.fr).apply(null,arguments)},tt=b._emscripten_bind_btHingeConstraint_setLimit_4=function(){return(tt=b._emscripten_bind_btHingeConstraint_setLimit_4=b.asm.gr).apply(null,arguments)},ut=b._emscripten_bind_btHingeConstraint_setLimit_5=function(){return(ut=b._emscripten_bind_btHingeConstraint_setLimit_5=b.asm.hr).apply(null,arguments)},vt=b._emscripten_bind_btHingeConstraint_enableAngularMotor_3= +function(){return(vt=b._emscripten_bind_btHingeConstraint_enableAngularMotor_3=b.asm.ir).apply(null,arguments)},wt=b._emscripten_bind_btHingeConstraint_setAngularOnly_1=function(){return(wt=b._emscripten_bind_btHingeConstraint_setAngularOnly_1=b.asm.jr).apply(null,arguments)},xt=b._emscripten_bind_btHingeConstraint_enableMotor_1=function(){return(xt=b._emscripten_bind_btHingeConstraint_enableMotor_1=b.asm.kr).apply(null,arguments)},yt=b._emscripten_bind_btHingeConstraint_setMaxMotorImpulse_1=function(){return(yt= +b._emscripten_bind_btHingeConstraint_setMaxMotorImpulse_1=b.asm.lr).apply(null,arguments)},zt=b._emscripten_bind_btHingeConstraint_setMotorTarget_2=function(){return(zt=b._emscripten_bind_btHingeConstraint_setMotorTarget_2=b.asm.mr).apply(null,arguments)},At=b._emscripten_bind_btHingeConstraint_enableFeedback_1=function(){return(At=b._emscripten_bind_btHingeConstraint_enableFeedback_1=b.asm.nr).apply(null,arguments)},Bt=b._emscripten_bind_btHingeConstraint_getBreakingImpulseThreshold_0=function(){return(Bt= +b._emscripten_bind_btHingeConstraint_getBreakingImpulseThreshold_0=b.asm.or).apply(null,arguments)},Ct=b._emscripten_bind_btHingeConstraint_setBreakingImpulseThreshold_1=function(){return(Ct=b._emscripten_bind_btHingeConstraint_setBreakingImpulseThreshold_1=b.asm.pr).apply(null,arguments)},Dt=b._emscripten_bind_btHingeConstraint_getParam_2=function(){return(Dt=b._emscripten_bind_btHingeConstraint_getParam_2=b.asm.qr).apply(null,arguments)},Et=b._emscripten_bind_btHingeConstraint_setParam_3=function(){return(Et= +b._emscripten_bind_btHingeConstraint_setParam_3=b.asm.rr).apply(null,arguments)},Ft=b._emscripten_bind_btHingeConstraint___destroy___0=function(){return(Ft=b._emscripten_bind_btHingeConstraint___destroy___0=b.asm.sr).apply(null,arguments)},Gt=b._emscripten_bind_btSliderConstraint_btSliderConstraint_3=function(){return(Gt=b._emscripten_bind_btSliderConstraint_btSliderConstraint_3=b.asm.tr).apply(null,arguments)},Ht=b._emscripten_bind_btSliderConstraint_btSliderConstraint_5=function(){return(Ht=b._emscripten_bind_btSliderConstraint_btSliderConstraint_5= +b.asm.ur).apply(null,arguments)},It=b._emscripten_bind_btSliderConstraint_getLinearPos_0=function(){return(It=b._emscripten_bind_btSliderConstraint_getLinearPos_0=b.asm.vr).apply(null,arguments)},Jt=b._emscripten_bind_btSliderConstraint_getAngularPos_0=function(){return(Jt=b._emscripten_bind_btSliderConstraint_getAngularPos_0=b.asm.wr).apply(null,arguments)},Kt=b._emscripten_bind_btSliderConstraint_setLowerLinLimit_1=function(){return(Kt=b._emscripten_bind_btSliderConstraint_setLowerLinLimit_1=b.asm.xr).apply(null, +arguments)},Lt=b._emscripten_bind_btSliderConstraint_setUpperLinLimit_1=function(){return(Lt=b._emscripten_bind_btSliderConstraint_setUpperLinLimit_1=b.asm.yr).apply(null,arguments)},Mt=b._emscripten_bind_btSliderConstraint_setLowerAngLimit_1=function(){return(Mt=b._emscripten_bind_btSliderConstraint_setLowerAngLimit_1=b.asm.zr).apply(null,arguments)},Nt=b._emscripten_bind_btSliderConstraint_setUpperAngLimit_1=function(){return(Nt=b._emscripten_bind_btSliderConstraint_setUpperAngLimit_1=b.asm.Ar).apply(null, +arguments)},Ot=b._emscripten_bind_btSliderConstraint_setPoweredLinMotor_1=function(){return(Ot=b._emscripten_bind_btSliderConstraint_setPoweredLinMotor_1=b.asm.Br).apply(null,arguments)},Pt=b._emscripten_bind_btSliderConstraint_setMaxLinMotorForce_1=function(){return(Pt=b._emscripten_bind_btSliderConstraint_setMaxLinMotorForce_1=b.asm.Cr).apply(null,arguments)},Qt=b._emscripten_bind_btSliderConstraint_setTargetLinMotorVelocity_1=function(){return(Qt=b._emscripten_bind_btSliderConstraint_setTargetLinMotorVelocity_1= +b.asm.Dr).apply(null,arguments)},Rt=b._emscripten_bind_btSliderConstraint_enableFeedback_1=function(){return(Rt=b._emscripten_bind_btSliderConstraint_enableFeedback_1=b.asm.Er).apply(null,arguments)},St=b._emscripten_bind_btSliderConstraint_getBreakingImpulseThreshold_0=function(){return(St=b._emscripten_bind_btSliderConstraint_getBreakingImpulseThreshold_0=b.asm.Fr).apply(null,arguments)},Tt=b._emscripten_bind_btSliderConstraint_setBreakingImpulseThreshold_1=function(){return(Tt=b._emscripten_bind_btSliderConstraint_setBreakingImpulseThreshold_1= +b.asm.Gr).apply(null,arguments)},Ut=b._emscripten_bind_btSliderConstraint_getParam_2=function(){return(Ut=b._emscripten_bind_btSliderConstraint_getParam_2=b.asm.Hr).apply(null,arguments)},Vt=b._emscripten_bind_btSliderConstraint_setParam_3=function(){return(Vt=b._emscripten_bind_btSliderConstraint_setParam_3=b.asm.Ir).apply(null,arguments)},Wt=b._emscripten_bind_btSliderConstraint___destroy___0=function(){return(Wt=b._emscripten_bind_btSliderConstraint___destroy___0=b.asm.Jr).apply(null,arguments)}, +Xt=b._emscripten_bind_btFixedConstraint_btFixedConstraint_4=function(){return(Xt=b._emscripten_bind_btFixedConstraint_btFixedConstraint_4=b.asm.Kr).apply(null,arguments)},Yt=b._emscripten_bind_btFixedConstraint_enableFeedback_1=function(){return(Yt=b._emscripten_bind_btFixedConstraint_enableFeedback_1=b.asm.Lr).apply(null,arguments)},Zt=b._emscripten_bind_btFixedConstraint_getBreakingImpulseThreshold_0=function(){return(Zt=b._emscripten_bind_btFixedConstraint_getBreakingImpulseThreshold_0=b.asm.Mr).apply(null, +arguments)},$t=b._emscripten_bind_btFixedConstraint_setBreakingImpulseThreshold_1=function(){return($t=b._emscripten_bind_btFixedConstraint_setBreakingImpulseThreshold_1=b.asm.Nr).apply(null,arguments)},au=b._emscripten_bind_btFixedConstraint_getParam_2=function(){return(au=b._emscripten_bind_btFixedConstraint_getParam_2=b.asm.Or).apply(null,arguments)},bu=b._emscripten_bind_btFixedConstraint_setParam_3=function(){return(bu=b._emscripten_bind_btFixedConstraint_setParam_3=b.asm.Pr).apply(null,arguments)}, +cu=b._emscripten_bind_btFixedConstraint___destroy___0=function(){return(cu=b._emscripten_bind_btFixedConstraint___destroy___0=b.asm.Qr).apply(null,arguments)},du=b._emscripten_bind_btConstraintSolver___destroy___0=function(){return(du=b._emscripten_bind_btConstraintSolver___destroy___0=b.asm.Rr).apply(null,arguments)},eu=b._emscripten_bind_btDispatcherInfo_get_m_timeStep_0=function(){return(eu=b._emscripten_bind_btDispatcherInfo_get_m_timeStep_0=b.asm.Sr).apply(null,arguments)},fu=b._emscripten_bind_btDispatcherInfo_set_m_timeStep_1= +function(){return(fu=b._emscripten_bind_btDispatcherInfo_set_m_timeStep_1=b.asm.Tr).apply(null,arguments)},gu=b._emscripten_bind_btDispatcherInfo_get_m_stepCount_0=function(){return(gu=b._emscripten_bind_btDispatcherInfo_get_m_stepCount_0=b.asm.Ur).apply(null,arguments)},hu=b._emscripten_bind_btDispatcherInfo_set_m_stepCount_1=function(){return(hu=b._emscripten_bind_btDispatcherInfo_set_m_stepCount_1=b.asm.Vr).apply(null,arguments)},iu=b._emscripten_bind_btDispatcherInfo_get_m_dispatchFunc_0=function(){return(iu= +b._emscripten_bind_btDispatcherInfo_get_m_dispatchFunc_0=b.asm.Wr).apply(null,arguments)},ju=b._emscripten_bind_btDispatcherInfo_set_m_dispatchFunc_1=function(){return(ju=b._emscripten_bind_btDispatcherInfo_set_m_dispatchFunc_1=b.asm.Xr).apply(null,arguments)},ku=b._emscripten_bind_btDispatcherInfo_get_m_timeOfImpact_0=function(){return(ku=b._emscripten_bind_btDispatcherInfo_get_m_timeOfImpact_0=b.asm.Yr).apply(null,arguments)},lu=b._emscripten_bind_btDispatcherInfo_set_m_timeOfImpact_1=function(){return(lu= +b._emscripten_bind_btDispatcherInfo_set_m_timeOfImpact_1=b.asm.Zr).apply(null,arguments)},mu=b._emscripten_bind_btDispatcherInfo_get_m_useContinuous_0=function(){return(mu=b._emscripten_bind_btDispatcherInfo_get_m_useContinuous_0=b.asm._r).apply(null,arguments)},nu=b._emscripten_bind_btDispatcherInfo_set_m_useContinuous_1=function(){return(nu=b._emscripten_bind_btDispatcherInfo_set_m_useContinuous_1=b.asm.$r).apply(null,arguments)},ou=b._emscripten_bind_btDispatcherInfo_get_m_enableSatConvex_0=function(){return(ou= +b._emscripten_bind_btDispatcherInfo_get_m_enableSatConvex_0=b.asm.as).apply(null,arguments)},pu=b._emscripten_bind_btDispatcherInfo_set_m_enableSatConvex_1=function(){return(pu=b._emscripten_bind_btDispatcherInfo_set_m_enableSatConvex_1=b.asm.bs).apply(null,arguments)},qu=b._emscripten_bind_btDispatcherInfo_get_m_enableSPU_0=function(){return(qu=b._emscripten_bind_btDispatcherInfo_get_m_enableSPU_0=b.asm.cs).apply(null,arguments)},ru=b._emscripten_bind_btDispatcherInfo_set_m_enableSPU_1=function(){return(ru= +b._emscripten_bind_btDispatcherInfo_set_m_enableSPU_1=b.asm.ds).apply(null,arguments)},su=b._emscripten_bind_btDispatcherInfo_get_m_useEpa_0=function(){return(su=b._emscripten_bind_btDispatcherInfo_get_m_useEpa_0=b.asm.es).apply(null,arguments)},tu=b._emscripten_bind_btDispatcherInfo_set_m_useEpa_1=function(){return(tu=b._emscripten_bind_btDispatcherInfo_set_m_useEpa_1=b.asm.fs).apply(null,arguments)},uu=b._emscripten_bind_btDispatcherInfo_get_m_allowedCcdPenetration_0=function(){return(uu=b._emscripten_bind_btDispatcherInfo_get_m_allowedCcdPenetration_0= +b.asm.gs).apply(null,arguments)},vu=b._emscripten_bind_btDispatcherInfo_set_m_allowedCcdPenetration_1=function(){return(vu=b._emscripten_bind_btDispatcherInfo_set_m_allowedCcdPenetration_1=b.asm.hs).apply(null,arguments)},wu=b._emscripten_bind_btDispatcherInfo_get_m_useConvexConservativeDistanceUtil_0=function(){return(wu=b._emscripten_bind_btDispatcherInfo_get_m_useConvexConservativeDistanceUtil_0=b.asm.is).apply(null,arguments)},xu=b._emscripten_bind_btDispatcherInfo_set_m_useConvexConservativeDistanceUtil_1= +function(){return(xu=b._emscripten_bind_btDispatcherInfo_set_m_useConvexConservativeDistanceUtil_1=b.asm.js).apply(null,arguments)},yu=b._emscripten_bind_btDispatcherInfo_get_m_convexConservativeDistanceThreshold_0=function(){return(yu=b._emscripten_bind_btDispatcherInfo_get_m_convexConservativeDistanceThreshold_0=b.asm.ks).apply(null,arguments)},zu=b._emscripten_bind_btDispatcherInfo_set_m_convexConservativeDistanceThreshold_1=function(){return(zu=b._emscripten_bind_btDispatcherInfo_set_m_convexConservativeDistanceThreshold_1= +b.asm.ls).apply(null,arguments)},Au=b._emscripten_bind_btDispatcherInfo___destroy___0=function(){return(Au=b._emscripten_bind_btDispatcherInfo___destroy___0=b.asm.ms).apply(null,arguments)},Bu=b._emscripten_bind_btContactSolverInfo_get_m_splitImpulse_0=function(){return(Bu=b._emscripten_bind_btContactSolverInfo_get_m_splitImpulse_0=b.asm.ns).apply(null,arguments)},Cu=b._emscripten_bind_btContactSolverInfo_set_m_splitImpulse_1=function(){return(Cu=b._emscripten_bind_btContactSolverInfo_set_m_splitImpulse_1= +b.asm.os).apply(null,arguments)},Du=b._emscripten_bind_btContactSolverInfo_get_m_splitImpulsePenetrationThreshold_0=function(){return(Du=b._emscripten_bind_btContactSolverInfo_get_m_splitImpulsePenetrationThreshold_0=b.asm.ps).apply(null,arguments)},Eu=b._emscripten_bind_btContactSolverInfo_set_m_splitImpulsePenetrationThreshold_1=function(){return(Eu=b._emscripten_bind_btContactSolverInfo_set_m_splitImpulsePenetrationThreshold_1=b.asm.qs).apply(null,arguments)},Fu=b._emscripten_bind_btContactSolverInfo_get_m_numIterations_0= +function(){return(Fu=b._emscripten_bind_btContactSolverInfo_get_m_numIterations_0=b.asm.rs).apply(null,arguments)},Gu=b._emscripten_bind_btContactSolverInfo_set_m_numIterations_1=function(){return(Gu=b._emscripten_bind_btContactSolverInfo_set_m_numIterations_1=b.asm.ss).apply(null,arguments)},Hu=b._emscripten_bind_btContactSolverInfo___destroy___0=function(){return(Hu=b._emscripten_bind_btContactSolverInfo___destroy___0=b.asm.ts).apply(null,arguments)},Iu=b._emscripten_bind_btVehicleTuning_btVehicleTuning_0= +function(){return(Iu=b._emscripten_bind_btVehicleTuning_btVehicleTuning_0=b.asm.us).apply(null,arguments)},Ju=b._emscripten_bind_btVehicleTuning_get_m_suspensionStiffness_0=function(){return(Ju=b._emscripten_bind_btVehicleTuning_get_m_suspensionStiffness_0=b.asm.vs).apply(null,arguments)},Ku=b._emscripten_bind_btVehicleTuning_set_m_suspensionStiffness_1=function(){return(Ku=b._emscripten_bind_btVehicleTuning_set_m_suspensionStiffness_1=b.asm.ws).apply(null,arguments)},Lu=b._emscripten_bind_btVehicleTuning_get_m_suspensionCompression_0= +function(){return(Lu=b._emscripten_bind_btVehicleTuning_get_m_suspensionCompression_0=b.asm.xs).apply(null,arguments)},Mu=b._emscripten_bind_btVehicleTuning_set_m_suspensionCompression_1=function(){return(Mu=b._emscripten_bind_btVehicleTuning_set_m_suspensionCompression_1=b.asm.ys).apply(null,arguments)},Nu=b._emscripten_bind_btVehicleTuning_get_m_suspensionDamping_0=function(){return(Nu=b._emscripten_bind_btVehicleTuning_get_m_suspensionDamping_0=b.asm.zs).apply(null,arguments)},Ou=b._emscripten_bind_btVehicleTuning_set_m_suspensionDamping_1= +function(){return(Ou=b._emscripten_bind_btVehicleTuning_set_m_suspensionDamping_1=b.asm.As).apply(null,arguments)},Pu=b._emscripten_bind_btVehicleTuning_get_m_maxSuspensionTravelCm_0=function(){return(Pu=b._emscripten_bind_btVehicleTuning_get_m_maxSuspensionTravelCm_0=b.asm.Bs).apply(null,arguments)},Qu=b._emscripten_bind_btVehicleTuning_set_m_maxSuspensionTravelCm_1=function(){return(Qu=b._emscripten_bind_btVehicleTuning_set_m_maxSuspensionTravelCm_1=b.asm.Cs).apply(null,arguments)},Ru=b._emscripten_bind_btVehicleTuning_get_m_frictionSlip_0= +function(){return(Ru=b._emscripten_bind_btVehicleTuning_get_m_frictionSlip_0=b.asm.Ds).apply(null,arguments)},Su=b._emscripten_bind_btVehicleTuning_set_m_frictionSlip_1=function(){return(Su=b._emscripten_bind_btVehicleTuning_set_m_frictionSlip_1=b.asm.Es).apply(null,arguments)},Tu=b._emscripten_bind_btVehicleTuning_get_m_maxSuspensionForce_0=function(){return(Tu=b._emscripten_bind_btVehicleTuning_get_m_maxSuspensionForce_0=b.asm.Fs).apply(null,arguments)},Uu=b._emscripten_bind_btVehicleTuning_set_m_maxSuspensionForce_1= +function(){return(Uu=b._emscripten_bind_btVehicleTuning_set_m_maxSuspensionForce_1=b.asm.Gs).apply(null,arguments)},Vu=b._emscripten_bind_btVehicleRaycasterResult_get_m_hitPointInWorld_0=function(){return(Vu=b._emscripten_bind_btVehicleRaycasterResult_get_m_hitPointInWorld_0=b.asm.Hs).apply(null,arguments)},Wu=b._emscripten_bind_btVehicleRaycasterResult_set_m_hitPointInWorld_1=function(){return(Wu=b._emscripten_bind_btVehicleRaycasterResult_set_m_hitPointInWorld_1=b.asm.Is).apply(null,arguments)}, +Xu=b._emscripten_bind_btVehicleRaycasterResult_get_m_hitNormalInWorld_0=function(){return(Xu=b._emscripten_bind_btVehicleRaycasterResult_get_m_hitNormalInWorld_0=b.asm.Js).apply(null,arguments)},Yu=b._emscripten_bind_btVehicleRaycasterResult_set_m_hitNormalInWorld_1=function(){return(Yu=b._emscripten_bind_btVehicleRaycasterResult_set_m_hitNormalInWorld_1=b.asm.Ks).apply(null,arguments)},Zu=b._emscripten_bind_btVehicleRaycasterResult_get_m_distFraction_0=function(){return(Zu=b._emscripten_bind_btVehicleRaycasterResult_get_m_distFraction_0= +b.asm.Ls).apply(null,arguments)},$u=b._emscripten_bind_btVehicleRaycasterResult_set_m_distFraction_1=function(){return($u=b._emscripten_bind_btVehicleRaycasterResult_set_m_distFraction_1=b.asm.Ms).apply(null,arguments)},av=b._emscripten_bind_btVehicleRaycasterResult___destroy___0=function(){return(av=b._emscripten_bind_btVehicleRaycasterResult___destroy___0=b.asm.Ns).apply(null,arguments)},bv=b._emscripten_bind_btDefaultVehicleRaycaster_btDefaultVehicleRaycaster_1=function(){return(bv=b._emscripten_bind_btDefaultVehicleRaycaster_btDefaultVehicleRaycaster_1= +b.asm.Os).apply(null,arguments)},cv=b._emscripten_bind_btDefaultVehicleRaycaster_castRay_3=function(){return(cv=b._emscripten_bind_btDefaultVehicleRaycaster_castRay_3=b.asm.Ps).apply(null,arguments)},dv=b._emscripten_bind_btDefaultVehicleRaycaster___destroy___0=function(){return(dv=b._emscripten_bind_btDefaultVehicleRaycaster___destroy___0=b.asm.Qs).apply(null,arguments)},ev=b._emscripten_bind_RaycastInfo_get_m_contactNormalWS_0=function(){return(ev=b._emscripten_bind_RaycastInfo_get_m_contactNormalWS_0= +b.asm.Rs).apply(null,arguments)},fv=b._emscripten_bind_RaycastInfo_set_m_contactNormalWS_1=function(){return(fv=b._emscripten_bind_RaycastInfo_set_m_contactNormalWS_1=b.asm.Ss).apply(null,arguments)},gv=b._emscripten_bind_RaycastInfo_get_m_contactPointWS_0=function(){return(gv=b._emscripten_bind_RaycastInfo_get_m_contactPointWS_0=b.asm.Ts).apply(null,arguments)},hv=b._emscripten_bind_RaycastInfo_set_m_contactPointWS_1=function(){return(hv=b._emscripten_bind_RaycastInfo_set_m_contactPointWS_1=b.asm.Us).apply(null, +arguments)},iv=b._emscripten_bind_RaycastInfo_get_m_suspensionLength_0=function(){return(iv=b._emscripten_bind_RaycastInfo_get_m_suspensionLength_0=b.asm.Vs).apply(null,arguments)},jv=b._emscripten_bind_RaycastInfo_set_m_suspensionLength_1=function(){return(jv=b._emscripten_bind_RaycastInfo_set_m_suspensionLength_1=b.asm.Ws).apply(null,arguments)},kv=b._emscripten_bind_RaycastInfo_get_m_hardPointWS_0=function(){return(kv=b._emscripten_bind_RaycastInfo_get_m_hardPointWS_0=b.asm.Xs).apply(null,arguments)}, +lv=b._emscripten_bind_RaycastInfo_set_m_hardPointWS_1=function(){return(lv=b._emscripten_bind_RaycastInfo_set_m_hardPointWS_1=b.asm.Ys).apply(null,arguments)},mv=b._emscripten_bind_RaycastInfo_get_m_wheelDirectionWS_0=function(){return(mv=b._emscripten_bind_RaycastInfo_get_m_wheelDirectionWS_0=b.asm.Zs).apply(null,arguments)},nv=b._emscripten_bind_RaycastInfo_set_m_wheelDirectionWS_1=function(){return(nv=b._emscripten_bind_RaycastInfo_set_m_wheelDirectionWS_1=b.asm._s).apply(null,arguments)},ov=b._emscripten_bind_RaycastInfo_get_m_wheelAxleWS_0= +function(){return(ov=b._emscripten_bind_RaycastInfo_get_m_wheelAxleWS_0=b.asm.$s).apply(null,arguments)},pv=b._emscripten_bind_RaycastInfo_set_m_wheelAxleWS_1=function(){return(pv=b._emscripten_bind_RaycastInfo_set_m_wheelAxleWS_1=b.asm.at).apply(null,arguments)},qv=b._emscripten_bind_RaycastInfo_get_m_isInContact_0=function(){return(qv=b._emscripten_bind_RaycastInfo_get_m_isInContact_0=b.asm.bt).apply(null,arguments)},rv=b._emscripten_bind_RaycastInfo_set_m_isInContact_1=function(){return(rv=b._emscripten_bind_RaycastInfo_set_m_isInContact_1= +b.asm.ct).apply(null,arguments)},sv=b._emscripten_bind_RaycastInfo_get_m_groundObject_0=function(){return(sv=b._emscripten_bind_RaycastInfo_get_m_groundObject_0=b.asm.dt).apply(null,arguments)},tv=b._emscripten_bind_RaycastInfo_set_m_groundObject_1=function(){return(tv=b._emscripten_bind_RaycastInfo_set_m_groundObject_1=b.asm.et).apply(null,arguments)},uv=b._emscripten_bind_RaycastInfo___destroy___0=function(){return(uv=b._emscripten_bind_RaycastInfo___destroy___0=b.asm.ft).apply(null,arguments)}, +vv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_chassisConnectionCS_0=function(){return(vv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_chassisConnectionCS_0=b.asm.gt).apply(null,arguments)},wv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_chassisConnectionCS_1=function(){return(wv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_chassisConnectionCS_1=b.asm.ht).apply(null,arguments)},xv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelDirectionCS_0=function(){return(xv= +b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelDirectionCS_0=b.asm.it).apply(null,arguments)},yv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelDirectionCS_1=function(){return(yv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelDirectionCS_1=b.asm.jt).apply(null,arguments)},zv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelAxleCS_0=function(){return(zv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelAxleCS_0=b.asm.kt).apply(null,arguments)},Av=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelAxleCS_1= +function(){return(Av=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelAxleCS_1=b.asm.lt).apply(null,arguments)},Bv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_suspensionRestLength_0=function(){return(Bv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_suspensionRestLength_0=b.asm.mt).apply(null,arguments)},Cv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_suspensionRestLength_1=function(){return(Cv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_suspensionRestLength_1= +b.asm.nt).apply(null,arguments)},Dv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_maxSuspensionTravelCm_0=function(){return(Dv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_maxSuspensionTravelCm_0=b.asm.ot).apply(null,arguments)},Ev=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_maxSuspensionTravelCm_1=function(){return(Ev=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_maxSuspensionTravelCm_1=b.asm.pt).apply(null,arguments)},Fv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelRadius_0= +function(){return(Fv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelRadius_0=b.asm.qt).apply(null,arguments)},Gv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelRadius_1=function(){return(Gv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelRadius_1=b.asm.rt).apply(null,arguments)},Hv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_suspensionStiffness_0=function(){return(Hv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_suspensionStiffness_0=b.asm.st).apply(null, +arguments)},Iv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_suspensionStiffness_1=function(){return(Iv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_suspensionStiffness_1=b.asm.tt).apply(null,arguments)},Jv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelsDampingCompression_0=function(){return(Jv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelsDampingCompression_0=b.asm.ut).apply(null,arguments)},Kv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelsDampingCompression_1= +function(){return(Kv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelsDampingCompression_1=b.asm.vt).apply(null,arguments)},Lv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelsDampingRelaxation_0=function(){return(Lv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_wheelsDampingRelaxation_0=b.asm.wt).apply(null,arguments)},Mv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelsDampingRelaxation_1=function(){return(Mv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_wheelsDampingRelaxation_1= +b.asm.xt).apply(null,arguments)},Nv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_frictionSlip_0=function(){return(Nv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_frictionSlip_0=b.asm.yt).apply(null,arguments)},Ov=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_frictionSlip_1=function(){return(Ov=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_frictionSlip_1=b.asm.zt).apply(null,arguments)},Pv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_maxSuspensionForce_0=function(){return(Pv= +b._emscripten_bind_btWheelInfoConstructionInfo_get_m_maxSuspensionForce_0=b.asm.At).apply(null,arguments)},Qv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_maxSuspensionForce_1=function(){return(Qv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_maxSuspensionForce_1=b.asm.Bt).apply(null,arguments)},Rv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_bIsFrontWheel_0=function(){return(Rv=b._emscripten_bind_btWheelInfoConstructionInfo_get_m_bIsFrontWheel_0=b.asm.Ct).apply(null,arguments)}, +Sv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_bIsFrontWheel_1=function(){return(Sv=b._emscripten_bind_btWheelInfoConstructionInfo_set_m_bIsFrontWheel_1=b.asm.Dt).apply(null,arguments)},Tv=b._emscripten_bind_btWheelInfoConstructionInfo___destroy___0=function(){return(Tv=b._emscripten_bind_btWheelInfoConstructionInfo___destroy___0=b.asm.Et).apply(null,arguments)},Uv=b._emscripten_bind_btWheelInfo_btWheelInfo_1=function(){return(Uv=b._emscripten_bind_btWheelInfo_btWheelInfo_1=b.asm.Ft).apply(null, +arguments)},Vv=b._emscripten_bind_btWheelInfo_getSuspensionRestLength_0=function(){return(Vv=b._emscripten_bind_btWheelInfo_getSuspensionRestLength_0=b.asm.Gt).apply(null,arguments)},Wv=b._emscripten_bind_btWheelInfo_updateWheel_2=function(){return(Wv=b._emscripten_bind_btWheelInfo_updateWheel_2=b.asm.Ht).apply(null,arguments)},Xv=b._emscripten_bind_btWheelInfo_get_m_suspensionStiffness_0=function(){return(Xv=b._emscripten_bind_btWheelInfo_get_m_suspensionStiffness_0=b.asm.It).apply(null,arguments)}, +Yv=b._emscripten_bind_btWheelInfo_set_m_suspensionStiffness_1=function(){return(Yv=b._emscripten_bind_btWheelInfo_set_m_suspensionStiffness_1=b.asm.Jt).apply(null,arguments)},Zv=b._emscripten_bind_btWheelInfo_get_m_frictionSlip_0=function(){return(Zv=b._emscripten_bind_btWheelInfo_get_m_frictionSlip_0=b.asm.Kt).apply(null,arguments)},$v=b._emscripten_bind_btWheelInfo_set_m_frictionSlip_1=function(){return($v=b._emscripten_bind_btWheelInfo_set_m_frictionSlip_1=b.asm.Lt).apply(null,arguments)},aw=b._emscripten_bind_btWheelInfo_get_m_engineForce_0= +function(){return(aw=b._emscripten_bind_btWheelInfo_get_m_engineForce_0=b.asm.Mt).apply(null,arguments)},bw=b._emscripten_bind_btWheelInfo_set_m_engineForce_1=function(){return(bw=b._emscripten_bind_btWheelInfo_set_m_engineForce_1=b.asm.Nt).apply(null,arguments)},cw=b._emscripten_bind_btWheelInfo_get_m_rollInfluence_0=function(){return(cw=b._emscripten_bind_btWheelInfo_get_m_rollInfluence_0=b.asm.Ot).apply(null,arguments)},dw=b._emscripten_bind_btWheelInfo_set_m_rollInfluence_1=function(){return(dw= +b._emscripten_bind_btWheelInfo_set_m_rollInfluence_1=b.asm.Pt).apply(null,arguments)},ew=b._emscripten_bind_btWheelInfo_get_m_suspensionRestLength1_0=function(){return(ew=b._emscripten_bind_btWheelInfo_get_m_suspensionRestLength1_0=b.asm.Qt).apply(null,arguments)},fw=b._emscripten_bind_btWheelInfo_set_m_suspensionRestLength1_1=function(){return(fw=b._emscripten_bind_btWheelInfo_set_m_suspensionRestLength1_1=b.asm.Rt).apply(null,arguments)},gw=b._emscripten_bind_btWheelInfo_get_m_wheelsRadius_0=function(){return(gw= +b._emscripten_bind_btWheelInfo_get_m_wheelsRadius_0=b.asm.St).apply(null,arguments)},hw=b._emscripten_bind_btWheelInfo_set_m_wheelsRadius_1=function(){return(hw=b._emscripten_bind_btWheelInfo_set_m_wheelsRadius_1=b.asm.Tt).apply(null,arguments)},iw=b._emscripten_bind_btWheelInfo_get_m_wheelsDampingCompression_0=function(){return(iw=b._emscripten_bind_btWheelInfo_get_m_wheelsDampingCompression_0=b.asm.Ut).apply(null,arguments)},jw=b._emscripten_bind_btWheelInfo_set_m_wheelsDampingCompression_1=function(){return(jw= +b._emscripten_bind_btWheelInfo_set_m_wheelsDampingCompression_1=b.asm.Vt).apply(null,arguments)},kw=b._emscripten_bind_btWheelInfo_get_m_wheelsDampingRelaxation_0=function(){return(kw=b._emscripten_bind_btWheelInfo_get_m_wheelsDampingRelaxation_0=b.asm.Wt).apply(null,arguments)},lw=b._emscripten_bind_btWheelInfo_set_m_wheelsDampingRelaxation_1=function(){return(lw=b._emscripten_bind_btWheelInfo_set_m_wheelsDampingRelaxation_1=b.asm.Xt).apply(null,arguments)},mw=b._emscripten_bind_btWheelInfo_get_m_steering_0= +function(){return(mw=b._emscripten_bind_btWheelInfo_get_m_steering_0=b.asm.Yt).apply(null,arguments)},nw=b._emscripten_bind_btWheelInfo_set_m_steering_1=function(){return(nw=b._emscripten_bind_btWheelInfo_set_m_steering_1=b.asm.Zt).apply(null,arguments)},ow=b._emscripten_bind_btWheelInfo_get_m_maxSuspensionForce_0=function(){return(ow=b._emscripten_bind_btWheelInfo_get_m_maxSuspensionForce_0=b.asm._t).apply(null,arguments)},pw=b._emscripten_bind_btWheelInfo_set_m_maxSuspensionForce_1=function(){return(pw= +b._emscripten_bind_btWheelInfo_set_m_maxSuspensionForce_1=b.asm.$t).apply(null,arguments)},qw=b._emscripten_bind_btWheelInfo_get_m_maxSuspensionTravelCm_0=function(){return(qw=b._emscripten_bind_btWheelInfo_get_m_maxSuspensionTravelCm_0=b.asm.au).apply(null,arguments)},rw=b._emscripten_bind_btWheelInfo_set_m_maxSuspensionTravelCm_1=function(){return(rw=b._emscripten_bind_btWheelInfo_set_m_maxSuspensionTravelCm_1=b.asm.bu).apply(null,arguments)},sw=b._emscripten_bind_btWheelInfo_get_m_wheelsSuspensionForce_0= +function(){return(sw=b._emscripten_bind_btWheelInfo_get_m_wheelsSuspensionForce_0=b.asm.cu).apply(null,arguments)},tw=b._emscripten_bind_btWheelInfo_set_m_wheelsSuspensionForce_1=function(){return(tw=b._emscripten_bind_btWheelInfo_set_m_wheelsSuspensionForce_1=b.asm.du).apply(null,arguments)},uw=b._emscripten_bind_btWheelInfo_get_m_bIsFrontWheel_0=function(){return(uw=b._emscripten_bind_btWheelInfo_get_m_bIsFrontWheel_0=b.asm.eu).apply(null,arguments)},vw=b._emscripten_bind_btWheelInfo_set_m_bIsFrontWheel_1= +function(){return(vw=b._emscripten_bind_btWheelInfo_set_m_bIsFrontWheel_1=b.asm.fu).apply(null,arguments)},ww=b._emscripten_bind_btWheelInfo_get_m_raycastInfo_0=function(){return(ww=b._emscripten_bind_btWheelInfo_get_m_raycastInfo_0=b.asm.gu).apply(null,arguments)},xw=b._emscripten_bind_btWheelInfo_set_m_raycastInfo_1=function(){return(xw=b._emscripten_bind_btWheelInfo_set_m_raycastInfo_1=b.asm.hu).apply(null,arguments)},yw=b._emscripten_bind_btWheelInfo_get_m_chassisConnectionPointCS_0=function(){return(yw= +b._emscripten_bind_btWheelInfo_get_m_chassisConnectionPointCS_0=b.asm.iu).apply(null,arguments)},zw=b._emscripten_bind_btWheelInfo_set_m_chassisConnectionPointCS_1=function(){return(zw=b._emscripten_bind_btWheelInfo_set_m_chassisConnectionPointCS_1=b.asm.ju).apply(null,arguments)},Aw=b._emscripten_bind_btWheelInfo_get_m_worldTransform_0=function(){return(Aw=b._emscripten_bind_btWheelInfo_get_m_worldTransform_0=b.asm.ku).apply(null,arguments)},Bw=b._emscripten_bind_btWheelInfo_set_m_worldTransform_1= +function(){return(Bw=b._emscripten_bind_btWheelInfo_set_m_worldTransform_1=b.asm.lu).apply(null,arguments)},Cw=b._emscripten_bind_btWheelInfo_get_m_wheelDirectionCS_0=function(){return(Cw=b._emscripten_bind_btWheelInfo_get_m_wheelDirectionCS_0=b.asm.mu).apply(null,arguments)},Dw=b._emscripten_bind_btWheelInfo_set_m_wheelDirectionCS_1=function(){return(Dw=b._emscripten_bind_btWheelInfo_set_m_wheelDirectionCS_1=b.asm.nu).apply(null,arguments)},Ew=b._emscripten_bind_btWheelInfo_get_m_wheelAxleCS_0=function(){return(Ew= +b._emscripten_bind_btWheelInfo_get_m_wheelAxleCS_0=b.asm.ou).apply(null,arguments)},Fw=b._emscripten_bind_btWheelInfo_set_m_wheelAxleCS_1=function(){return(Fw=b._emscripten_bind_btWheelInfo_set_m_wheelAxleCS_1=b.asm.pu).apply(null,arguments)},Gw=b._emscripten_bind_btWheelInfo_get_m_rotation_0=function(){return(Gw=b._emscripten_bind_btWheelInfo_get_m_rotation_0=b.asm.qu).apply(null,arguments)},Hw=b._emscripten_bind_btWheelInfo_set_m_rotation_1=function(){return(Hw=b._emscripten_bind_btWheelInfo_set_m_rotation_1= +b.asm.ru).apply(null,arguments)},Iw=b._emscripten_bind_btWheelInfo_get_m_deltaRotation_0=function(){return(Iw=b._emscripten_bind_btWheelInfo_get_m_deltaRotation_0=b.asm.su).apply(null,arguments)},Jw=b._emscripten_bind_btWheelInfo_set_m_deltaRotation_1=function(){return(Jw=b._emscripten_bind_btWheelInfo_set_m_deltaRotation_1=b.asm.tu).apply(null,arguments)},Kw=b._emscripten_bind_btWheelInfo_get_m_brake_0=function(){return(Kw=b._emscripten_bind_btWheelInfo_get_m_brake_0=b.asm.uu).apply(null,arguments)}, +Lw=b._emscripten_bind_btWheelInfo_set_m_brake_1=function(){return(Lw=b._emscripten_bind_btWheelInfo_set_m_brake_1=b.asm.vu).apply(null,arguments)},Mw=b._emscripten_bind_btWheelInfo_get_m_clippedInvContactDotSuspension_0=function(){return(Mw=b._emscripten_bind_btWheelInfo_get_m_clippedInvContactDotSuspension_0=b.asm.wu).apply(null,arguments)},Nw=b._emscripten_bind_btWheelInfo_set_m_clippedInvContactDotSuspension_1=function(){return(Nw=b._emscripten_bind_btWheelInfo_set_m_clippedInvContactDotSuspension_1= +b.asm.xu).apply(null,arguments)},Ow=b._emscripten_bind_btWheelInfo_get_m_suspensionRelativeVelocity_0=function(){return(Ow=b._emscripten_bind_btWheelInfo_get_m_suspensionRelativeVelocity_0=b.asm.yu).apply(null,arguments)},Pw=b._emscripten_bind_btWheelInfo_set_m_suspensionRelativeVelocity_1=function(){return(Pw=b._emscripten_bind_btWheelInfo_set_m_suspensionRelativeVelocity_1=b.asm.zu).apply(null,arguments)},Qw=b._emscripten_bind_btWheelInfo_get_m_skidInfo_0=function(){return(Qw=b._emscripten_bind_btWheelInfo_get_m_skidInfo_0= +b.asm.Au).apply(null,arguments)},Rw=b._emscripten_bind_btWheelInfo_set_m_skidInfo_1=function(){return(Rw=b._emscripten_bind_btWheelInfo_set_m_skidInfo_1=b.asm.Bu).apply(null,arguments)},Sw=b._emscripten_bind_btWheelInfo___destroy___0=function(){return(Sw=b._emscripten_bind_btWheelInfo___destroy___0=b.asm.Cu).apply(null,arguments)},Tw=b._emscripten_bind_btKinematicCharacterController_btKinematicCharacterController_3=function(){return(Tw=b._emscripten_bind_btKinematicCharacterController_btKinematicCharacterController_3= +b.asm.Du).apply(null,arguments)},Uw=b._emscripten_bind_btKinematicCharacterController_btKinematicCharacterController_4=function(){return(Uw=b._emscripten_bind_btKinematicCharacterController_btKinematicCharacterController_4=b.asm.Eu).apply(null,arguments)},Vw=b._emscripten_bind_btKinematicCharacterController_setUpAxis_1=function(){return(Vw=b._emscripten_bind_btKinematicCharacterController_setUpAxis_1=b.asm.Fu).apply(null,arguments)},Ww=b._emscripten_bind_btKinematicCharacterController_setWalkDirection_1= +function(){return(Ww=b._emscripten_bind_btKinematicCharacterController_setWalkDirection_1=b.asm.Gu).apply(null,arguments)},Xw=b._emscripten_bind_btKinematicCharacterController_setVelocityForTimeInterval_2=function(){return(Xw=b._emscripten_bind_btKinematicCharacterController_setVelocityForTimeInterval_2=b.asm.Hu).apply(null,arguments)},Yw=b._emscripten_bind_btKinematicCharacterController_warp_1=function(){return(Yw=b._emscripten_bind_btKinematicCharacterController_warp_1=b.asm.Iu).apply(null,arguments)}, +Zw=b._emscripten_bind_btKinematicCharacterController_preStep_1=function(){return(Zw=b._emscripten_bind_btKinematicCharacterController_preStep_1=b.asm.Ju).apply(null,arguments)},$w=b._emscripten_bind_btKinematicCharacterController_playerStep_2=function(){return($w=b._emscripten_bind_btKinematicCharacterController_playerStep_2=b.asm.Ku).apply(null,arguments)},ax=b._emscripten_bind_btKinematicCharacterController_setFallSpeed_1=function(){return(ax=b._emscripten_bind_btKinematicCharacterController_setFallSpeed_1= +b.asm.Lu).apply(null,arguments)},bx=b._emscripten_bind_btKinematicCharacterController_setJumpSpeed_1=function(){return(bx=b._emscripten_bind_btKinematicCharacterController_setJumpSpeed_1=b.asm.Mu).apply(null,arguments)},cx=b._emscripten_bind_btKinematicCharacterController_setMaxJumpHeight_1=function(){return(cx=b._emscripten_bind_btKinematicCharacterController_setMaxJumpHeight_1=b.asm.Nu).apply(null,arguments)},dx=b._emscripten_bind_btKinematicCharacterController_canJump_0=function(){return(dx=b._emscripten_bind_btKinematicCharacterController_canJump_0= +b.asm.Ou).apply(null,arguments)},ex=b._emscripten_bind_btKinematicCharacterController_jump_0=function(){return(ex=b._emscripten_bind_btKinematicCharacterController_jump_0=b.asm.Pu).apply(null,arguments)},fx=b._emscripten_bind_btKinematicCharacterController_setGravity_1=function(){return(fx=b._emscripten_bind_btKinematicCharacterController_setGravity_1=b.asm.Qu).apply(null,arguments)},gx=b._emscripten_bind_btKinematicCharacterController_getGravity_0=function(){return(gx=b._emscripten_bind_btKinematicCharacterController_getGravity_0= +b.asm.Ru).apply(null,arguments)},hx=b._emscripten_bind_btKinematicCharacterController_setMaxSlope_1=function(){return(hx=b._emscripten_bind_btKinematicCharacterController_setMaxSlope_1=b.asm.Su).apply(null,arguments)},ix=b._emscripten_bind_btKinematicCharacterController_getMaxSlope_0=function(){return(ix=b._emscripten_bind_btKinematicCharacterController_getMaxSlope_0=b.asm.Tu).apply(null,arguments)},jx=b._emscripten_bind_btKinematicCharacterController_getGhostObject_0=function(){return(jx=b._emscripten_bind_btKinematicCharacterController_getGhostObject_0= +b.asm.Uu).apply(null,arguments)},kx=b._emscripten_bind_btKinematicCharacterController_setUseGhostSweepTest_1=function(){return(kx=b._emscripten_bind_btKinematicCharacterController_setUseGhostSweepTest_1=b.asm.Vu).apply(null,arguments)},lx=b._emscripten_bind_btKinematicCharacterController_onGround_0=function(){return(lx=b._emscripten_bind_btKinematicCharacterController_onGround_0=b.asm.Wu).apply(null,arguments)},mx=b._emscripten_bind_btKinematicCharacterController_setUpInterpolate_1=function(){return(mx= +b._emscripten_bind_btKinematicCharacterController_setUpInterpolate_1=b.asm.Xu).apply(null,arguments)},nx=b._emscripten_bind_btKinematicCharacterController_updateAction_2=function(){return(nx=b._emscripten_bind_btKinematicCharacterController_updateAction_2=b.asm.Yu).apply(null,arguments)},ox=b._emscripten_bind_btKinematicCharacterController___destroy___0=function(){return(ox=b._emscripten_bind_btKinematicCharacterController___destroy___0=b.asm.Zu).apply(null,arguments)},px=b._emscripten_bind_btRaycastVehicle_btRaycastVehicle_3= +function(){return(px=b._emscripten_bind_btRaycastVehicle_btRaycastVehicle_3=b.asm._u).apply(null,arguments)},qx=b._emscripten_bind_btRaycastVehicle_applyEngineForce_2=function(){return(qx=b._emscripten_bind_btRaycastVehicle_applyEngineForce_2=b.asm.$u).apply(null,arguments)},rx=b._emscripten_bind_btRaycastVehicle_setSteeringValue_2=function(){return(rx=b._emscripten_bind_btRaycastVehicle_setSteeringValue_2=b.asm.av).apply(null,arguments)},sx=b._emscripten_bind_btRaycastVehicle_getWheelTransformWS_1= +function(){return(sx=b._emscripten_bind_btRaycastVehicle_getWheelTransformWS_1=b.asm.bv).apply(null,arguments)},tx=b._emscripten_bind_btRaycastVehicle_updateWheelTransform_2=function(){return(tx=b._emscripten_bind_btRaycastVehicle_updateWheelTransform_2=b.asm.cv).apply(null,arguments)},ux=b._emscripten_bind_btRaycastVehicle_addWheel_7=function(){return(ux=b._emscripten_bind_btRaycastVehicle_addWheel_7=b.asm.dv).apply(null,arguments)},vx=b._emscripten_bind_btRaycastVehicle_getNumWheels_0=function(){return(vx= +b._emscripten_bind_btRaycastVehicle_getNumWheels_0=b.asm.ev).apply(null,arguments)},wx=b._emscripten_bind_btRaycastVehicle_getRigidBody_0=function(){return(wx=b._emscripten_bind_btRaycastVehicle_getRigidBody_0=b.asm.fv).apply(null,arguments)},xx=b._emscripten_bind_btRaycastVehicle_getWheelInfo_1=function(){return(xx=b._emscripten_bind_btRaycastVehicle_getWheelInfo_1=b.asm.gv).apply(null,arguments)},yx=b._emscripten_bind_btRaycastVehicle_setBrake_2=function(){return(yx=b._emscripten_bind_btRaycastVehicle_setBrake_2= +b.asm.hv).apply(null,arguments)},zx=b._emscripten_bind_btRaycastVehicle_setCoordinateSystem_3=function(){return(zx=b._emscripten_bind_btRaycastVehicle_setCoordinateSystem_3=b.asm.iv).apply(null,arguments)},Ax=b._emscripten_bind_btRaycastVehicle_getCurrentSpeedKmHour_0=function(){return(Ax=b._emscripten_bind_btRaycastVehicle_getCurrentSpeedKmHour_0=b.asm.jv).apply(null,arguments)},Bx=b._emscripten_bind_btRaycastVehicle_getChassisWorldTransform_0=function(){return(Bx=b._emscripten_bind_btRaycastVehicle_getChassisWorldTransform_0= +b.asm.kv).apply(null,arguments)},Cx=b._emscripten_bind_btRaycastVehicle_rayCast_1=function(){return(Cx=b._emscripten_bind_btRaycastVehicle_rayCast_1=b.asm.lv).apply(null,arguments)},Dx=b._emscripten_bind_btRaycastVehicle_updateVehicle_1=function(){return(Dx=b._emscripten_bind_btRaycastVehicle_updateVehicle_1=b.asm.mv).apply(null,arguments)},Ex=b._emscripten_bind_btRaycastVehicle_resetSuspension_0=function(){return(Ex=b._emscripten_bind_btRaycastVehicle_resetSuspension_0=b.asm.nv).apply(null,arguments)}, +Fx=b._emscripten_bind_btRaycastVehicle_getSteeringValue_1=function(){return(Fx=b._emscripten_bind_btRaycastVehicle_getSteeringValue_1=b.asm.ov).apply(null,arguments)},Gx=b._emscripten_bind_btRaycastVehicle_updateWheelTransformsWS_1=function(){return(Gx=b._emscripten_bind_btRaycastVehicle_updateWheelTransformsWS_1=b.asm.pv).apply(null,arguments)},Hx=b._emscripten_bind_btRaycastVehicle_updateWheelTransformsWS_2=function(){return(Hx=b._emscripten_bind_btRaycastVehicle_updateWheelTransformsWS_2=b.asm.qv).apply(null, +arguments)},Ix=b._emscripten_bind_btRaycastVehicle_setPitchControl_1=function(){return(Ix=b._emscripten_bind_btRaycastVehicle_setPitchControl_1=b.asm.rv).apply(null,arguments)},Jx=b._emscripten_bind_btRaycastVehicle_updateSuspension_1=function(){return(Jx=b._emscripten_bind_btRaycastVehicle_updateSuspension_1=b.asm.sv).apply(null,arguments)},Kx=b._emscripten_bind_btRaycastVehicle_updateFriction_1=function(){return(Kx=b._emscripten_bind_btRaycastVehicle_updateFriction_1=b.asm.tv).apply(null,arguments)}, +Lx=b._emscripten_bind_btRaycastVehicle_getRightAxis_0=function(){return(Lx=b._emscripten_bind_btRaycastVehicle_getRightAxis_0=b.asm.uv).apply(null,arguments)},Mx=b._emscripten_bind_btRaycastVehicle_getUpAxis_0=function(){return(Mx=b._emscripten_bind_btRaycastVehicle_getUpAxis_0=b.asm.vv).apply(null,arguments)},Nx=b._emscripten_bind_btRaycastVehicle_getForwardAxis_0=function(){return(Nx=b._emscripten_bind_btRaycastVehicle_getForwardAxis_0=b.asm.wv).apply(null,arguments)},Ox=b._emscripten_bind_btRaycastVehicle_getForwardVector_0= +function(){return(Ox=b._emscripten_bind_btRaycastVehicle_getForwardVector_0=b.asm.xv).apply(null,arguments)},Px=b._emscripten_bind_btRaycastVehicle_getUserConstraintType_0=function(){return(Px=b._emscripten_bind_btRaycastVehicle_getUserConstraintType_0=b.asm.yv).apply(null,arguments)},Qx=b._emscripten_bind_btRaycastVehicle_setUserConstraintType_1=function(){return(Qx=b._emscripten_bind_btRaycastVehicle_setUserConstraintType_1=b.asm.zv).apply(null,arguments)},Rx=b._emscripten_bind_btRaycastVehicle_setUserConstraintId_1= +function(){return(Rx=b._emscripten_bind_btRaycastVehicle_setUserConstraintId_1=b.asm.Av).apply(null,arguments)},Sx=b._emscripten_bind_btRaycastVehicle_getUserConstraintId_0=function(){return(Sx=b._emscripten_bind_btRaycastVehicle_getUserConstraintId_0=b.asm.Bv).apply(null,arguments)},Tx=b._emscripten_bind_btRaycastVehicle_updateAction_2=function(){return(Tx=b._emscripten_bind_btRaycastVehicle_updateAction_2=b.asm.Cv).apply(null,arguments)},Ux=b._emscripten_bind_btRaycastVehicle___destroy___0=function(){return(Ux= +b._emscripten_bind_btRaycastVehicle___destroy___0=b.asm.Dv).apply(null,arguments)},Vx=b._emscripten_bind_btPairCachingGhostObject_btPairCachingGhostObject_0=function(){return(Vx=b._emscripten_bind_btPairCachingGhostObject_btPairCachingGhostObject_0=b.asm.Ev).apply(null,arguments)},Wx=b._emscripten_bind_btPairCachingGhostObject_setAnisotropicFriction_2=function(){return(Wx=b._emscripten_bind_btPairCachingGhostObject_setAnisotropicFriction_2=b.asm.Fv).apply(null,arguments)},Xx=b._emscripten_bind_btPairCachingGhostObject_getCollisionShape_0= +function(){return(Xx=b._emscripten_bind_btPairCachingGhostObject_getCollisionShape_0=b.asm.Gv).apply(null,arguments)},Yx=b._emscripten_bind_btPairCachingGhostObject_setContactProcessingThreshold_1=function(){return(Yx=b._emscripten_bind_btPairCachingGhostObject_setContactProcessingThreshold_1=b.asm.Hv).apply(null,arguments)},Zx=b._emscripten_bind_btPairCachingGhostObject_setActivationState_1=function(){return(Zx=b._emscripten_bind_btPairCachingGhostObject_setActivationState_1=b.asm.Iv).apply(null, +arguments)},$x=b._emscripten_bind_btPairCachingGhostObject_forceActivationState_1=function(){return($x=b._emscripten_bind_btPairCachingGhostObject_forceActivationState_1=b.asm.Jv).apply(null,arguments)},ay=b._emscripten_bind_btPairCachingGhostObject_activate_0=function(){return(ay=b._emscripten_bind_btPairCachingGhostObject_activate_0=b.asm.Kv).apply(null,arguments)},by=b._emscripten_bind_btPairCachingGhostObject_activate_1=function(){return(by=b._emscripten_bind_btPairCachingGhostObject_activate_1= +b.asm.Lv).apply(null,arguments)},cy=b._emscripten_bind_btPairCachingGhostObject_isActive_0=function(){return(cy=b._emscripten_bind_btPairCachingGhostObject_isActive_0=b.asm.Mv).apply(null,arguments)},dy=b._emscripten_bind_btPairCachingGhostObject_isKinematicObject_0=function(){return(dy=b._emscripten_bind_btPairCachingGhostObject_isKinematicObject_0=b.asm.Nv).apply(null,arguments)},ey=b._emscripten_bind_btPairCachingGhostObject_isStaticObject_0=function(){return(ey=b._emscripten_bind_btPairCachingGhostObject_isStaticObject_0= +b.asm.Ov).apply(null,arguments)},fy=b._emscripten_bind_btPairCachingGhostObject_isStaticOrKinematicObject_0=function(){return(fy=b._emscripten_bind_btPairCachingGhostObject_isStaticOrKinematicObject_0=b.asm.Pv).apply(null,arguments)},gy=b._emscripten_bind_btPairCachingGhostObject_getRestitution_0=function(){return(gy=b._emscripten_bind_btPairCachingGhostObject_getRestitution_0=b.asm.Qv).apply(null,arguments)},hy=b._emscripten_bind_btPairCachingGhostObject_getFriction_0=function(){return(hy=b._emscripten_bind_btPairCachingGhostObject_getFriction_0= +b.asm.Rv).apply(null,arguments)},iy=b._emscripten_bind_btPairCachingGhostObject_getRollingFriction_0=function(){return(iy=b._emscripten_bind_btPairCachingGhostObject_getRollingFriction_0=b.asm.Sv).apply(null,arguments)},jy=b._emscripten_bind_btPairCachingGhostObject_setRestitution_1=function(){return(jy=b._emscripten_bind_btPairCachingGhostObject_setRestitution_1=b.asm.Tv).apply(null,arguments)},ky=b._emscripten_bind_btPairCachingGhostObject_setFriction_1=function(){return(ky=b._emscripten_bind_btPairCachingGhostObject_setFriction_1= +b.asm.Uv).apply(null,arguments)},ly=b._emscripten_bind_btPairCachingGhostObject_setRollingFriction_1=function(){return(ly=b._emscripten_bind_btPairCachingGhostObject_setRollingFriction_1=b.asm.Vv).apply(null,arguments)},my=b._emscripten_bind_btPairCachingGhostObject_getWorldTransform_0=function(){return(my=b._emscripten_bind_btPairCachingGhostObject_getWorldTransform_0=b.asm.Wv).apply(null,arguments)},ny=b._emscripten_bind_btPairCachingGhostObject_getCollisionFlags_0=function(){return(ny=b._emscripten_bind_btPairCachingGhostObject_getCollisionFlags_0= +b.asm.Xv).apply(null,arguments)},oy=b._emscripten_bind_btPairCachingGhostObject_setCollisionFlags_1=function(){return(oy=b._emscripten_bind_btPairCachingGhostObject_setCollisionFlags_1=b.asm.Yv).apply(null,arguments)},py=b._emscripten_bind_btPairCachingGhostObject_setWorldTransform_1=function(){return(py=b._emscripten_bind_btPairCachingGhostObject_setWorldTransform_1=b.asm.Zv).apply(null,arguments)},qy=b._emscripten_bind_btPairCachingGhostObject_setCollisionShape_1=function(){return(qy=b._emscripten_bind_btPairCachingGhostObject_setCollisionShape_1= +b.asm._v).apply(null,arguments)},ry=b._emscripten_bind_btPairCachingGhostObject_setCcdMotionThreshold_1=function(){return(ry=b._emscripten_bind_btPairCachingGhostObject_setCcdMotionThreshold_1=b.asm.$v).apply(null,arguments)},sy=b._emscripten_bind_btPairCachingGhostObject_setCcdSweptSphereRadius_1=function(){return(sy=b._emscripten_bind_btPairCachingGhostObject_setCcdSweptSphereRadius_1=b.asm.aw).apply(null,arguments)},ty=b._emscripten_bind_btPairCachingGhostObject_getUserIndex_0=function(){return(ty= +b._emscripten_bind_btPairCachingGhostObject_getUserIndex_0=b.asm.bw).apply(null,arguments)},uy=b._emscripten_bind_btPairCachingGhostObject_setUserIndex_1=function(){return(uy=b._emscripten_bind_btPairCachingGhostObject_setUserIndex_1=b.asm.cw).apply(null,arguments)},vy=b._emscripten_bind_btPairCachingGhostObject_getUserPointer_0=function(){return(vy=b._emscripten_bind_btPairCachingGhostObject_getUserPointer_0=b.asm.dw).apply(null,arguments)},wy=b._emscripten_bind_btPairCachingGhostObject_setUserPointer_1= +function(){return(wy=b._emscripten_bind_btPairCachingGhostObject_setUserPointer_1=b.asm.ew).apply(null,arguments)},xy=b._emscripten_bind_btPairCachingGhostObject_getBroadphaseHandle_0=function(){return(xy=b._emscripten_bind_btPairCachingGhostObject_getBroadphaseHandle_0=b.asm.fw).apply(null,arguments)},yy=b._emscripten_bind_btPairCachingGhostObject_getNumOverlappingObjects_0=function(){return(yy=b._emscripten_bind_btPairCachingGhostObject_getNumOverlappingObjects_0=b.asm.gw).apply(null,arguments)}, +zy=b._emscripten_bind_btPairCachingGhostObject_getOverlappingObject_1=function(){return(zy=b._emscripten_bind_btPairCachingGhostObject_getOverlappingObject_1=b.asm.hw).apply(null,arguments)},Ay=b._emscripten_bind_btPairCachingGhostObject___destroy___0=function(){return(Ay=b._emscripten_bind_btPairCachingGhostObject___destroy___0=b.asm.iw).apply(null,arguments)},By=b._emscripten_bind_btGhostPairCallback_btGhostPairCallback_0=function(){return(By=b._emscripten_bind_btGhostPairCallback_btGhostPairCallback_0= +b.asm.jw).apply(null,arguments)},Cy=b._emscripten_bind_btGhostPairCallback___destroy___0=function(){return(Cy=b._emscripten_bind_btGhostPairCallback___destroy___0=b.asm.kw).apply(null,arguments)},Dy=b._emscripten_bind_btSoftBodyWorldInfo_btSoftBodyWorldInfo_0=function(){return(Dy=b._emscripten_bind_btSoftBodyWorldInfo_btSoftBodyWorldInfo_0=b.asm.lw).apply(null,arguments)},Ey=b._emscripten_bind_btSoftBodyWorldInfo_get_air_density_0=function(){return(Ey=b._emscripten_bind_btSoftBodyWorldInfo_get_air_density_0= +b.asm.mw).apply(null,arguments)},Fy=b._emscripten_bind_btSoftBodyWorldInfo_set_air_density_1=function(){return(Fy=b._emscripten_bind_btSoftBodyWorldInfo_set_air_density_1=b.asm.nw).apply(null,arguments)},Gy=b._emscripten_bind_btSoftBodyWorldInfo_get_water_density_0=function(){return(Gy=b._emscripten_bind_btSoftBodyWorldInfo_get_water_density_0=b.asm.ow).apply(null,arguments)},Hy=b._emscripten_bind_btSoftBodyWorldInfo_set_water_density_1=function(){return(Hy=b._emscripten_bind_btSoftBodyWorldInfo_set_water_density_1= +b.asm.pw).apply(null,arguments)},Iy=b._emscripten_bind_btSoftBodyWorldInfo_get_water_offset_0=function(){return(Iy=b._emscripten_bind_btSoftBodyWorldInfo_get_water_offset_0=b.asm.qw).apply(null,arguments)},Jy=b._emscripten_bind_btSoftBodyWorldInfo_set_water_offset_1=function(){return(Jy=b._emscripten_bind_btSoftBodyWorldInfo_set_water_offset_1=b.asm.rw).apply(null,arguments)},Ky=b._emscripten_bind_btSoftBodyWorldInfo_get_m_maxDisplacement_0=function(){return(Ky=b._emscripten_bind_btSoftBodyWorldInfo_get_m_maxDisplacement_0= +b.asm.sw).apply(null,arguments)},Ly=b._emscripten_bind_btSoftBodyWorldInfo_set_m_maxDisplacement_1=function(){return(Ly=b._emscripten_bind_btSoftBodyWorldInfo_set_m_maxDisplacement_1=b.asm.tw).apply(null,arguments)},My=b._emscripten_bind_btSoftBodyWorldInfo_get_water_normal_0=function(){return(My=b._emscripten_bind_btSoftBodyWorldInfo_get_water_normal_0=b.asm.uw).apply(null,arguments)},Ny=b._emscripten_bind_btSoftBodyWorldInfo_set_water_normal_1=function(){return(Ny=b._emscripten_bind_btSoftBodyWorldInfo_set_water_normal_1= +b.asm.vw).apply(null,arguments)},Oy=b._emscripten_bind_btSoftBodyWorldInfo_get_m_broadphase_0=function(){return(Oy=b._emscripten_bind_btSoftBodyWorldInfo_get_m_broadphase_0=b.asm.ww).apply(null,arguments)},Py=b._emscripten_bind_btSoftBodyWorldInfo_set_m_broadphase_1=function(){return(Py=b._emscripten_bind_btSoftBodyWorldInfo_set_m_broadphase_1=b.asm.xw).apply(null,arguments)},Qy=b._emscripten_bind_btSoftBodyWorldInfo_get_m_dispatcher_0=function(){return(Qy=b._emscripten_bind_btSoftBodyWorldInfo_get_m_dispatcher_0= +b.asm.yw).apply(null,arguments)},Ry=b._emscripten_bind_btSoftBodyWorldInfo_set_m_dispatcher_1=function(){return(Ry=b._emscripten_bind_btSoftBodyWorldInfo_set_m_dispatcher_1=b.asm.zw).apply(null,arguments)},Sy=b._emscripten_bind_btSoftBodyWorldInfo_get_m_gravity_0=function(){return(Sy=b._emscripten_bind_btSoftBodyWorldInfo_get_m_gravity_0=b.asm.Aw).apply(null,arguments)},Ty=b._emscripten_bind_btSoftBodyWorldInfo_set_m_gravity_1=function(){return(Ty=b._emscripten_bind_btSoftBodyWorldInfo_set_m_gravity_1= +b.asm.Bw).apply(null,arguments)},Uy=b._emscripten_bind_btSoftBodyWorldInfo___destroy___0=function(){return(Uy=b._emscripten_bind_btSoftBodyWorldInfo___destroy___0=b.asm.Cw).apply(null,arguments)},Vy=b._emscripten_bind_Face_get_m_n_1=function(){return(Vy=b._emscripten_bind_Face_get_m_n_1=b.asm.Dw).apply(null,arguments)},Wy=b._emscripten_bind_Face_set_m_n_2=function(){return(Wy=b._emscripten_bind_Face_set_m_n_2=b.asm.Ew).apply(null,arguments)},Xy=b._emscripten_bind_Face_get_m_normal_0=function(){return(Xy= +b._emscripten_bind_Face_get_m_normal_0=b.asm.Fw).apply(null,arguments)},Yy=b._emscripten_bind_Face_set_m_normal_1=function(){return(Yy=b._emscripten_bind_Face_set_m_normal_1=b.asm.Gw).apply(null,arguments)},Zy=b._emscripten_bind_Face_get_m_ra_0=function(){return(Zy=b._emscripten_bind_Face_get_m_ra_0=b.asm.Hw).apply(null,arguments)},$y=b._emscripten_bind_Face_set_m_ra_1=function(){return($y=b._emscripten_bind_Face_set_m_ra_1=b.asm.Iw).apply(null,arguments)},az=b._emscripten_bind_Face___destroy___0= +function(){return(az=b._emscripten_bind_Face___destroy___0=b.asm.Jw).apply(null,arguments)},bz=b._emscripten_bind_tFaceArray_size_0=function(){return(bz=b._emscripten_bind_tFaceArray_size_0=b.asm.Kw).apply(null,arguments)},cz=b._emscripten_bind_tFaceArray_at_1=function(){return(cz=b._emscripten_bind_tFaceArray_at_1=b.asm.Lw).apply(null,arguments)},dz=b._emscripten_bind_tFaceArray___destroy___0=function(){return(dz=b._emscripten_bind_tFaceArray___destroy___0=b.asm.Mw).apply(null,arguments)},ez=b._emscripten_bind_Node_get_m_x_0= +function(){return(ez=b._emscripten_bind_Node_get_m_x_0=b.asm.Nw).apply(null,arguments)},fz=b._emscripten_bind_Node_set_m_x_1=function(){return(fz=b._emscripten_bind_Node_set_m_x_1=b.asm.Ow).apply(null,arguments)},gz=b._emscripten_bind_Node_get_m_q_0=function(){return(gz=b._emscripten_bind_Node_get_m_q_0=b.asm.Pw).apply(null,arguments)},hz=b._emscripten_bind_Node_set_m_q_1=function(){return(hz=b._emscripten_bind_Node_set_m_q_1=b.asm.Qw).apply(null,arguments)},iz=b._emscripten_bind_Node_get_m_v_0=function(){return(iz= +b._emscripten_bind_Node_get_m_v_0=b.asm.Rw).apply(null,arguments)},jz=b._emscripten_bind_Node_set_m_v_1=function(){return(jz=b._emscripten_bind_Node_set_m_v_1=b.asm.Sw).apply(null,arguments)},kz=b._emscripten_bind_Node_get_m_f_0=function(){return(kz=b._emscripten_bind_Node_get_m_f_0=b.asm.Tw).apply(null,arguments)},lz=b._emscripten_bind_Node_set_m_f_1=function(){return(lz=b._emscripten_bind_Node_set_m_f_1=b.asm.Uw).apply(null,arguments)},mz=b._emscripten_bind_Node_get_m_n_0=function(){return(mz=b._emscripten_bind_Node_get_m_n_0= +b.asm.Vw).apply(null,arguments)},nz=b._emscripten_bind_Node_set_m_n_1=function(){return(nz=b._emscripten_bind_Node_set_m_n_1=b.asm.Ww).apply(null,arguments)},oz=b._emscripten_bind_Node_get_m_im_0=function(){return(oz=b._emscripten_bind_Node_get_m_im_0=b.asm.Xw).apply(null,arguments)},pz=b._emscripten_bind_Node_set_m_im_1=function(){return(pz=b._emscripten_bind_Node_set_m_im_1=b.asm.Yw).apply(null,arguments)},qz=b._emscripten_bind_Node_get_m_area_0=function(){return(qz=b._emscripten_bind_Node_get_m_area_0= +b.asm.Zw).apply(null,arguments)},rz=b._emscripten_bind_Node_set_m_area_1=function(){return(rz=b._emscripten_bind_Node_set_m_area_1=b.asm._w).apply(null,arguments)},sz=b._emscripten_bind_Node___destroy___0=function(){return(sz=b._emscripten_bind_Node___destroy___0=b.asm.$w).apply(null,arguments)},tz=b._emscripten_bind_tNodeArray_size_0=function(){return(tz=b._emscripten_bind_tNodeArray_size_0=b.asm.ax).apply(null,arguments)},uz=b._emscripten_bind_tNodeArray_at_1=function(){return(uz=b._emscripten_bind_tNodeArray_at_1= +b.asm.bx).apply(null,arguments)},vz=b._emscripten_bind_tNodeArray___destroy___0=function(){return(vz=b._emscripten_bind_tNodeArray___destroy___0=b.asm.cx).apply(null,arguments)},wz=b._emscripten_bind_Material_get_m_kLST_0=function(){return(wz=b._emscripten_bind_Material_get_m_kLST_0=b.asm.dx).apply(null,arguments)},xz=b._emscripten_bind_Material_set_m_kLST_1=function(){return(xz=b._emscripten_bind_Material_set_m_kLST_1=b.asm.ex).apply(null,arguments)},yz=b._emscripten_bind_Material_get_m_kAST_0=function(){return(yz= +b._emscripten_bind_Material_get_m_kAST_0=b.asm.fx).apply(null,arguments)},zz=b._emscripten_bind_Material_set_m_kAST_1=function(){return(zz=b._emscripten_bind_Material_set_m_kAST_1=b.asm.gx).apply(null,arguments)},Az=b._emscripten_bind_Material_get_m_kVST_0=function(){return(Az=b._emscripten_bind_Material_get_m_kVST_0=b.asm.hx).apply(null,arguments)},Bz=b._emscripten_bind_Material_set_m_kVST_1=function(){return(Bz=b._emscripten_bind_Material_set_m_kVST_1=b.asm.ix).apply(null,arguments)},Cz=b._emscripten_bind_Material_get_m_flags_0= +function(){return(Cz=b._emscripten_bind_Material_get_m_flags_0=b.asm.jx).apply(null,arguments)},Dz=b._emscripten_bind_Material_set_m_flags_1=function(){return(Dz=b._emscripten_bind_Material_set_m_flags_1=b.asm.kx).apply(null,arguments)},Ez=b._emscripten_bind_Material___destroy___0=function(){return(Ez=b._emscripten_bind_Material___destroy___0=b.asm.lx).apply(null,arguments)},Fz=b._emscripten_bind_tMaterialArray_size_0=function(){return(Fz=b._emscripten_bind_tMaterialArray_size_0=b.asm.mx).apply(null, +arguments)},Gz=b._emscripten_bind_tMaterialArray_at_1=function(){return(Gz=b._emscripten_bind_tMaterialArray_at_1=b.asm.nx).apply(null,arguments)},Hz=b._emscripten_bind_tMaterialArray___destroy___0=function(){return(Hz=b._emscripten_bind_tMaterialArray___destroy___0=b.asm.ox).apply(null,arguments)},Iz=b._emscripten_bind_Anchor_get_m_node_0=function(){return(Iz=b._emscripten_bind_Anchor_get_m_node_0=b.asm.px).apply(null,arguments)},Jz=b._emscripten_bind_Anchor_set_m_node_1=function(){return(Jz=b._emscripten_bind_Anchor_set_m_node_1= +b.asm.qx).apply(null,arguments)},Kz=b._emscripten_bind_Anchor_get_m_local_0=function(){return(Kz=b._emscripten_bind_Anchor_get_m_local_0=b.asm.rx).apply(null,arguments)},Lz=b._emscripten_bind_Anchor_set_m_local_1=function(){return(Lz=b._emscripten_bind_Anchor_set_m_local_1=b.asm.sx).apply(null,arguments)},Mz=b._emscripten_bind_Anchor_get_m_body_0=function(){return(Mz=b._emscripten_bind_Anchor_get_m_body_0=b.asm.tx).apply(null,arguments)},Nz=b._emscripten_bind_Anchor_set_m_body_1=function(){return(Nz= +b._emscripten_bind_Anchor_set_m_body_1=b.asm.ux).apply(null,arguments)},Oz=b._emscripten_bind_Anchor_get_m_influence_0=function(){return(Oz=b._emscripten_bind_Anchor_get_m_influence_0=b.asm.vx).apply(null,arguments)},Pz=b._emscripten_bind_Anchor_set_m_influence_1=function(){return(Pz=b._emscripten_bind_Anchor_set_m_influence_1=b.asm.wx).apply(null,arguments)},Qz=b._emscripten_bind_Anchor_get_m_c0_0=function(){return(Qz=b._emscripten_bind_Anchor_get_m_c0_0=b.asm.xx).apply(null,arguments)},Rz=b._emscripten_bind_Anchor_set_m_c0_1= +function(){return(Rz=b._emscripten_bind_Anchor_set_m_c0_1=b.asm.yx).apply(null,arguments)},Sz=b._emscripten_bind_Anchor_get_m_c1_0=function(){return(Sz=b._emscripten_bind_Anchor_get_m_c1_0=b.asm.zx).apply(null,arguments)},Tz=b._emscripten_bind_Anchor_set_m_c1_1=function(){return(Tz=b._emscripten_bind_Anchor_set_m_c1_1=b.asm.Ax).apply(null,arguments)},Uz=b._emscripten_bind_Anchor_get_m_c2_0=function(){return(Uz=b._emscripten_bind_Anchor_get_m_c2_0=b.asm.Bx).apply(null,arguments)},Vz=b._emscripten_bind_Anchor_set_m_c2_1= +function(){return(Vz=b._emscripten_bind_Anchor_set_m_c2_1=b.asm.Cx).apply(null,arguments)},Wz=b._emscripten_bind_Anchor___destroy___0=function(){return(Wz=b._emscripten_bind_Anchor___destroy___0=b.asm.Dx).apply(null,arguments)},Xz=b._emscripten_bind_tAnchorArray_size_0=function(){return(Xz=b._emscripten_bind_tAnchorArray_size_0=b.asm.Ex).apply(null,arguments)},Yz=b._emscripten_bind_tAnchorArray_at_1=function(){return(Yz=b._emscripten_bind_tAnchorArray_at_1=b.asm.Fx).apply(null,arguments)},Zz=b._emscripten_bind_tAnchorArray_clear_0= +function(){return(Zz=b._emscripten_bind_tAnchorArray_clear_0=b.asm.Gx).apply(null,arguments)},$z=b._emscripten_bind_tAnchorArray_push_back_1=function(){return($z=b._emscripten_bind_tAnchorArray_push_back_1=b.asm.Hx).apply(null,arguments)},aA=b._emscripten_bind_tAnchorArray_pop_back_0=function(){return(aA=b._emscripten_bind_tAnchorArray_pop_back_0=b.asm.Ix).apply(null,arguments)},bA=b._emscripten_bind_tAnchorArray___destroy___0=function(){return(bA=b._emscripten_bind_tAnchorArray___destroy___0=b.asm.Jx).apply(null, +arguments)},cA=b._emscripten_bind_Config_get_kVCF_0=function(){return(cA=b._emscripten_bind_Config_get_kVCF_0=b.asm.Kx).apply(null,arguments)},dA=b._emscripten_bind_Config_set_kVCF_1=function(){return(dA=b._emscripten_bind_Config_set_kVCF_1=b.asm.Lx).apply(null,arguments)},eA=b._emscripten_bind_Config_get_kDP_0=function(){return(eA=b._emscripten_bind_Config_get_kDP_0=b.asm.Mx).apply(null,arguments)},fA=b._emscripten_bind_Config_set_kDP_1=function(){return(fA=b._emscripten_bind_Config_set_kDP_1=b.asm.Nx).apply(null, +arguments)},gA=b._emscripten_bind_Config_get_kDG_0=function(){return(gA=b._emscripten_bind_Config_get_kDG_0=b.asm.Ox).apply(null,arguments)},hA=b._emscripten_bind_Config_set_kDG_1=function(){return(hA=b._emscripten_bind_Config_set_kDG_1=b.asm.Px).apply(null,arguments)},iA=b._emscripten_bind_Config_get_kLF_0=function(){return(iA=b._emscripten_bind_Config_get_kLF_0=b.asm.Qx).apply(null,arguments)},jA=b._emscripten_bind_Config_set_kLF_1=function(){return(jA=b._emscripten_bind_Config_set_kLF_1=b.asm.Rx).apply(null, +arguments)},kA=b._emscripten_bind_Config_get_kPR_0=function(){return(kA=b._emscripten_bind_Config_get_kPR_0=b.asm.Sx).apply(null,arguments)},lA=b._emscripten_bind_Config_set_kPR_1=function(){return(lA=b._emscripten_bind_Config_set_kPR_1=b.asm.Tx).apply(null,arguments)},mA=b._emscripten_bind_Config_get_kVC_0=function(){return(mA=b._emscripten_bind_Config_get_kVC_0=b.asm.Ux).apply(null,arguments)},nA=b._emscripten_bind_Config_set_kVC_1=function(){return(nA=b._emscripten_bind_Config_set_kVC_1=b.asm.Vx).apply(null, +arguments)},oA=b._emscripten_bind_Config_get_kDF_0=function(){return(oA=b._emscripten_bind_Config_get_kDF_0=b.asm.Wx).apply(null,arguments)},pA=b._emscripten_bind_Config_set_kDF_1=function(){return(pA=b._emscripten_bind_Config_set_kDF_1=b.asm.Xx).apply(null,arguments)},qA=b._emscripten_bind_Config_get_kMT_0=function(){return(qA=b._emscripten_bind_Config_get_kMT_0=b.asm.Yx).apply(null,arguments)},rA=b._emscripten_bind_Config_set_kMT_1=function(){return(rA=b._emscripten_bind_Config_set_kMT_1=b.asm.Zx).apply(null, +arguments)},sA=b._emscripten_bind_Config_get_kCHR_0=function(){return(sA=b._emscripten_bind_Config_get_kCHR_0=b.asm._x).apply(null,arguments)},tA=b._emscripten_bind_Config_set_kCHR_1=function(){return(tA=b._emscripten_bind_Config_set_kCHR_1=b.asm.$x).apply(null,arguments)},uA=b._emscripten_bind_Config_get_kKHR_0=function(){return(uA=b._emscripten_bind_Config_get_kKHR_0=b.asm.ay).apply(null,arguments)},vA=b._emscripten_bind_Config_set_kKHR_1=function(){return(vA=b._emscripten_bind_Config_set_kKHR_1= +b.asm.by).apply(null,arguments)},wA=b._emscripten_bind_Config_get_kSHR_0=function(){return(wA=b._emscripten_bind_Config_get_kSHR_0=b.asm.cy).apply(null,arguments)},xA=b._emscripten_bind_Config_set_kSHR_1=function(){return(xA=b._emscripten_bind_Config_set_kSHR_1=b.asm.dy).apply(null,arguments)},yA=b._emscripten_bind_Config_get_kAHR_0=function(){return(yA=b._emscripten_bind_Config_get_kAHR_0=b.asm.ey).apply(null,arguments)},zA=b._emscripten_bind_Config_set_kAHR_1=function(){return(zA=b._emscripten_bind_Config_set_kAHR_1= +b.asm.fy).apply(null,arguments)},AA=b._emscripten_bind_Config_get_kSRHR_CL_0=function(){return(AA=b._emscripten_bind_Config_get_kSRHR_CL_0=b.asm.gy).apply(null,arguments)},BA=b._emscripten_bind_Config_set_kSRHR_CL_1=function(){return(BA=b._emscripten_bind_Config_set_kSRHR_CL_1=b.asm.hy).apply(null,arguments)},CA=b._emscripten_bind_Config_get_kSKHR_CL_0=function(){return(CA=b._emscripten_bind_Config_get_kSKHR_CL_0=b.asm.iy).apply(null,arguments)},DA=b._emscripten_bind_Config_set_kSKHR_CL_1=function(){return(DA= +b._emscripten_bind_Config_set_kSKHR_CL_1=b.asm.jy).apply(null,arguments)},EA=b._emscripten_bind_Config_get_kSSHR_CL_0=function(){return(EA=b._emscripten_bind_Config_get_kSSHR_CL_0=b.asm.ky).apply(null,arguments)},FA=b._emscripten_bind_Config_set_kSSHR_CL_1=function(){return(FA=b._emscripten_bind_Config_set_kSSHR_CL_1=b.asm.ly).apply(null,arguments)},GA=b._emscripten_bind_Config_get_kSR_SPLT_CL_0=function(){return(GA=b._emscripten_bind_Config_get_kSR_SPLT_CL_0=b.asm.my).apply(null,arguments)},HA=b._emscripten_bind_Config_set_kSR_SPLT_CL_1= +function(){return(HA=b._emscripten_bind_Config_set_kSR_SPLT_CL_1=b.asm.ny).apply(null,arguments)},IA=b._emscripten_bind_Config_get_kSK_SPLT_CL_0=function(){return(IA=b._emscripten_bind_Config_get_kSK_SPLT_CL_0=b.asm.oy).apply(null,arguments)},JA=b._emscripten_bind_Config_set_kSK_SPLT_CL_1=function(){return(JA=b._emscripten_bind_Config_set_kSK_SPLT_CL_1=b.asm.py).apply(null,arguments)},KA=b._emscripten_bind_Config_get_kSS_SPLT_CL_0=function(){return(KA=b._emscripten_bind_Config_get_kSS_SPLT_CL_0=b.asm.qy).apply(null, +arguments)},LA=b._emscripten_bind_Config_set_kSS_SPLT_CL_1=function(){return(LA=b._emscripten_bind_Config_set_kSS_SPLT_CL_1=b.asm.ry).apply(null,arguments)},MA=b._emscripten_bind_Config_get_maxvolume_0=function(){return(MA=b._emscripten_bind_Config_get_maxvolume_0=b.asm.sy).apply(null,arguments)},NA=b._emscripten_bind_Config_set_maxvolume_1=function(){return(NA=b._emscripten_bind_Config_set_maxvolume_1=b.asm.ty).apply(null,arguments)},OA=b._emscripten_bind_Config_get_timescale_0=function(){return(OA= +b._emscripten_bind_Config_get_timescale_0=b.asm.uy).apply(null,arguments)},PA=b._emscripten_bind_Config_set_timescale_1=function(){return(PA=b._emscripten_bind_Config_set_timescale_1=b.asm.vy).apply(null,arguments)},QA=b._emscripten_bind_Config_get_viterations_0=function(){return(QA=b._emscripten_bind_Config_get_viterations_0=b.asm.wy).apply(null,arguments)},RA=b._emscripten_bind_Config_set_viterations_1=function(){return(RA=b._emscripten_bind_Config_set_viterations_1=b.asm.xy).apply(null,arguments)}, +SA=b._emscripten_bind_Config_get_piterations_0=function(){return(SA=b._emscripten_bind_Config_get_piterations_0=b.asm.yy).apply(null,arguments)},TA=b._emscripten_bind_Config_set_piterations_1=function(){return(TA=b._emscripten_bind_Config_set_piterations_1=b.asm.zy).apply(null,arguments)},UA=b._emscripten_bind_Config_get_diterations_0=function(){return(UA=b._emscripten_bind_Config_get_diterations_0=b.asm.Ay).apply(null,arguments)},VA=b._emscripten_bind_Config_set_diterations_1=function(){return(VA= +b._emscripten_bind_Config_set_diterations_1=b.asm.By).apply(null,arguments)},WA=b._emscripten_bind_Config_get_citerations_0=function(){return(WA=b._emscripten_bind_Config_get_citerations_0=b.asm.Cy).apply(null,arguments)},XA=b._emscripten_bind_Config_set_citerations_1=function(){return(XA=b._emscripten_bind_Config_set_citerations_1=b.asm.Dy).apply(null,arguments)},YA=b._emscripten_bind_Config_get_collisions_0=function(){return(YA=b._emscripten_bind_Config_get_collisions_0=b.asm.Ey).apply(null,arguments)}, +ZA=b._emscripten_bind_Config_set_collisions_1=function(){return(ZA=b._emscripten_bind_Config_set_collisions_1=b.asm.Fy).apply(null,arguments)},$A=b._emscripten_bind_Config___destroy___0=function(){return($A=b._emscripten_bind_Config___destroy___0=b.asm.Gy).apply(null,arguments)},aB=b._emscripten_bind_btSoftBody_btSoftBody_4=function(){return(aB=b._emscripten_bind_btSoftBody_btSoftBody_4=b.asm.Hy).apply(null,arguments)},bB=b._emscripten_bind_btSoftBody_checkLink_2=function(){return(bB=b._emscripten_bind_btSoftBody_checkLink_2= +b.asm.Iy).apply(null,arguments)},cB=b._emscripten_bind_btSoftBody_checkFace_3=function(){return(cB=b._emscripten_bind_btSoftBody_checkFace_3=b.asm.Jy).apply(null,arguments)},dB=b._emscripten_bind_btSoftBody_appendMaterial_0=function(){return(dB=b._emscripten_bind_btSoftBody_appendMaterial_0=b.asm.Ky).apply(null,arguments)},eB=b._emscripten_bind_btSoftBody_appendNode_2=function(){return(eB=b._emscripten_bind_btSoftBody_appendNode_2=b.asm.Ly).apply(null,arguments)},fB=b._emscripten_bind_btSoftBody_appendLink_4= +function(){return(fB=b._emscripten_bind_btSoftBody_appendLink_4=b.asm.My).apply(null,arguments)},gB=b._emscripten_bind_btSoftBody_appendFace_4=function(){return(gB=b._emscripten_bind_btSoftBody_appendFace_4=b.asm.Ny).apply(null,arguments)},hB=b._emscripten_bind_btSoftBody_appendTetra_5=function(){return(hB=b._emscripten_bind_btSoftBody_appendTetra_5=b.asm.Oy).apply(null,arguments)},iB=b._emscripten_bind_btSoftBody_appendAnchor_4=function(){return(iB=b._emscripten_bind_btSoftBody_appendAnchor_4=b.asm.Py).apply(null, +arguments)},jB=b._emscripten_bind_btSoftBody_addForce_1=function(){return(jB=b._emscripten_bind_btSoftBody_addForce_1=b.asm.Qy).apply(null,arguments)},kB=b._emscripten_bind_btSoftBody_addForce_2=function(){return(kB=b._emscripten_bind_btSoftBody_addForce_2=b.asm.Ry).apply(null,arguments)},lB=b._emscripten_bind_btSoftBody_addAeroForceToNode_2=function(){return(lB=b._emscripten_bind_btSoftBody_addAeroForceToNode_2=b.asm.Sy).apply(null,arguments)},mB=b._emscripten_bind_btSoftBody_getTotalMass_0=function(){return(mB= +b._emscripten_bind_btSoftBody_getTotalMass_0=b.asm.Ty).apply(null,arguments)},nB=b._emscripten_bind_btSoftBody_setTotalMass_2=function(){return(nB=b._emscripten_bind_btSoftBody_setTotalMass_2=b.asm.Uy).apply(null,arguments)},oB=b._emscripten_bind_btSoftBody_setMass_2=function(){return(oB=b._emscripten_bind_btSoftBody_setMass_2=b.asm.Vy).apply(null,arguments)},pB=b._emscripten_bind_btSoftBody_transform_1=function(){return(pB=b._emscripten_bind_btSoftBody_transform_1=b.asm.Wy).apply(null,arguments)}, +qB=b._emscripten_bind_btSoftBody_translate_1=function(){return(qB=b._emscripten_bind_btSoftBody_translate_1=b.asm.Xy).apply(null,arguments)},rB=b._emscripten_bind_btSoftBody_rotate_1=function(){return(rB=b._emscripten_bind_btSoftBody_rotate_1=b.asm.Yy).apply(null,arguments)},sB=b._emscripten_bind_btSoftBody_scale_1=function(){return(sB=b._emscripten_bind_btSoftBody_scale_1=b.asm.Zy).apply(null,arguments)},tB=b._emscripten_bind_btSoftBody_generateClusters_1=function(){return(tB=b._emscripten_bind_btSoftBody_generateClusters_1= +b.asm._y).apply(null,arguments)},uB=b._emscripten_bind_btSoftBody_generateClusters_2=function(){return(uB=b._emscripten_bind_btSoftBody_generateClusters_2=b.asm.$y).apply(null,arguments)},vB=b._emscripten_bind_btSoftBody_generateBendingConstraints_2=function(){return(vB=b._emscripten_bind_btSoftBody_generateBendingConstraints_2=b.asm.az).apply(null,arguments)},wB=b._emscripten_bind_btSoftBody_upcast_1=function(){return(wB=b._emscripten_bind_btSoftBody_upcast_1=b.asm.bz).apply(null,arguments)},xB= +b._emscripten_bind_btSoftBody_getRestLengthScale_0=function(){return(xB=b._emscripten_bind_btSoftBody_getRestLengthScale_0=b.asm.cz).apply(null,arguments)},yB=b._emscripten_bind_btSoftBody_setRestLengthScale_1=function(){return(yB=b._emscripten_bind_btSoftBody_setRestLengthScale_1=b.asm.dz).apply(null,arguments)},zB=b._emscripten_bind_btSoftBody_setAnisotropicFriction_2=function(){return(zB=b._emscripten_bind_btSoftBody_setAnisotropicFriction_2=b.asm.ez).apply(null,arguments)},AB=b._emscripten_bind_btSoftBody_getCollisionShape_0= +function(){return(AB=b._emscripten_bind_btSoftBody_getCollisionShape_0=b.asm.fz).apply(null,arguments)},BB=b._emscripten_bind_btSoftBody_setContactProcessingThreshold_1=function(){return(BB=b._emscripten_bind_btSoftBody_setContactProcessingThreshold_1=b.asm.gz).apply(null,arguments)},CB=b._emscripten_bind_btSoftBody_setActivationState_1=function(){return(CB=b._emscripten_bind_btSoftBody_setActivationState_1=b.asm.hz).apply(null,arguments)},DB=b._emscripten_bind_btSoftBody_forceActivationState_1=function(){return(DB= +b._emscripten_bind_btSoftBody_forceActivationState_1=b.asm.iz).apply(null,arguments)},EB=b._emscripten_bind_btSoftBody_activate_0=function(){return(EB=b._emscripten_bind_btSoftBody_activate_0=b.asm.jz).apply(null,arguments)},FB=b._emscripten_bind_btSoftBody_activate_1=function(){return(FB=b._emscripten_bind_btSoftBody_activate_1=b.asm.kz).apply(null,arguments)},GB=b._emscripten_bind_btSoftBody_isActive_0=function(){return(GB=b._emscripten_bind_btSoftBody_isActive_0=b.asm.lz).apply(null,arguments)}, +HB=b._emscripten_bind_btSoftBody_isKinematicObject_0=function(){return(HB=b._emscripten_bind_btSoftBody_isKinematicObject_0=b.asm.mz).apply(null,arguments)},IB=b._emscripten_bind_btSoftBody_isStaticObject_0=function(){return(IB=b._emscripten_bind_btSoftBody_isStaticObject_0=b.asm.nz).apply(null,arguments)},JB=b._emscripten_bind_btSoftBody_isStaticOrKinematicObject_0=function(){return(JB=b._emscripten_bind_btSoftBody_isStaticOrKinematicObject_0=b.asm.oz).apply(null,arguments)},KB=b._emscripten_bind_btSoftBody_getRestitution_0= +function(){return(KB=b._emscripten_bind_btSoftBody_getRestitution_0=b.asm.pz).apply(null,arguments)},LB=b._emscripten_bind_btSoftBody_getFriction_0=function(){return(LB=b._emscripten_bind_btSoftBody_getFriction_0=b.asm.qz).apply(null,arguments)},MB=b._emscripten_bind_btSoftBody_getRollingFriction_0=function(){return(MB=b._emscripten_bind_btSoftBody_getRollingFriction_0=b.asm.rz).apply(null,arguments)},NB=b._emscripten_bind_btSoftBody_setRestitution_1=function(){return(NB=b._emscripten_bind_btSoftBody_setRestitution_1= +b.asm.sz).apply(null,arguments)},OB=b._emscripten_bind_btSoftBody_setFriction_1=function(){return(OB=b._emscripten_bind_btSoftBody_setFriction_1=b.asm.tz).apply(null,arguments)},PB=b._emscripten_bind_btSoftBody_setRollingFriction_1=function(){return(PB=b._emscripten_bind_btSoftBody_setRollingFriction_1=b.asm.uz).apply(null,arguments)},QB=b._emscripten_bind_btSoftBody_getWorldTransform_0=function(){return(QB=b._emscripten_bind_btSoftBody_getWorldTransform_0=b.asm.vz).apply(null,arguments)},RB=b._emscripten_bind_btSoftBody_getCollisionFlags_0= +function(){return(RB=b._emscripten_bind_btSoftBody_getCollisionFlags_0=b.asm.wz).apply(null,arguments)},SB=b._emscripten_bind_btSoftBody_setCollisionFlags_1=function(){return(SB=b._emscripten_bind_btSoftBody_setCollisionFlags_1=b.asm.xz).apply(null,arguments)},TB=b._emscripten_bind_btSoftBody_setWorldTransform_1=function(){return(TB=b._emscripten_bind_btSoftBody_setWorldTransform_1=b.asm.yz).apply(null,arguments)},UB=b._emscripten_bind_btSoftBody_setCollisionShape_1=function(){return(UB=b._emscripten_bind_btSoftBody_setCollisionShape_1= +b.asm.zz).apply(null,arguments)},VB=b._emscripten_bind_btSoftBody_setCcdMotionThreshold_1=function(){return(VB=b._emscripten_bind_btSoftBody_setCcdMotionThreshold_1=b.asm.Az).apply(null,arguments)},WB=b._emscripten_bind_btSoftBody_setCcdSweptSphereRadius_1=function(){return(WB=b._emscripten_bind_btSoftBody_setCcdSweptSphereRadius_1=b.asm.Bz).apply(null,arguments)},XB=b._emscripten_bind_btSoftBody_getUserIndex_0=function(){return(XB=b._emscripten_bind_btSoftBody_getUserIndex_0=b.asm.Cz).apply(null, +arguments)},YB=b._emscripten_bind_btSoftBody_setUserIndex_1=function(){return(YB=b._emscripten_bind_btSoftBody_setUserIndex_1=b.asm.Dz).apply(null,arguments)},ZB=b._emscripten_bind_btSoftBody_getUserPointer_0=function(){return(ZB=b._emscripten_bind_btSoftBody_getUserPointer_0=b.asm.Ez).apply(null,arguments)},$B=b._emscripten_bind_btSoftBody_setUserPointer_1=function(){return($B=b._emscripten_bind_btSoftBody_setUserPointer_1=b.asm.Fz).apply(null,arguments)},aC=b._emscripten_bind_btSoftBody_getBroadphaseHandle_0= +function(){return(aC=b._emscripten_bind_btSoftBody_getBroadphaseHandle_0=b.asm.Gz).apply(null,arguments)},bC=b._emscripten_bind_btSoftBody_get_m_cfg_0=function(){return(bC=b._emscripten_bind_btSoftBody_get_m_cfg_0=b.asm.Hz).apply(null,arguments)},cC=b._emscripten_bind_btSoftBody_set_m_cfg_1=function(){return(cC=b._emscripten_bind_btSoftBody_set_m_cfg_1=b.asm.Iz).apply(null,arguments)},dC=b._emscripten_bind_btSoftBody_get_m_nodes_0=function(){return(dC=b._emscripten_bind_btSoftBody_get_m_nodes_0=b.asm.Jz).apply(null, +arguments)},eC=b._emscripten_bind_btSoftBody_set_m_nodes_1=function(){return(eC=b._emscripten_bind_btSoftBody_set_m_nodes_1=b.asm.Kz).apply(null,arguments)},fC=b._emscripten_bind_btSoftBody_get_m_faces_0=function(){return(fC=b._emscripten_bind_btSoftBody_get_m_faces_0=b.asm.Lz).apply(null,arguments)},gC=b._emscripten_bind_btSoftBody_set_m_faces_1=function(){return(gC=b._emscripten_bind_btSoftBody_set_m_faces_1=b.asm.Mz).apply(null,arguments)},hC=b._emscripten_bind_btSoftBody_get_m_materials_0=function(){return(hC= +b._emscripten_bind_btSoftBody_get_m_materials_0=b.asm.Nz).apply(null,arguments)},iC=b._emscripten_bind_btSoftBody_set_m_materials_1=function(){return(iC=b._emscripten_bind_btSoftBody_set_m_materials_1=b.asm.Oz).apply(null,arguments)},jC=b._emscripten_bind_btSoftBody_get_m_anchors_0=function(){return(jC=b._emscripten_bind_btSoftBody_get_m_anchors_0=b.asm.Pz).apply(null,arguments)},kC=b._emscripten_bind_btSoftBody_set_m_anchors_1=function(){return(kC=b._emscripten_bind_btSoftBody_set_m_anchors_1=b.asm.Qz).apply(null, +arguments)},lC=b._emscripten_bind_btSoftBody___destroy___0=function(){return(lC=b._emscripten_bind_btSoftBody___destroy___0=b.asm.Rz).apply(null,arguments)},mC=b._emscripten_bind_btSoftBodyRigidBodyCollisionConfiguration_btSoftBodyRigidBodyCollisionConfiguration_0=function(){return(mC=b._emscripten_bind_btSoftBodyRigidBodyCollisionConfiguration_btSoftBodyRigidBodyCollisionConfiguration_0=b.asm.Sz).apply(null,arguments)},nC=b._emscripten_bind_btSoftBodyRigidBodyCollisionConfiguration_btSoftBodyRigidBodyCollisionConfiguration_1= +function(){return(nC=b._emscripten_bind_btSoftBodyRigidBodyCollisionConfiguration_btSoftBodyRigidBodyCollisionConfiguration_1=b.asm.Tz).apply(null,arguments)},oC=b._emscripten_bind_btSoftBodyRigidBodyCollisionConfiguration___destroy___0=function(){return(oC=b._emscripten_bind_btSoftBodyRigidBodyCollisionConfiguration___destroy___0=b.asm.Uz).apply(null,arguments)},pC=b._emscripten_bind_btDefaultSoftBodySolver_btDefaultSoftBodySolver_0=function(){return(pC=b._emscripten_bind_btDefaultSoftBodySolver_btDefaultSoftBodySolver_0= +b.asm.Vz).apply(null,arguments)},qC=b._emscripten_bind_btDefaultSoftBodySolver___destroy___0=function(){return(qC=b._emscripten_bind_btDefaultSoftBodySolver___destroy___0=b.asm.Wz).apply(null,arguments)},rC=b._emscripten_bind_btSoftBodyArray_size_0=function(){return(rC=b._emscripten_bind_btSoftBodyArray_size_0=b.asm.Xz).apply(null,arguments)},sC=b._emscripten_bind_btSoftBodyArray_at_1=function(){return(sC=b._emscripten_bind_btSoftBodyArray_at_1=b.asm.Yz).apply(null,arguments)},tC=b._emscripten_bind_btSoftBodyArray___destroy___0= +function(){return(tC=b._emscripten_bind_btSoftBodyArray___destroy___0=b.asm.Zz).apply(null,arguments)},uC=b._emscripten_bind_btSoftRigidDynamicsWorld_btSoftRigidDynamicsWorld_5=function(){return(uC=b._emscripten_bind_btSoftRigidDynamicsWorld_btSoftRigidDynamicsWorld_5=b.asm._z).apply(null,arguments)},vC=b._emscripten_bind_btSoftRigidDynamicsWorld_addSoftBody_3=function(){return(vC=b._emscripten_bind_btSoftRigidDynamicsWorld_addSoftBody_3=b.asm.$z).apply(null,arguments)},wC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeSoftBody_1= +function(){return(wC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeSoftBody_1=b.asm.aA).apply(null,arguments)},xC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeCollisionObject_1=function(){return(xC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeCollisionObject_1=b.asm.bA).apply(null,arguments)},yC=b._emscripten_bind_btSoftRigidDynamicsWorld_getWorldInfo_0=function(){return(yC=b._emscripten_bind_btSoftRigidDynamicsWorld_getWorldInfo_0=b.asm.cA).apply(null,arguments)},zC=b._emscripten_bind_btSoftRigidDynamicsWorld_getSoftBodyArray_0= +function(){return(zC=b._emscripten_bind_btSoftRigidDynamicsWorld_getSoftBodyArray_0=b.asm.dA).apply(null,arguments)},AC=b._emscripten_bind_btSoftRigidDynamicsWorld_getDispatcher_0=function(){return(AC=b._emscripten_bind_btSoftRigidDynamicsWorld_getDispatcher_0=b.asm.eA).apply(null,arguments)},BC=b._emscripten_bind_btSoftRigidDynamicsWorld_rayTest_3=function(){return(BC=b._emscripten_bind_btSoftRigidDynamicsWorld_rayTest_3=b.asm.fA).apply(null,arguments)},CC=b._emscripten_bind_btSoftRigidDynamicsWorld_getPairCache_0= +function(){return(CC=b._emscripten_bind_btSoftRigidDynamicsWorld_getPairCache_0=b.asm.gA).apply(null,arguments)},DC=b._emscripten_bind_btSoftRigidDynamicsWorld_getDispatchInfo_0=function(){return(DC=b._emscripten_bind_btSoftRigidDynamicsWorld_getDispatchInfo_0=b.asm.hA).apply(null,arguments)},EC=b._emscripten_bind_btSoftRigidDynamicsWorld_addCollisionObject_1=function(){return(EC=b._emscripten_bind_btSoftRigidDynamicsWorld_addCollisionObject_1=b.asm.iA).apply(null,arguments)},FC=b._emscripten_bind_btSoftRigidDynamicsWorld_addCollisionObject_2= +function(){return(FC=b._emscripten_bind_btSoftRigidDynamicsWorld_addCollisionObject_2=b.asm.jA).apply(null,arguments)},GC=b._emscripten_bind_btSoftRigidDynamicsWorld_addCollisionObject_3=function(){return(GC=b._emscripten_bind_btSoftRigidDynamicsWorld_addCollisionObject_3=b.asm.kA).apply(null,arguments)},HC=b._emscripten_bind_btSoftRigidDynamicsWorld_getBroadphase_0=function(){return(HC=b._emscripten_bind_btSoftRigidDynamicsWorld_getBroadphase_0=b.asm.lA).apply(null,arguments)},IC=b._emscripten_bind_btSoftRigidDynamicsWorld_convexSweepTest_5= +function(){return(IC=b._emscripten_bind_btSoftRigidDynamicsWorld_convexSweepTest_5=b.asm.mA).apply(null,arguments)},JC=b._emscripten_bind_btSoftRigidDynamicsWorld_contactPairTest_3=function(){return(JC=b._emscripten_bind_btSoftRigidDynamicsWorld_contactPairTest_3=b.asm.nA).apply(null,arguments)},KC=b._emscripten_bind_btSoftRigidDynamicsWorld_contactTest_2=function(){return(KC=b._emscripten_bind_btSoftRigidDynamicsWorld_contactTest_2=b.asm.oA).apply(null,arguments)},LC=b._emscripten_bind_btSoftRigidDynamicsWorld_updateSingleAabb_1= +function(){return(LC=b._emscripten_bind_btSoftRigidDynamicsWorld_updateSingleAabb_1=b.asm.pA).apply(null,arguments)},MC=b._emscripten_bind_btSoftRigidDynamicsWorld_setDebugDrawer_1=function(){return(MC=b._emscripten_bind_btSoftRigidDynamicsWorld_setDebugDrawer_1=b.asm.qA).apply(null,arguments)},NC=b._emscripten_bind_btSoftRigidDynamicsWorld_getDebugDrawer_0=function(){return(NC=b._emscripten_bind_btSoftRigidDynamicsWorld_getDebugDrawer_0=b.asm.rA).apply(null,arguments)},OC=b._emscripten_bind_btSoftRigidDynamicsWorld_debugDrawWorld_0= +function(){return(OC=b._emscripten_bind_btSoftRigidDynamicsWorld_debugDrawWorld_0=b.asm.sA).apply(null,arguments)},PC=b._emscripten_bind_btSoftRigidDynamicsWorld_debugDrawObject_3=function(){return(PC=b._emscripten_bind_btSoftRigidDynamicsWorld_debugDrawObject_3=b.asm.tA).apply(null,arguments)},QC=b._emscripten_bind_btSoftRigidDynamicsWorld_setGravity_1=function(){return(QC=b._emscripten_bind_btSoftRigidDynamicsWorld_setGravity_1=b.asm.uA).apply(null,arguments)},RC=b._emscripten_bind_btSoftRigidDynamicsWorld_getGravity_0= +function(){return(RC=b._emscripten_bind_btSoftRigidDynamicsWorld_getGravity_0=b.asm.vA).apply(null,arguments)},SC=b._emscripten_bind_btSoftRigidDynamicsWorld_addRigidBody_1=function(){return(SC=b._emscripten_bind_btSoftRigidDynamicsWorld_addRigidBody_1=b.asm.wA).apply(null,arguments)},TC=b._emscripten_bind_btSoftRigidDynamicsWorld_addRigidBody_3=function(){return(TC=b._emscripten_bind_btSoftRigidDynamicsWorld_addRigidBody_3=b.asm.xA).apply(null,arguments)},UC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeRigidBody_1= +function(){return(UC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeRigidBody_1=b.asm.yA).apply(null,arguments)},VC=b._emscripten_bind_btSoftRigidDynamicsWorld_addConstraint_1=function(){return(VC=b._emscripten_bind_btSoftRigidDynamicsWorld_addConstraint_1=b.asm.zA).apply(null,arguments)},WC=b._emscripten_bind_btSoftRigidDynamicsWorld_addConstraint_2=function(){return(WC=b._emscripten_bind_btSoftRigidDynamicsWorld_addConstraint_2=b.asm.AA).apply(null,arguments)},XC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeConstraint_1= +function(){return(XC=b._emscripten_bind_btSoftRigidDynamicsWorld_removeConstraint_1=b.asm.BA).apply(null,arguments)},YC=b._emscripten_bind_btSoftRigidDynamicsWorld_stepSimulation_1=function(){return(YC=b._emscripten_bind_btSoftRigidDynamicsWorld_stepSimulation_1=b.asm.CA).apply(null,arguments)},ZC=b._emscripten_bind_btSoftRigidDynamicsWorld_stepSimulation_2=function(){return(ZC=b._emscripten_bind_btSoftRigidDynamicsWorld_stepSimulation_2=b.asm.DA).apply(null,arguments)},$C=b._emscripten_bind_btSoftRigidDynamicsWorld_stepSimulation_3= +function(){return($C=b._emscripten_bind_btSoftRigidDynamicsWorld_stepSimulation_3=b.asm.EA).apply(null,arguments)},aD=b._emscripten_bind_btSoftRigidDynamicsWorld_setContactAddedCallback_1=function(){return(aD=b._emscripten_bind_btSoftRigidDynamicsWorld_setContactAddedCallback_1=b.asm.FA).apply(null,arguments)},bD=b._emscripten_bind_btSoftRigidDynamicsWorld_setContactProcessedCallback_1=function(){return(bD=b._emscripten_bind_btSoftRigidDynamicsWorld_setContactProcessedCallback_1=b.asm.GA).apply(null, +arguments)},cD=b._emscripten_bind_btSoftRigidDynamicsWorld_setContactDestroyedCallback_1=function(){return(cD=b._emscripten_bind_btSoftRigidDynamicsWorld_setContactDestroyedCallback_1=b.asm.HA).apply(null,arguments)},dD=b._emscripten_bind_btSoftRigidDynamicsWorld_addAction_1=function(){return(dD=b._emscripten_bind_btSoftRigidDynamicsWorld_addAction_1=b.asm.IA).apply(null,arguments)},eD=b._emscripten_bind_btSoftRigidDynamicsWorld_removeAction_1=function(){return(eD=b._emscripten_bind_btSoftRigidDynamicsWorld_removeAction_1= +b.asm.JA).apply(null,arguments)},fD=b._emscripten_bind_btSoftRigidDynamicsWorld_getSolverInfo_0=function(){return(fD=b._emscripten_bind_btSoftRigidDynamicsWorld_getSolverInfo_0=b.asm.KA).apply(null,arguments)},gD=b._emscripten_bind_btSoftRigidDynamicsWorld_setInternalTickCallback_1=function(){return(gD=b._emscripten_bind_btSoftRigidDynamicsWorld_setInternalTickCallback_1=b.asm.LA).apply(null,arguments)},hD=b._emscripten_bind_btSoftRigidDynamicsWorld_setInternalTickCallback_2=function(){return(hD= +b._emscripten_bind_btSoftRigidDynamicsWorld_setInternalTickCallback_2=b.asm.MA).apply(null,arguments)},iD=b._emscripten_bind_btSoftRigidDynamicsWorld_setInternalTickCallback_3=function(){return(iD=b._emscripten_bind_btSoftRigidDynamicsWorld_setInternalTickCallback_3=b.asm.NA).apply(null,arguments)},jD=b._emscripten_bind_btSoftRigidDynamicsWorld___destroy___0=function(){return(jD=b._emscripten_bind_btSoftRigidDynamicsWorld___destroy___0=b.asm.OA).apply(null,arguments)},kD=b._emscripten_bind_btSoftBodyHelpers_btSoftBodyHelpers_0= +function(){return(kD=b._emscripten_bind_btSoftBodyHelpers_btSoftBodyHelpers_0=b.asm.PA).apply(null,arguments)},lD=b._emscripten_bind_btSoftBodyHelpers_CreateRope_5=function(){return(lD=b._emscripten_bind_btSoftBodyHelpers_CreateRope_5=b.asm.QA).apply(null,arguments)},mD=b._emscripten_bind_btSoftBodyHelpers_CreatePatch_9=function(){return(mD=b._emscripten_bind_btSoftBodyHelpers_CreatePatch_9=b.asm.RA).apply(null,arguments)},nD=b._emscripten_bind_btSoftBodyHelpers_CreatePatchUV_10=function(){return(nD= +b._emscripten_bind_btSoftBodyHelpers_CreatePatchUV_10=b.asm.SA).apply(null,arguments)},oD=b._emscripten_bind_btSoftBodyHelpers_CreateEllipsoid_4=function(){return(oD=b._emscripten_bind_btSoftBodyHelpers_CreateEllipsoid_4=b.asm.TA).apply(null,arguments)},pD=b._emscripten_bind_btSoftBodyHelpers_CreateFromTriMesh_5=function(){return(pD=b._emscripten_bind_btSoftBodyHelpers_CreateFromTriMesh_5=b.asm.UA).apply(null,arguments)},qD=b._emscripten_bind_btSoftBodyHelpers_CreateFromConvexHull_4=function(){return(qD= +b._emscripten_bind_btSoftBodyHelpers_CreateFromConvexHull_4=b.asm.VA).apply(null,arguments)},rD=b._emscripten_bind_btSoftBodyHelpers___destroy___0=function(){return(rD=b._emscripten_bind_btSoftBodyHelpers___destroy___0=b.asm.WA).apply(null,arguments)},sD=b._emscripten_enum_PHY_ScalarType_PHY_FLOAT=function(){return(sD=b._emscripten_enum_PHY_ScalarType_PHY_FLOAT=b.asm.XA).apply(null,arguments)},tD=b._emscripten_enum_PHY_ScalarType_PHY_DOUBLE=function(){return(tD=b._emscripten_enum_PHY_ScalarType_PHY_DOUBLE= +b.asm.YA).apply(null,arguments)},uD=b._emscripten_enum_PHY_ScalarType_PHY_INTEGER=function(){return(uD=b._emscripten_enum_PHY_ScalarType_PHY_INTEGER=b.asm.ZA).apply(null,arguments)},vD=b._emscripten_enum_PHY_ScalarType_PHY_SHORT=function(){return(vD=b._emscripten_enum_PHY_ScalarType_PHY_SHORT=b.asm._A).apply(null,arguments)},wD=b._emscripten_enum_PHY_ScalarType_PHY_FIXEDPOINT88=function(){return(wD=b._emscripten_enum_PHY_ScalarType_PHY_FIXEDPOINT88=b.asm.$A).apply(null,arguments)},xD=b._emscripten_enum_PHY_ScalarType_PHY_UCHAR= +function(){return(xD=b._emscripten_enum_PHY_ScalarType_PHY_UCHAR=b.asm.aB).apply(null,arguments)},yD=b._emscripten_enum_eGIMPACT_SHAPE_TYPE_CONST_GIMPACT_COMPOUND_SHAPE=function(){return(yD=b._emscripten_enum_eGIMPACT_SHAPE_TYPE_CONST_GIMPACT_COMPOUND_SHAPE=b.asm.bB).apply(null,arguments)},zD=b._emscripten_enum_eGIMPACT_SHAPE_TYPE_CONST_GIMPACT_TRIMESH_SHAPE_PART=function(){return(zD=b._emscripten_enum_eGIMPACT_SHAPE_TYPE_CONST_GIMPACT_TRIMESH_SHAPE_PART=b.asm.cB).apply(null,arguments)},AD=b._emscripten_enum_eGIMPACT_SHAPE_TYPE_CONST_GIMPACT_TRIMESH_SHAPE= +function(){return(AD=b._emscripten_enum_eGIMPACT_SHAPE_TYPE_CONST_GIMPACT_TRIMESH_SHAPE=b.asm.dB).apply(null,arguments)},BD=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_ERP=function(){return(BD=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_ERP=b.asm.eB).apply(null,arguments)},CD=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_STOP_ERP=function(){return(CD=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_STOP_ERP=b.asm.fB).apply(null,arguments)},DD=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_CFM= +function(){return(DD=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_CFM=b.asm.gB).apply(null,arguments)},ED=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_STOP_CFM=function(){return(ED=b._emscripten_enum_btConstraintParams_BT_CONSTRAINT_STOP_CFM=b.asm.hB).apply(null,arguments)};b._malloc=function(){return(b._malloc=b.asm.jB).apply(null,arguments)};b.___start_em_js=27240;b.___stop_em_js=27338;b.UTF8ToString=sa; +b.addFunction=function(a,c){if(!Ua){Ua=new WeakMap;var d=ya.length;if(Ua)for(var e=0;e<0+d;e++){var g=e;var n=Ta[g];n||(g>=Ta.length&&(Ta.length=g+1),Ta[g]=n=ya.get(g));(g=n)&&Ua.set(g,e)}}if(d=Ua.get(a)||0)return d;if(Va.length)d=Va.pop();else{try{ya.grow(1)}catch(T){if(!(T instanceof RangeError))throw T;throw"Unable to grow wasm table. Set ALLOW_TABLE_GROWTH.";}d=ya.length-1}try{e=d,ya.set(e,a),Ta[e]=ya.get(e)}catch(T){if(!(T instanceof TypeError))throw T;if("function"==typeof WebAssembly.Function){e= +WebAssembly.Function;g={i:"i32",j:"i32",f:"f32",d:"f64",p:"i32"};n={parameters:[],results:"v"==c[0]?[]:[g[c[0]]]};for(var z=1;zz?e.push(z):e.push(z%128|128,z>>7);for(z=0;zg?c.push(g):c.push(g%128|128,g>>7);c.push.apply(c, +e);c.push(2,7,1,1,101,1,102,0,0,7,5,1,1,102,0,0);c=new WebAssembly.Module(new Uint8Array(c));c=(new WebAssembly.Instance(c,{e:{f:a}})).exports.f}e=d;ya.set(e,c);Ta[e]=ya.get(e)}Ua.set(a,d);return d};var FD;Ha=function GD(){FD||HD();FD||(Ha=GD)}; +function HD(){function a(){if(!FD&&(FD=!0,b.calledRun=!0,!qa)){Ca=!0;Qa(Aa);aa(b);if(b.onRuntimeInitialized)b.onRuntimeInitialized();if(b.postRun)for("function"==typeof b.postRun&&(b.postRun=[b.postRun]);b.postRun.length;){var c=b.postRun.shift();Ba.unshift(c)}Qa(Ba)}}if(!(0=JD?(0>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var e=0;e=e?c++:2047>=e?c+=2:55296<=e&&57343>=e?(c+=4,++d):c+=3}c=Array(c+1);e=c.length;d=0;if(0=n){var z=a.charCodeAt(++g);n=65536+((n&1023)<<10)|z&1023}if(127>=n){if(d>=e)break;c[d++]=n}else{if(2047>=n){if(d+1>=e)break;c[d++]=192|n>>6}else{if(65535>=n){if(d+2>=e)break;c[d++]=224|n>>12}else{if(d+3>=e)break;c[d++]=240|n>>18;c[d++]= +128|n>>12&63}c[d++]=128|n>>6&63}c[d++]=128|n&63}}c[d]=0}a=OD(c,ua);PD(c,ua,a);return a}return a}function RD(a){if("object"===typeof a){var c=OD(a,wa);PD(a,wa,c);return c}return a}function l(){throw"cannot construct a btCollisionShape, no constructor in IDL";}l.prototype=Object.create(f.prototype);l.prototype.constructor=l;l.prototype.lB=l;l.mB={};b.btCollisionShape=l;l.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Xa(c,a)}; +l.prototype.getLocalScaling=function(){return k(Ya(this.kB),m)};l.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Za(d,a,c)};l.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$a(c,a)};l.prototype.getMargin=function(){return ab(this.kB)};l.prototype.__destroy__=function(){bb(this.kB)};function SD(){throw"cannot construct a btCollisionWorld, no constructor in IDL";}SD.prototype=Object.create(f.prototype); +SD.prototype.constructor=SD;SD.prototype.lB=SD;SD.mB={};b.btCollisionWorld=SD;SD.prototype.getDispatcher=function(){return k(cb(this.kB),TD)};SD.prototype.rayTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);db(e,a,c,d)};SD.prototype.getPairCache=function(){return k(eb(this.kB),UD)};SD.prototype.getDispatchInfo=function(){return k(fb(this.kB),p)}; +SD.prototype.addCollisionObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?gb(e,a):void 0===d?hb(e,a,c):ib(e,a,c,d)};SD.prototype.removeCollisionObject=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jb(c,a)};SD.prototype.getBroadphase=function(){return k(kb(this.kB),VD)}; +SD.prototype.convexSweepTest=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);lb(n,a,c,d,e,g)};SD.prototype.contactPairTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);mb(e,a,c,d)}; +SD.prototype.contactTest=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);nb(d,a,c)};SD.prototype.updateSingleAabb=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ob(c,a)};SD.prototype.setDebugDrawer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pb(c,a)};SD.prototype.getDebugDrawer=function(){return k(qb(this.kB),WD)};SD.prototype.debugDrawWorld=function(){rb(this.kB)}; +SD.prototype.debugDrawObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);sb(e,a,c,d)};SD.prototype.__destroy__=function(){tb(this.kB)};function q(){throw"cannot construct a btCollisionObject, no constructor in IDL";}q.prototype=Object.create(f.prototype);q.prototype.constructor=q;q.prototype.lB=q;q.mB={};b.btCollisionObject=q; +q.prototype.setAnisotropicFriction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);ub(d,a,c)};q.prototype.getCollisionShape=function(){return k(vb(this.kB),l)};q.prototype.setContactProcessingThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wb(c,a)};q.prototype.setActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xb(c,a)}; +q.prototype.forceActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yb(c,a)};q.prototype.activate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);void 0===a?zb(c):Ab(c,a)};q.prototype.isActive=function(){return!!Bb(this.kB)};q.prototype.isKinematicObject=function(){return!!Cb(this.kB)};q.prototype.isStaticObject=function(){return!!Db(this.kB)};q.prototype.isStaticOrKinematicObject=function(){return!!Eb(this.kB)};q.prototype.getRestitution=function(){return Fb(this.kB)}; +q.prototype.getFriction=function(){return Gb(this.kB)};q.prototype.getRollingFriction=function(){return Hb(this.kB)};q.prototype.setRestitution=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ib(c,a)};q.prototype.setFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jb(c,a)};q.prototype.setRollingFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kb(c,a)};q.prototype.getWorldTransform=function(){return k(Lb(this.kB),r)};q.prototype.getCollisionFlags=function(){return Mb(this.kB)}; +q.prototype.setCollisionFlags=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nb(c,a)};q.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ob(c,a)};q.prototype.setCollisionShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pb(c,a)};q.prototype.setCcdMotionThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qb(c,a)};q.prototype.setCcdSweptSphereRadius=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rb(c,a)}; +q.prototype.getUserIndex=function(){return Sb(this.kB)};q.prototype.setUserIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tb(c,a)};q.prototype.getUserPointer=function(){return k(Ub(this.kB),XD)};q.prototype.setUserPointer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vb(c,a)};q.prototype.getBroadphaseHandle=function(){return k(Wb(this.kB),YD)};q.prototype.__destroy__=function(){Xb(this.kB)}; +function ZD(){throw"cannot construct a btConcaveShape, no constructor in IDL";}ZD.prototype=Object.create(l.prototype);ZD.prototype.constructor=ZD;ZD.prototype.lB=ZD;ZD.mB={};b.btConcaveShape=ZD;ZD.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yb(c,a)};ZD.prototype.getLocalScaling=function(){return k(Zb(this.kB),m)};ZD.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);$b(d,a,c)}; +ZD.prototype.__destroy__=function(){ac(this.kB)};function $D(){throw"cannot construct a btCollisionAlgorithm, no constructor in IDL";}$D.prototype=Object.create(f.prototype);$D.prototype.constructor=$D;$D.prototype.lB=$D;$D.mB={};b.btCollisionAlgorithm=$D;$D.prototype.__destroy__=function(){bc(this.kB)};function aE(){throw"cannot construct a btTypedConstraint, no constructor in IDL";}aE.prototype=Object.create(f.prototype);aE.prototype.constructor=aE;aE.prototype.lB=aE;aE.mB={}; +b.btTypedConstraint=aE;aE.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cc(c,a)};aE.prototype.getBreakingImpulseThreshold=function(){return ec(this.kB)};aE.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fc(c,a)};aE.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return gc(d,a,c)}; +aE.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);hc(e,a,c,d)};aE.prototype.__destroy__=function(){ic(this.kB)};function bE(){throw"cannot construct a btDynamicsWorld, no constructor in IDL";}bE.prototype=Object.create(SD.prototype);bE.prototype.constructor=bE;bE.prototype.lB=bE;bE.mB={};b.btDynamicsWorld=bE;bE.prototype.addAction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jc(c,a)}; +bE.prototype.removeAction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kc(c,a)};bE.prototype.getSolverInfo=function(){return k(lc(this.kB),t)};bE.prototype.setInternalTickCallback=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?mc(e,a):void 0===d?nc(e,a,c):oc(e,a,c,d)};bE.prototype.getDispatcher=function(){return k(pc(this.kB),TD)}; +bE.prototype.rayTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);qc(e,a,c,d)};bE.prototype.getPairCache=function(){return k(rc(this.kB),UD)};bE.prototype.getDispatchInfo=function(){return k(sc(this.kB),p)}; +bE.prototype.addCollisionObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?tc(e,a):void 0===d?uc(e,a,c):vc(e,a,c,d)};bE.prototype.removeCollisionObject=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wc(c,a)};bE.prototype.getBroadphase=function(){return k(xc(this.kB),VD)}; +bE.prototype.convexSweepTest=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);yc(n,a,c,d,e,g)};bE.prototype.contactPairTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);zc(e,a,c,d)}; +bE.prototype.contactTest=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Ac(d,a,c)};bE.prototype.updateSingleAabb=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bc(c,a)};bE.prototype.setDebugDrawer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Cc(c,a)};bE.prototype.getDebugDrawer=function(){return k(Dc(this.kB),WD)};bE.prototype.debugDrawWorld=function(){Ec(this.kB)}; +bE.prototype.debugDrawObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Fc(e,a,c,d)};bE.prototype.__destroy__=function(){Gc(this.kB)};function WD(){throw"cannot construct a btIDebugDraw, no constructor in IDL";}WD.prototype=Object.create(f.prototype);WD.prototype.constructor=WD;WD.prototype.lB=WD;WD.mB={};b.btIDebugDraw=WD; +WD.prototype.drawLine=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Hc(e,a,c,d)};WD.prototype.drawContactPoint=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);Ic(n,a,c,d,e,g)}; +WD.prototype.reportErrorWarning=function(a){var c=this.kB;ND();a=a&&"object"===typeof a?a.kB:QD(a);Jc(c,a)};WD.prototype.draw3dText=function(a,c){var d=this.kB;ND();a&&"object"===typeof a&&(a=a.kB);c=c&&"object"===typeof c?c.kB:QD(c);Kc(d,a,c)};WD.prototype.setDebugMode=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lc(c,a)};WD.prototype.getDebugMode=function(){return Mc(this.kB)};WD.prototype.__destroy__=function(){Nc(this.kB)}; +function m(a,c,d){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);this.kB=void 0===a?Oc():void 0===c?_emscripten_bind_btVector3_btVector3_1(a):void 0===d?_emscripten_bind_btVector3_btVector3_2(a,c):Pc(a,c,d);h(m)[this.kB]=this}m.prototype=Object.create(f.prototype);m.prototype.constructor=m;m.prototype.lB=m;m.mB={};b.btVector3=m;m.prototype.length=m.prototype.length=function(){return Qc(this.kB)};m.prototype.x=m.prototype.x=function(){return Rc(this.kB)}; +m.prototype.y=m.prototype.y=function(){return Sc(this.kB)};m.prototype.z=m.prototype.z=function(){return Tc(this.kB)};m.prototype.setX=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Uc(c,a)};m.prototype.setY=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vc(c,a)};m.prototype.setZ=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Wc(c,a)}; +m.prototype.setValue=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Xc(e,a,c,d)};m.prototype.normalize=m.prototype.normalize=function(){Yc(this.kB)};m.prototype.rotate=m.prototype.rotate=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return k(Zc(d,a,c),m)};m.prototype.dot=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return $c(c,a)}; +m.prototype.op_mul=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(ad(c,a),m)};m.prototype.op_add=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(bd(c,a),m)};m.prototype.op_sub=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(cd(c,a),m)};m.prototype.__destroy__=function(){dd(this.kB)};function cE(){throw"cannot construct a btQuadWord, no constructor in IDL";}cE.prototype=Object.create(f.prototype);cE.prototype.constructor=cE; +cE.prototype.lB=cE;cE.mB={};b.btQuadWord=cE;cE.prototype.x=cE.prototype.x=function(){return ed(this.kB)};cE.prototype.y=cE.prototype.y=function(){return fd(this.kB)};cE.prototype.z=cE.prototype.z=function(){return gd(this.kB)};cE.prototype.w=cE.prototype.w=function(){return hd(this.kB)};cE.prototype.setX=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jd(c,a)};cE.prototype.setY=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kd(c,a)}; +cE.prototype.setZ=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ld(c,a)};cE.prototype.setW=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);md(c,a)};cE.prototype.__destroy__=function(){nd(this.kB)};function dE(){throw"cannot construct a btMotionState, no constructor in IDL";}dE.prototype=Object.create(f.prototype);dE.prototype.constructor=dE;dE.prototype.lB=dE;dE.mB={};b.btMotionState=dE; +dE.prototype.getWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);od(c,a)};dE.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pd(c,a)};dE.prototype.__destroy__=function(){qd(this.kB)};function u(){throw"cannot construct a RayResultCallback, no constructor in IDL";}u.prototype=Object.create(f.prototype);u.prototype.constructor=u;u.prototype.lB=u;u.mB={};b.RayResultCallback=u;u.prototype.hasHit=function(){return!!rd(this.kB)}; +u.prototype.get_m_collisionFilterGroup=u.prototype.nB=function(){return sd(this.kB)};u.prototype.set_m_collisionFilterGroup=u.prototype.pB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);td(c,a)};Object.defineProperty(u.prototype,"m_collisionFilterGroup",{get:u.prototype.nB,set:u.prototype.pB});u.prototype.get_m_collisionFilterMask=u.prototype.oB=function(){return ud(this.kB)}; +u.prototype.set_m_collisionFilterMask=u.prototype.qB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vd(c,a)};Object.defineProperty(u.prototype,"m_collisionFilterMask",{get:u.prototype.oB,set:u.prototype.qB});u.prototype.get_m_closestHitFraction=u.prototype.rB=function(){return wd(this.kB)};u.prototype.set_m_closestHitFraction=u.prototype.sB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xd(c,a)};Object.defineProperty(u.prototype,"m_closestHitFraction",{get:u.prototype.rB,set:u.prototype.sB}); +u.prototype.get_m_collisionObject=u.prototype.vB=function(){return k(yd(this.kB),q)};u.prototype.set_m_collisionObject=u.prototype.CB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zd(c,a)};Object.defineProperty(u.prototype,"m_collisionObject",{get:u.prototype.vB,set:u.prototype.CB});u.prototype.get_m_flags=u.prototype.tB=function(){return Ad(this.kB)};u.prototype.set_m_flags=u.prototype.uB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bd(c,a)}; +Object.defineProperty(u.prototype,"m_flags",{get:u.prototype.tB,set:u.prototype.uB});u.prototype.__destroy__=function(){Cd(this.kB)};function eE(){throw"cannot construct a ContactResultCallback, no constructor in IDL";}eE.prototype=Object.create(f.prototype);eE.prototype.constructor=eE;eE.prototype.lB=eE;eE.mB={};b.ContactResultCallback=eE; +eE.prototype.addSingleResult=function(a,c,d,e,g,n,z){var T=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);return Dd(T,a,c,d,e,g,n,z)};eE.prototype.__destroy__=function(){Ed(this.kB)};function v(){throw"cannot construct a ConvexResultCallback, no constructor in IDL";}v.prototype=Object.create(f.prototype); +v.prototype.constructor=v;v.prototype.lB=v;v.mB={};b.ConvexResultCallback=v;v.prototype.hasHit=function(){return!!Fd(this.kB)};v.prototype.get_m_collisionFilterGroup=v.prototype.nB=function(){return Gd(this.kB)};v.prototype.set_m_collisionFilterGroup=v.prototype.pB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Hd(c,a)};Object.defineProperty(v.prototype,"m_collisionFilterGroup",{get:v.prototype.nB,set:v.prototype.pB});v.prototype.get_m_collisionFilterMask=v.prototype.oB=function(){return Id(this.kB)}; +v.prototype.set_m_collisionFilterMask=v.prototype.qB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jd(c,a)};Object.defineProperty(v.prototype,"m_collisionFilterMask",{get:v.prototype.oB,set:v.prototype.qB});v.prototype.get_m_closestHitFraction=v.prototype.rB=function(){return Kd(this.kB)};v.prototype.set_m_closestHitFraction=v.prototype.sB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ld(c,a)};Object.defineProperty(v.prototype,"m_closestHitFraction",{get:v.prototype.rB,set:v.prototype.sB}); +v.prototype.__destroy__=function(){Md(this.kB)};function fE(){throw"cannot construct a btConvexShape, no constructor in IDL";}fE.prototype=Object.create(l.prototype);fE.prototype.constructor=fE;fE.prototype.lB=fE;fE.mB={};b.btConvexShape=fE;fE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nd(c,a)};fE.prototype.getLocalScaling=function(){return k(Od(this.kB),m)}; +fE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Pd(d,a,c)};fE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qd(c,a)};fE.prototype.getMargin=function(){return Rd(this.kB)};fE.prototype.__destroy__=function(){Sd(this.kB)};function gE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Td(a,c);h(gE)[this.kB]=this}gE.prototype=Object.create(l.prototype); +gE.prototype.constructor=gE;gE.prototype.lB=gE;gE.mB={};b.btCapsuleShape=gE;gE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ud(c,a)};gE.prototype.getMargin=function(){return Vd(this.kB)};gE.prototype.getUpAxis=function(){return Wd(this.kB)};gE.prototype.getRadius=function(){return Xd(this.kB)};gE.prototype.getHalfHeight=function(){return Yd(this.kB)};gE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zd(c,a)}; +gE.prototype.getLocalScaling=function(){return k($d(this.kB),m)};gE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);ae(d,a,c)};gE.prototype.__destroy__=function(){be(this.kB)};function hE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=ce(a);h(hE)[this.kB]=this}hE.prototype=Object.create(l.prototype);hE.prototype.constructor=hE;hE.prototype.lB=hE;hE.mB={};b.btCylinderShape=hE; +hE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);de(c,a)};hE.prototype.getMargin=function(){return ee(this.kB)};hE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fe(c,a)};hE.prototype.getLocalScaling=function(){return k(ge(this.kB),m)};hE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);he(d,a,c)};hE.prototype.__destroy__=function(){ie(this.kB)}; +function iE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=je(a,c);h(iE)[this.kB]=this}iE.prototype=Object.create(l.prototype);iE.prototype.constructor=iE;iE.prototype.lB=iE;iE.mB={};b.btConeShape=iE;iE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ke(c,a)};iE.prototype.getLocalScaling=function(){return k(le(this.kB),m)}; +iE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);me(d,a,c)};iE.prototype.__destroy__=function(){ne(this.kB)};function jE(){throw"cannot construct a btStridingMeshInterface, no constructor in IDL";}jE.prototype=Object.create(f.prototype);jE.prototype.constructor=jE;jE.prototype.lB=jE;jE.mB={};b.btStridingMeshInterface=jE;jE.prototype.setScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);oe(c,a)}; +jE.prototype.__destroy__=function(){pe(this.kB)};function kE(){throw"cannot construct a btTriangleMeshShape, no constructor in IDL";}kE.prototype=Object.create(ZD.prototype);kE.prototype.constructor=kE;kE.prototype.lB=kE;kE.mB={};b.btTriangleMeshShape=kE;kE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qe(c,a)};kE.prototype.getLocalScaling=function(){return k(re(this.kB),m)}; +kE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);se(d,a,c)};kE.prototype.__destroy__=function(){te(this.kB)};function lE(){throw"cannot construct a btPrimitiveManagerBase, no constructor in IDL";}lE.prototype=Object.create(f.prototype);lE.prototype.constructor=lE;lE.prototype.lB=lE;lE.mB={};b.btPrimitiveManagerBase=lE;lE.prototype.is_trimesh=function(){return!!ue(this.kB)};lE.prototype.get_primitive_count=function(){return ve(this.kB)}; +lE.prototype.get_primitive_box=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);we(d,a,c)};lE.prototype.get_primitive_triangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);xe(d,a,c)};lE.prototype.__destroy__=function(){ye(this.kB)};function w(){throw"cannot construct a btGImpactShapeInterface, no constructor in IDL";}w.prototype=Object.create(ZD.prototype);w.prototype.constructor=w;w.prototype.lB=w; +w.mB={};b.btGImpactShapeInterface=w;w.prototype.updateBound=function(){ze(this.kB)};w.prototype.postUpdate=function(){Ae(this.kB)};w.prototype.getShapeType=function(){return Be(this.kB)};w.prototype.getName=function(){return sa(Ce(this.kB))};w.prototype.getGImpactShapeType=function(){return De(this.kB)};w.prototype.getPrimitiveManager=function(){return k(Ee(this.kB),lE)};w.prototype.getNumChildShapes=function(){return Fe(this.kB)};w.prototype.childrenHasTransform=function(){return!!Ge(this.kB)}; +w.prototype.needsRetrieveTriangles=function(){return!!He(this.kB)};w.prototype.needsRetrieveTetrahedrons=function(){return!!Ie(this.kB)};w.prototype.getBulletTriangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Je(d,a,c)};w.prototype.getBulletTetrahedron=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Ke(d,a,c)}; +w.prototype.getChildShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Le(c,a),l)};w.prototype.getChildTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Me(c,a),r)};w.prototype.setChildTransform=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Ne(d,a,c)};w.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Oe(c,a)}; +w.prototype.getLocalScaling=function(){return k(Pe(this.kB),m)};w.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Qe(d,a,c)};w.prototype.__destroy__=function(){Re(this.kB)};function mE(){throw"cannot construct a btActivatingCollisionAlgorithm, no constructor in IDL";}mE.prototype=Object.create($D.prototype);mE.prototype.constructor=mE;mE.prototype.lB=mE;mE.mB={};b.btActivatingCollisionAlgorithm=mE; +mE.prototype.__destroy__=function(){Se(this.kB)};function nE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=void 0===a?Te():Ue(a);h(nE)[this.kB]=this}nE.prototype=Object.create(f.prototype);nE.prototype.constructor=nE;nE.prototype.lB=nE;nE.mB={};b.btDefaultCollisionConfiguration=nE;nE.prototype.__destroy__=function(){Ve(this.kB)};function TD(){throw"cannot construct a btDispatcher, no constructor in IDL";}TD.prototype=Object.create(f.prototype);TD.prototype.constructor=TD;TD.prototype.lB=TD;TD.mB={}; +b.btDispatcher=TD;TD.prototype.getNumManifolds=function(){return We(this.kB)};TD.prototype.getManifoldByIndexInternal=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Xe(c,a),oE)};TD.prototype.__destroy__=function(){Ye(this.kB)}; +function pE(a,c,d,e,g){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);this.kB=void 0===e?Ze(a,c,d):void 0===g?_emscripten_bind_btGeneric6DofConstraint_btGeneric6DofConstraint_4(a,c,d,e):$e(a,c,d,e,g);h(pE)[this.kB]=this}pE.prototype=Object.create(aE.prototype);pE.prototype.constructor=pE;pE.prototype.lB=pE;pE.mB={};b.btGeneric6DofConstraint=pE; +pE.prototype.setLinearLowerLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);af(c,a)};pE.prototype.setLinearUpperLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bf(c,a)};pE.prototype.setAngularLowerLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cf(c,a)};pE.prototype.setAngularUpperLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);df(c,a)};pE.prototype.getFrameOffsetA=function(){return k(ef(this.kB),r)}; +pE.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ff(c,a)};pE.prototype.getBreakingImpulseThreshold=function(){return gf(this.kB)};pE.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hf(c,a)};pE.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return jf(d,a,c)}; +pE.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);kf(e,a,c,d)};pE.prototype.__destroy__=function(){lf(this.kB)};function x(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=mf(a,c,d,e);h(x)[this.kB]=this}x.prototype=Object.create(bE.prototype);x.prototype.constructor=x;x.prototype.lB=x;x.mB={}; +b.btDiscreteDynamicsWorld=x;x.prototype.setGravity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);nf(c,a)};x.prototype.getGravity=function(){return k(of(this.kB),m)};x.prototype.addRigidBody=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?pf(e,a):void 0===d?_emscripten_bind_btDiscreteDynamicsWorld_addRigidBody_2(e,a,c):qf(e,a,c,d)}; +x.prototype.removeRigidBody=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rf(c,a)};x.prototype.addConstraint=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);void 0===c?sf(d,a):tf(d,a,c)};x.prototype.removeConstraint=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);uf(c,a)}; +x.prototype.stepSimulation=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);return void 0===c?vf(e,a):void 0===d?wf(e,a,c):xf(e,a,c,d)};x.prototype.setContactAddedCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yf(c,a)};x.prototype.setContactProcessedCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zf(c,a)}; +x.prototype.setContactDestroyedCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Af(c,a)};x.prototype.getDispatcher=function(){return k(Bf(this.kB),TD)};x.prototype.rayTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Cf(e,a,c,d)};x.prototype.getPairCache=function(){return k(Df(this.kB),UD)};x.prototype.getDispatchInfo=function(){return k(Ef(this.kB),p)}; +x.prototype.addCollisionObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?Ff(e,a):void 0===d?Gf(e,a,c):Hf(e,a,c,d)};x.prototype.removeCollisionObject=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);If(c,a)};x.prototype.getBroadphase=function(){return k(Jf(this.kB),VD)}; +x.prototype.convexSweepTest=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);Kf(n,a,c,d,e,g)};x.prototype.contactPairTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Lf(e,a,c,d)}; +x.prototype.contactTest=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Mf(d,a,c)};x.prototype.updateSingleAabb=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nf(c,a)};x.prototype.setDebugDrawer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Of(c,a)};x.prototype.getDebugDrawer=function(){return k(Pf(this.kB),WD)};x.prototype.debugDrawWorld=function(){Qf(this.kB)}; +x.prototype.debugDrawObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Rf(e,a,c,d)};x.prototype.addAction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Sf(c,a)};x.prototype.removeAction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tf(c,a)};x.prototype.getSolverInfo=function(){return k(Uf(this.kB),t)}; +x.prototype.setInternalTickCallback=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?Vf(e,a):void 0===d?Wf(e,a,c):Xf(e,a,c,d)};x.prototype.__destroy__=function(){Yf(this.kB)};function qE(){throw"cannot construct a btVehicleRaycaster, no constructor in IDL";}qE.prototype=Object.create(f.prototype);qE.prototype.constructor=qE;qE.prototype.lB=qE;qE.mB={};b.btVehicleRaycaster=qE; +qE.prototype.castRay=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Zf(e,a,c,d)};qE.prototype.__destroy__=function(){$f(this.kB)};function rE(){throw"cannot construct a btActionInterface, no constructor in IDL";}rE.prototype=Object.create(f.prototype);rE.prototype.constructor=rE;rE.prototype.lB=rE;rE.mB={};b.btActionInterface=rE; +rE.prototype.updateAction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);ag(d,a,c)};rE.prototype.__destroy__=function(){bg(this.kB)};function y(){this.kB=cg();h(y)[this.kB]=this}y.prototype=Object.create(q.prototype);y.prototype.constructor=y;y.prototype.lB=y;y.mB={};b.btGhostObject=y;y.prototype.getNumOverlappingObjects=function(){return dg(this.kB)}; +y.prototype.getOverlappingObject=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(eg(c,a),q)};y.prototype.setAnisotropicFriction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);fg(d,a,c)};y.prototype.getCollisionShape=function(){return k(gg(this.kB),l)};y.prototype.setContactProcessingThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hg(c,a)}; +y.prototype.setActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ig(c,a)};y.prototype.forceActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jg(c,a)};y.prototype.activate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);void 0===a?kg(c):lg(c,a)};y.prototype.isActive=function(){return!!mg(this.kB)};y.prototype.isKinematicObject=function(){return!!ng(this.kB)};y.prototype.isStaticObject=function(){return!!og(this.kB)}; +y.prototype.isStaticOrKinematicObject=function(){return!!pg(this.kB)};y.prototype.getRestitution=function(){return qg(this.kB)};y.prototype.getFriction=function(){return rg(this.kB)};y.prototype.getRollingFriction=function(){return sg(this.kB)};y.prototype.setRestitution=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tg(c,a)};y.prototype.setFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ug(c,a)}; +y.prototype.setRollingFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vg(c,a)};y.prototype.getWorldTransform=function(){return k(wg(this.kB),r)};y.prototype.getCollisionFlags=function(){return xg(this.kB)};y.prototype.setCollisionFlags=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yg(c,a)};y.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zg(c,a)}; +y.prototype.setCollisionShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ag(c,a)};y.prototype.setCcdMotionThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bg(c,a)};y.prototype.setCcdSweptSphereRadius=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Cg(c,a)};y.prototype.getUserIndex=function(){return Dg(this.kB)};y.prototype.setUserIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Eg(c,a)}; +y.prototype.getUserPointer=function(){return k(Fg(this.kB),XD)};y.prototype.setUserPointer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Gg(c,a)};y.prototype.getBroadphaseHandle=function(){return k(Hg(this.kB),YD)};y.prototype.__destroy__=function(){Ig(this.kB)};function sE(){throw"cannot construct a btSoftBodySolver, no constructor in IDL";}sE.prototype=Object.create(f.prototype);sE.prototype.constructor=sE;sE.prototype.lB=sE;sE.mB={};b.btSoftBodySolver=sE;sE.prototype.__destroy__=function(){Jg(this.kB)}; +function XD(){throw"cannot construct a VoidPtr, no constructor in IDL";}XD.prototype=Object.create(f.prototype);XD.prototype.constructor=XD;XD.prototype.lB=XD;XD.mB={};b.VoidPtr=XD;XD.prototype.__destroy__=function(){Kg(this.kB)};function tE(){this.kB=Lg();h(tE)[this.kB]=this}tE.prototype=Object.create(WD.prototype);tE.prototype.constructor=tE;tE.prototype.lB=tE;tE.mB={};b.DebugDrawer=tE; +tE.prototype.drawLine=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Mg(e,a,c,d)};tE.prototype.drawContactPoint=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);Ng(n,a,c,d,e,g)}; +tE.prototype.reportErrorWarning=function(a){var c=this.kB;ND();a=a&&"object"===typeof a?a.kB:QD(a);Og(c,a)};tE.prototype.draw3dText=function(a,c){var d=this.kB;ND();a&&"object"===typeof a&&(a=a.kB);c=c&&"object"===typeof c?c.kB:QD(c);Pg(d,a,c)};tE.prototype.setDebugMode=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qg(c,a)};tE.prototype.getDebugMode=function(){return Rg(this.kB)};tE.prototype.__destroy__=function(){Sg(this.kB)}; +function A(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=void 0===a?Tg():void 0===c?_emscripten_bind_btVector4_btVector4_1(a):void 0===d?_emscripten_bind_btVector4_btVector4_2(a,c):void 0===e?_emscripten_bind_btVector4_btVector4_3(a,c,d):Ug(a,c,d,e);h(A)[this.kB]=this}A.prototype=Object.create(m.prototype);A.prototype.constructor=A;A.prototype.lB=A;A.mB={};b.btVector4=A; +A.prototype.w=A.prototype.w=function(){return Vg(this.kB)};A.prototype.setValue=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);Wg(g,a,c,d,e)};A.prototype.length=A.prototype.length=function(){return Xg(this.kB)};A.prototype.x=A.prototype.x=function(){return Yg(this.kB)};A.prototype.y=A.prototype.y=function(){return Zg(this.kB)};A.prototype.z=A.prototype.z=function(){return $g(this.kB)}; +A.prototype.setX=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ah(c,a)};A.prototype.setY=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bh(c,a)};A.prototype.setZ=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ch(c,a)};A.prototype.normalize=A.prototype.normalize=function(){dh(this.kB)};A.prototype.rotate=A.prototype.rotate=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return k(eh(d,a,c),m)}; +A.prototype.dot=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return fh(c,a)};A.prototype.op_mul=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(gh(c,a),m)};A.prototype.op_add=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(hh(c,a),m)};A.prototype.op_sub=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(ih(c,a),m)};A.prototype.__destroy__=function(){jh(this.kB)}; +function B(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=kh(a,c,d,e);h(B)[this.kB]=this}B.prototype=Object.create(cE.prototype);B.prototype.constructor=B;B.prototype.lB=B;B.mB={};b.btQuaternion=B;B.prototype.setValue=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);lh(g,a,c,d,e)}; +B.prototype.setEulerZYX=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);mh(e,a,c,d)};B.prototype.setRotation=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);nh(d,a,c)};B.prototype.normalize=B.prototype.normalize=function(){oh(this.kB)};B.prototype.length2=function(){return ph(this.kB)};B.prototype.length=B.prototype.length=function(){return qh(this.kB)}; +B.prototype.dot=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return rh(c,a)};B.prototype.normalized=function(){return k(sh(this.kB),B)};B.prototype.getAxis=function(){return k(th(this.kB),m)};B.prototype.inverse=B.prototype.inverse=function(){return k(uh(this.kB),B)};B.prototype.getAngle=function(){return vh(this.kB)};B.prototype.getAngleShortestPath=function(){return wh(this.kB)}; +B.prototype.angle=B.prototype.angle=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return xh(c,a)};B.prototype.angleShortestPath=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return yh(c,a)};B.prototype.op_add=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(zh(c,a),B)};B.prototype.op_sub=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Ah(c,a),B)}; +B.prototype.op_mul=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Bh(c,a),B)};B.prototype.op_mulq=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Ch(c,a),B)};B.prototype.op_div=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Dh(c,a),B)};B.prototype.x=B.prototype.x=function(){return Eh(this.kB)};B.prototype.y=B.prototype.y=function(){return Fh(this.kB)};B.prototype.z=B.prototype.z=function(){return Gh(this.kB)}; +B.prototype.w=B.prototype.w=function(){return Hh(this.kB)};B.prototype.setX=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ih(c,a)};B.prototype.setY=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jh(c,a)};B.prototype.setZ=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kh(c,a)};B.prototype.setW=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lh(c,a)};B.prototype.__destroy__=function(){Mh(this.kB)}; +function uE(){throw"cannot construct a btMatrix3x3, no constructor in IDL";}uE.prototype=Object.create(f.prototype);uE.prototype.constructor=uE;uE.prototype.lB=uE;uE.mB={};b.btMatrix3x3=uE;uE.prototype.setEulerZYX=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Nh(e,a,c,d)};uE.prototype.getRotation=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Oh(c,a)}; +uE.prototype.getRow=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Ph(c,a),m)};uE.prototype.__destroy__=function(){Qh(this.kB)};function r(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=void 0===a?Rh():void 0===c?_emscripten_bind_btTransform_btTransform_1(a):Sh(a,c);h(r)[this.kB]=this}r.prototype=Object.create(f.prototype);r.prototype.constructor=r;r.prototype.lB=r;r.mB={};b.btTransform=r;r.prototype.setIdentity=function(){Th(this.kB)}; +r.prototype.setOrigin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Uh(c,a)};r.prototype.setRotation=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vh(c,a)};r.prototype.getOrigin=function(){return k(Wh(this.kB),m)};r.prototype.getRotation=function(){return k(Xh(this.kB),B)};r.prototype.getBasis=function(){return k(Yh(this.kB),uE)};r.prototype.setFromOpenGLMatrix=function(a){var c=this.kB;ND();"object"==typeof a&&(a=RD(a));Zh(c,a)}; +r.prototype.inverse=r.prototype.inverse=function(){return k($h(this.kB),r)};r.prototype.op_mul=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(ai(c,a),r)};r.prototype.__destroy__=function(){bi(this.kB)};function vE(){this.kB=ci();h(vE)[this.kB]=this}vE.prototype=Object.create(dE.prototype);vE.prototype.constructor=vE;vE.prototype.lB=vE;vE.mB={};b.MotionState=vE;vE.prototype.getWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);di(c,a)}; +vE.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ei(c,a)};vE.prototype.__destroy__=function(){fi(this.kB)};function wE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=void 0===a?gi():void 0===c?hi(a):ii(a,c);h(wE)[this.kB]=this}wE.prototype=Object.create(dE.prototype);wE.prototype.constructor=wE;wE.prototype.lB=wE;wE.mB={};b.btDefaultMotionState=wE; +wE.prototype.getWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ji(c,a)};wE.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ki(c,a)};wE.prototype.get_m_graphicsWorldTrans=wE.prototype.lD=function(){return k(li(this.kB),r)};wE.prototype.set_m_graphicsWorldTrans=wE.prototype.cG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);mi(c,a)};Object.defineProperty(wE.prototype,"m_graphicsWorldTrans",{get:wE.prototype.lD,set:wE.prototype.cG}); +wE.prototype.__destroy__=function(){ni(this.kB)};function xE(){throw"cannot construct a btCollisionObjectWrapper, no constructor in IDL";}xE.prototype=Object.create(f.prototype);xE.prototype.constructor=xE;xE.prototype.lB=xE;xE.mB={};b.btCollisionObjectWrapper=xE;xE.prototype.getWorldTransform=function(){return k(oi(this.kB),r)};xE.prototype.getCollisionObject=function(){return k(pi(this.kB),q)};xE.prototype.getCollisionShape=function(){return k(qi(this.kB),l)}; +function C(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=ri(a,c);h(C)[this.kB]=this}C.prototype=Object.create(u.prototype);C.prototype.constructor=C;C.prototype.lB=C;C.mB={};b.ClosestRayResultCallback=C;C.prototype.hasHit=function(){return!!si(this.kB)};C.prototype.get_m_rayFromWorld=C.prototype.NB=function(){return k(ti(this.kB),m)};C.prototype.set_m_rayFromWorld=C.prototype.XB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ui(c,a)}; +Object.defineProperty(C.prototype,"m_rayFromWorld",{get:C.prototype.NB,set:C.prototype.XB});C.prototype.get_m_rayToWorld=C.prototype.OB=function(){return k(vi(this.kB),m)};C.prototype.set_m_rayToWorld=C.prototype.YB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wi(c,a)};Object.defineProperty(C.prototype,"m_rayToWorld",{get:C.prototype.OB,set:C.prototype.YB});C.prototype.get_m_hitNormalWorld=C.prototype.xB=function(){return k(xi(this.kB),m)}; +C.prototype.set_m_hitNormalWorld=C.prototype.EB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yi(c,a)};Object.defineProperty(C.prototype,"m_hitNormalWorld",{get:C.prototype.xB,set:C.prototype.EB});C.prototype.get_m_hitPointWorld=C.prototype.yB=function(){return k(zi(this.kB),m)};C.prototype.set_m_hitPointWorld=C.prototype.FB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ai(c,a)};Object.defineProperty(C.prototype,"m_hitPointWorld",{get:C.prototype.yB,set:C.prototype.FB}); +C.prototype.get_m_collisionFilterGroup=C.prototype.nB=function(){return Bi(this.kB)};C.prototype.set_m_collisionFilterGroup=C.prototype.pB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ci(c,a)};Object.defineProperty(C.prototype,"m_collisionFilterGroup",{get:C.prototype.nB,set:C.prototype.pB});C.prototype.get_m_collisionFilterMask=C.prototype.oB=function(){return Di(this.kB)}; +C.prototype.set_m_collisionFilterMask=C.prototype.qB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ei(c,a)};Object.defineProperty(C.prototype,"m_collisionFilterMask",{get:C.prototype.oB,set:C.prototype.qB});C.prototype.get_m_closestHitFraction=C.prototype.rB=function(){return Fi(this.kB)};C.prototype.set_m_closestHitFraction=C.prototype.sB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Gi(c,a)};Object.defineProperty(C.prototype,"m_closestHitFraction",{get:C.prototype.rB,set:C.prototype.sB}); +C.prototype.get_m_collisionObject=C.prototype.vB=function(){return k(Hi(this.kB),q)};C.prototype.set_m_collisionObject=C.prototype.CB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ii(c,a)};Object.defineProperty(C.prototype,"m_collisionObject",{get:C.prototype.vB,set:C.prototype.CB});C.prototype.get_m_flags=C.prototype.tB=function(){return Ji(this.kB)};C.prototype.set_m_flags=C.prototype.uB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ki(c,a)}; +Object.defineProperty(C.prototype,"m_flags",{get:C.prototype.tB,set:C.prototype.uB});C.prototype.__destroy__=function(){Li(this.kB)};function yE(){throw"cannot construct a btConstCollisionObjectArray, no constructor in IDL";}yE.prototype=Object.create(f.prototype);yE.prototype.constructor=yE;yE.prototype.lB=yE;yE.mB={};b.btConstCollisionObjectArray=yE;yE.prototype.size=yE.prototype.size=function(){return Mi(this.kB)}; +yE.prototype.at=yE.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Ni(c,a),q)};yE.prototype.__destroy__=function(){Oi(this.kB)};function zE(){throw"cannot construct a btScalarArray, no constructor in IDL";}zE.prototype=Object.create(f.prototype);zE.prototype.constructor=zE;zE.prototype.lB=zE;zE.mB={};b.btScalarArray=zE;zE.prototype.size=zE.prototype.size=function(){return Pi(this.kB)}; +zE.prototype.at=zE.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return Qi(c,a)};zE.prototype.__destroy__=function(){Ri(this.kB)};function D(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Si(a,c);h(D)[this.kB]=this}D.prototype=Object.create(u.prototype);D.prototype.constructor=D;D.prototype.lB=D;D.mB={};b.AllHitsRayResultCallback=D;D.prototype.hasHit=function(){return!!Ti(this.kB)}; +D.prototype.get_m_collisionObjects=D.prototype.UC=function(){return k(Ui(this.kB),yE)};D.prototype.set_m_collisionObjects=D.prototype.LF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vi(c,a)};Object.defineProperty(D.prototype,"m_collisionObjects",{get:D.prototype.UC,set:D.prototype.LF});D.prototype.get_m_rayFromWorld=D.prototype.NB=function(){return k(Wi(this.kB),m)};D.prototype.set_m_rayFromWorld=D.prototype.XB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Xi(c,a)}; +Object.defineProperty(D.prototype,"m_rayFromWorld",{get:D.prototype.NB,set:D.prototype.XB});D.prototype.get_m_rayToWorld=D.prototype.OB=function(){return k(Yi(this.kB),m)};D.prototype.set_m_rayToWorld=D.prototype.YB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zi(c,a)};Object.defineProperty(D.prototype,"m_rayToWorld",{get:D.prototype.OB,set:D.prototype.YB});D.prototype.get_m_hitNormalWorld=D.prototype.xB=function(){return k($i(this.kB),AE)}; +D.prototype.set_m_hitNormalWorld=D.prototype.EB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);aj(c,a)};Object.defineProperty(D.prototype,"m_hitNormalWorld",{get:D.prototype.xB,set:D.prototype.EB});D.prototype.get_m_hitPointWorld=D.prototype.yB=function(){return k(bj(this.kB),AE)};D.prototype.set_m_hitPointWorld=D.prototype.FB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cj(c,a)};Object.defineProperty(D.prototype,"m_hitPointWorld",{get:D.prototype.yB,set:D.prototype.FB}); +D.prototype.get_m_hitFractions=D.prototype.qD=function(){return k(dj(this.kB),zE)};D.prototype.set_m_hitFractions=D.prototype.hG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ej(c,a)};Object.defineProperty(D.prototype,"m_hitFractions",{get:D.prototype.qD,set:D.prototype.hG});D.prototype.get_m_collisionFilterGroup=D.prototype.nB=function(){return fj(this.kB)};D.prototype.set_m_collisionFilterGroup=D.prototype.pB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);gj(c,a)}; +Object.defineProperty(D.prototype,"m_collisionFilterGroup",{get:D.prototype.nB,set:D.prototype.pB});D.prototype.get_m_collisionFilterMask=D.prototype.oB=function(){return hj(this.kB)};D.prototype.set_m_collisionFilterMask=D.prototype.qB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ij(c,a)};Object.defineProperty(D.prototype,"m_collisionFilterMask",{get:D.prototype.oB,set:D.prototype.qB});D.prototype.get_m_closestHitFraction=D.prototype.rB=function(){return jj(this.kB)}; +D.prototype.set_m_closestHitFraction=D.prototype.sB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kj(c,a)};Object.defineProperty(D.prototype,"m_closestHitFraction",{get:D.prototype.rB,set:D.prototype.sB});D.prototype.get_m_collisionObject=D.prototype.vB=function(){return k(lj(this.kB),q)};D.prototype.set_m_collisionObject=D.prototype.CB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);mj(c,a)};Object.defineProperty(D.prototype,"m_collisionObject",{get:D.prototype.vB,set:D.prototype.CB}); +D.prototype.get_m_flags=D.prototype.tB=function(){return nj(this.kB)};D.prototype.set_m_flags=D.prototype.uB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);oj(c,a)};Object.defineProperty(D.prototype,"m_flags",{get:D.prototype.tB,set:D.prototype.uB});D.prototype.__destroy__=function(){pj(this.kB)};function E(){throw"cannot construct a btManifoldPoint, no constructor in IDL";}E.prototype=Object.create(f.prototype);E.prototype.constructor=E;E.prototype.lB=E;E.mB={};b.btManifoldPoint=E; +E.prototype.getPositionWorldOnA=function(){return k(qj(this.kB),m)};E.prototype.getPositionWorldOnB=function(){return k(rj(this.kB),m)};E.prototype.getAppliedImpulse=function(){return sj(this.kB)};E.prototype.getDistance=function(){return tj(this.kB)};E.prototype.get_m_localPointA=E.prototype.GD=function(){return k(uj(this.kB),m)};E.prototype.set_m_localPointA=E.prototype.xG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vj(c,a)}; +Object.defineProperty(E.prototype,"m_localPointA",{get:E.prototype.GD,set:E.prototype.xG});E.prototype.get_m_localPointB=E.prototype.HD=function(){return k(wj(this.kB),m)};E.prototype.set_m_localPointB=E.prototype.yG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xj(c,a)};Object.defineProperty(E.prototype,"m_localPointB",{get:E.prototype.HD,set:E.prototype.yG});E.prototype.get_m_positionWorldOnB=E.prototype.YD=function(){return k(yj(this.kB),m)}; +E.prototype.set_m_positionWorldOnB=E.prototype.PG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zj(c,a)};Object.defineProperty(E.prototype,"m_positionWorldOnB",{get:E.prototype.YD,set:E.prototype.PG});E.prototype.get_m_positionWorldOnA=E.prototype.XD=function(){return k(Aj(this.kB),m)};E.prototype.set_m_positionWorldOnA=E.prototype.OG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bj(c,a)};Object.defineProperty(E.prototype,"m_positionWorldOnA",{get:E.prototype.XD,set:E.prototype.OG}); +E.prototype.get_m_normalWorldOnB=E.prototype.SD=function(){return k(Cj(this.kB),m)};E.prototype.set_m_normalWorldOnB=E.prototype.JG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dj(c,a)};Object.defineProperty(E.prototype,"m_normalWorldOnB",{get:E.prototype.SD,set:E.prototype.JG});E.prototype.get_m_userPersistentData=E.prototype.zE=function(){return Ej(this.kB)};E.prototype.set_m_userPersistentData=E.prototype.rH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fj(c,a)}; +Object.defineProperty(E.prototype,"m_userPersistentData",{get:E.prototype.zE,set:E.prototype.rH});E.prototype.__destroy__=function(){Gj(this.kB)};function BE(){this.kB=Hj();h(BE)[this.kB]=this}BE.prototype=Object.create(eE.prototype);BE.prototype.constructor=BE;BE.prototype.lB=BE;BE.mB={};b.ConcreteContactResultCallback=BE; +BE.prototype.addSingleResult=function(a,c,d,e,g,n,z){var T=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);return Ij(T,a,c,d,e,g,n,z)};BE.prototype.__destroy__=function(){Jj(this.kB)};function CE(){throw"cannot construct a LocalShapeInfo, no constructor in IDL";}CE.prototype=Object.create(f.prototype); +CE.prototype.constructor=CE;CE.prototype.lB=CE;CE.mB={};b.LocalShapeInfo=CE;CE.prototype.get_m_shapePart=CE.prototype.gE=function(){return Kj(this.kB)};CE.prototype.set_m_shapePart=CE.prototype.ZG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lj(c,a)};Object.defineProperty(CE.prototype,"m_shapePart",{get:CE.prototype.gE,set:CE.prototype.ZG});CE.prototype.get_m_triangleIndex=CE.prototype.vE=function(){return Mj(this.kB)}; +CE.prototype.set_m_triangleIndex=CE.prototype.nH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nj(c,a)};Object.defineProperty(CE.prototype,"m_triangleIndex",{get:CE.prototype.vE,set:CE.prototype.nH});CE.prototype.__destroy__=function(){Oj(this.kB)};function F(a,c,d,e,g){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);this.kB=Pj(a,c,d,e,g);h(F)[this.kB]=this} +F.prototype=Object.create(f.prototype);F.prototype.constructor=F;F.prototype.lB=F;F.mB={};b.LocalConvexResult=F;F.prototype.get_m_hitCollisionObject=F.prototype.LB=function(){return k(Qj(this.kB),q)};F.prototype.set_m_hitCollisionObject=F.prototype.VB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rj(c,a)};Object.defineProperty(F.prototype,"m_hitCollisionObject",{get:F.prototype.LB,set:F.prototype.VB});F.prototype.get_m_localShapeInfo=F.prototype.ID=function(){return k(Sj(this.kB),CE)}; +F.prototype.set_m_localShapeInfo=F.prototype.zG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tj(c,a)};Object.defineProperty(F.prototype,"m_localShapeInfo",{get:F.prototype.ID,set:F.prototype.zG});F.prototype.get_m_hitNormalLocal=F.prototype.sD=function(){return k(Uj(this.kB),m)};F.prototype.set_m_hitNormalLocal=F.prototype.jG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vj(c,a)};Object.defineProperty(F.prototype,"m_hitNormalLocal",{get:F.prototype.sD,set:F.prototype.jG}); +F.prototype.get_m_hitPointLocal=F.prototype.uD=function(){return k(Wj(this.kB),m)};F.prototype.set_m_hitPointLocal=F.prototype.lG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Xj(c,a)};Object.defineProperty(F.prototype,"m_hitPointLocal",{get:F.prototype.uD,set:F.prototype.lG});F.prototype.get_m_hitFraction=F.prototype.pD=function(){return Yj(this.kB)};F.prototype.set_m_hitFraction=F.prototype.gG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zj(c,a)}; +Object.defineProperty(F.prototype,"m_hitFraction",{get:F.prototype.pD,set:F.prototype.gG});F.prototype.__destroy__=function(){ak(this.kB)};function G(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=bk(a,c);h(G)[this.kB]=this}G.prototype=Object.create(v.prototype);G.prototype.constructor=G;G.prototype.lB=G;G.mB={};b.ClosestConvexResultCallback=G;G.prototype.hasHit=function(){return!!ck(this.kB)}; +G.prototype.get_m_hitCollisionObject=G.prototype.LB=function(){return k(dk(this.kB),q)};G.prototype.set_m_hitCollisionObject=G.prototype.VB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ek(c,a)};Object.defineProperty(G.prototype,"m_hitCollisionObject",{get:G.prototype.LB,set:G.prototype.VB});G.prototype.get_m_convexFromWorld=G.prototype.ZC=function(){return k(fk(this.kB),m)}; +G.prototype.set_m_convexFromWorld=G.prototype.QF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);gk(c,a)};Object.defineProperty(G.prototype,"m_convexFromWorld",{get:G.prototype.ZC,set:G.prototype.QF});G.prototype.get_m_convexToWorld=G.prototype.$C=function(){return k(hk(this.kB),m)};G.prototype.set_m_convexToWorld=G.prototype.RF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ik(c,a)};Object.defineProperty(G.prototype,"m_convexToWorld",{get:G.prototype.$C,set:G.prototype.RF}); +G.prototype.get_m_hitNormalWorld=G.prototype.xB=function(){return k(jk(this.kB),m)};G.prototype.set_m_hitNormalWorld=G.prototype.EB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kk(c,a)};Object.defineProperty(G.prototype,"m_hitNormalWorld",{get:G.prototype.xB,set:G.prototype.EB});G.prototype.get_m_hitPointWorld=G.prototype.yB=function(){return k(lk(this.kB),m)};G.prototype.set_m_hitPointWorld=G.prototype.FB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);mk(c,a)}; +Object.defineProperty(G.prototype,"m_hitPointWorld",{get:G.prototype.yB,set:G.prototype.FB});G.prototype.get_m_collisionFilterGroup=G.prototype.nB=function(){return nk(this.kB)};G.prototype.set_m_collisionFilterGroup=G.prototype.pB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ok(c,a)};Object.defineProperty(G.prototype,"m_collisionFilterGroup",{get:G.prototype.nB,set:G.prototype.pB});G.prototype.get_m_collisionFilterMask=G.prototype.oB=function(){return pk(this.kB)}; +G.prototype.set_m_collisionFilterMask=G.prototype.qB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qk(c,a)};Object.defineProperty(G.prototype,"m_collisionFilterMask",{get:G.prototype.oB,set:G.prototype.qB});G.prototype.get_m_closestHitFraction=G.prototype.rB=function(){return rk(this.kB)};G.prototype.set_m_closestHitFraction=G.prototype.sB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);sk(c,a)};Object.defineProperty(G.prototype,"m_closestHitFraction",{get:G.prototype.rB,set:G.prototype.sB}); +G.prototype.__destroy__=function(){tk(this.kB)};function DE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=void 0===c?uk(a):vk(a,c);h(DE)[this.kB]=this}DE.prototype=Object.create(fE.prototype);DE.prototype.constructor=DE;DE.prototype.lB=DE;DE.mB={};b.btConvexTriangleMeshShape=DE;DE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wk(c,a)};DE.prototype.getLocalScaling=function(){return k(xk(this.kB),m)}; +DE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);yk(d,a,c)};DE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zk(c,a)};DE.prototype.getMargin=function(){return Ak(this.kB)};DE.prototype.__destroy__=function(){Bk(this.kB)};function EE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=Ck(a);h(EE)[this.kB]=this}EE.prototype=Object.create(l.prototype);EE.prototype.constructor=EE;EE.prototype.lB=EE; +EE.mB={};b.btBoxShape=EE;EE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dk(c,a)};EE.prototype.getMargin=function(){return Ek(this.kB)};EE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fk(c,a)};EE.prototype.getLocalScaling=function(){return k(Gk(this.kB),m)};EE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Hk(d,a,c)};EE.prototype.__destroy__=function(){Ik(this.kB)}; +function FE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Jk(a,c);h(FE)[this.kB]=this}FE.prototype=Object.create(gE.prototype);FE.prototype.constructor=FE;FE.prototype.lB=FE;FE.mB={};b.btCapsuleShapeX=FE;FE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kk(c,a)};FE.prototype.getMargin=function(){return Lk(this.kB)};FE.prototype.getUpAxis=function(){return Mk(this.kB)};FE.prototype.getRadius=function(){return Nk(this.kB)}; +FE.prototype.getHalfHeight=function(){return Ok(this.kB)};FE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pk(c,a)};FE.prototype.getLocalScaling=function(){return k(Qk(this.kB),m)};FE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Rk(d,a,c)};FE.prototype.__destroy__=function(){Sk(this.kB)}; +function GE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Tk(a,c);h(GE)[this.kB]=this}GE.prototype=Object.create(gE.prototype);GE.prototype.constructor=GE;GE.prototype.lB=GE;GE.mB={};b.btCapsuleShapeZ=GE;GE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Uk(c,a)};GE.prototype.getMargin=function(){return Vk(this.kB)};GE.prototype.getUpAxis=function(){return Wk(this.kB)};GE.prototype.getRadius=function(){return Xk(this.kB)}; +GE.prototype.getHalfHeight=function(){return Yk(this.kB)};GE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zk(c,a)};GE.prototype.getLocalScaling=function(){return k($k(this.kB),m)};GE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);al(d,a,c)};GE.prototype.__destroy__=function(){bl(this.kB)};function HE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=cl(a);h(HE)[this.kB]=this} +HE.prototype=Object.create(hE.prototype);HE.prototype.constructor=HE;HE.prototype.lB=HE;HE.mB={};b.btCylinderShapeX=HE;HE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dl(c,a)};HE.prototype.getMargin=function(){return el(this.kB)};HE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fl(c,a)};HE.prototype.getLocalScaling=function(){return k(gl(this.kB),m)}; +HE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);hl(d,a,c)};HE.prototype.__destroy__=function(){il(this.kB)};function IE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=jl(a);h(IE)[this.kB]=this}IE.prototype=Object.create(hE.prototype);IE.prototype.constructor=IE;IE.prototype.lB=IE;IE.mB={};b.btCylinderShapeZ=IE;IE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kl(c,a)}; +IE.prototype.getMargin=function(){return ll(this.kB)};IE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ml(c,a)};IE.prototype.getLocalScaling=function(){return k(nl(this.kB),m)};IE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);ol(d,a,c)};IE.prototype.__destroy__=function(){pl(this.kB)};function JE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=ql(a);h(JE)[this.kB]=this}JE.prototype=Object.create(l.prototype); +JE.prototype.constructor=JE;JE.prototype.lB=JE;JE.mB={};b.btSphereShape=JE;JE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rl(c,a)};JE.prototype.getMargin=function(){return sl(this.kB)};JE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tl(c,a)};JE.prototype.getLocalScaling=function(){return k(ul(this.kB),m)}; +JE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);vl(d,a,c)};JE.prototype.__destroy__=function(){wl(this.kB)};function KE(a,c,d){ND();a&&"object"===typeof a&&(a=a.kB);"object"==typeof c&&(c=RD(c));d&&"object"===typeof d&&(d=d.kB);this.kB=xl(a,c,d);h(KE)[this.kB]=this}KE.prototype=Object.create(l.prototype);KE.prototype.constructor=KE;KE.prototype.lB=KE;KE.mB={};b.btMultiSphereShape=KE; +KE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yl(c,a)};KE.prototype.getLocalScaling=function(){return k(zl(this.kB),m)};KE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Al(d,a,c)};KE.prototype.__destroy__=function(){Bl(this.kB)};function LE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Cl(a,c);h(LE)[this.kB]=this}LE.prototype=Object.create(iE.prototype); +LE.prototype.constructor=LE;LE.prototype.lB=LE;LE.mB={};b.btConeShapeX=LE;LE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dl(c,a)};LE.prototype.getLocalScaling=function(){return k(El(this.kB),m)};LE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Fl(d,a,c)};LE.prototype.__destroy__=function(){Gl(this.kB)}; +function ME(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Hl(a,c);h(ME)[this.kB]=this}ME.prototype=Object.create(iE.prototype);ME.prototype.constructor=ME;ME.prototype.lB=ME;ME.mB={};b.btConeShapeZ=ME;ME.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Il(c,a)};ME.prototype.getLocalScaling=function(){return k(Jl(this.kB),m)}; +ME.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Kl(d,a,c)};ME.prototype.__destroy__=function(){Ll(this.kB)};function NE(){throw"cannot construct a btIntArray, no constructor in IDL";}NE.prototype=Object.create(f.prototype);NE.prototype.constructor=NE;NE.prototype.lB=NE;NE.mB={};b.btIntArray=NE;NE.prototype.size=NE.prototype.size=function(){return Ml(this.kB)}; +NE.prototype.at=NE.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return Nl(c,a)};NE.prototype.__destroy__=function(){Ol(this.kB)};function OE(){throw"cannot construct a btFace, no constructor in IDL";}OE.prototype=Object.create(f.prototype);OE.prototype.constructor=OE;OE.prototype.lB=OE;OE.mB={};b.btFace=OE;OE.prototype.get_m_indices=OE.prototype.xD=function(){return k(Pl(this.kB),NE)}; +OE.prototype.set_m_indices=OE.prototype.oG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ql(c,a)};Object.defineProperty(OE.prototype,"m_indices",{get:OE.prototype.xD,set:OE.prototype.oG});OE.prototype.get_m_plane=OE.prototype.WD=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return Rl(c,a)};OE.prototype.set_m_plane=OE.prototype.NG=function(a,c){var d=this.kB;ND();a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Sl(d,a,c)}; +Object.defineProperty(OE.prototype,"m_plane",{get:OE.prototype.WD,set:OE.prototype.NG});OE.prototype.__destroy__=function(){Tl(this.kB)};function AE(){throw"cannot construct a btVector3Array, no constructor in IDL";}AE.prototype=Object.create(f.prototype);AE.prototype.constructor=AE;AE.prototype.lB=AE;AE.mB={};b.btVector3Array=AE;AE.prototype.size=AE.prototype.size=function(){return Ul(this.kB)}; +AE.prototype.at=AE.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Vl(c,a),m)};AE.prototype.__destroy__=function(){Wl(this.kB)};function PE(){throw"cannot construct a btFaceArray, no constructor in IDL";}PE.prototype=Object.create(f.prototype);PE.prototype.constructor=PE;PE.prototype.lB=PE;PE.mB={};b.btFaceArray=PE;PE.prototype.size=PE.prototype.size=function(){return Xl(this.kB)}; +PE.prototype.at=PE.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Yl(c,a),OE)};PE.prototype.__destroy__=function(){Zl(this.kB)};function QE(){throw"cannot construct a btConvexPolyhedron, no constructor in IDL";}QE.prototype=Object.create(f.prototype);QE.prototype.constructor=QE;QE.prototype.lB=QE;QE.mB={};b.btConvexPolyhedron=QE;QE.prototype.get_m_vertices=QE.prototype.BE=function(){return k($l(this.kB),AE)}; +QE.prototype.set_m_vertices=QE.prototype.tH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);am(c,a)};Object.defineProperty(QE.prototype,"m_vertices",{get:QE.prototype.BE,set:QE.prototype.tH});QE.prototype.get_m_faces=QE.prototype.KB=function(){return k(bm(this.kB),PE)};QE.prototype.set_m_faces=QE.prototype.UB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cm(c,a)};Object.defineProperty(QE.prototype,"m_faces",{get:QE.prototype.KB,set:QE.prototype.UB}); +QE.prototype.__destroy__=function(){dm(this.kB)};function RE(a,c){ND();"object"==typeof a&&(a=RD(a));c&&"object"===typeof c&&(c=c.kB);this.kB=void 0===a?em():void 0===c?fm(a):gm(a,c);h(RE)[this.kB]=this}RE.prototype=Object.create(l.prototype);RE.prototype.constructor=RE;RE.prototype.lB=RE;RE.mB={};b.btConvexHullShape=RE;RE.prototype.addPoint=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);void 0===c?hm(d,a):im(d,a,c)}; +RE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jm(c,a)};RE.prototype.getMargin=function(){return km(this.kB)};RE.prototype.getNumVertices=function(){return lm(this.kB)};RE.prototype.initializePolyhedralFeatures=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return!!mm(c,a)};RE.prototype.recalcLocalAabb=function(){nm(this.kB)};RE.prototype.getConvexPolyhedron=function(){return k(om(this.kB),QE)}; +RE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pm(c,a)};RE.prototype.getLocalScaling=function(){return k(qm(this.kB),m)};RE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);rm(d,a,c)};RE.prototype.__destroy__=function(){sm(this.kB)};function SE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=tm(a);h(SE)[this.kB]=this}SE.prototype=Object.create(f.prototype);SE.prototype.constructor=SE; +SE.prototype.lB=SE;SE.mB={};b.btShapeHull=SE;SE.prototype.buildHull=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return!!um(c,a)};SE.prototype.numVertices=function(){return wm(this.kB)};SE.prototype.getVertexPointer=function(){return k(xm(this.kB),m)};SE.prototype.__destroy__=function(){ym(this.kB)};function TE(a){a&&"object"===typeof a&&(a=a.kB);this.kB=void 0===a?zm():Am(a);h(TE)[this.kB]=this}TE.prototype=Object.create(l.prototype);TE.prototype.constructor=TE;TE.prototype.lB=TE; +TE.mB={};b.btCompoundShape=TE;TE.prototype.addChildShape=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Bm(d,a,c)};TE.prototype.removeChildShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Cm(c,a)};TE.prototype.removeChildShapeByIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dm(c,a)};TE.prototype.getNumChildShapes=function(){return Em(this.kB)}; +TE.prototype.getChildShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Fm(c,a),l)};TE.prototype.updateChildTransform=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===d?Gm(e,a,c):Hm(e,a,c,d)};TE.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Im(c,a)};TE.prototype.getMargin=function(){return Jm(this.kB)}; +TE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Km(c,a)};TE.prototype.getLocalScaling=function(){return k(Lm(this.kB),m)};TE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Mm(d,a,c)};TE.prototype.__destroy__=function(){Nm(this.kB)};function UE(){throw"cannot construct a btIndexedMesh, no constructor in IDL";}UE.prototype=Object.create(f.prototype);UE.prototype.constructor=UE; +UE.prototype.lB=UE;UE.mB={};b.btIndexedMesh=UE;UE.prototype.get_m_numTriangles=UE.prototype.UD=function(){return Om(this.kB)};UE.prototype.set_m_numTriangles=UE.prototype.LG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pm(c,a)};Object.defineProperty(UE.prototype,"m_numTriangles",{get:UE.prototype.UD,set:UE.prototype.LG});UE.prototype.__destroy__=function(){Qm(this.kB)};function VE(){throw"cannot construct a btIndexedMeshArray, no constructor in IDL";}VE.prototype=Object.create(f.prototype); +VE.prototype.constructor=VE;VE.prototype.lB=VE;VE.mB={};b.btIndexedMeshArray=VE;VE.prototype.size=VE.prototype.size=function(){return Rm(this.kB)};VE.prototype.at=VE.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Sm(c,a),UE)};VE.prototype.__destroy__=function(){Tm(this.kB)};function WE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=void 0===a?Um():void 0===c?Vm(a):Wm(a,c);h(WE)[this.kB]=this}WE.prototype=Object.create(jE.prototype); +WE.prototype.constructor=WE;WE.prototype.lB=WE;WE.mB={};b.btTriangleMesh=WE;WE.prototype.addTriangle=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);void 0===e?Xm(g,a,c,d):Ym(g,a,c,d,e)};WE.prototype.findOrAddVertex=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return Zm(d,a,c)}; +WE.prototype.addIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$m(c,a)};WE.prototype.getIndexedMeshArray=function(){return k(an(this.kB),VE)};WE.prototype.setScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bn(c,a)};WE.prototype.__destroy__=function(){cn(this.kB)};function XE(){this.kB=dn();h(XE)[this.kB]=this}XE.prototype=Object.create(ZD.prototype);XE.prototype.constructor=XE;XE.prototype.lB=XE;XE.mB={};b.btEmptyShape=XE; +XE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);en(c,a)};XE.prototype.getLocalScaling=function(){return k(fn(this.kB),m)};XE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);gn(d,a,c)};XE.prototype.__destroy__=function(){hn(this.kB)};function YE(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=jn(a,c);h(YE)[this.kB]=this}YE.prototype=Object.create(ZD.prototype); +YE.prototype.constructor=YE;YE.prototype.lB=YE;YE.mB={};b.btStaticPlaneShape=YE;YE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kn(c,a)};YE.prototype.getLocalScaling=function(){return k(ln(this.kB),m)};YE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);mn(d,a,c)};YE.prototype.__destroy__=function(){nn(this.kB)}; +function ZE(a,c,d){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);this.kB=void 0===d?on(a,c):pn(a,c,d);h(ZE)[this.kB]=this}ZE.prototype=Object.create(kE.prototype);ZE.prototype.constructor=ZE;ZE.prototype.lB=ZE;ZE.mB={};b.btBvhTriangleMeshShape=ZE;ZE.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qn(c,a)};ZE.prototype.getLocalScaling=function(){return k(rn(this.kB),m)}; +ZE.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);sn(d,a,c)};ZE.prototype.__destroy__=function(){tn(this.kB)}; +function $E(a,c,d,e,g,n,z,T,Da){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);T&&"object"===typeof T&&(T=T.kB);Da&&"object"===typeof Da&&(Da=Da.kB);this.kB=un(a,c,d,e,g,n,z,T,Da);h($E)[this.kB]=this}$E.prototype=Object.create(ZD.prototype);$E.prototype.constructor=$E;$E.prototype.lB=$E;$E.mB={}; +b.btHeightfieldTerrainShape=$E;$E.prototype.setMargin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vn(c,a)};$E.prototype.getMargin=function(){return wn(this.kB)};$E.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xn(c,a)};$E.prototype.getLocalScaling=function(){return k(yn(this.kB),m)};$E.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);zn(d,a,c)}; +$E.prototype.__destroy__=function(){An(this.kB)};function aF(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=Bn(a,c,d,e);h(aF)[this.kB]=this}aF.prototype=Object.create(f.prototype);aF.prototype.constructor=aF;aF.prototype.lB=aF;aF.mB={};b.btAABB=aF;aF.prototype.invalidate=function(){Cn(this.kB)};aF.prototype.increment_margin=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dn(c,a)}; +aF.prototype.copy_with_margin=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);En(d,a,c)};aF.prototype.__destroy__=function(){Fn(this.kB)};function bF(){this.kB=Gn();h(bF)[this.kB]=this}bF.prototype=Object.create(f.prototype);bF.prototype.constructor=bF;bF.prototype.lB=bF;bF.mB={};b.btPrimitiveTriangle=bF;bF.prototype.__destroy__=function(){Hn(this.kB)}; +function cF(a,c,d){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);this.kB=In(a,c,d);h(cF)[this.kB]=this}cF.prototype=Object.create(f.prototype);cF.prototype.constructor=cF;cF.prototype.lB=cF;cF.mB={};b.btTriangleShapeEx=cF;cF.prototype.getAabb=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Jn(e,a,c,d)}; +cF.prototype.applyTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kn(c,a)};cF.prototype.buildTriPlane=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ln(c,a)};cF.prototype.__destroy__=function(){Mn(this.kB)};function dF(){this.kB=Nn();h(dF)[this.kB]=this}dF.prototype=Object.create(f.prototype);dF.prototype.constructor=dF;dF.prototype.lB=dF;dF.mB={};b.btTetrahedronShapeEx=dF; +dF.prototype.setVertices=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);On(g,a,c,d,e)};dF.prototype.__destroy__=function(){Pn(this.kB)};function eF(){throw"cannot construct a CompoundPrimitiveManager, no constructor in IDL";}eF.prototype=Object.create(lE.prototype);eF.prototype.constructor=eF;eF.prototype.lB=eF;eF.mB={};b.CompoundPrimitiveManager=eF; +eF.prototype.get_primitive_count=function(){return Qn(this.kB)};eF.prototype.get_primitive_box=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Rn(d,a,c)};eF.prototype.get_primitive_triangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Sn(d,a,c)};eF.prototype.is_trimesh=function(){return!!Tn(this.kB)};eF.prototype.get_m_compoundShape=eF.prototype.VC=function(){return k(Un(this.kB),H)}; +eF.prototype.set_m_compoundShape=eF.prototype.MF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vn(c,a)};Object.defineProperty(eF.prototype,"m_compoundShape",{get:eF.prototype.VC,set:eF.prototype.MF});eF.prototype.__destroy__=function(){Wn(this.kB)};function H(a){a&&"object"===typeof a&&(a=a.kB);this.kB=void 0===a?Xn():Yn(a);h(H)[this.kB]=this}H.prototype=Object.create(w.prototype);H.prototype.constructor=H;H.prototype.lB=H;H.mB={};b.btGImpactCompoundShape=H; +H.prototype.childrenHasTransform=function(){return!!Zn(this.kB)};H.prototype.getPrimitiveManager=function(){return k($n(this.kB),lE)};H.prototype.getCompoundPrimitiveManager=function(){return k(ao(this.kB),eF)};H.prototype.getNumChildShapes=function(){return bo(this.kB)};H.prototype.addChildShape=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);co(d,a,c)}; +H.prototype.getChildShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(eo(c,a),l)};H.prototype.getChildAabb=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);fo(g,a,c,d,e)};H.prototype.getChildTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(go(c,a),r)}; +H.prototype.setChildTransform=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);ho(d,a,c)};H.prototype.calculateLocalInertia=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);io(d,a,c)};H.prototype.getName=function(){return sa(jo(this.kB))};H.prototype.getGImpactShapeType=function(){return ko(this.kB)};H.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lo(c,a)}; +H.prototype.getLocalScaling=function(){return k(mo(this.kB),m)};H.prototype.updateBound=function(){no(this.kB)};H.prototype.postUpdate=function(){oo(this.kB)};H.prototype.getShapeType=function(){return po(this.kB)};H.prototype.needsRetrieveTriangles=function(){return!!qo(this.kB)};H.prototype.needsRetrieveTetrahedrons=function(){return!!ro(this.kB)};H.prototype.getBulletTriangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);so(d,a,c)}; +H.prototype.getBulletTetrahedron=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);to(d,a,c)};H.prototype.__destroy__=function(){uo(this.kB)};function I(a){a&&"object"===typeof a&&(a=a.kB);this.kB=void 0===a?vo():wo(a);h(I)[this.kB]=this}I.prototype=Object.create(lE.prototype);I.prototype.constructor=I;I.prototype.lB=I;I.mB={};b.TrimeshPrimitiveManager=I;I.prototype.lock=I.prototype.lock=function(){xo(this.kB)};I.prototype.unlock=I.prototype.unlock=function(){yo(this.kB)}; +I.prototype.is_trimesh=function(){return!!zo(this.kB)};I.prototype.get_vertex_count=function(){return Ao(this.kB)};I.prototype.get_indices=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);Bo(g,a,c,d,e)};I.prototype.get_vertex=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Co(d,a,c)}; +I.prototype.get_bullet_triangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Do(d,a,c)};I.prototype.get_m_margin=I.prototype.LD=function(){return Eo(this.kB)};I.prototype.set_m_margin=I.prototype.CG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fo(c,a)};Object.defineProperty(I.prototype,"m_margin",{get:I.prototype.LD,set:I.prototype.CG});I.prototype.get_m_meshInterface=I.prototype.OD=function(){return k(Go(this.kB),jE)}; +I.prototype.set_m_meshInterface=I.prototype.FG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ho(c,a)};Object.defineProperty(I.prototype,"m_meshInterface",{get:I.prototype.OD,set:I.prototype.FG});I.prototype.get_m_part=I.prototype.VD=function(){return Io(this.kB)};I.prototype.set_m_part=I.prototype.MG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jo(c,a)};Object.defineProperty(I.prototype,"m_part",{get:I.prototype.VD,set:I.prototype.MG}); +I.prototype.get_m_lock_count=I.prototype.JD=function(){return Ko(this.kB)};I.prototype.set_m_lock_count=I.prototype.AG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lo(c,a)};Object.defineProperty(I.prototype,"m_lock_count",{get:I.prototype.JD,set:I.prototype.AG});I.prototype.get_numverts=I.prototype.LE=function(){return Mo(this.kB)};I.prototype.set_numverts=I.prototype.DH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);No(c,a)}; +Object.defineProperty(I.prototype,"numverts",{get:I.prototype.LE,set:I.prototype.DH});I.prototype.get_type=I.prototype.PE=function(){return Oo(this.kB)};I.prototype.set_type=I.prototype.HH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Po(c,a)};Object.defineProperty(I.prototype,"type",{get:I.prototype.PE,set:I.prototype.HH});I.prototype.get_stride=I.prototype.NE=function(){return Qo(this.kB)}; +I.prototype.set_stride=I.prototype.FH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ro(c,a)};Object.defineProperty(I.prototype,"stride",{get:I.prototype.NE,set:I.prototype.FH});I.prototype.get_indexstride=I.prototype.gC=function(){return So(this.kB)};I.prototype.set_indexstride=I.prototype.YE=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);To(c,a)};Object.defineProperty(I.prototype,"indexstride",{get:I.prototype.gC,set:I.prototype.YE}); +I.prototype.get_numfaces=I.prototype.KE=function(){return Uo(this.kB)};I.prototype.set_numfaces=I.prototype.CH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vo(c,a)};Object.defineProperty(I.prototype,"numfaces",{get:I.prototype.KE,set:I.prototype.CH});I.prototype.get_indicestype=I.prototype.hC=function(){return Wo(this.kB)};I.prototype.set_indicestype=I.prototype.ZE=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Xo(c,a)}; +Object.defineProperty(I.prototype,"indicestype",{get:I.prototype.hC,set:I.prototype.ZE});I.prototype.__destroy__=function(){Yo(this.kB)};function fF(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=Zo(a,c);h(fF)[this.kB]=this}fF.prototype=Object.create(w.prototype);fF.prototype.constructor=fF;fF.prototype.lB=fF;fF.mB={};b.btGImpactMeshShapePart=fF;fF.prototype.getTrimeshPrimitiveManager=function(){return k($o(this.kB),I)};fF.prototype.getVertexCount=function(){return ap(this.kB)}; +fF.prototype.getVertex=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);bp(d,a,c)};fF.prototype.getPart=function(){return cp(this.kB)};fF.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dp(c,a)};fF.prototype.getLocalScaling=function(){return k(ep(this.kB),m)};fF.prototype.updateBound=function(){fp(this.kB)};fF.prototype.postUpdate=function(){gp(this.kB)};fF.prototype.getShapeType=function(){return hp(this.kB)}; +fF.prototype.needsRetrieveTriangles=function(){return!!ip(this.kB)};fF.prototype.needsRetrieveTetrahedrons=function(){return!!jp(this.kB)};fF.prototype.getBulletTriangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);kp(d,a,c)};fF.prototype.getBulletTetrahedron=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);lp(d,a,c)};fF.prototype.__destroy__=function(){mp(this.kB)}; +function gF(a){a&&"object"===typeof a&&(a=a.kB);this.kB=np(a);h(gF)[this.kB]=this}gF.prototype=Object.create(w.prototype);gF.prototype.constructor=gF;gF.prototype.lB=gF;gF.mB={};b.btGImpactMeshShape=gF;gF.prototype.getMeshInterface=function(){return k(op(this.kB),jE)};gF.prototype.getMeshPartCount=function(){return pp(this.kB)};gF.prototype.getMeshPart=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(qp(c,a),fF)};gF.prototype.calculateSerializeBufferSize=function(){return rp(this.kB)}; +gF.prototype.setLocalScaling=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);sp(c,a)};gF.prototype.getLocalScaling=function(){return k(tp(this.kB),m)};gF.prototype.updateBound=function(){up(this.kB)};gF.prototype.postUpdate=function(){vp(this.kB)};gF.prototype.getShapeType=function(){return wp(this.kB)};gF.prototype.needsRetrieveTriangles=function(){return!!xp(this.kB)};gF.prototype.needsRetrieveTetrahedrons=function(){return!!yp(this.kB)}; +gF.prototype.getBulletTriangle=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);zp(d,a,c)};gF.prototype.getBulletTetrahedron=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Ap(d,a,c)};gF.prototype.__destroy__=function(){Bp(this.kB)}; +function hF(a,c){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);this.kB=void 0===a?Cp():void 0===c?_emscripten_bind_btCollisionAlgorithmConstructionInfo_btCollisionAlgorithmConstructionInfo_1(a):Dp(a,c);h(hF)[this.kB]=this}hF.prototype=Object.create(f.prototype);hF.prototype.constructor=hF;hF.prototype.lB=hF;hF.mB={};b.btCollisionAlgorithmConstructionInfo=hF;hF.prototype.get_m_dispatcher1=hF.prototype.eD=function(){return k(Ep(this.kB),TD)}; +hF.prototype.set_m_dispatcher1=hF.prototype.WF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fp(c,a)};Object.defineProperty(hF.prototype,"m_dispatcher1",{get:hF.prototype.eD,set:hF.prototype.WF});hF.prototype.get_m_manifold=hF.prototype.KD=function(){return k(Gp(this.kB),oE)};hF.prototype.set_m_manifold=hF.prototype.BG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Hp(c,a)};Object.defineProperty(hF.prototype,"m_manifold",{get:hF.prototype.KD,set:hF.prototype.BG}); +hF.prototype.__destroy__=function(){Ip(this.kB)};function iF(a,c,d){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);this.kB=Jp(a,c,d);h(iF)[this.kB]=this}iF.prototype=Object.create(mE.prototype);iF.prototype.constructor=iF;iF.prototype.lB=iF;iF.mB={};b.btGImpactCollisionAlgorithm=iF;iF.prototype.registerAlgorithm=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kp(c,a)};iF.prototype.__destroy__=function(){Lp(this.kB)}; +function jF(){this.kB=Mp();h(jF)[this.kB]=this}jF.prototype=Object.create(f.prototype);jF.prototype.constructor=jF;jF.prototype.lB=jF;jF.mB={};b.btDefaultCollisionConstructionInfo=jF;jF.prototype.__destroy__=function(){Np(this.kB)};function oE(){this.kB=Op();h(oE)[this.kB]=this}oE.prototype=Object.create(f.prototype);oE.prototype.constructor=oE;oE.prototype.lB=oE;oE.mB={};b.btPersistentManifold=oE;oE.prototype.getBody0=function(){return k(Pp(this.kB),q)}; +oE.prototype.getBody1=function(){return k(Qp(this.kB),q)};oE.prototype.getNumContacts=function(){return Rp(this.kB)};oE.prototype.getContactPoint=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Sp(c,a),E)};oE.prototype.__destroy__=function(){Tp(this.kB)};function kF(a){a&&"object"===typeof a&&(a=a.kB);this.kB=Up(a);h(kF)[this.kB]=this}kF.prototype=Object.create(TD.prototype);kF.prototype.constructor=kF;kF.prototype.lB=kF;kF.mB={};b.btCollisionDispatcher=kF; +kF.prototype.getNumManifolds=function(){return Vp(this.kB)};kF.prototype.getManifoldByIndexInternal=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Wp(c,a),oE)};kF.prototype.__destroy__=function(){Xp(this.kB)};function lF(){throw"cannot construct a btOverlappingPairCallback, no constructor in IDL";}lF.prototype=Object.create(f.prototype);lF.prototype.constructor=lF;lF.prototype.lB=lF;lF.mB={};b.btOverlappingPairCallback=lF;lF.prototype.__destroy__=function(){Yp(this.kB)}; +function UD(){throw"cannot construct a btOverlappingPairCache, no constructor in IDL";}UD.prototype=Object.create(f.prototype);UD.prototype.constructor=UD;UD.prototype.lB=UD;UD.mB={};b.btOverlappingPairCache=UD;UD.prototype.setInternalGhostPairCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zp(c,a)};UD.prototype.getNumOverlappingPairs=function(){return $p(this.kB)};UD.prototype.__destroy__=function(){aq(this.kB)}; +function mF(a,c,d,e,g){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);this.kB=void 0===d?bq(a,c):void 0===e?cq(a,c,d):void 0===g?dq(a,c,d,e):eq(a,c,d,e,g);h(mF)[this.kB]=this}mF.prototype=Object.create(f.prototype);mF.prototype.constructor=mF;mF.prototype.lB=mF;mF.mB={};b.btAxisSweep3=mF;mF.prototype.__destroy__=function(){fq(this.kB)}; +function VD(){throw"cannot construct a btBroadphaseInterface, no constructor in IDL";}VD.prototype=Object.create(f.prototype);VD.prototype.constructor=VD;VD.prototype.lB=VD;VD.mB={};b.btBroadphaseInterface=VD;VD.prototype.getOverlappingPairCache=function(){return k(gq(this.kB),UD)};VD.prototype.__destroy__=function(){hq(this.kB)};function nF(){throw"cannot construct a btCollisionConfiguration, no constructor in IDL";}nF.prototype=Object.create(f.prototype);nF.prototype.constructor=nF; +nF.prototype.lB=nF;nF.mB={};b.btCollisionConfiguration=nF;nF.prototype.__destroy__=function(){iq(this.kB)};function oF(){this.kB=jq();h(oF)[this.kB]=this}oF.prototype=Object.create(f.prototype);oF.prototype.constructor=oF;oF.prototype.lB=oF;oF.mB={};b.btDbvtBroadphase=oF;oF.prototype.__destroy__=function(){kq(this.kB)};function YD(){throw"cannot construct a btBroadphaseProxy, no constructor in IDL";}YD.prototype=Object.create(f.prototype);YD.prototype.constructor=YD;YD.prototype.lB=YD;YD.mB={}; +b.btBroadphaseProxy=YD;YD.prototype.get_m_collisionFilterGroup=YD.prototype.nB=function(){return lq(this.kB)};YD.prototype.set_m_collisionFilterGroup=YD.prototype.pB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);mq(c,a)};Object.defineProperty(YD.prototype,"m_collisionFilterGroup",{get:YD.prototype.nB,set:YD.prototype.pB});YD.prototype.get_m_collisionFilterMask=YD.prototype.oB=function(){return nq(this.kB)}; +YD.prototype.set_m_collisionFilterMask=YD.prototype.qB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);oq(c,a)};Object.defineProperty(YD.prototype,"m_collisionFilterMask",{get:YD.prototype.oB,set:YD.prototype.qB});YD.prototype.__destroy__=function(){pq(this.kB)};function J(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=void 0===e?qq(a,c,d):rq(a,c,d,e);h(J)[this.kB]=this}J.prototype=Object.create(f.prototype); +J.prototype.constructor=J;J.prototype.lB=J;J.mB={};b.btRigidBodyConstructionInfo=J;J.prototype.get_m_linearDamping=J.prototype.DD=function(){return sq(this.kB)};J.prototype.set_m_linearDamping=J.prototype.uG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tq(c,a)};Object.defineProperty(J.prototype,"m_linearDamping",{get:J.prototype.DD,set:J.prototype.uG});J.prototype.get_m_angularDamping=J.prototype.HC=function(){return uq(this.kB)}; +J.prototype.set_m_angularDamping=J.prototype.yF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vq(c,a)};Object.defineProperty(J.prototype,"m_angularDamping",{get:J.prototype.HC,set:J.prototype.yF});J.prototype.get_m_friction=J.prototype.kD=function(){return wq(this.kB)};J.prototype.set_m_friction=J.prototype.bG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xq(c,a)};Object.defineProperty(J.prototype,"m_friction",{get:J.prototype.kD,set:J.prototype.bG}); +J.prototype.get_m_rollingFriction=J.prototype.dE=function(){return yq(this.kB)};J.prototype.set_m_rollingFriction=J.prototype.WG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zq(c,a)};Object.defineProperty(J.prototype,"m_rollingFriction",{get:J.prototype.dE,set:J.prototype.WG});J.prototype.get_m_restitution=J.prototype.bE=function(){return Aq(this.kB)};J.prototype.set_m_restitution=J.prototype.UG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bq(c,a)}; +Object.defineProperty(J.prototype,"m_restitution",{get:J.prototype.bE,set:J.prototype.UG});J.prototype.get_m_linearSleepingThreshold=J.prototype.ED=function(){return Cq(this.kB)};J.prototype.set_m_linearSleepingThreshold=J.prototype.vG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dq(c,a)};Object.defineProperty(J.prototype,"m_linearSleepingThreshold",{get:J.prototype.ED,set:J.prototype.vG});J.prototype.get_m_angularSleepingThreshold=J.prototype.IC=function(){return Eq(this.kB)}; +J.prototype.set_m_angularSleepingThreshold=J.prototype.zF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fq(c,a)};Object.defineProperty(J.prototype,"m_angularSleepingThreshold",{get:J.prototype.IC,set:J.prototype.zF});J.prototype.get_m_additionalDamping=J.prototype.CC=function(){return!!Gq(this.kB)};J.prototype.set_m_additionalDamping=J.prototype.tF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Hq(c,a)}; +Object.defineProperty(J.prototype,"m_additionalDamping",{get:J.prototype.CC,set:J.prototype.tF});J.prototype.get_m_additionalDampingFactor=J.prototype.DC=function(){return Iq(this.kB)};J.prototype.set_m_additionalDampingFactor=J.prototype.uF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jq(c,a)};Object.defineProperty(J.prototype,"m_additionalDampingFactor",{get:J.prototype.DC,set:J.prototype.uF});J.prototype.get_m_additionalLinearDampingThresholdSqr=J.prototype.EC=function(){return Kq(this.kB)}; +J.prototype.set_m_additionalLinearDampingThresholdSqr=J.prototype.vF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lq(c,a)};Object.defineProperty(J.prototype,"m_additionalLinearDampingThresholdSqr",{get:J.prototype.EC,set:J.prototype.vF});J.prototype.get_m_additionalAngularDampingThresholdSqr=J.prototype.BC=function(){return Mq(this.kB)};J.prototype.set_m_additionalAngularDampingThresholdSqr=J.prototype.sF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nq(c,a)}; +Object.defineProperty(J.prototype,"m_additionalAngularDampingThresholdSqr",{get:J.prototype.BC,set:J.prototype.sF});J.prototype.get_m_additionalAngularDampingFactor=J.prototype.AC=function(){return Oq(this.kB)};J.prototype.set_m_additionalAngularDampingFactor=J.prototype.rF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pq(c,a)};Object.defineProperty(J.prototype,"m_additionalAngularDampingFactor",{get:J.prototype.AC,set:J.prototype.rF});J.prototype.__destroy__=function(){Qq(this.kB)}; +function K(a){a&&"object"===typeof a&&(a=a.kB);this.kB=Rq(a);h(K)[this.kB]=this}K.prototype=Object.create(q.prototype);K.prototype.constructor=K;K.prototype.lB=K;K.mB={};b.btRigidBody=K;K.prototype.getCenterOfMassTransform=function(){return k(Sq(this.kB),r)};K.prototype.setCenterOfMassTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tq(c,a)};K.prototype.setSleepingThresholds=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Uq(d,a,c)}; +K.prototype.getLinearDamping=function(){return Vq(this.kB)};K.prototype.getAngularDamping=function(){return Wq(this.kB)};K.prototype.setDamping=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Xq(d,a,c)};K.prototype.setMassProps=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Yq(d,a,c)};K.prototype.getLinearFactor=function(){return k(Zq(this.kB),m)}; +K.prototype.setLinearFactor=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$q(c,a)};K.prototype.applyTorque=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ar(c,a)};K.prototype.applyLocalTorque=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);br(c,a)};K.prototype.applyForce=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);cr(d,a,c)}; +K.prototype.applyCentralForce=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dr(c,a)};K.prototype.applyCentralLocalForce=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);er(c,a)};K.prototype.applyTorqueImpulse=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fr(c,a)};K.prototype.applyImpulse=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);gr(d,a,c)}; +K.prototype.applyCentralImpulse=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hr(c,a)};K.prototype.updateInertiaTensor=function(){ir(this.kB)};K.prototype.getLinearVelocity=function(){return k(jr(this.kB),m)};K.prototype.getAngularVelocity=function(){return k(kr(this.kB),m)};K.prototype.setLinearVelocity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lr(c,a)};K.prototype.setAngularVelocity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);mr(c,a)}; +K.prototype.getMotionState=function(){return k(nr(this.kB),dE)};K.prototype.setMotionState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);or(c,a)};K.prototype.getAngularFactor=function(){return k(pr(this.kB),m)};K.prototype.setAngularFactor=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qr(c,a)};K.prototype.upcast=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(rr(c,a),K)}; +K.prototype.getAabb=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);sr(d,a,c)};K.prototype.applyGravity=function(){tr(this.kB)};K.prototype.getGravity=function(){return k(ur(this.kB),m)};K.prototype.setGravity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vr(c,a)};K.prototype.getBroadphaseProxy=function(){return k(wr(this.kB),YD)};K.prototype.clearForces=function(){xr(this.kB)}; +K.prototype.setFlags=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yr(c,a)};K.prototype.getFlags=function(){return zr(this.kB)};K.prototype.setAnisotropicFriction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Ar(d,a,c)};K.prototype.getCollisionShape=function(){return k(Br(this.kB),l)};K.prototype.setContactProcessingThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Cr(c,a)}; +K.prototype.setActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dr(c,a)};K.prototype.forceActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Er(c,a)};K.prototype.activate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);void 0===a?Fr(c):Gr(c,a)};K.prototype.isActive=function(){return!!Hr(this.kB)};K.prototype.isKinematicObject=function(){return!!Ir(this.kB)};K.prototype.isStaticObject=function(){return!!Jr(this.kB)}; +K.prototype.isStaticOrKinematicObject=function(){return!!Kr(this.kB)};K.prototype.getRestitution=function(){return Lr(this.kB)};K.prototype.getFriction=function(){return Mr(this.kB)};K.prototype.getRollingFriction=function(){return Nr(this.kB)};K.prototype.setRestitution=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Or(c,a)};K.prototype.setFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pr(c,a)}; +K.prototype.setRollingFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qr(c,a)};K.prototype.getWorldTransform=function(){return k(Rr(this.kB),r)};K.prototype.getCollisionFlags=function(){return Sr(this.kB)};K.prototype.setCollisionFlags=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tr(c,a)};K.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ur(c,a)}; +K.prototype.setCollisionShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vr(c,a)};K.prototype.setCcdMotionThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Wr(c,a)};K.prototype.setCcdSweptSphereRadius=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Xr(c,a)};K.prototype.getUserIndex=function(){return Yr(this.kB)};K.prototype.setUserIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zr(c,a)}; +K.prototype.getUserPointer=function(){return k($r(this.kB),XD)};K.prototype.setUserPointer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);as(c,a)};K.prototype.getBroadphaseHandle=function(){return k(bs(this.kB),YD)};K.prototype.__destroy__=function(){cs(this.kB)};function L(){this.kB=ds();h(L)[this.kB]=this}L.prototype=Object.create(f.prototype);L.prototype.constructor=L;L.prototype.lB=L;L.mB={};b.btConstraintSetting=L;L.prototype.get_m_tau=L.prototype.sE=function(){return es(this.kB)}; +L.prototype.set_m_tau=L.prototype.kH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);gs(c,a)};Object.defineProperty(L.prototype,"m_tau",{get:L.prototype.sE,set:L.prototype.kH});L.prototype.get_m_damping=L.prototype.aD=function(){return hs(this.kB)};L.prototype.set_m_damping=L.prototype.SF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);is(c,a)};Object.defineProperty(L.prototype,"m_damping",{get:L.prototype.aD,set:L.prototype.SF}); +L.prototype.get_m_impulseClamp=L.prototype.wD=function(){return js(this.kB)};L.prototype.set_m_impulseClamp=L.prototype.nG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ks(c,a)};Object.defineProperty(L.prototype,"m_impulseClamp",{get:L.prototype.wD,set:L.prototype.nG});L.prototype.__destroy__=function(){ls(this.kB)}; +function pF(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=void 0===d?ms(a,c):void 0===e?_emscripten_bind_btPoint2PointConstraint_btPoint2PointConstraint_3(a,c,d):ns(a,c,d,e);h(pF)[this.kB]=this}pF.prototype=Object.create(aE.prototype);pF.prototype.constructor=pF;pF.prototype.lB=pF;pF.mB={};b.btPoint2PointConstraint=pF; +pF.prototype.setPivotA=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ps(c,a)};pF.prototype.setPivotB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qs(c,a)};pF.prototype.getPivotInA=function(){return k(rs(this.kB),m)};pF.prototype.getPivotInB=function(){return k(ss(this.kB),m)};pF.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ts(c,a)};pF.prototype.getBreakingImpulseThreshold=function(){return us(this.kB)}; +pF.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vs(c,a)};pF.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return xs(d,a,c)};pF.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);ys(e,a,c,d)};pF.prototype.get_m_setting=pF.prototype.fE=function(){return k(zs(this.kB),L)}; +pF.prototype.set_m_setting=pF.prototype.YG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);As(c,a)};Object.defineProperty(pF.prototype,"m_setting",{get:pF.prototype.fE,set:pF.prototype.YG});pF.prototype.__destroy__=function(){Bs(this.kB)}; +function qF(a,c,d,e,g){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);this.kB=void 0===e?Cs(a,c,d):void 0===g?_emscripten_bind_btGeneric6DofSpringConstraint_btGeneric6DofSpringConstraint_4(a,c,d,e):Ds(a,c,d,e,g);h(qF)[this.kB]=this}qF.prototype=Object.create(pE.prototype);qF.prototype.constructor=qF;qF.prototype.lB=qF;qF.mB={};b.btGeneric6DofSpringConstraint=qF; +qF.prototype.enableSpring=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Es(d,a,c)};qF.prototype.setStiffness=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Fs(d,a,c)};qF.prototype.setDamping=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Gs(d,a,c)}; +qF.prototype.setEquilibriumPoint=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);void 0===a?Hs(d):void 0===c?Is(d,a):Js(d,a,c)};qF.prototype.setLinearLowerLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ks(c,a)};qF.prototype.setLinearUpperLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ls(c,a)};qF.prototype.setAngularLowerLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ms(c,a)}; +qF.prototype.setAngularUpperLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ns(c,a)};qF.prototype.getFrameOffsetA=function(){return k(Os(this.kB),r)};qF.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ps(c,a)};qF.prototype.getBreakingImpulseThreshold=function(){return Qs(this.kB)};qF.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rs(c,a)}; +qF.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return Ss(d,a,c)};qF.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Ts(e,a,c,d)};qF.prototype.__destroy__=function(){Us(this.kB)};function rF(){this.kB=Vs();h(rF)[this.kB]=this}rF.prototype=Object.create(f.prototype);rF.prototype.constructor=rF;rF.prototype.lB=rF;rF.mB={}; +b.btSequentialImpulseConstraintSolver=rF;rF.prototype.__destroy__=function(){Ws(this.kB)};function sF(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=void 0===d?Xs(a,c):void 0===e?_emscripten_bind_btConeTwistConstraint_btConeTwistConstraint_3(a,c,d):Ys(a,c,d,e);h(sF)[this.kB]=this}sF.prototype=Object.create(aE.prototype);sF.prototype.constructor=sF;sF.prototype.lB=sF;sF.mB={}; +b.btConeTwistConstraint=sF;sF.prototype.setLimit=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Zs(d,a,c)};sF.prototype.setAngularOnly=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$s(c,a)};sF.prototype.setDamping=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);at(c,a)};sF.prototype.enableMotor=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bt(c,a)}; +sF.prototype.setMaxMotorImpulse=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ct(c,a)};sF.prototype.setMaxMotorImpulseNormalized=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dt(c,a)};sF.prototype.setMotorTarget=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);et(c,a)};sF.prototype.setMotorTargetInConstraintSpace=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ft(c,a)}; +sF.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);gt(c,a)};sF.prototype.getBreakingImpulseThreshold=function(){return ht(this.kB)};sF.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);it(c,a)};sF.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return jt(d,a,c)}; +sF.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);kt(e,a,c,d)};sF.prototype.__destroy__=function(){lt(this.kB)}; +function tF(a,c,d,e,g,n,z){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);this.kB=void 0===d?mt(a,c):void 0===e?nt(a,c,d):void 0===g?ot(a,c,d,e):void 0===n?pt(a,c,d,e,g):void 0===z?qt(a,c,d,e,g,n):rt(a,c,d,e,g,n,z);h(tF)[this.kB]=this}tF.prototype=Object.create(aE.prototype);tF.prototype.constructor=tF; +tF.prototype.lB=tF;tF.mB={};b.btHingeConstraint=tF;tF.prototype.getHingeAngle=function(){return st(this.kB)};tF.prototype.setLimit=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);void 0===g?tt(n,a,c,d,e):ut(n,a,c,d,e,g)}; +tF.prototype.enableAngularMotor=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);vt(e,a,c,d)};tF.prototype.setAngularOnly=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wt(c,a)};tF.prototype.enableMotor=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xt(c,a)};tF.prototype.setMaxMotorImpulse=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yt(c,a)}; +tF.prototype.setMotorTarget=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);zt(d,a,c)};tF.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);At(c,a)};tF.prototype.getBreakingImpulseThreshold=function(){return Bt(this.kB)};tF.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ct(c,a)}; +tF.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return Dt(d,a,c)};tF.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Et(e,a,c,d)};tF.prototype.__destroy__=function(){Ft(this.kB)}; +function uF(a,c,d,e,g){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);this.kB=void 0===e?Gt(a,c,d):void 0===g?_emscripten_bind_btSliderConstraint_btSliderConstraint_4(a,c,d,e):Ht(a,c,d,e,g);h(uF)[this.kB]=this}uF.prototype=Object.create(aE.prototype);uF.prototype.constructor=uF;uF.prototype.lB=uF;uF.mB={};b.btSliderConstraint=uF;uF.prototype.getLinearPos=function(){return It(this.kB)}; +uF.prototype.getAngularPos=function(){return Jt(this.kB)};uF.prototype.setLowerLinLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kt(c,a)};uF.prototype.setUpperLinLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lt(c,a)};uF.prototype.setLowerAngLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Mt(c,a)};uF.prototype.setUpperAngLimit=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nt(c,a)}; +uF.prototype.setPoweredLinMotor=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ot(c,a)};uF.prototype.setMaxLinMotorForce=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pt(c,a)};uF.prototype.setTargetLinMotorVelocity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qt(c,a)};uF.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rt(c,a)};uF.prototype.getBreakingImpulseThreshold=function(){return St(this.kB)}; +uF.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tt(c,a)};uF.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return Ut(d,a,c)};uF.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);Vt(e,a,c,d)};uF.prototype.__destroy__=function(){Wt(this.kB)}; +function vF(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=Xt(a,c,d,e);h(vF)[this.kB]=this}vF.prototype=Object.create(aE.prototype);vF.prototype.constructor=vF;vF.prototype.lB=vF;vF.mB={};b.btFixedConstraint=vF;vF.prototype.enableFeedback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yt(c,a)};vF.prototype.getBreakingImpulseThreshold=function(){return Zt(this.kB)}; +vF.prototype.setBreakingImpulseThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$t(c,a)};vF.prototype.getParam=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return au(d,a,c)};vF.prototype.setParam=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);bu(e,a,c,d)};vF.prototype.__destroy__=function(){cu(this.kB)}; +function wF(){throw"cannot construct a btConstraintSolver, no constructor in IDL";}wF.prototype=Object.create(f.prototype);wF.prototype.constructor=wF;wF.prototype.lB=wF;wF.mB={};b.btConstraintSolver=wF;wF.prototype.__destroy__=function(){du(this.kB)};function p(){throw"cannot construct a btDispatcherInfo, no constructor in IDL";}p.prototype=Object.create(f.prototype);p.prototype.constructor=p;p.prototype.lB=p;p.mB={};b.btDispatcherInfo=p;p.prototype.get_m_timeStep=p.prototype.uE=function(){return eu(this.kB)}; +p.prototype.set_m_timeStep=p.prototype.mH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fu(c,a)};Object.defineProperty(p.prototype,"m_timeStep",{get:p.prototype.uE,set:p.prototype.mH});p.prototype.get_m_stepCount=p.prototype.lE=function(){return gu(this.kB)};p.prototype.set_m_stepCount=p.prototype.dH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hu(c,a)};Object.defineProperty(p.prototype,"m_stepCount",{get:p.prototype.lE,set:p.prototype.dH}); +p.prototype.get_m_dispatchFunc=p.prototype.cD=function(){return iu(this.kB)};p.prototype.set_m_dispatchFunc=p.prototype.UF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ju(c,a)};Object.defineProperty(p.prototype,"m_dispatchFunc",{get:p.prototype.cD,set:p.prototype.UF});p.prototype.get_m_timeOfImpact=p.prototype.tE=function(){return ku(this.kB)};p.prototype.set_m_timeOfImpact=p.prototype.lH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lu(c,a)}; +Object.defineProperty(p.prototype,"m_timeOfImpact",{get:p.prototype.tE,set:p.prototype.lH});p.prototype.get_m_useContinuous=p.prototype.wE=function(){return!!mu(this.kB)};p.prototype.set_m_useContinuous=p.prototype.oH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);nu(c,a)};Object.defineProperty(p.prototype,"m_useContinuous",{get:p.prototype.wE,set:p.prototype.oH});p.prototype.get_m_enableSatConvex=p.prototype.hD=function(){return!!ou(this.kB)}; +p.prototype.set_m_enableSatConvex=p.prototype.ZF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pu(c,a)};Object.defineProperty(p.prototype,"m_enableSatConvex",{get:p.prototype.hD,set:p.prototype.ZF});p.prototype.get_m_enableSPU=p.prototype.gD=function(){return!!qu(this.kB)};p.prototype.set_m_enableSPU=p.prototype.YF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ru(c,a)};Object.defineProperty(p.prototype,"m_enableSPU",{get:p.prototype.gD,set:p.prototype.YF}); +p.prototype.get_m_useEpa=p.prototype.yE=function(){return!!su(this.kB)};p.prototype.set_m_useEpa=p.prototype.qH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tu(c,a)};Object.defineProperty(p.prototype,"m_useEpa",{get:p.prototype.yE,set:p.prototype.qH});p.prototype.get_m_allowedCcdPenetration=p.prototype.FC=function(){return uu(this.kB)};p.prototype.set_m_allowedCcdPenetration=p.prototype.wF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vu(c,a)}; +Object.defineProperty(p.prototype,"m_allowedCcdPenetration",{get:p.prototype.FC,set:p.prototype.wF});p.prototype.get_m_useConvexConservativeDistanceUtil=p.prototype.xE=function(){return!!wu(this.kB)};p.prototype.set_m_useConvexConservativeDistanceUtil=p.prototype.pH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xu(c,a)};Object.defineProperty(p.prototype,"m_useConvexConservativeDistanceUtil",{get:p.prototype.xE,set:p.prototype.pH}); +p.prototype.get_m_convexConservativeDistanceThreshold=p.prototype.YC=function(){return yu(this.kB)};p.prototype.set_m_convexConservativeDistanceThreshold=p.prototype.PF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zu(c,a)};Object.defineProperty(p.prototype,"m_convexConservativeDistanceThreshold",{get:p.prototype.YC,set:p.prototype.PF});p.prototype.__destroy__=function(){Au(this.kB)};function t(){throw"cannot construct a btContactSolverInfo, no constructor in IDL";}t.prototype=Object.create(f.prototype); +t.prototype.constructor=t;t.prototype.lB=t;t.mB={};b.btContactSolverInfo=t;t.prototype.get_m_splitImpulse=t.prototype.iE=function(){return!!Bu(this.kB)};t.prototype.set_m_splitImpulse=t.prototype.aH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Cu(c,a)};Object.defineProperty(t.prototype,"m_splitImpulse",{get:t.prototype.iE,set:t.prototype.aH});t.prototype.get_m_splitImpulsePenetrationThreshold=t.prototype.jE=function(){return Du(this.kB)}; +t.prototype.set_m_splitImpulsePenetrationThreshold=t.prototype.bH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Eu(c,a)};Object.defineProperty(t.prototype,"m_splitImpulsePenetrationThreshold",{get:t.prototype.jE,set:t.prototype.bH});t.prototype.get_m_numIterations=t.prototype.TD=function(){return Fu(this.kB)};t.prototype.set_m_numIterations=t.prototype.KG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Gu(c,a)}; +Object.defineProperty(t.prototype,"m_numIterations",{get:t.prototype.TD,set:t.prototype.KG});t.prototype.__destroy__=function(){Hu(this.kB)};function M(){this.kB=Iu();h(M)[this.kB]=this}M.prototype=Object.create(f.prototype);M.prototype.constructor=M;M.prototype.lB=M;M.mB={};b.btVehicleTuning=M;M.prototype.get_m_suspensionStiffness=M.prototype.BB=function(){return Ju(this.kB)};M.prototype.set_m_suspensionStiffness=M.prototype.IB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ku(c,a)}; +Object.defineProperty(M.prototype,"m_suspensionStiffness",{get:M.prototype.BB,set:M.prototype.IB});M.prototype.get_m_suspensionCompression=M.prototype.mE=function(){return Lu(this.kB)};M.prototype.set_m_suspensionCompression=M.prototype.eH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Mu(c,a)};Object.defineProperty(M.prototype,"m_suspensionCompression",{get:M.prototype.mE,set:M.prototype.eH});M.prototype.get_m_suspensionDamping=M.prototype.nE=function(){return Nu(this.kB)}; +M.prototype.set_m_suspensionDamping=M.prototype.fH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ou(c,a)};Object.defineProperty(M.prototype,"m_suspensionDamping",{get:M.prototype.nE,set:M.prototype.fH});M.prototype.get_m_maxSuspensionTravelCm=M.prototype.AB=function(){return Pu(this.kB)};M.prototype.set_m_maxSuspensionTravelCm=M.prototype.HB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qu(c,a)}; +Object.defineProperty(M.prototype,"m_maxSuspensionTravelCm",{get:M.prototype.AB,set:M.prototype.HB});M.prototype.get_m_frictionSlip=M.prototype.wB=function(){return Ru(this.kB)};M.prototype.set_m_frictionSlip=M.prototype.DB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Su(c,a)};Object.defineProperty(M.prototype,"m_frictionSlip",{get:M.prototype.wB,set:M.prototype.DB});M.prototype.get_m_maxSuspensionForce=M.prototype.zB=function(){return Tu(this.kB)}; +M.prototype.set_m_maxSuspensionForce=M.prototype.GB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Uu(c,a)};Object.defineProperty(M.prototype,"m_maxSuspensionForce",{get:M.prototype.zB,set:M.prototype.GB});function xF(){throw"cannot construct a btVehicleRaycasterResult, no constructor in IDL";}xF.prototype=Object.create(f.prototype);xF.prototype.constructor=xF;xF.prototype.lB=xF;xF.mB={};b.btVehicleRaycasterResult=xF; +xF.prototype.get_m_hitPointInWorld=xF.prototype.tD=function(){return k(Vu(this.kB),m)};xF.prototype.set_m_hitPointInWorld=xF.prototype.kG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Wu(c,a)};Object.defineProperty(xF.prototype,"m_hitPointInWorld",{get:xF.prototype.tD,set:xF.prototype.kG});xF.prototype.get_m_hitNormalInWorld=xF.prototype.rD=function(){return k(Xu(this.kB),m)}; +xF.prototype.set_m_hitNormalInWorld=xF.prototype.iG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yu(c,a)};Object.defineProperty(xF.prototype,"m_hitNormalInWorld",{get:xF.prototype.rD,set:xF.prototype.iG});xF.prototype.get_m_distFraction=xF.prototype.fD=function(){return Zu(this.kB)};xF.prototype.set_m_distFraction=xF.prototype.XF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$u(c,a)};Object.defineProperty(xF.prototype,"m_distFraction",{get:xF.prototype.fD,set:xF.prototype.XF}); +xF.prototype.__destroy__=function(){av(this.kB)};function yF(a){a&&"object"===typeof a&&(a=a.kB);this.kB=bv(a);h(yF)[this.kB]=this}yF.prototype=Object.create(qE.prototype);yF.prototype.constructor=yF;yF.prototype.lB=yF;yF.mB={};b.btDefaultVehicleRaycaster=yF;yF.prototype.castRay=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);cv(e,a,c,d)};yF.prototype.__destroy__=function(){dv(this.kB)}; +function N(){throw"cannot construct a RaycastInfo, no constructor in IDL";}N.prototype=Object.create(f.prototype);N.prototype.constructor=N;N.prototype.lB=N;N.mB={};b.RaycastInfo=N;N.prototype.get_m_contactNormalWS=N.prototype.WC=function(){return k(ev(this.kB),m)};N.prototype.set_m_contactNormalWS=N.prototype.NF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fv(c,a)};Object.defineProperty(N.prototype,"m_contactNormalWS",{get:N.prototype.WC,set:N.prototype.NF}); +N.prototype.get_m_contactPointWS=N.prototype.XC=function(){return k(gv(this.kB),m)};N.prototype.set_m_contactPointWS=N.prototype.OF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hv(c,a)};Object.defineProperty(N.prototype,"m_contactPointWS",{get:N.prototype.XC,set:N.prototype.OF});N.prototype.get_m_suspensionLength=N.prototype.oE=function(){return iv(this.kB)};N.prototype.set_m_suspensionLength=N.prototype.gH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jv(c,a)}; +Object.defineProperty(N.prototype,"m_suspensionLength",{get:N.prototype.oE,set:N.prototype.gH});N.prototype.get_m_hardPointWS=N.prototype.oD=function(){return k(kv(this.kB),m)};N.prototype.set_m_hardPointWS=N.prototype.fG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lv(c,a)};Object.defineProperty(N.prototype,"m_hardPointWS",{get:N.prototype.oD,set:N.prototype.fG});N.prototype.get_m_wheelDirectionWS=N.prototype.DE=function(){return k(mv(this.kB),m)}; +N.prototype.set_m_wheelDirectionWS=N.prototype.vH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);nv(c,a)};Object.defineProperty(N.prototype,"m_wheelDirectionWS",{get:N.prototype.DE,set:N.prototype.vH});N.prototype.get_m_wheelAxleWS=N.prototype.CE=function(){return k(ov(this.kB),m)};N.prototype.set_m_wheelAxleWS=N.prototype.uH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pv(c,a)};Object.defineProperty(N.prototype,"m_wheelAxleWS",{get:N.prototype.CE,set:N.prototype.uH}); +N.prototype.get_m_isInContact=N.prototype.zD=function(){return!!qv(this.kB)};N.prototype.set_m_isInContact=N.prototype.qG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rv(c,a)};Object.defineProperty(N.prototype,"m_isInContact",{get:N.prototype.zD,set:N.prototype.qG});N.prototype.get_m_groundObject=N.prototype.nD=function(){return sv(this.kB)};N.prototype.set_m_groundObject=N.prototype.eG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tv(c,a)}; +Object.defineProperty(N.prototype,"m_groundObject",{get:N.prototype.nD,set:N.prototype.eG});N.prototype.__destroy__=function(){uv(this.kB)};function O(){throw"cannot construct a btWheelInfoConstructionInfo, no constructor in IDL";}O.prototype=Object.create(f.prototype);O.prototype.constructor=O;O.prototype.lB=O;O.mB={};b.btWheelInfoConstructionInfo=O;O.prototype.get_m_chassisConnectionCS=O.prototype.RC=function(){return k(vv(this.kB),m)}; +O.prototype.set_m_chassisConnectionCS=O.prototype.IF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wv(c,a)};Object.defineProperty(O.prototype,"m_chassisConnectionCS",{get:O.prototype.RC,set:O.prototype.IF});O.prototype.get_m_wheelDirectionCS=O.prototype.QB=function(){return k(xv(this.kB),m)};O.prototype.set_m_wheelDirectionCS=O.prototype.$B=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yv(c,a)};Object.defineProperty(O.prototype,"m_wheelDirectionCS",{get:O.prototype.QB,set:O.prototype.$B}); +O.prototype.get_m_wheelAxleCS=O.prototype.PB=function(){return k(zv(this.kB),m)};O.prototype.set_m_wheelAxleCS=O.prototype.ZB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Av(c,a)};Object.defineProperty(O.prototype,"m_wheelAxleCS",{get:O.prototype.PB,set:O.prototype.ZB});O.prototype.get_m_suspensionRestLength=O.prototype.qE=function(){return Bv(this.kB)};O.prototype.set_m_suspensionRestLength=O.prototype.iH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Cv(c,a)}; +Object.defineProperty(O.prototype,"m_suspensionRestLength",{get:O.prototype.qE,set:O.prototype.iH});O.prototype.get_m_maxSuspensionTravelCm=O.prototype.AB=function(){return Dv(this.kB)};O.prototype.set_m_maxSuspensionTravelCm=O.prototype.HB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ev(c,a)};Object.defineProperty(O.prototype,"m_maxSuspensionTravelCm",{get:O.prototype.AB,set:O.prototype.HB});O.prototype.get_m_wheelRadius=O.prototype.EE=function(){return Fv(this.kB)}; +O.prototype.set_m_wheelRadius=O.prototype.wH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Gv(c,a)};Object.defineProperty(O.prototype,"m_wheelRadius",{get:O.prototype.EE,set:O.prototype.wH});O.prototype.get_m_suspensionStiffness=O.prototype.BB=function(){return Hv(this.kB)};O.prototype.set_m_suspensionStiffness=O.prototype.IB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Iv(c,a)};Object.defineProperty(O.prototype,"m_suspensionStiffness",{get:O.prototype.BB,set:O.prototype.IB}); +O.prototype.get_m_wheelsDampingCompression=O.prototype.RB=function(){return Jv(this.kB)};O.prototype.set_m_wheelsDampingCompression=O.prototype.aC=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kv(c,a)};Object.defineProperty(O.prototype,"m_wheelsDampingCompression",{get:O.prototype.RB,set:O.prototype.aC});O.prototype.get_m_wheelsDampingRelaxation=O.prototype.SB=function(){return Lv(this.kB)}; +O.prototype.set_m_wheelsDampingRelaxation=O.prototype.bC=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Mv(c,a)};Object.defineProperty(O.prototype,"m_wheelsDampingRelaxation",{get:O.prototype.SB,set:O.prototype.bC});O.prototype.get_m_frictionSlip=O.prototype.wB=function(){return Nv(this.kB)};O.prototype.set_m_frictionSlip=O.prototype.DB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ov(c,a)};Object.defineProperty(O.prototype,"m_frictionSlip",{get:O.prototype.wB,set:O.prototype.DB}); +O.prototype.get_m_maxSuspensionForce=O.prototype.zB=function(){return Pv(this.kB)};O.prototype.set_m_maxSuspensionForce=O.prototype.GB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qv(c,a)};Object.defineProperty(O.prototype,"m_maxSuspensionForce",{get:O.prototype.zB,set:O.prototype.GB});O.prototype.get_m_bIsFrontWheel=O.prototype.JB=function(){return!!Rv(this.kB)};O.prototype.set_m_bIsFrontWheel=O.prototype.TB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Sv(c,a)}; +Object.defineProperty(O.prototype,"m_bIsFrontWheel",{get:O.prototype.JB,set:O.prototype.TB});O.prototype.__destroy__=function(){Tv(this.kB)};function P(a){a&&"object"===typeof a&&(a=a.kB);this.kB=Uv(a);h(P)[this.kB]=this}P.prototype=Object.create(f.prototype);P.prototype.constructor=P;P.prototype.lB=P;P.mB={};b.btWheelInfo=P;P.prototype.getSuspensionRestLength=function(){return Vv(this.kB)}; +P.prototype.updateWheel=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Wv(d,a,c)};P.prototype.get_m_suspensionStiffness=P.prototype.BB=function(){return Xv(this.kB)};P.prototype.set_m_suspensionStiffness=P.prototype.IB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yv(c,a)};Object.defineProperty(P.prototype,"m_suspensionStiffness",{get:P.prototype.BB,set:P.prototype.IB});P.prototype.get_m_frictionSlip=P.prototype.wB=function(){return Zv(this.kB)}; +P.prototype.set_m_frictionSlip=P.prototype.DB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$v(c,a)};Object.defineProperty(P.prototype,"m_frictionSlip",{get:P.prototype.wB,set:P.prototype.DB});P.prototype.get_m_engineForce=P.prototype.iD=function(){return aw(this.kB)};P.prototype.set_m_engineForce=P.prototype.$F=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bw(c,a)};Object.defineProperty(P.prototype,"m_engineForce",{get:P.prototype.iD,set:P.prototype.$F}); +P.prototype.get_m_rollInfluence=P.prototype.cE=function(){return cw(this.kB)};P.prototype.set_m_rollInfluence=P.prototype.VG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dw(c,a)};Object.defineProperty(P.prototype,"m_rollInfluence",{get:P.prototype.cE,set:P.prototype.VG});P.prototype.get_m_suspensionRestLength1=P.prototype.rE=function(){return ew(this.kB)};P.prototype.set_m_suspensionRestLength1=P.prototype.jH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fw(c,a)}; +Object.defineProperty(P.prototype,"m_suspensionRestLength1",{get:P.prototype.rE,set:P.prototype.jH});P.prototype.get_m_wheelsRadius=P.prototype.FE=function(){return gw(this.kB)};P.prototype.set_m_wheelsRadius=P.prototype.xH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hw(c,a)};Object.defineProperty(P.prototype,"m_wheelsRadius",{get:P.prototype.FE,set:P.prototype.xH});P.prototype.get_m_wheelsDampingCompression=P.prototype.RB=function(){return iw(this.kB)}; +P.prototype.set_m_wheelsDampingCompression=P.prototype.aC=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jw(c,a)};Object.defineProperty(P.prototype,"m_wheelsDampingCompression",{get:P.prototype.RB,set:P.prototype.aC});P.prototype.get_m_wheelsDampingRelaxation=P.prototype.SB=function(){return kw(this.kB)};P.prototype.set_m_wheelsDampingRelaxation=P.prototype.bC=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lw(c,a)}; +Object.defineProperty(P.prototype,"m_wheelsDampingRelaxation",{get:P.prototype.SB,set:P.prototype.bC});P.prototype.get_m_steering=P.prototype.kE=function(){return mw(this.kB)};P.prototype.set_m_steering=P.prototype.cH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);nw(c,a)};Object.defineProperty(P.prototype,"m_steering",{get:P.prototype.kE,set:P.prototype.cH});P.prototype.get_m_maxSuspensionForce=P.prototype.zB=function(){return ow(this.kB)}; +P.prototype.set_m_maxSuspensionForce=P.prototype.GB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pw(c,a)};Object.defineProperty(P.prototype,"m_maxSuspensionForce",{get:P.prototype.zB,set:P.prototype.GB});P.prototype.get_m_maxSuspensionTravelCm=P.prototype.AB=function(){return qw(this.kB)};P.prototype.set_m_maxSuspensionTravelCm=P.prototype.HB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rw(c,a)}; +Object.defineProperty(P.prototype,"m_maxSuspensionTravelCm",{get:P.prototype.AB,set:P.prototype.HB});P.prototype.get_m_wheelsSuspensionForce=P.prototype.GE=function(){return sw(this.kB)};P.prototype.set_m_wheelsSuspensionForce=P.prototype.yH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tw(c,a)};Object.defineProperty(P.prototype,"m_wheelsSuspensionForce",{get:P.prototype.GE,set:P.prototype.yH});P.prototype.get_m_bIsFrontWheel=P.prototype.JB=function(){return!!uw(this.kB)}; +P.prototype.set_m_bIsFrontWheel=P.prototype.TB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vw(c,a)};Object.defineProperty(P.prototype,"m_bIsFrontWheel",{get:P.prototype.JB,set:P.prototype.TB});P.prototype.get_m_raycastInfo=P.prototype.aE=function(){return k(ww(this.kB),N)};P.prototype.set_m_raycastInfo=P.prototype.TG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xw(c,a)};Object.defineProperty(P.prototype,"m_raycastInfo",{get:P.prototype.aE,set:P.prototype.TG}); +P.prototype.get_m_chassisConnectionPointCS=P.prototype.SC=function(){return k(yw(this.kB),m)};P.prototype.set_m_chassisConnectionPointCS=P.prototype.JF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zw(c,a)};Object.defineProperty(P.prototype,"m_chassisConnectionPointCS",{get:P.prototype.SC,set:P.prototype.JF});P.prototype.get_m_worldTransform=P.prototype.HE=function(){return k(Aw(this.kB),r)}; +P.prototype.set_m_worldTransform=P.prototype.zH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bw(c,a)};Object.defineProperty(P.prototype,"m_worldTransform",{get:P.prototype.HE,set:P.prototype.zH});P.prototype.get_m_wheelDirectionCS=P.prototype.QB=function(){return k(Cw(this.kB),m)};P.prototype.set_m_wheelDirectionCS=P.prototype.$B=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dw(c,a)};Object.defineProperty(P.prototype,"m_wheelDirectionCS",{get:P.prototype.QB,set:P.prototype.$B}); +P.prototype.get_m_wheelAxleCS=P.prototype.PB=function(){return k(Ew(this.kB),m)};P.prototype.set_m_wheelAxleCS=P.prototype.ZB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fw(c,a)};Object.defineProperty(P.prototype,"m_wheelAxleCS",{get:P.prototype.PB,set:P.prototype.ZB});P.prototype.get_m_rotation=P.prototype.eE=function(){return Gw(this.kB)};P.prototype.set_m_rotation=P.prototype.XG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Hw(c,a)}; +Object.defineProperty(P.prototype,"m_rotation",{get:P.prototype.eE,set:P.prototype.XG});P.prototype.get_m_deltaRotation=P.prototype.bD=function(){return Iw(this.kB)};P.prototype.set_m_deltaRotation=P.prototype.TF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jw(c,a)};Object.defineProperty(P.prototype,"m_deltaRotation",{get:P.prototype.bD,set:P.prototype.TF});P.prototype.get_m_brake=P.prototype.LC=function(){return Kw(this.kB)}; +P.prototype.set_m_brake=P.prototype.CF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lw(c,a)};Object.defineProperty(P.prototype,"m_brake",{get:P.prototype.LC,set:P.prototype.CF});P.prototype.get_m_clippedInvContactDotSuspension=P.prototype.TC=function(){return Mw(this.kB)};P.prototype.set_m_clippedInvContactDotSuspension=P.prototype.KF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nw(c,a)}; +Object.defineProperty(P.prototype,"m_clippedInvContactDotSuspension",{get:P.prototype.TC,set:P.prototype.KF});P.prototype.get_m_suspensionRelativeVelocity=P.prototype.pE=function(){return Ow(this.kB)};P.prototype.set_m_suspensionRelativeVelocity=P.prototype.hH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pw(c,a)};Object.defineProperty(P.prototype,"m_suspensionRelativeVelocity",{get:P.prototype.pE,set:P.prototype.hH});P.prototype.get_m_skidInfo=P.prototype.hE=function(){return Qw(this.kB)}; +P.prototype.set_m_skidInfo=P.prototype.$G=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rw(c,a)};Object.defineProperty(P.prototype,"m_skidInfo",{get:P.prototype.hE,set:P.prototype.$G});P.prototype.__destroy__=function(){Sw(this.kB)};function zF(a,c,d,e){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);this.kB=void 0===e?Tw(a,c,d):Uw(a,c,d,e);h(zF)[this.kB]=this}zF.prototype=Object.create(rE.prototype); +zF.prototype.constructor=zF;zF.prototype.lB=zF;zF.mB={};b.btKinematicCharacterController=zF;zF.prototype.setUpAxis=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vw(c,a)};zF.prototype.setWalkDirection=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ww(c,a)};zF.prototype.setVelocityForTimeInterval=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Xw(d,a,c)}; +zF.prototype.warp=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yw(c,a)};zF.prototype.preStep=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zw(c,a)};zF.prototype.playerStep=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);$w(d,a,c)};zF.prototype.setFallSpeed=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ax(c,a)};zF.prototype.setJumpSpeed=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bx(c,a)}; +zF.prototype.setMaxJumpHeight=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cx(c,a)};zF.prototype.canJump=function(){return!!dx(this.kB)};zF.prototype.jump=function(){ex(this.kB)};zF.prototype.setGravity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fx(c,a)};zF.prototype.getGravity=function(){return gx(this.kB)};zF.prototype.setMaxSlope=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hx(c,a)};zF.prototype.getMaxSlope=function(){return ix(this.kB)}; +zF.prototype.getGhostObject=function(){return k(jx(this.kB),Q)};zF.prototype.setUseGhostSweepTest=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kx(c,a)};zF.prototype.onGround=function(){return!!lx(this.kB)};zF.prototype.setUpInterpolate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);mx(c,a)};zF.prototype.updateAction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);nx(d,a,c)};zF.prototype.__destroy__=function(){ox(this.kB)}; +function R(a,c,d){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);this.kB=px(a,c,d);h(R)[this.kB]=this}R.prototype=Object.create(rE.prototype);R.prototype.constructor=R;R.prototype.lB=R;R.mB={};b.btRaycastVehicle=R;R.prototype.applyEngineForce=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);qx(d,a,c)}; +R.prototype.setSteeringValue=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);rx(d,a,c)};R.prototype.getWheelTransformWS=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(sx(c,a),r)};R.prototype.updateWheelTransform=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);tx(d,a,c)}; +R.prototype.addWheel=function(a,c,d,e,g,n,z){var T=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);return k(ux(T,a,c,d,e,g,n,z),P)};R.prototype.getNumWheels=function(){return vx(this.kB)};R.prototype.getRigidBody=function(){return k(wx(this.kB),K)}; +R.prototype.getWheelInfo=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(xx(c,a),P)};R.prototype.setBrake=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);yx(d,a,c)};R.prototype.setCoordinateSystem=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);zx(e,a,c,d)};R.prototype.getCurrentSpeedKmHour=function(){return Ax(this.kB)}; +R.prototype.getChassisWorldTransform=function(){return k(Bx(this.kB),r)};R.prototype.rayCast=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return Cx(c,a)};R.prototype.updateVehicle=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dx(c,a)};R.prototype.resetSuspension=function(){Ex(this.kB)};R.prototype.getSteeringValue=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return Fx(c,a)}; +R.prototype.updateWheelTransformsWS=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);void 0===c?Gx(d,a):Hx(d,a,c)};R.prototype.setPitchControl=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ix(c,a)};R.prototype.updateSuspension=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jx(c,a)};R.prototype.updateFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Kx(c,a)};R.prototype.getRightAxis=function(){return Lx(this.kB)}; +R.prototype.getUpAxis=function(){return Mx(this.kB)};R.prototype.getForwardAxis=function(){return Nx(this.kB)};R.prototype.getForwardVector=function(){return k(Ox(this.kB),m)};R.prototype.getUserConstraintType=function(){return Px(this.kB)};R.prototype.setUserConstraintType=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Qx(c,a)};R.prototype.setUserConstraintId=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rx(c,a)};R.prototype.getUserConstraintId=function(){return Sx(this.kB)}; +R.prototype.updateAction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Tx(d,a,c)};R.prototype.__destroy__=function(){Ux(this.kB)};function Q(){this.kB=Vx();h(Q)[this.kB]=this}Q.prototype=Object.create(y.prototype);Q.prototype.constructor=Q;Q.prototype.lB=Q;Q.mB={};b.btPairCachingGhostObject=Q;Q.prototype.setAnisotropicFriction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Wx(d,a,c)}; +Q.prototype.getCollisionShape=function(){return k(Xx(this.kB),l)};Q.prototype.setContactProcessingThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yx(c,a)};Q.prototype.setActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Zx(c,a)};Q.prototype.forceActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$x(c,a)};Q.prototype.activate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);void 0===a?ay(c):by(c,a)}; +Q.prototype.isActive=function(){return!!cy(this.kB)};Q.prototype.isKinematicObject=function(){return!!dy(this.kB)};Q.prototype.isStaticObject=function(){return!!ey(this.kB)};Q.prototype.isStaticOrKinematicObject=function(){return!!fy(this.kB)};Q.prototype.getRestitution=function(){return gy(this.kB)};Q.prototype.getFriction=function(){return hy(this.kB)};Q.prototype.getRollingFriction=function(){return iy(this.kB)}; +Q.prototype.setRestitution=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jy(c,a)};Q.prototype.setFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ky(c,a)};Q.prototype.setRollingFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ly(c,a)};Q.prototype.getWorldTransform=function(){return k(my(this.kB),r)};Q.prototype.getCollisionFlags=function(){return ny(this.kB)}; +Q.prototype.setCollisionFlags=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);oy(c,a)};Q.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);py(c,a)};Q.prototype.setCollisionShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qy(c,a)};Q.prototype.setCcdMotionThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ry(c,a)};Q.prototype.setCcdSweptSphereRadius=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);sy(c,a)}; +Q.prototype.getUserIndex=function(){return ty(this.kB)};Q.prototype.setUserIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);uy(c,a)};Q.prototype.getUserPointer=function(){return k(vy(this.kB),XD)};Q.prototype.setUserPointer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wy(c,a)};Q.prototype.getBroadphaseHandle=function(){return k(xy(this.kB),YD)};Q.prototype.getNumOverlappingObjects=function(){return yy(this.kB)}; +Q.prototype.getOverlappingObject=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(zy(c,a),q)};Q.prototype.__destroy__=function(){Ay(this.kB)};function AF(){this.kB=By();h(AF)[this.kB]=this}AF.prototype=Object.create(f.prototype);AF.prototype.constructor=AF;AF.prototype.lB=AF;AF.mB={};b.btGhostPairCallback=AF;AF.prototype.__destroy__=function(){Cy(this.kB)};function S(){this.kB=Dy();h(S)[this.kB]=this}S.prototype=Object.create(f.prototype);S.prototype.constructor=S; +S.prototype.lB=S;S.mB={};b.btSoftBodyWorldInfo=S;S.prototype.get_air_density=S.prototype.cC=function(){return Ey(this.kB)};S.prototype.set_air_density=S.prototype.UE=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Fy(c,a)};Object.defineProperty(S.prototype,"air_density",{get:S.prototype.cC,set:S.prototype.UE});S.prototype.get_water_density=S.prototype.RE=function(){return Gy(this.kB)}; +S.prototype.set_water_density=S.prototype.JH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Hy(c,a)};Object.defineProperty(S.prototype,"water_density",{get:S.prototype.RE,set:S.prototype.JH});S.prototype.get_water_offset=S.prototype.TE=function(){return Iy(this.kB)};S.prototype.set_water_offset=S.prototype.LH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jy(c,a)};Object.defineProperty(S.prototype,"water_offset",{get:S.prototype.TE,set:S.prototype.LH}); +S.prototype.get_m_maxDisplacement=S.prototype.ND=function(){return Ky(this.kB)};S.prototype.set_m_maxDisplacement=S.prototype.EG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ly(c,a)};Object.defineProperty(S.prototype,"m_maxDisplacement",{get:S.prototype.ND,set:S.prototype.EG});S.prototype.get_water_normal=S.prototype.SE=function(){return k(My(this.kB),m)};S.prototype.set_water_normal=S.prototype.KH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ny(c,a)}; +Object.defineProperty(S.prototype,"water_normal",{get:S.prototype.SE,set:S.prototype.KH});S.prototype.get_m_broadphase=S.prototype.MC=function(){return k(Oy(this.kB),VD)};S.prototype.set_m_broadphase=S.prototype.DF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Py(c,a)};Object.defineProperty(S.prototype,"m_broadphase",{get:S.prototype.MC,set:S.prototype.DF});S.prototype.get_m_dispatcher=S.prototype.dD=function(){return k(Qy(this.kB),TD)}; +S.prototype.set_m_dispatcher=S.prototype.VF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ry(c,a)};Object.defineProperty(S.prototype,"m_dispatcher",{get:S.prototype.dD,set:S.prototype.VF});S.prototype.get_m_gravity=S.prototype.mD=function(){return k(Sy(this.kB),m)};S.prototype.set_m_gravity=S.prototype.dG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Ty(c,a)};Object.defineProperty(S.prototype,"m_gravity",{get:S.prototype.mD,set:S.prototype.dG});S.prototype.__destroy__=function(){Uy(this.kB)}; +function U(){throw"cannot construct a Face, no constructor in IDL";}U.prototype=Object.create(f.prototype);U.prototype.constructor=U;U.prototype.lB=U;U.mB={};b.Face=U;U.prototype.get_m_n=U.prototype.MB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Vy(c,a),Node)};U.prototype.set_m_n=U.prototype.WB=function(a,c){var d=this.kB;ND();a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);Wy(d,a,c)};Object.defineProperty(U.prototype,"m_n",{get:U.prototype.MB,set:U.prototype.WB}); +U.prototype.get_m_normal=U.prototype.RD=function(){return k(Xy(this.kB),m)};U.prototype.set_m_normal=U.prototype.IG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Yy(c,a)};Object.defineProperty(U.prototype,"m_normal",{get:U.prototype.RD,set:U.prototype.IG});U.prototype.get_m_ra=U.prototype.$D=function(){return Zy(this.kB)};U.prototype.set_m_ra=U.prototype.SG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$y(c,a)}; +Object.defineProperty(U.prototype,"m_ra",{get:U.prototype.$D,set:U.prototype.SG});U.prototype.__destroy__=function(){az(this.kB)};function BF(){throw"cannot construct a tFaceArray, no constructor in IDL";}BF.prototype=Object.create(f.prototype);BF.prototype.constructor=BF;BF.prototype.lB=BF;BF.mB={};b.tFaceArray=BF;BF.prototype.size=BF.prototype.size=function(){return bz(this.kB)};BF.prototype.at=BF.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(cz(c,a),U)}; +BF.prototype.__destroy__=function(){dz(this.kB)};function Node(){throw"cannot construct a Node, no constructor in IDL";}Node.prototype=Object.create(f.prototype);Node.prototype.constructor=Node;Node.prototype.lB=Node;Node.mB={};b.Node=Node;Node.prototype.get_m_x=Node.prototype.IE=function(){return k(ez(this.kB),m)};Node.prototype.set_m_x=Node.prototype.AH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fz(c,a)};Object.defineProperty(Node.prototype,"m_x",{get:Node.prototype.IE,set:Node.prototype.AH}); +Node.prototype.get_m_q=Node.prototype.ZD=function(){return k(gz(this.kB),m)};Node.prototype.set_m_q=Node.prototype.QG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hz(c,a)};Object.defineProperty(Node.prototype,"m_q",{get:Node.prototype.ZD,set:Node.prototype.QG});Node.prototype.get_m_v=Node.prototype.AE=function(){return k(iz(this.kB),m)};Node.prototype.set_m_v=Node.prototype.sH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jz(c,a)}; +Object.defineProperty(Node.prototype,"m_v",{get:Node.prototype.AE,set:Node.prototype.sH});Node.prototype.get_m_f=Node.prototype.jD=function(){return k(kz(this.kB),m)};Node.prototype.set_m_f=Node.prototype.aG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lz(c,a)};Object.defineProperty(Node.prototype,"m_f",{get:Node.prototype.jD,set:Node.prototype.aG});Node.prototype.get_m_n=Node.prototype.MB=function(){return k(mz(this.kB),m)}; +Node.prototype.set_m_n=Node.prototype.WB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);nz(c,a)};Object.defineProperty(Node.prototype,"m_n",{get:Node.prototype.MB,set:Node.prototype.WB});Node.prototype.get_m_im=Node.prototype.vD=function(){return oz(this.kB)};Node.prototype.set_m_im=Node.prototype.mG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pz(c,a)};Object.defineProperty(Node.prototype,"m_im",{get:Node.prototype.vD,set:Node.prototype.mG}); +Node.prototype.get_m_area=Node.prototype.JC=function(){return qz(this.kB)};Node.prototype.set_m_area=Node.prototype.AF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rz(c,a)};Object.defineProperty(Node.prototype,"m_area",{get:Node.prototype.JC,set:Node.prototype.AF});Node.prototype.__destroy__=function(){sz(this.kB)};function CF(){throw"cannot construct a tNodeArray, no constructor in IDL";}CF.prototype=Object.create(f.prototype);CF.prototype.constructor=CF;CF.prototype.lB=CF;CF.mB={}; +b.tNodeArray=CF;CF.prototype.size=CF.prototype.size=function(){return tz(this.kB)};CF.prototype.at=CF.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(uz(c,a),Node)};CF.prototype.__destroy__=function(){vz(this.kB)};function V(){throw"cannot construct a Material, no constructor in IDL";}V.prototype=Object.create(f.prototype);V.prototype.constructor=V;V.prototype.lB=V;V.mB={};b.Material=V;V.prototype.get_m_kLST=V.prototype.BD=function(){return wz(this.kB)}; +V.prototype.set_m_kLST=V.prototype.sG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xz(c,a)};Object.defineProperty(V.prototype,"m_kLST",{get:V.prototype.BD,set:V.prototype.sG});V.prototype.get_m_kAST=V.prototype.AD=function(){return yz(this.kB)};V.prototype.set_m_kAST=V.prototype.rG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zz(c,a)};Object.defineProperty(V.prototype,"m_kAST",{get:V.prototype.AD,set:V.prototype.rG});V.prototype.get_m_kVST=V.prototype.CD=function(){return Az(this.kB)}; +V.prototype.set_m_kVST=V.prototype.tG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Bz(c,a)};Object.defineProperty(V.prototype,"m_kVST",{get:V.prototype.CD,set:V.prototype.tG});V.prototype.get_m_flags=V.prototype.tB=function(){return Cz(this.kB)};V.prototype.set_m_flags=V.prototype.uB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Dz(c,a)};Object.defineProperty(V.prototype,"m_flags",{get:V.prototype.tB,set:V.prototype.uB});V.prototype.__destroy__=function(){Ez(this.kB)}; +function DF(){throw"cannot construct a tMaterialArray, no constructor in IDL";}DF.prototype=Object.create(f.prototype);DF.prototype.constructor=DF;DF.prototype.lB=DF;DF.mB={};b.tMaterialArray=DF;DF.prototype.size=DF.prototype.size=function(){return Fz(this.kB)};DF.prototype.at=DF.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Gz(c,a),V)};DF.prototype.__destroy__=function(){Hz(this.kB)};function W(){throw"cannot construct a Anchor, no constructor in IDL";} +W.prototype=Object.create(f.prototype);W.prototype.constructor=W;W.prototype.lB=W;W.mB={};b.Anchor=W;W.prototype.get_m_node=W.prototype.PD=function(){return k(Iz(this.kB),Node)};W.prototype.set_m_node=W.prototype.GG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Jz(c,a)};Object.defineProperty(W.prototype,"m_node",{get:W.prototype.PD,set:W.prototype.GG});W.prototype.get_m_local=W.prototype.FD=function(){return k(Kz(this.kB),m)}; +W.prototype.set_m_local=W.prototype.wG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Lz(c,a)};Object.defineProperty(W.prototype,"m_local",{get:W.prototype.FD,set:W.prototype.wG});W.prototype.get_m_body=W.prototype.KC=function(){return k(Mz(this.kB),K)};W.prototype.set_m_body=W.prototype.BF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Nz(c,a)};Object.defineProperty(W.prototype,"m_body",{get:W.prototype.KC,set:W.prototype.BF});W.prototype.get_m_influence=W.prototype.yD=function(){return Oz(this.kB)}; +W.prototype.set_m_influence=W.prototype.pG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Pz(c,a)};Object.defineProperty(W.prototype,"m_influence",{get:W.prototype.yD,set:W.prototype.pG});W.prototype.get_m_c0=W.prototype.NC=function(){return k(Qz(this.kB),uE)};W.prototype.set_m_c0=W.prototype.EF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Rz(c,a)};Object.defineProperty(W.prototype,"m_c0",{get:W.prototype.NC,set:W.prototype.EF}); +W.prototype.get_m_c1=W.prototype.OC=function(){return k(Sz(this.kB),m)};W.prototype.set_m_c1=W.prototype.FF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Tz(c,a)};Object.defineProperty(W.prototype,"m_c1",{get:W.prototype.OC,set:W.prototype.FF});W.prototype.get_m_c2=W.prototype.PC=function(){return Uz(this.kB)};W.prototype.set_m_c2=W.prototype.GF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);Vz(c,a)};Object.defineProperty(W.prototype,"m_c2",{get:W.prototype.PC,set:W.prototype.GF}); +W.prototype.__destroy__=function(){Wz(this.kB)};function EF(){throw"cannot construct a tAnchorArray, no constructor in IDL";}EF.prototype=Object.create(f.prototype);EF.prototype.constructor=EF;EF.prototype.lB=EF;EF.mB={};b.tAnchorArray=EF;EF.prototype.size=EF.prototype.size=function(){return Xz(this.kB)};EF.prototype.at=EF.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(Yz(c,a),W)};EF.prototype.clear=EF.prototype.clear=function(){Zz(this.kB)}; +EF.prototype.push_back=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$z(c,a)};EF.prototype.pop_back=function(){aA(this.kB)};EF.prototype.__destroy__=function(){bA(this.kB)};function X(){throw"cannot construct a Config, no constructor in IDL";}X.prototype=Object.create(f.prototype);X.prototype.constructor=X;X.prototype.lB=X;X.mB={};b.Config=X;X.prototype.get_kVCF=X.prototype.zC=function(){return cA(this.kB)}; +X.prototype.set_kVCF=X.prototype.qF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dA(c,a)};Object.defineProperty(X.prototype,"kVCF",{get:X.prototype.zC,set:X.prototype.qF});X.prototype.get_kDP=X.prototype.mC=function(){return eA(this.kB)};X.prototype.set_kDP=X.prototype.dF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);fA(c,a)};Object.defineProperty(X.prototype,"kDP",{get:X.prototype.mC,set:X.prototype.dF});X.prototype.get_kDG=X.prototype.lC=function(){return gA(this.kB)}; +X.prototype.set_kDG=X.prototype.cF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);hA(c,a)};Object.defineProperty(X.prototype,"kDG",{get:X.prototype.lC,set:X.prototype.cF});X.prototype.get_kLF=X.prototype.oC=function(){return iA(this.kB)};X.prototype.set_kLF=X.prototype.fF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);jA(c,a)};Object.defineProperty(X.prototype,"kLF",{get:X.prototype.oC,set:X.prototype.fF});X.prototype.get_kPR=X.prototype.qC=function(){return kA(this.kB)}; +X.prototype.set_kPR=X.prototype.hF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);lA(c,a)};Object.defineProperty(X.prototype,"kPR",{get:X.prototype.qC,set:X.prototype.hF});X.prototype.get_kVC=X.prototype.yC=function(){return mA(this.kB)};X.prototype.set_kVC=X.prototype.pF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);nA(c,a)};Object.defineProperty(X.prototype,"kVC",{get:X.prototype.yC,set:X.prototype.pF});X.prototype.get_kDF=X.prototype.kC=function(){return oA(this.kB)}; +X.prototype.set_kDF=X.prototype.bF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pA(c,a)};Object.defineProperty(X.prototype,"kDF",{get:X.prototype.kC,set:X.prototype.bF});X.prototype.get_kMT=X.prototype.pC=function(){return qA(this.kB)};X.prototype.set_kMT=X.prototype.gF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rA(c,a)};Object.defineProperty(X.prototype,"kMT",{get:X.prototype.pC,set:X.prototype.gF});X.prototype.get_kCHR=X.prototype.jC=function(){return sA(this.kB)}; +X.prototype.set_kCHR=X.prototype.aF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);tA(c,a)};Object.defineProperty(X.prototype,"kCHR",{get:X.prototype.jC,set:X.prototype.aF});X.prototype.get_kKHR=X.prototype.nC=function(){return uA(this.kB)};X.prototype.set_kKHR=X.prototype.eF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);vA(c,a)};Object.defineProperty(X.prototype,"kKHR",{get:X.prototype.nC,set:X.prototype.eF});X.prototype.get_kSHR=X.prototype.rC=function(){return wA(this.kB)}; +X.prototype.set_kSHR=X.prototype.iF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xA(c,a)};Object.defineProperty(X.prototype,"kSHR",{get:X.prototype.rC,set:X.prototype.iF});X.prototype.get_kAHR=X.prototype.iC=function(){return yA(this.kB)};X.prototype.set_kAHR=X.prototype.$E=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);zA(c,a)};Object.defineProperty(X.prototype,"kAHR",{get:X.prototype.iC,set:X.prototype.$E});X.prototype.get_kSRHR_CL=X.prototype.uC=function(){return AA(this.kB)}; +X.prototype.set_kSRHR_CL=X.prototype.lF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);BA(c,a)};Object.defineProperty(X.prototype,"kSRHR_CL",{get:X.prototype.uC,set:X.prototype.lF});X.prototype.get_kSKHR_CL=X.prototype.sC=function(){return CA(this.kB)};X.prototype.set_kSKHR_CL=X.prototype.jF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);DA(c,a)};Object.defineProperty(X.prototype,"kSKHR_CL",{get:X.prototype.sC,set:X.prototype.jF});X.prototype.get_kSSHR_CL=X.prototype.wC=function(){return EA(this.kB)}; +X.prototype.set_kSSHR_CL=X.prototype.nF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);FA(c,a)};Object.defineProperty(X.prototype,"kSSHR_CL",{get:X.prototype.wC,set:X.prototype.nF});X.prototype.get_kSR_SPLT_CL=X.prototype.vC=function(){return GA(this.kB)};X.prototype.set_kSR_SPLT_CL=X.prototype.mF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);HA(c,a)};Object.defineProperty(X.prototype,"kSR_SPLT_CL",{get:X.prototype.vC,set:X.prototype.mF}); +X.prototype.get_kSK_SPLT_CL=X.prototype.tC=function(){return IA(this.kB)};X.prototype.set_kSK_SPLT_CL=X.prototype.kF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);JA(c,a)};Object.defineProperty(X.prototype,"kSK_SPLT_CL",{get:X.prototype.tC,set:X.prototype.kF});X.prototype.get_kSS_SPLT_CL=X.prototype.xC=function(){return KA(this.kB)};X.prototype.set_kSS_SPLT_CL=X.prototype.oF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);LA(c,a)}; +Object.defineProperty(X.prototype,"kSS_SPLT_CL",{get:X.prototype.xC,set:X.prototype.oF});X.prototype.get_maxvolume=X.prototype.JE=function(){return MA(this.kB)};X.prototype.set_maxvolume=X.prototype.BH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);NA(c,a)};Object.defineProperty(X.prototype,"maxvolume",{get:X.prototype.JE,set:X.prototype.BH});X.prototype.get_timescale=X.prototype.OE=function(){return OA(this.kB)}; +X.prototype.set_timescale=X.prototype.GH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);PA(c,a)};Object.defineProperty(X.prototype,"timescale",{get:X.prototype.OE,set:X.prototype.GH});X.prototype.get_viterations=X.prototype.QE=function(){return QA(this.kB)};X.prototype.set_viterations=X.prototype.IH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);RA(c,a)};Object.defineProperty(X.prototype,"viterations",{get:X.prototype.QE,set:X.prototype.IH}); +X.prototype.get_piterations=X.prototype.ME=function(){return SA(this.kB)};X.prototype.set_piterations=X.prototype.EH=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);TA(c,a)};Object.defineProperty(X.prototype,"piterations",{get:X.prototype.ME,set:X.prototype.EH});X.prototype.get_diterations=X.prototype.fC=function(){return UA(this.kB)};X.prototype.set_diterations=X.prototype.XE=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);VA(c,a)}; +Object.defineProperty(X.prototype,"diterations",{get:X.prototype.fC,set:X.prototype.XE});X.prototype.get_citerations=X.prototype.dC=function(){return WA(this.kB)};X.prototype.set_citerations=X.prototype.VE=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);XA(c,a)};Object.defineProperty(X.prototype,"citerations",{get:X.prototype.dC,set:X.prototype.VE});X.prototype.get_collisions=X.prototype.eC=function(){return YA(this.kB)}; +X.prototype.set_collisions=X.prototype.WE=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);ZA(c,a)};Object.defineProperty(X.prototype,"collisions",{get:X.prototype.eC,set:X.prototype.WE});X.prototype.__destroy__=function(){$A(this.kB)};function Y(a,c,d,e){ND();a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);"object"==typeof e&&(e=RD(e));this.kB=aB(a,c,d,e);h(Y)[this.kB]=this}Y.prototype=Object.create(q.prototype); +Y.prototype.constructor=Y;Y.prototype.lB=Y;Y.mB={};b.btSoftBody=Y;Y.prototype.checkLink=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return!!bB(d,a,c)};Y.prototype.checkFace=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);return!!cB(e,a,c,d)};Y.prototype.appendMaterial=function(){return k(dB(this.kB),V)}; +Y.prototype.appendNode=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);eB(d,a,c)};Y.prototype.appendLink=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);fB(g,a,c,d,e)}; +Y.prototype.appendFace=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);gB(g,a,c,d,e)};Y.prototype.appendTetra=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);hB(n,a,c,d,e,g)}; +Y.prototype.appendAnchor=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);iB(g,a,c,d,e)};Y.prototype.addForce=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);void 0===c?jB(d,a):kB(d,a,c)};Y.prototype.addAeroForceToNode=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);lB(d,a,c)}; +Y.prototype.getTotalMass=function(){return mB(this.kB)};Y.prototype.setTotalMass=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);nB(d,a,c)};Y.prototype.setMass=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);oB(d,a,c)};Y.prototype.transform=Y.prototype.transform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);pB(c,a)}; +Y.prototype.translate=Y.prototype.translate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);qB(c,a)};Y.prototype.rotate=Y.prototype.rotate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);rB(c,a)};Y.prototype.scale=Y.prototype.scale=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);sB(c,a)};Y.prototype.generateClusters=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return void 0===c?tB(d,a):uB(d,a,c)}; +Y.prototype.generateBendingConstraints=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);return vB(d,a,c)};Y.prototype.upcast=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(wB(c,a),Y)};Y.prototype.getRestLengthScale=function(){return xB(this.kB)};Y.prototype.setRestLengthScale=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);yB(c,a)}; +Y.prototype.setAnisotropicFriction=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);zB(d,a,c)};Y.prototype.getCollisionShape=function(){return k(AB(this.kB),l)};Y.prototype.setContactProcessingThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);BB(c,a)};Y.prototype.setActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);CB(c,a)}; +Y.prototype.forceActivationState=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);DB(c,a)};Y.prototype.activate=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);void 0===a?EB(c):FB(c,a)};Y.prototype.isActive=function(){return!!GB(this.kB)};Y.prototype.isKinematicObject=function(){return!!HB(this.kB)};Y.prototype.isStaticObject=function(){return!!IB(this.kB)};Y.prototype.isStaticOrKinematicObject=function(){return!!JB(this.kB)};Y.prototype.getRestitution=function(){return KB(this.kB)}; +Y.prototype.getFriction=function(){return LB(this.kB)};Y.prototype.getRollingFriction=function(){return MB(this.kB)};Y.prototype.setRestitution=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);NB(c,a)};Y.prototype.setFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);OB(c,a)};Y.prototype.setRollingFriction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);PB(c,a)};Y.prototype.getWorldTransform=function(){return k(QB(this.kB),r)};Y.prototype.getCollisionFlags=function(){return RB(this.kB)}; +Y.prototype.setCollisionFlags=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);SB(c,a)};Y.prototype.setWorldTransform=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);TB(c,a)};Y.prototype.setCollisionShape=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);UB(c,a)};Y.prototype.setCcdMotionThreshold=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);VB(c,a)};Y.prototype.setCcdSweptSphereRadius=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);WB(c,a)}; +Y.prototype.getUserIndex=function(){return XB(this.kB)};Y.prototype.setUserIndex=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);YB(c,a)};Y.prototype.getUserPointer=function(){return k(ZB(this.kB),XD)};Y.prototype.setUserPointer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);$B(c,a)};Y.prototype.getBroadphaseHandle=function(){return k(aC(this.kB),YD)};Y.prototype.get_m_cfg=Y.prototype.QC=function(){return k(bC(this.kB),X)}; +Y.prototype.set_m_cfg=Y.prototype.HF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cC(c,a)};Object.defineProperty(Y.prototype,"m_cfg",{get:Y.prototype.QC,set:Y.prototype.HF});Y.prototype.get_m_nodes=Y.prototype.QD=function(){return k(dC(this.kB),CF)};Y.prototype.set_m_nodes=Y.prototype.HG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);eC(c,a)};Object.defineProperty(Y.prototype,"m_nodes",{get:Y.prototype.QD,set:Y.prototype.HG}); +Y.prototype.get_m_faces=Y.prototype.KB=function(){return k(fC(this.kB),BF)};Y.prototype.set_m_faces=Y.prototype.UB=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);gC(c,a)};Object.defineProperty(Y.prototype,"m_faces",{get:Y.prototype.KB,set:Y.prototype.UB});Y.prototype.get_m_materials=Y.prototype.MD=function(){return k(hC(this.kB),DF)};Y.prototype.set_m_materials=Y.prototype.DG=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);iC(c,a)}; +Object.defineProperty(Y.prototype,"m_materials",{get:Y.prototype.MD,set:Y.prototype.DG});Y.prototype.get_m_anchors=Y.prototype.GC=function(){return k(jC(this.kB),EF)};Y.prototype.set_m_anchors=Y.prototype.xF=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);kC(c,a)};Object.defineProperty(Y.prototype,"m_anchors",{get:Y.prototype.GC,set:Y.prototype.xF});Y.prototype.__destroy__=function(){lC(this.kB)}; +function FF(a){a&&"object"===typeof a&&(a=a.kB);this.kB=void 0===a?mC():nC(a);h(FF)[this.kB]=this}FF.prototype=Object.create(nE.prototype);FF.prototype.constructor=FF;FF.prototype.lB=FF;FF.mB={};b.btSoftBodyRigidBodyCollisionConfiguration=FF;FF.prototype.__destroy__=function(){oC(this.kB)};function GF(){this.kB=pC();h(GF)[this.kB]=this}GF.prototype=Object.create(sE.prototype);GF.prototype.constructor=GF;GF.prototype.lB=GF;GF.mB={};b.btDefaultSoftBodySolver=GF;GF.prototype.__destroy__=function(){qC(this.kB)}; +function HF(){throw"cannot construct a btSoftBodyArray, no constructor in IDL";}HF.prototype=Object.create(f.prototype);HF.prototype.constructor=HF;HF.prototype.lB=HF;HF.mB={};b.btSoftBodyArray=HF;HF.prototype.size=HF.prototype.size=function(){return rC(this.kB)};HF.prototype.at=HF.prototype.at=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);return k(sC(c,a),Y)};HF.prototype.__destroy__=function(){tC(this.kB)}; +function Z(a,c,d,e,g){a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);this.kB=uC(a,c,d,e,g);h(Z)[this.kB]=this}Z.prototype=Object.create(x.prototype);Z.prototype.constructor=Z;Z.prototype.lB=Z;Z.mB={};b.btSoftRigidDynamicsWorld=Z; +Z.prototype.addSoftBody=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);vC(e,a,c,d)};Z.prototype.removeSoftBody=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);wC(c,a)};Z.prototype.removeCollisionObject=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);xC(c,a)};Z.prototype.getWorldInfo=function(){return k(yC(this.kB),S)};Z.prototype.getSoftBodyArray=function(){return k(zC(this.kB),HF)}; +Z.prototype.getDispatcher=function(){return k(AC(this.kB),TD)};Z.prototype.rayTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);BC(e,a,c,d)};Z.prototype.getPairCache=function(){return k(CC(this.kB),UD)};Z.prototype.getDispatchInfo=function(){return k(DC(this.kB),p)}; +Z.prototype.addCollisionObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?EC(e,a):void 0===d?FC(e,a,c):GC(e,a,c,d)};Z.prototype.getBroadphase=function(){return k(HC(this.kB),VD)}; +Z.prototype.convexSweepTest=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);IC(n,a,c,d,e,g)};Z.prototype.contactPairTest=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);JC(e,a,c,d)}; +Z.prototype.contactTest=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);KC(d,a,c)};Z.prototype.updateSingleAabb=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);LC(c,a)};Z.prototype.setDebugDrawer=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);MC(c,a)};Z.prototype.getDebugDrawer=function(){return k(NC(this.kB),WD)};Z.prototype.debugDrawWorld=function(){OC(this.kB)}; +Z.prototype.debugDrawObject=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);PC(e,a,c,d)};Z.prototype.setGravity=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);QC(c,a)};Z.prototype.getGravity=function(){return k(RC(this.kB),m)}; +Z.prototype.addRigidBody=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?SC(e,a):void 0===d?_emscripten_bind_btSoftRigidDynamicsWorld_addRigidBody_2(e,a,c):TC(e,a,c,d)};Z.prototype.removeRigidBody=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);UC(c,a)}; +Z.prototype.addConstraint=function(a,c){var d=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);void 0===c?VC(d,a):WC(d,a,c)};Z.prototype.removeConstraint=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);XC(c,a)};Z.prototype.stepSimulation=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);return void 0===c?YC(e,a):void 0===d?ZC(e,a,c):$C(e,a,c,d)}; +Z.prototype.setContactAddedCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);aD(c,a)};Z.prototype.setContactProcessedCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);bD(c,a)};Z.prototype.setContactDestroyedCallback=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);cD(c,a)};Z.prototype.addAction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);dD(c,a)};Z.prototype.removeAction=function(a){var c=this.kB;a&&"object"===typeof a&&(a=a.kB);eD(c,a)}; +Z.prototype.getSolverInfo=function(){return k(fD(this.kB),t)};Z.prototype.setInternalTickCallback=function(a,c,d){var e=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);void 0===c?gD(e,a):void 0===d?hD(e,a,c):iD(e,a,c,d)};Z.prototype.__destroy__=function(){jD(this.kB)};function IF(){this.kB=kD();h(IF)[this.kB]=this}IF.prototype=Object.create(f.prototype);IF.prototype.constructor=IF;IF.prototype.lB=IF;IF.mB={};b.btSoftBodyHelpers=IF; +IF.prototype.CreateRope=function(a,c,d,e,g){var n=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);return k(lD(n,a,c,d,e,g),Y)}; +IF.prototype.CreatePatch=function(a,c,d,e,g,n,z,T,Da){var dc=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);T&&"object"===typeof T&&(T=T.kB);Da&&"object"===typeof Da&&(Da=Da.kB);return k(mD(dc,a,c,d,e,g,n,z,T,Da),Y)}; +IF.prototype.CreatePatchUV=function(a,c,d,e,g,n,z,T,Da,dc){var JF=this.kB;ND();a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);n&&"object"===typeof n&&(n=n.kB);z&&"object"===typeof z&&(z=z.kB);T&&"object"===typeof T&&(T=T.kB);Da&&"object"===typeof Da&&(Da=Da.kB);"object"==typeof dc&&(dc=RD(dc));return k(nD(JF,a,c,d,e,g,n,z,T,Da,dc),Y)}; +IF.prototype.CreateEllipsoid=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);return k(oD(g,a,c,d,e),Y)}; +IF.prototype.CreateFromTriMesh=function(a,c,d,e,g){var n=this.kB;ND();a&&"object"===typeof a&&(a=a.kB);"object"==typeof c&&(c=RD(c));if("object"==typeof d&&"object"===typeof d){var z=OD(d,va);PD(d,va,z);d=z}e&&"object"===typeof e&&(e=e.kB);g&&"object"===typeof g&&(g=g.kB);return k(pD(n,a,c,d,e,g),Y)}; +IF.prototype.CreateFromConvexHull=function(a,c,d,e){var g=this.kB;a&&"object"===typeof a&&(a=a.kB);c&&"object"===typeof c&&(c=c.kB);d&&"object"===typeof d&&(d=d.kB);e&&"object"===typeof e&&(e=e.kB);return k(qD(g,a,c,d,e),Y)};IF.prototype.__destroy__=function(){rD(this.kB)}; +(function(){function a(){b.PHY_FLOAT=sD();b.PHY_DOUBLE=tD();b.PHY_INTEGER=uD();b.PHY_SHORT=vD();b.PHY_FIXEDPOINT88=wD();b.PHY_UCHAR=xD();b.CONST_GIMPACT_COMPOUND_SHAPE=yD();b.CONST_GIMPACT_TRIMESH_SHAPE_PART=zD();b.CONST_GIMPACT_TRIMESH_SHAPE=AD();b.BT_CONSTRAINT_ERP=BD();b.BT_CONSTRAINT_STOP_ERP=CD();b.BT_CONSTRAINT_CFM=DD();b.BT_CONSTRAINT_STOP_CFM=ED()}Ca?a():Aa.unshift(a)})();b.CONTACT_ADDED_CALLBACK_SIGNATURE="iiiiiiii";b.CONTACT_DESTROYED_CALLBACK_SIGNATURE="ii"; +b.CONTACT_PROCESSED_CALLBACK_SIGNATURE="iiii";b.INTERNAL_TICK_CALLBACK_SIGNATURE="vif";this.Ammo=b; + + + return Ammo.ready +} + +); +})(); +if (typeof exports === 'object' && typeof module === 'object') + module.exports = Ammo; +else if (typeof define === 'function' && define['amd']) + define([], function() { return Ammo; }); +else if (typeof exports === 'object') + exports["Ammo"] = Ammo; diff --git a/frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.wasm b/frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.wasm new file mode 100644 index 0000000000..a013d060a5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.wasm differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/0.webp b/frontend/apps/artcraft/app/public/resources/avatars/0.webp new file mode 100644 index 0000000000..f95afe03b2 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/0.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/1.webp b/frontend/apps/artcraft/app/public/resources/avatars/1.webp new file mode 100644 index 0000000000..6acf0da539 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/1.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/10.webp b/frontend/apps/artcraft/app/public/resources/avatars/10.webp new file mode 100644 index 0000000000..f6fd4df884 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/10.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/11.webp b/frontend/apps/artcraft/app/public/resources/avatars/11.webp new file mode 100644 index 0000000000..c14bcebda5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/11.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/12.webp b/frontend/apps/artcraft/app/public/resources/avatars/12.webp new file mode 100644 index 0000000000..0078476c03 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/12.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/13.webp b/frontend/apps/artcraft/app/public/resources/avatars/13.webp new file mode 100644 index 0000000000..e8b1635f36 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/13.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/14.webp b/frontend/apps/artcraft/app/public/resources/avatars/14.webp new file mode 100644 index 0000000000..fd532381f9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/14.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/15.webp b/frontend/apps/artcraft/app/public/resources/avatars/15.webp new file mode 100644 index 0000000000..c61aea6d96 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/15.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/16.webp b/frontend/apps/artcraft/app/public/resources/avatars/16.webp new file mode 100644 index 0000000000..f112d25df5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/16.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/17.webp b/frontend/apps/artcraft/app/public/resources/avatars/17.webp new file mode 100644 index 0000000000..f7ab766002 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/17.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/18.webp b/frontend/apps/artcraft/app/public/resources/avatars/18.webp new file mode 100644 index 0000000000..fa3fc2f0ac Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/18.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/19.webp b/frontend/apps/artcraft/app/public/resources/avatars/19.webp new file mode 100644 index 0000000000..0207cd889e Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/19.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/2.webp b/frontend/apps/artcraft/app/public/resources/avatars/2.webp new file mode 100644 index 0000000000..03777b780f Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/2.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/20.webp b/frontend/apps/artcraft/app/public/resources/avatars/20.webp new file mode 100644 index 0000000000..367f411541 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/20.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/21.webp b/frontend/apps/artcraft/app/public/resources/avatars/21.webp new file mode 100644 index 0000000000..e6eeb4d155 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/21.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/22.webp b/frontend/apps/artcraft/app/public/resources/avatars/22.webp new file mode 100644 index 0000000000..f8c0ffca87 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/22.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/23.webp b/frontend/apps/artcraft/app/public/resources/avatars/23.webp new file mode 100644 index 0000000000..15137375b5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/23.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/24.webp b/frontend/apps/artcraft/app/public/resources/avatars/24.webp new file mode 100644 index 0000000000..4e925a9e35 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/24.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/3.webp b/frontend/apps/artcraft/app/public/resources/avatars/3.webp new file mode 100644 index 0000000000..ccb44b0afc Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/3.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/4.webp b/frontend/apps/artcraft/app/public/resources/avatars/4.webp new file mode 100644 index 0000000000..421877e2e4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/4.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/5.webp b/frontend/apps/artcraft/app/public/resources/avatars/5.webp new file mode 100644 index 0000000000..2b4db489a0 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/5.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/6.webp b/frontend/apps/artcraft/app/public/resources/avatars/6.webp new file mode 100644 index 0000000000..dc8e5a2d96 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/6.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/7.webp b/frontend/apps/artcraft/app/public/resources/avatars/7.webp new file mode 100644 index 0000000000..d711281f67 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/7.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/8.webp b/frontend/apps/artcraft/app/public/resources/avatars/8.webp new file mode 100644 index 0000000000..a71c77e64a Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/8.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/avatars/9.webp b/frontend/apps/artcraft/app/public/resources/avatars/9.webp new file mode 100644 index 0000000000..bf72a412ce Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/avatars/9.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img03.png b/frontend/apps/artcraft/app/public/resources/characters/img03.png new file mode 100644 index 0000000000..65f45ed1d5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img03.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img04.png b/frontend/apps/artcraft/app/public/resources/characters/img04.png new file mode 100644 index 0000000000..58b3b402aa Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img04.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img06.png b/frontend/apps/artcraft/app/public/resources/characters/img06.png new file mode 100644 index 0000000000..df89a6bc09 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img06.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img07.png b/frontend/apps/artcraft/app/public/resources/characters/img07.png new file mode 100644 index 0000000000..8f8bab1bfc Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img07.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img08.png b/frontend/apps/artcraft/app/public/resources/characters/img08.png new file mode 100644 index 0000000000..5cb4e1d0d2 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img08.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img11.png b/frontend/apps/artcraft/app/public/resources/characters/img11.png new file mode 100644 index 0000000000..85cc64d37f Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img11.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img13.png b/frontend/apps/artcraft/app/public/resources/characters/img13.png new file mode 100644 index 0000000000..ae0af1c8d5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img13.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img14.png b/frontend/apps/artcraft/app/public/resources/characters/img14.png new file mode 100644 index 0000000000..fa32a7a3e7 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img14.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/img15.png b/frontend/apps/artcraft/app/public/resources/characters/img15.png new file mode 100644 index 0000000000..039b9666c9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/img15.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters/roko.png b/frontend/apps/artcraft/app/public/resources/characters/roko.png new file mode 100644 index 0000000000..9c5a9a0b5e Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters/roko.png differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Baseball Bunt.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Baseball Bunt.webp new file mode 100644 index 0000000000..1bc0779051 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Baseball Bunt.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Breakdance Ready.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Breakdance Ready.webp new file mode 100644 index 0000000000..0341b774a6 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Breakdance Ready.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Breakdance Uprock Var 1.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Breakdance Uprock Var 1.webp new file mode 100644 index 0000000000..02faff7b67 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Breakdance Uprock Var 1.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Catwalk Walk Forward Crossed.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Catwalk Walk Forward Crossed.webp new file mode 100644 index 0000000000..30e905ff21 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Catwalk Walk Forward Crossed.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Convulsing.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Convulsing.webp new file mode 100644 index 0000000000..4217107595 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Convulsing.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Corkscrew Evade.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Corkscrew Evade.webp new file mode 100644 index 0000000000..a60da25e2f Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Corkscrew Evade.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Femme Peek Around Corner.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Femme Peek Around Corner.webp new file mode 100644 index 0000000000..78d1e85984 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Femme Peek Around Corner.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Getting Bit.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Getting Bit.webp new file mode 100644 index 0000000000..dc7583905f Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Getting Bit.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Ginga Variation 3.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Ginga Variation 3.webp new file mode 100644 index 0000000000..2604231308 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Ginga Variation 3.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Great Sword Impact.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Great Sword Impact.webp new file mode 100644 index 0000000000..b2a8f63cd1 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Great Sword Impact.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Jump Forward.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Jump Forward.webp new file mode 100644 index 0000000000..95321baded Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Jump Forward.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Laying Breathless.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Laying Breathless.webp new file mode 100644 index 0000000000..6d608b40be Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Laying Breathless.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Leaning.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Leaning.webp new file mode 100644 index 0000000000..de6ca04563 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Leaning.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Lengthy Head Nod.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Lengthy Head Nod.webp new file mode 100644 index 0000000000..62215bbf73 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Lengthy Head Nod.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Look Around.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Look Around.webp new file mode 100644 index 0000000000..0fc3b39e38 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Look Around.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Orc Walk.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Orc Walk.webp new file mode 100644 index 0000000000..ac1f04cee7 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Orc Walk.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Pushing.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Pushing.webp new file mode 100644 index 0000000000..08050dad13 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Pushing.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Right Hook.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Right Hook.webp new file mode 100644 index 0000000000..bd7035ccd0 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Right Hook.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Right Strafe Walking.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Right Strafe Walking.webp new file mode 100644 index 0000000000..cdacfec2ed Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Right Strafe Walking.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Run Look Back.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Run Look Back.webp new file mode 100644 index 0000000000..ce57476f38 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Run Look Back.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Sitting Yell.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Sitting Yell.webp new file mode 100644 index 0000000000..8241892b46 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Sitting Yell.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Sneaking Forward.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Sneaking Forward.webp new file mode 100644 index 0000000000..30723e9bbc Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Sneaking Forward.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Standard Walk.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Standard Walk.webp new file mode 100644 index 0000000000..9801212f76 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Standard Walk.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Sword And Shield Casting.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Sword And Shield Casting.webp new file mode 100644 index 0000000000..3e94e94fd2 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Sword And Shield Casting.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Sword And Shield Jump.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Sword And Shield Jump.webp new file mode 100644 index 0000000000..d3403312b4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Sword And Shield Jump.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Texting While Standing.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Texting While Standing.webp new file mode 100644 index 0000000000..7e23caad00 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Texting While Standing.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Texting.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Texting.webp new file mode 100644 index 0000000000..aa2c26d800 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Texting.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Tonic Seizure.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Tonic Seizure.webp new file mode 100644 index 0000000000..1bcff1a9e7 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Tonic Seizure.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Twist Dance.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Twist Dance.webp new file mode 100644 index 0000000000..95451e1bb4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Twist Dance.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Uppercut.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Uppercut.webp new file mode 100644 index 0000000000..7160a2c630 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Uppercut.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/characters_gif/Zombie Biting.webp b/frontend/apps/artcraft/app/public/resources/characters_gif/Zombie Biting.webp new file mode 100644 index 0000000000..8769927de0 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/characters_gif/Zombie Biting.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/fonts/CarterOneRegular.json b/frontend/apps/artcraft/app/public/resources/fonts/CarterOneRegular.json new file mode 100644 index 0000000000..3ce2206375 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/fonts/CarterOneRegular.json @@ -0,0 +1,1647 @@ +{ + "glyphs": { + "0": { + "ha": 1105, + "x_min": 94, + "x_max": 1096, + "o": "m 94 460 q 126 677 94 576 q 225 853 159 778 q 388 970 290 928 q 617 1013 486 1013 q 767 994 699 1013 q 888 940 835 975 q 980 859 941 906 q 1045 755 1019 811 q 1083 637 1071 699 q 1096 510 1096 574 q 1059 280 1096 382 q 954 109 1021 179 q 791 2 886 39 q 581 -35 696 -35 q 384 0 473 -35 q 230 99 294 35 q 130 255 165 164 q 94 460 94 347 m 387 486 q 395 376 387 432 q 426 275 404 321 q 487 201 448 230 q 587 173 526 173 q 663 188 630 173 q 721 228 696 203 q 762 287 745 254 q 789 359 779 321 q 804 437 800 397 q 808 514 808 477 q 804 578 808 545 q 790 646 800 612 q 765 710 781 680 q 728 763 749 740 q 677 800 706 787 q 610 814 648 814 q 533 800 566 814 q 474 762 499 786 q 432 707 449 739 q 406 638 416 675 q 391 563 395 602 q 387 486 387 524 z " + }, + "1": { + "ha": 468, + "x_min": 112, + "x_max": 441, + "o": "m 153 876 q 156 910 154 900 q 172 922 159 920 l 407 979 q 425 981 417 981 q 438 975 434 981 q 441 952 441 969 l 401 113 q 399 87 401 98 q 393 71 397 77 q 383 61 389 64 q 369 54 377 57 l 157 -8 q 141 -10 149 -10 q 121 -4 130 -10 q 112 20 112 3 q 112 22 112 20 q 113 35 112 24 q 114 67 113 45 q 117 130 115 90 q 122 232 119 170 q 129 384 125 294 q 139 595 134 474 q 153 876 145 717 z " + }, + "2": { + "ha": 768, + "x_min": 67, + "x_max": 758, + "o": "m 682 613 q 663 579 676 602 q 633 528 650 557 q 595 466 615 499 q 554 400 574 433 q 514 336 533 367 q 480 282 494 306 q 456 245 465 259 q 448 231 448 231 q 453 231 448 231 q 480 232 461 231 q 523 236 498 234 q 575 241 547 238 q 631 246 604 244 q 683 250 659 248 q 723 252 707 252 q 758 237 758 252 q 752 218 758 227 q 747 207 747 208 l 650 24 q 648 21 648 24 q 646 18 648 18 q 621 3 633 5 q 602 1 610 2 q 500 -1 544 0 q 420 -2 456 -1 q 356 -2 385 -2 q 299 -3 327 -3 q 244 -3 272 -3 q 183 -3 216 -3 q 93 7 118 -3 q 67 37 67 17 q 76 67 67 50 q 100 106 85 84 q 188 231 143 168 q 273 353 233 294 q 348 461 314 411 q 404 545 382 511 q 430 598 422 574 q 438 645 438 623 q 427 693 438 672 q 396 729 416 714 q 349 751 376 743 q 292 758 323 758 q 214 746 254 758 q 135 709 174 734 q 113 701 121 701 q 103 707 106 701 q 101 718 101 713 q 108 780 101 745 q 123 848 115 815 q 138 904 132 880 q 145 931 145 928 q 163 944 146 931 q 216 971 180 956 q 310 1000 252 987 q 450 1013 367 1013 q 548 992 498 1013 q 636 937 597 972 q 699 854 675 902 q 724 751 724 806 q 713 684 724 718 q 682 613 703 650 z " + }, + "3": { + "ha": 752, + "x_min": 65, + "x_max": 730, + "o": "m 441 -33 q 413 -35 427 -34 q 387 -35 399 -35 q 315 -31 349 -35 q 249 -17 281 -26 q 183 6 216 -7 q 111 35 149 19 q 97 44 102 37 q 91 58 93 50 q 89 75 90 66 q 88 90 89 83 q 84 120 87 98 q 78 167 81 142 q 71 213 75 191 q 66 243 68 235 q 65 249 65 245 q 72 264 65 258 q 87 270 79 270 q 93 269 90 270 q 142 237 117 254 q 195 208 167 221 q 256 186 224 195 q 329 177 289 177 q 406 198 380 177 q 431 271 431 220 q 428 308 431 288 q 414 345 425 328 q 383 374 404 362 q 330 386 363 386 l 208 386 q 210 405 208 389 q 215 443 212 420 q 221 489 218 465 q 228 535 224 514 q 233 572 231 557 q 236 588 235 587 q 244 617 240 610 q 253 625 249 625 q 278 620 265 621 q 304 618 291 618 q 377 644 351 618 q 404 717 404 669 q 399 749 404 733 q 384 776 394 764 q 357 795 373 788 q 319 803 341 803 q 269 800 294 803 q 219 792 243 797 q 171 781 194 787 q 129 768 148 774 q 131 802 131 781 q 131 823 131 812 q 130 845 130 833 q 132 888 130 866 q 138 928 133 909 q 151 960 142 946 q 176 978 160 973 q 241 997 214 990 q 292 1007 269 1004 q 340 1011 316 1010 q 394 1013 363 1013 q 507 998 450 1013 q 611 951 564 983 q 687 872 657 920 q 716 759 716 825 q 704 681 716 716 q 674 618 692 646 q 635 569 656 590 q 595 534 614 549 q 640 504 616 523 q 683 456 663 486 q 716 386 703 427 q 730 290 730 345 q 708 177 730 233 q 647 77 686 121 q 555 2 608 32 q 441 -33 503 -27 z " + }, + "4": { + "ha": 917, + "x_min": 16, + "x_max": 914, + "o": "m 913 387 q 910 372 912 382 q 905 348 907 361 q 900 319 903 335 q 894 289 897 304 l 878 212 q 873 196 876 201 q 858 189 869 191 q 823 187 846 187 q 758 187 800 187 l 746 63 q 743 51 745 57 q 734 42 741 45 q 712 33 730 40 q 671 18 695 26 q 621 -1 648 9 q 569 -19 593 -11 q 526 -33 545 -27 q 500 -38 507 -38 q 483 -33 490 -38 q 476 -14 476 -28 q 479 69 476 19 q 488 184 482 119 q 374 183 430 184 q 269 182 317 182 q 184 182 220 182 q 129 182 147 182 q 43 186 69 182 q 16 205 16 190 q 23 224 16 213 q 45 261 29 234 q 84 326 62 288 q 134 408 107 363 q 188 497 161 452 l 329 730 q 375 813 355 772 q 419 888 395 854 q 476 947 443 922 q 564 983 510 972 l 779 1040 q 797 1042 789 1042 q 809 1038 804 1042 q 813 1021 813 1034 l 813 1013 q 808 899 810 950 q 802 803 805 848 q 797 715 800 758 q 791 627 793 673 q 785 531 788 582 q 780 416 783 479 q 855 418 825 418 q 890 419 884 419 q 906 413 898 418 q 914 395 914 409 q 913 387 914 389 m 507 408 q 521 506 515 454 q 531 612 527 558 q 538 718 535 665 q 543 821 541 771 l 296 408 l 507 408 z " + }, + "5": { + "ha": 732, + "x_min": 41, + "x_max": 715, + "o": "m 96 188 q 103 195 98 191 q 116 199 108 199 l 120 199 q 169 194 146 195 q 223 193 192 193 q 291 198 258 193 q 349 216 323 203 q 391 255 375 230 q 406 320 406 280 q 388 394 406 367 q 340 433 370 420 q 276 449 311 446 q 206 452 241 452 q 182 452 193 452 q 163 452 172 452 q 134 455 142 452 q 126 471 126 458 q 127 482 126 476 q 129 496 127 488 q 139 619 131 557 q 157 742 146 680 q 178 864 167 803 q 198 986 189 925 q 452 1015 327 1001 q 707 1046 576 1029 q 715 1031 715 1046 q 713 1019 715 1024 q 709 1005 713 1017 q 701 976 706 994 q 690 937 696 958 q 679 893 684 915 q 667 853 673 872 q 658 821 662 833 q 635 785 651 798 q 596 766 618 772 q 549 757 574 759 q 496 755 523 755 q 452 755 475 755 q 409 755 429 755 q 382 753 393 755 q 363 744 371 751 q 353 722 356 736 q 349 684 349 708 q 493 660 427 680 q 609 601 559 640 q 687 503 659 562 q 715 360 715 444 q 693 223 715 283 q 634 120 671 164 q 548 46 597 76 q 441 -2 498 16 q 324 -27 385 -20 q 206 -35 264 -35 q 123 -31 163 -35 q 52 -10 83 -26 q 45 -5 49 -9 q 41 1 41 -1 l 96 188 z " + }, + "6": { + "ha": 873, + "x_min": 79, + "x_max": 875, + "o": "m 450 -35 q 287 -3 356 -35 q 171 82 217 28 q 101 208 124 136 q 79 361 79 280 q 95 484 79 425 q 139 596 111 542 q 202 702 166 650 q 278 802 239 753 q 357 899 317 851 q 432 994 397 946 q 443 1008 437 1000 q 456 1022 449 1016 q 470 1033 463 1029 q 487 1038 477 1038 l 657 1038 q 686 1033 674 1038 q 699 1018 699 1028 q 688 996 699 1008 q 579 848 628 926 q 487 685 530 770 q 534 694 511 692 q 581 697 556 697 q 711 668 656 697 q 803 592 766 639 q 857 488 839 546 q 875 372 875 430 q 848 206 875 281 q 767 78 821 131 q 634 -5 713 24 q 450 -35 554 -35 m 387 325 q 391 280 387 305 q 404 231 394 254 q 430 192 414 208 q 473 176 447 176 q 518 187 499 176 q 549 216 536 198 q 570 256 562 233 q 581 303 577 279 q 586 351 585 327 q 587 393 587 374 q 585 441 587 416 q 575 486 583 466 q 550 518 566 505 q 504 531 533 531 q 442 510 465 531 q 407 458 419 489 q 391 391 395 427 q 387 325 387 355 z " + }, + "7": { + "ha": 723, + "x_min": 69, + "x_max": 791, + "o": "m 770 897 l 621 517 q 561 366 591 441 q 505 222 531 290 q 457 102 478 155 q 425 21 436 50 q 410 -17 414 -3 q 401 -36 405 -30 q 393 -45 397 -43 q 380 -49 389 -47 q 326 -61 354 -55 q 271 -71 298 -66 q 219 -80 244 -76 q 174 -87 194 -84 q 167 -87 172 -87 q 150 -81 155 -87 q 144 -64 144 -74 q 151 -40 144 -54 q 174 16 157 -24 q 214 111 191 56 q 264 231 237 166 q 319 362 292 296 l 458 701 l 321 700 q 263 700 293 700 q 206 699 234 699 q 154 699 178 699 q 111 699 129 699 q 80 702 90 699 q 69 714 69 705 q 73 732 69 723 q 77 742 77 741 q 103 798 90 771 q 129 850 116 825 q 154 903 141 876 q 179 958 166 929 q 181 961 180 960 q 182 964 181 963 q 205 980 194 977 q 225 982 216 982 q 357 983 300 983 q 460 984 414 984 q 544 985 507 985 q 617 986 582 985 q 688 986 652 986 q 766 986 724 986 q 791 964 791 986 q 786 937 791 954 q 770 897 781 921 z " + }, + "8": { + "ha": 868, + "x_min": 76, + "x_max": 848, + "o": "m 425 -35 q 341 -28 383 -35 q 261 -6 300 -21 q 189 32 222 9 q 130 87 155 55 q 90 159 104 119 q 76 251 76 200 q 89 339 76 300 q 123 411 101 378 q 173 475 144 445 q 234 536 202 505 q 160 619 183 571 q 137 726 137 667 q 152 817 137 777 q 195 890 168 858 q 259 945 222 922 q 339 983 296 968 q 429 1006 382 998 q 526 1013 477 1013 q 626 997 575 1013 q 716 948 676 980 q 782 870 757 916 q 808 765 808 824 q 799 702 808 732 q 777 647 791 673 q 746 598 764 621 q 710 557 728 576 q 764 512 739 537 q 808 455 789 487 q 838 382 827 422 q 848 290 848 341 q 830 190 848 235 q 781 109 812 145 q 707 46 749 73 q 618 1 665 19 q 522 -26 572 -17 q 425 -35 472 -35 m 381 269 q 385 213 381 243 q 398 156 388 182 q 428 111 409 129 q 478 93 447 93 q 510 104 496 93 q 532 133 523 115 q 548 173 542 151 q 557 216 554 195 q 562 257 561 238 q 564 288 564 276 q 558 339 564 311 q 541 393 553 368 q 511 435 530 418 q 468 452 493 452 q 434 442 448 452 q 411 417 420 433 q 396 382 401 402 q 387 342 390 363 q 382 303 383 322 q 381 269 381 283 m 391 742 q 396 699 391 722 q 413 656 401 676 q 441 623 424 636 q 484 610 458 610 q 522 626 508 610 q 543 666 536 643 q 552 715 550 690 q 553 756 553 740 q 549 796 553 775 q 536 834 545 817 q 511 863 527 851 q 473 874 496 874 q 433 861 449 874 q 408 828 417 848 q 395 785 399 808 q 391 742 391 762 z " + }, + "9": { + "ha": 899, + "x_min": 74, + "x_max": 905, + "o": "m 417 -14 q 399 2 399 -9 q 406 18 399 9 q 444 80 426 47 q 478 149 463 113 q 506 218 493 184 q 527 283 518 253 q 456 267 492 273 q 384 261 419 261 q 261 289 318 261 q 163 362 205 316 q 98 468 121 408 q 74 592 74 528 q 93 714 74 657 q 147 817 113 770 q 228 900 181 863 q 328 961 274 937 q 439 999 381 986 q 557 1013 498 1013 q 700 977 636 1013 q 810 884 764 941 q 880 756 855 827 q 905 614 905 685 q 889 484 905 547 q 847 360 873 420 q 786 244 821 300 q 713 135 751 188 q 634 33 674 82 q 558 -62 595 -16 q 553 -66 556 -64 q 545 -68 550 -68 q 536 -66 541 -68 q 504 -49 518 -56 q 476 -35 490 -41 q 448 -24 463 -29 q 417 -14 434 -18 m 371 642 q 379 573 371 606 q 405 515 387 541 q 450 475 422 490 q 517 460 477 460 q 543 462 532 460 q 571 471 553 465 q 583 523 578 501 q 590 564 588 545 q 594 602 593 583 q 595 646 595 621 q 594 681 595 661 q 589 722 593 701 q 580 764 586 743 q 563 800 574 784 q 537 825 553 815 q 500 835 522 835 q 437 816 463 835 q 397 769 412 798 q 377 706 382 741 q 371 642 371 672 z " + }, + " ": { + "ha": 298, + "x_min": 0, + "x_max": 0, + "o": "" + }, + " ": { + "ha": 298, + "x_min": 0, + "x_max": 0, + "o": "" + }, + "!": { + "ha": 373, + "x_min": 49, + "x_max": 416, + "o": "m 362 793 q 320 582 340 688 q 281 369 299 477 q 277 338 280 347 q 262 327 274 329 q 210 317 235 322 q 155 304 184 311 q 149 306 150 304 q 148 311 148 307 l 141 981 q 146 1011 142 1002 q 161 1023 149 1020 q 277 1052 218 1036 q 402 1086 336 1069 q 408 1086 404 1086 q 416 1080 416 1086 q 413 1063 416 1079 q 406 1022 410 1047 q 396 965 401 997 q 385 902 391 934 q 373 842 378 871 q 362 793 367 813 m 338 232 q 335 187 336 203 q 334 164 334 172 q 333 154 333 156 q 333 151 333 152 q 332 147 332 150 q 331 134 332 144 q 329 105 330 124 q 325 53 328 86 q 320 22 323 31 q 305 11 317 14 q 184 -13 248 -1 q 62 -45 121 -26 q 56 -46 60 -46 l 50 -41 q 49 -39 49 -39 l 64 146 q 68 177 65 168 q 83 189 71 186 q 200 212 141 199 q 325 238 259 224 q 331 239 327 239 q 338 232 338 239 z " + }, + "\"": { + "ha": 747, + "x_min": 118, + "x_max": 802, + "o": "m 136 532 q 127 733 131 628 q 118 941 123 838 l 118 949 q 122 974 118 967 q 138 982 126 980 q 205 982 170 982 q 276 983 241 983 q 347 983 312 983 q 412 983 381 983 q 422 980 418 983 q 427 967 427 977 l 427 963 l 321 531 q 316 512 318 520 q 312 500 315 505 q 306 493 310 495 q 296 490 302 490 q 225 490 260 490 q 150 489 191 489 q 142 494 144 489 q 138 507 139 500 q 136 522 136 514 q 136 532 136 529 m 511 532 q 502 733 506 628 q 493 941 498 838 l 493 949 q 497 974 493 967 q 513 982 501 980 q 581 982 545 982 q 651 983 616 983 q 722 983 687 983 q 787 983 756 983 q 798 980 793 983 q 802 967 802 977 l 802 963 l 696 531 q 691 512 693 520 q 687 500 690 505 q 681 493 685 495 q 671 490 677 490 q 600 490 635 490 q 525 489 566 489 q 517 494 519 489 q 513 507 514 500 q 511 522 511 514 q 511 532 511 529 z " + }, + "#": { + "ha": 1114, + "x_min": 69, + "x_max": 1147, + "o": "m 275 474 l 296 561 l 202 561 q 141 564 161 561 q 121 578 121 568 q 128 601 121 583 q 144 640 134 618 q 165 684 153 661 q 186 724 176 707 q 200 737 194 731 q 221 744 206 743 l 341 742 q 371 871 357 807 q 399 1001 385 935 q 408 1028 404 1021 q 420 1036 411 1036 l 425 1036 q 478 1019 451 1028 q 531 1001 505 1009 q 584 985 558 992 q 635 973 610 977 q 643 969 640 972 q 647 962 647 967 l 591 739 l 709 736 q 739 868 724 802 q 769 1001 754 933 q 777 1028 774 1021 q 790 1036 781 1036 l 795 1036 q 847 1019 821 1028 q 901 1001 874 1009 q 954 985 928 992 q 1004 973 980 977 q 1013 969 1009 972 q 1017 962 1017 967 l 961 739 q 1017 746 990 743 q 1067 752 1044 750 q 1105 756 1089 755 q 1128 758 1121 758 q 1141 755 1134 758 q 1147 741 1147 751 q 1136 711 1147 735 q 1107 650 1125 687 l 1075 587 q 1061 572 1070 577 q 1034 566 1051 568 q 988 565 1016 565 q 918 566 960 566 l 896 473 q 980 485 943 481 q 1029 490 1017 490 q 1041 486 1034 490 q 1048 472 1048 483 q 1042 452 1048 468 q 1028 414 1037 437 q 1009 362 1020 391 q 989 305 999 334 q 978 290 984 295 q 951 285 971 285 l 924 285 q 891 285 910 285 q 850 286 873 285 l 780 3 q 777 -17 777 -7 q 766 -26 777 -26 q 748 -22 758 -26 l 619 14 q 601 19 614 16 q 576 28 589 23 q 553 42 563 34 q 543 60 543 50 q 545 68 543 62 q 560 126 553 99 q 574 181 567 154 q 587 234 581 208 q 601 288 594 260 q 541 289 570 288 q 481 290 511 289 l 410 3 q 407 -17 408 -7 q 397 -26 407 -26 q 378 -22 389 -26 l 250 14 q 232 19 244 16 q 206 28 219 23 q 183 42 193 34 q 174 60 174 50 q 175 68 174 62 q 205 182 191 130 q 232 292 218 235 q 156 292 187 292 q 106 294 125 292 q 78 298 87 295 q 69 309 69 302 q 77 332 69 315 q 96 371 84 349 q 122 415 108 393 q 148 456 136 438 q 161 469 155 463 q 182 475 168 475 l 275 474 m 644 468 q 655 516 649 492 q 667 564 661 540 q 607 563 636 564 q 549 563 578 563 l 526 470 l 644 468 z " + }, + "$": { + "ha": 673, + "x_min": 83, + "x_max": 676, + "o": "m 517 927 q 602 920 566 924 q 658 914 638 917 l 676 715 q 662 716 675 715 q 633 720 650 718 q 598 723 616 722 q 569 725 581 725 q 532 723 555 725 q 487 713 509 720 q 450 693 465 706 q 434 658 434 680 q 451 610 434 631 q 492 568 467 589 q 547 525 517 547 q 601 476 576 503 q 643 415 626 449 q 659 336 659 380 q 644 265 659 298 q 601 204 628 232 q 538 153 574 176 q 463 113 503 130 q 461 79 463 102 q 457 30 459 57 q 453 -27 455 2 q 450 -81 451 -56 l 396 -80 q 368 -81 387 -80 q 325 -84 349 -82 q 278 -86 302 -85 q 235 -87 254 -87 l 242 42 l 189 40 q 137 38 165 39 q 83 37 109 37 q 106 140 94 90 q 130 243 118 190 q 217 257 175 247 q 291 281 258 267 q 342 314 323 296 q 361 353 361 332 q 356 379 361 365 q 335 410 351 393 q 291 451 319 428 q 214 503 262 473 q 128 583 154 540 q 102 676 102 625 q 121 745 102 710 q 168 809 139 780 q 233 860 197 839 q 306 888 269 881 l 327 1017 q 329 1032 328 1026 q 330 1042 329 1038 q 332 1048 330 1046 q 340 1050 334 1050 q 438 1058 387 1054 q 538 1070 490 1063 q 544 1069 543 1070 q 545 1065 545 1067 q 542 1050 545 1064 q 535 1016 539 1036 q 526 972 530 996 q 517 927 521 948 z " + }, + "%": { + "ha": 1487, + "x_min": 103, + "x_max": 1476, + "o": "m 717 586 q 827 720 770 652 q 938 857 883 789 q 950 871 946 867 q 961 876 954 876 q 967 876 966 876 q 1032 825 1000 849 q 1096 782 1063 802 q 1110 775 1101 780 q 1118 766 1118 771 q 1116 761 1118 763 l 582 113 q 575 103 576 106 q 568 100 574 100 q 561 101 565 100 q 549 104 556 102 q 487 153 510 136 q 450 180 463 171 q 431 193 437 190 q 422 200 426 197 q 416 209 417 203 q 416 222 416 214 q 493 312 456 268 q 566 400 530 356 q 639 491 602 445 q 717 586 676 536 m 103 764 q 121 889 103 831 q 175 989 139 947 q 265 1056 211 1031 q 391 1080 319 1080 q 508 1056 458 1080 q 591 991 558 1031 q 639 899 623 951 q 654 791 654 846 q 634 661 654 718 q 576 564 614 604 q 487 503 539 524 q 372 482 435 482 q 263 502 313 482 q 178 559 214 522 q 123 648 142 595 q 103 764 103 700 m 277 778 q 281 724 277 751 q 296 675 285 697 q 326 639 307 653 q 375 625 345 625 q 428 641 407 625 q 461 682 449 657 q 479 735 474 706 q 484 793 484 765 q 479 842 484 816 q 463 890 474 868 q 433 927 452 913 q 387 942 414 942 q 334 927 355 942 q 300 888 313 912 q 282 836 287 865 q 277 778 277 806 m 924 247 q 942 372 924 314 q 996 472 960 429 q 1086 538 1032 514 q 1212 563 1140 563 q 1330 538 1280 563 q 1412 474 1379 514 q 1460 381 1444 433 q 1476 273 1476 329 q 1455 143 1476 201 q 1398 46 1435 86 q 1308 -14 1360 7 q 1193 -35 1256 -35 q 1084 -15 1134 -35 q 999 41 1035 5 q 944 130 964 78 q 924 247 924 182 m 1098 260 q 1102 207 1098 234 q 1117 158 1106 180 q 1147 122 1128 136 q 1196 108 1166 108 q 1249 124 1228 108 q 1283 164 1270 140 q 1300 218 1295 189 q 1305 275 1305 248 q 1300 325 1305 298 q 1284 373 1295 351 q 1254 410 1273 395 q 1208 425 1236 425 q 1155 410 1176 425 q 1121 371 1134 395 q 1103 318 1108 347 q 1098 260 1098 289 z " + }, + "&": { + "ha": 1058, + "x_min": 54, + "x_max": 1080, + "o": "m 54 201 q 56 242 54 222 q 65 281 58 261 q 89 324 73 302 q 132 373 104 346 q 202 433 160 400 q 306 509 245 467 q 243 623 263 566 q 222 736 222 681 q 247 831 222 787 q 317 910 271 876 q 432 963 364 943 q 589 982 500 982 q 631 978 608 982 q 679 964 654 973 q 726 939 703 954 q 767 900 749 923 q 796 848 785 878 q 807 779 807 818 q 795 714 807 743 q 759 656 783 686 q 698 593 735 627 q 611 515 661 559 l 772 385 q 787 372 780 378 q 802 367 794 366 q 809 373 806 372 q 817 374 813 374 l 971 517 q 1002 498 983 512 q 1038 466 1021 484 q 1067 430 1055 448 q 1080 398 1080 411 q 1077 385 1080 390 l 954 256 l 962 250 q 976 239 968 245 q 995 225 985 233 q 1018 209 1006 217 q 1042 192 1029 200 q 1050 182 1048 187 q 1051 175 1051 176 q 1043 151 1051 165 q 1022 121 1035 137 q 995 87 1010 104 q 964 55 979 70 q 937 27 949 39 q 917 8 924 14 q 903 1 910 1 q 893 5 897 1 l 757 89 q 636 28 688 52 q 539 -9 585 5 q 451 -29 494 -24 q 357 -35 408 -35 q 245 -20 300 -35 q 149 23 191 -6 q 80 97 106 53 q 54 201 54 141 m 349 245 q 353 215 349 230 q 367 187 357 199 q 395 166 377 174 q 440 158 412 158 q 465 159 449 158 q 500 167 480 161 q 544 185 519 173 q 594 220 568 198 l 433 377 q 403 359 418 369 q 376 333 388 349 q 356 297 363 318 q 349 245 349 275 m 536 823 q 499 817 515 823 q 473 799 483 810 q 457 773 463 787 q 452 742 452 758 q 464 691 452 717 q 496 645 475 665 q 572 714 549 680 q 595 773 595 747 q 579 810 595 796 q 536 823 564 823 z " + }, + "'": { + "ha": 372, + "x_min": 118, + "x_max": 427, + "o": "m 136 532 q 127 732 131 627 q 118 940 123 838 l 118 949 q 122 973 118 966 q 138 981 126 979 q 205 982 170 981 q 276 982 241 982 q 347 982 312 982 q 412 983 381 983 q 422 979 418 983 q 427 966 427 976 l 427 962 l 321 530 q 316 512 318 519 q 312 499 315 504 q 306 492 310 494 q 296 490 302 490 q 225 489 260 490 q 150 488 191 488 q 142 494 144 488 q 138 506 139 499 q 136 521 136 513 q 136 532 136 528 z " + }, + "(": { + "ha": 568, + "x_min": 68, + "x_max": 616, + "o": "m 524 63 q 516 13 520 35 q 507 -30 511 -9 q 497 -73 502 -51 q 487 -123 492 -94 q 473 -136 484 -136 q 471 -135 471 -136 q 469 -135 470 -135 q 287 -74 363 -119 q 162 37 211 -28 q 91 184 113 102 q 68 354 68 265 q 74 448 68 394 q 96 561 80 502 q 139 680 112 620 q 210 793 167 741 q 276 863 237 826 q 364 931 315 899 q 470 986 413 963 q 590 1015 528 1009 q 598 1011 593 1015 q 604 995 602 1008 q 606 974 604 995 q 610 923 608 952 q 614 861 612 893 q 616 808 616 829 q 612 792 616 796 q 596 785 607 788 q 498 729 543 770 q 421 633 454 689 q 370 510 389 577 q 351 373 351 442 q 360 283 351 327 q 388 201 369 239 q 438 137 408 164 q 511 96 469 109 q 521 90 517 95 q 525 74 525 85 q 525 69 525 71 q 524 63 525 66 z " + }, + ")": { + "ha": 569, + "x_min": 58, + "x_max": 574, + "o": "m 58 63 q 64 88 58 81 q 76 96 69 94 q 171 150 131 113 q 238 237 212 187 q 277 345 264 287 q 290 459 290 402 q 277 570 290 517 q 241 667 264 623 q 183 741 217 710 q 107 785 149 772 q 94 790 98 787 q 90 800 90 793 q 91 808 90 806 q 102 861 95 829 q 116 923 109 893 q 129 973 123 952 q 135 995 135 994 q 144 1011 138 1008 q 153 1015 149 1015 q 268 986 214 1009 q 364 931 321 963 q 440 863 408 899 q 494 793 473 826 q 532 720 517 758 q 556 641 547 681 q 570 562 566 602 q 574 486 574 522 q 561 345 574 414 q 523 210 549 275 q 456 88 496 145 q 361 -14 416 31 q 236 -91 307 -60 q 80 -135 166 -122 q 78 -135 79 -135 q 76 -136 77 -136 q 64 -123 64 -136 q 63 -81 64 -100 q 61 -44 62 -62 q 59 -10 60 -27 q 58 26 58 7 l 58 63 l 58 63 z " + }, + "*": { + "ha": 865, + "x_min": 79, + "x_max": 835, + "o": "m 294 347 l 224 397 q 115 468 152 444 q 79 507 79 493 q 82 517 79 511 q 92 534 85 524 q 109 561 98 545 q 136 599 120 576 l 174 654 q 181 660 178 658 q 190 663 185 663 q 213 650 199 663 l 260 610 q 309 570 282 592 q 365 524 336 547 l 368 553 q 379 671 374 629 q 387 734 383 713 q 396 761 391 755 q 409 768 401 766 l 421 768 q 450 767 429 768 q 495 766 471 766 q 539 765 518 766 q 569 765 561 765 l 573 765 q 587 758 583 762 q 592 741 592 753 q 592 736 592 739 q 591 730 592 733 l 581 669 q 572 606 576 641 q 564 532 568 570 q 613 579 588 555 q 660 624 638 604 q 699 656 682 644 q 724 669 715 669 q 732 667 728 669 q 740 661 736 665 l 778 606 q 803 568 793 584 q 821 542 814 553 q 831 525 828 531 q 835 514 835 518 q 832 504 835 509 q 822 493 830 499 q 800 476 814 486 q 762 449 786 466 q 703 408 738 433 q 618 347 667 383 l 798 208 q 819 188 813 195 q 826 176 825 181 q 821 163 826 170 q 808 144 815 157 q 781 106 800 132 q 726 32 762 81 q 712 24 721 24 q 689 35 701 24 l 539 153 l 526 -13 q 523 -37 525 -28 q 519 -50 522 -45 q 513 -56 517 -54 q 503 -57 509 -57 l 490 -57 q 481 -57 486 -57 q 469 -58 476 -58 q 423 -55 454 -58 q 340 -50 393 -53 q 327 -42 332 -50 q 321 -22 321 -35 l 340 149 l 218 62 q 205 54 211 57 q 195 52 200 52 q 180 59 186 52 q 129 115 147 96 q 106 144 111 134 q 93 163 98 157 q 87 176 87 170 q 94 188 87 181 q 116 208 100 195 l 294 347 z " + }, + "+": { + "ha": 652, + "x_min": 73, + "x_max": 654, + "o": "m 256 357 q 196 357 222 357 q 154 357 170 357 q 93 360 113 357 q 73 376 73 364 q 77 401 73 382 q 86 445 81 420 q 96 493 91 469 q 104 535 101 518 q 115 550 106 543 q 139 557 124 557 l 275 559 l 277 574 q 281 653 280 620 q 286 707 283 686 q 296 738 289 728 q 317 748 302 748 q 320 748 318 748 q 323 747 321 747 q 336 748 328 747 q 355 748 345 748 q 379 749 366 749 q 404 749 392 749 l 469 751 q 483 744 478 749 q 488 728 488 740 q 488 726 488 726 q 487 721 488 725 q 486 708 487 717 q 484 682 486 699 q 480 635 483 664 q 475 563 478 606 l 486 564 q 535 565 511 564 q 579 566 559 566 q 614 567 600 566 q 635 567 629 567 q 647 563 640 567 q 654 547 654 559 q 650 521 653 546 q 643 452 648 496 l 635 387 q 628 373 631 377 q 616 366 624 368 q 595 364 609 364 q 557 363 581 363 l 458 361 l 450 262 q 445 219 448 236 q 437 193 441 203 q 428 180 433 184 q 414 176 422 176 q 383 177 403 176 q 341 178 363 177 q 298 179 319 178 q 261 179 276 179 q 248 187 253 180 q 243 208 243 194 l 243 212 l 256 357 z " + }, + ",": { + "ha": 398, + "x_min": 56, + "x_max": 361, + "o": "m 176 -225 q 164 -219 169 -221 q 152 -214 160 -218 q 129 -202 144 -210 q 85 -178 114 -194 q 63 -159 63 -166 q 65 -153 63 -157 q 106 -89 85 -123 q 147 -21 127 -56 l 90 -20 l 77 -20 q 62 -19 68 -20 q 56 -14 56 -18 l 56 -12 q 60 34 58 17 q 62 59 62 50 q 63 73 63 68 q 64 80 64 77 q 65 88 64 83 q 66 104 65 93 q 68 136 66 116 q 73 189 70 156 q 77 220 74 212 q 92 231 80 229 q 156 231 123 231 q 222 232 189 231 q 287 232 255 232 q 349 232 319 232 q 361 216 361 232 l 349 50 q 345 24 347 35 q 341 6 343 14 q 337 -11 340 -3 q 332 -30 335 -19 q 318 -55 328 -39 q 296 -87 309 -70 q 271 -123 284 -105 q 246 -157 258 -141 q 226 -184 235 -173 q 214 -200 217 -196 q 209 -207 212 -203 q 202 -216 206 -212 q 193 -224 198 -220 q 184 -227 189 -227 q 176 -225 180 -227 z " + }, + "-": { + "ha": 650, + "x_min": 65, + "x_max": 659, + "o": "m 437 573 q 505 575 472 574 q 565 577 538 576 q 612 579 592 578 q 640 581 632 581 q 652 576 646 581 q 659 561 659 572 q 653 538 659 555 q 636 499 646 522 q 613 447 626 475 q 587 387 600 418 q 582 376 584 380 q 575 369 579 372 q 566 365 572 366 q 549 363 559 363 q 427 361 488 362 q 312 359 366 360 q 215 357 258 358 q 146 357 172 357 q 85 360 105 357 q 65 376 65 364 q 74 394 65 379 q 96 430 82 410 q 126 474 109 451 q 161 517 144 498 q 193 550 178 537 q 219 564 209 564 l 437 573 z " + }, + ".": { + "ha": 389, + "x_min": 61, + "x_max": 353, + "o": "m 61 -26 q 63 25 62 6 q 64 54 64 43 q 64 68 64 64 q 64 76 64 72 q 65 85 64 79 q 66 103 65 90 q 68 137 66 115 q 71 196 69 159 q 76 227 73 218 q 91 237 79 235 q 216 238 151 237 q 338 239 281 238 q 349 235 345 239 q 353 222 353 232 l 334 9 q 329 -21 331 -13 q 313 -31 326 -30 q 195 -33 256 -32 q 68 -33 134 -33 q 63 -33 64 -33 q 61 -28 61 -32 l 61 -26 z " + }, + "/": { + "ha": 591, + "x_min": 39, + "x_max": 646, + "o": "m 301 640 q 380 819 342 729 q 459 1001 418 908 q 473 1028 468 1021 q 487 1036 477 1036 l 492 1036 q 564 1004 528 1021 q 635 980 600 987 q 643 977 640 979 q 646 971 646 975 l 646 969 l 211 10 q 207 -1 208 4 q 204 -11 205 -7 q 199 -17 202 -15 q 191 -20 197 -20 q 174 -15 183 -20 l 105 14 q 89 19 100 16 q 67 28 78 23 q 47 40 56 33 q 39 56 39 47 q 42 68 39 62 q 102 198 73 134 q 161 329 131 261 q 225 473 191 397 q 301 640 259 549 z " + }, + ":": { + "ha": 453, + "x_min": 108, + "x_max": 413, + "o": "m 121 493 q 123 544 123 525 q 124 572 124 562 q 125 587 125 583 q 125 594 125 591 q 125 604 125 598 q 126 622 125 609 q 128 656 127 634 q 132 715 130 678 q 136 746 133 737 q 151 756 139 754 q 276 756 211 756 q 399 758 341 757 q 409 754 405 758 q 413 741 413 751 l 394 528 q 389 497 391 506 q 374 488 387 489 q 255 486 316 487 q 129 486 195 486 q 123 486 125 486 q 121 490 121 487 l 121 493 m 108 -26 q 110 25 109 6 q 111 54 111 43 q 111 68 111 64 q 111 76 111 72 q 112 85 111 79 q 113 103 112 90 q 115 137 113 115 q 118 196 116 159 q 122 227 119 218 q 138 237 125 235 q 262 238 197 237 q 385 239 328 238 q 395 235 391 239 q 399 222 399 232 l 380 9 q 375 -21 378 -13 q 360 -31 373 -30 q 242 -33 302 -32 q 115 -33 181 -33 q 110 -33 111 -33 q 108 -28 108 -32 l 108 -26 z " + }, + ";": { + "ha": 439, + "x_min": 95, + "x_max": 401, + "o": "m 216 -224 q 204 -218 208 -220 q 192 -212 199 -216 q 169 -200 184 -208 q 124 -176 153 -193 q 102 -157 102 -165 q 104 -152 102 -155 q 145 -88 124 -121 q 186 -20 167 -54 l 129 -19 l 117 -19 q 101 -18 107 -19 q 95 -12 95 -17 l 95 -11 q 99 35 98 18 q 102 61 101 52 q 103 74 102 70 q 103 81 103 78 q 104 90 104 85 q 105 106 104 94 q 107 137 106 117 q 112 191 109 157 q 116 222 113 213 q 132 232 119 231 q 195 232 162 232 q 261 233 229 233 q 327 233 294 233 q 389 233 359 233 q 400 217 400 233 l 388 52 q 384 26 386 36 q 381 7 382 16 q 377 -9 379 -1 q 371 -28 374 -18 q 357 -53 367 -38 q 336 -86 348 -68 q 310 -122 323 -104 q 286 -156 297 -140 q 265 -183 274 -172 q 254 -199 256 -195 q 249 -206 252 -201 q 241 -215 245 -210 q 233 -222 237 -219 q 223 -226 228 -226 q 216 -224 220 -226 m 110 492 q 112 543 111 524 q 113 572 113 562 q 113 586 113 582 q 113 594 113 590 q 114 603 113 597 q 115 621 114 608 q 117 655 115 633 q 120 714 118 677 q 124 745 121 736 q 140 755 127 753 q 264 756 199 755 q 387 757 330 756 q 397 753 393 757 q 401 741 401 750 l 382 528 q 377 497 380 505 q 362 487 375 488 q 244 486 304 486 q 117 485 183 485 q 112 486 113 485 q 110 490 110 486 l 110 492 z " + }, + "<": { + "ha": 730, + "x_min": 78, + "x_max": 703, + "o": "m 697 220 q 703 212 703 216 q 701 204 703 208 q 654 100 673 153 q 616 -7 635 46 q 600 -20 612 -20 q 589 -18 594 -20 q 574 -12 583 -16 l 129 248 q 100 265 107 258 q 92 286 92 272 q 87 390 90 337 q 79 500 85 443 q 78 502 79 501 q 78 503 78 503 l 79 508 q 82 514 79 511 q 87 516 85 516 q 145 542 109 527 q 224 573 180 556 q 319 609 269 591 q 422 648 370 628 q 527 687 475 667 q 627 724 579 706 q 657 730 648 730 q 668 720 666 730 q 677 610 672 667 q 694 502 682 553 q 686 486 694 487 l 388 376 q 418 361 395 372 q 471 334 441 349 q 536 301 502 319 q 603 268 571 283 q 660 239 635 252 q 697 220 686 226 z " + }, + "=": { + "ha": 720, + "x_min": 111, + "x_max": 671, + "o": "m 389 602 q 478 603 435 602 q 556 605 521 604 q 616 607 591 606 q 652 608 642 608 q 665 604 658 608 q 671 588 671 600 q 666 563 672 583 q 649 508 659 543 l 633 454 q 622 437 628 443 q 595 431 615 431 q 473 429 534 430 q 358 427 412 428 q 261 425 304 426 q 192 425 218 425 q 131 428 151 425 q 111 444 111 432 q 115 464 111 448 q 124 499 119 479 q 136 539 130 519 q 149 576 143 560 q 163 590 157 583 q 184 598 169 597 l 389 602 m 389 283 q 478 284 435 283 q 556 287 521 286 q 616 289 591 288 q 652 290 642 290 q 665 286 658 290 q 671 270 671 281 q 666 245 672 265 q 649 190 659 224 l 633 136 q 622 119 628 125 q 595 113 615 113 q 473 111 534 112 q 358 109 412 110 q 261 107 304 108 q 192 106 218 106 q 131 110 151 106 q 111 125 111 114 q 115 146 111 130 q 124 181 119 161 q 136 221 130 201 q 149 258 143 242 q 163 272 157 264 q 184 280 169 279 l 389 283 z " + }, + ">": { + "ha": 713, + "x_min": 90, + "x_max": 714, + "o": "m 95 220 q 132 239 106 226 q 189 268 157 252 q 256 301 221 283 q 321 334 290 319 q 374 361 351 349 q 404 376 397 372 l 106 486 q 98 502 98 487 q 115 610 110 553 q 124 720 120 667 q 135 730 126 730 q 165 724 144 730 q 265 687 213 706 q 370 648 317 667 q 473 609 422 628 q 568 573 524 591 q 647 542 612 556 q 705 516 683 527 q 710 514 707 516 q 713 508 713 511 l 714 503 q 714 502 714 503 q 713 500 713 501 q 705 390 707 443 q 700 286 702 337 q 692 265 700 272 q 663 248 685 258 l 218 -12 q 203 -18 209 -16 q 193 -20 198 -20 q 182 -17 185 -20 q 176 -7 178 -14 q 137 100 156 46 q 91 204 117 153 q 90 212 90 208 q 91 217 90 215 q 95 220 92 218 z " + }, + "?": { + "ha": 791, + "x_min": 98, + "x_max": 800, + "o": "m 294 406 q 383 469 340 431 q 457 549 425 507 q 508 634 489 591 q 526 711 526 676 q 505 773 526 749 q 436 797 484 797 q 391 790 409 797 q 363 774 374 784 q 349 751 353 764 q 345 728 345 739 q 349 699 345 712 q 354 682 354 686 q 345 675 354 677 q 314 669 335 673 q 257 660 292 666 q 170 639 222 653 q 140 631 151 633 q 123 629 129 629 q 110 640 115 629 q 102 664 105 650 q 99 692 100 677 q 98 717 98 707 q 129 828 98 779 q 209 911 159 878 q 320 963 258 945 q 445 981 382 981 q 591 957 526 981 q 703 893 656 932 q 774 805 749 854 q 800 710 800 757 q 786 634 800 671 q 750 560 773 596 q 697 490 728 524 q 634 425 667 456 q 567 366 601 394 q 500 312 532 337 q 495 308 499 310 q 488 306 492 306 q 483 307 486 306 q 478 309 479 307 q 432 330 452 321 q 391 348 411 340 q 351 362 372 356 q 305 374 330 369 q 291 381 296 376 q 287 390 287 385 q 293 398 292 393 q 294 406 294 404 m 296 -26 q 298 25 298 6 q 299 54 299 43 q 300 68 300 64 q 300 76 300 72 q 300 85 300 79 q 301 103 300 90 q 303 137 302 115 q 307 196 304 159 q 311 227 308 218 q 326 237 314 235 q 451 238 386 237 q 574 239 516 238 q 585 234 581 239 q 588 218 588 230 l 569 9 q 564 -21 566 -13 q 549 -31 562 -30 q 430 -33 491 -32 q 304 -33 370 -33 q 297 -32 298 -33 q 296 -26 296 -31 z " + }, + "@": { + "ha": 1207, + "x_min": 82, + "x_max": 1213, + "o": "m 710 330 q 723 413 718 378 q 731 471 728 447 q 735 510 734 494 q 738 536 737 526 q 711 556 728 546 q 676 567 695 567 q 645 558 658 567 q 622 534 631 549 q 606 503 612 520 q 596 468 600 485 q 591 435 593 450 q 590 410 590 420 q 606 329 590 355 q 650 304 621 304 q 677 311 662 304 q 710 330 693 318 m 732 236 q 665 181 699 199 q 598 164 631 164 q 520 180 557 164 q 454 227 483 197 q 407 300 425 257 q 390 397 390 343 q 412 521 390 465 q 472 618 435 578 q 558 681 510 659 q 659 703 607 703 q 715 691 690 703 q 763 659 741 678 q 767 679 766 671 q 769 690 768 686 q 775 696 771 694 q 789 698 780 697 q 861 692 825 692 q 937 691 897 691 l 892 364 q 892 356 892 361 q 892 347 892 351 q 896 313 892 328 q 906 285 899 297 q 921 267 912 273 q 939 260 930 260 q 969 272 953 260 q 999 312 985 284 q 1022 386 1013 340 q 1031 500 1031 432 q 1011 614 1031 559 q 950 710 991 669 q 845 776 909 751 q 696 801 782 801 q 567 780 624 801 q 467 724 510 759 q 394 645 424 690 q 345 555 363 601 q 317 465 326 509 q 308 386 308 420 q 334 243 308 302 q 413 147 361 184 q 543 92 465 109 q 723 75 621 75 q 741 69 734 75 q 747 43 747 63 q 744 14 747 31 q 736 -30 741 -4 q 725 -78 730 -60 q 714 -105 720 -96 q 701 -117 708 -115 q 684 -119 694 -119 q 432 -92 544 -119 q 243 -7 321 -64 q 124 136 165 50 q 82 338 82 222 q 107 525 82 437 q 176 687 132 613 q 282 819 220 760 q 419 918 345 878 q 580 981 494 959 q 756 1002 665 1002 q 950 962 865 1002 q 1094 853 1035 922 q 1183 693 1152 785 q 1213 500 1213 602 q 1191 361 1213 429 q 1129 241 1168 294 q 1040 157 1090 189 q 934 125 989 125 q 826 151 876 125 q 732 236 775 176 z " + }, + "A": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 z " + }, + "B": { + "ha": 970, + "x_min": 85, + "x_max": 971, + "o": "m 172 9 q 153 19 158 12 q 149 35 149 26 q 149 38 149 37 q 149 41 149 39 q 161 194 155 114 q 175 358 168 275 q 190 524 182 441 q 205 684 197 606 l 204 684 q 189 680 201 684 q 160 670 176 675 q 128 661 144 665 q 102 657 112 657 q 89 664 93 657 q 85 679 85 671 q 87 690 85 685 q 125 789 106 743 q 165 884 145 836 q 272 933 216 912 q 384 967 328 954 q 497 988 441 981 q 606 996 553 996 q 746 980 680 996 q 860 933 811 964 q 936 853 908 901 q 964 741 964 806 q 952 688 964 713 q 921 638 940 662 q 878 595 901 615 q 831 559 854 575 q 889 516 863 535 q 934 475 916 496 q 962 428 952 454 q 971 365 971 402 q 951 256 971 308 q 896 160 931 204 q 813 79 861 115 q 710 18 766 43 q 592 -21 654 -7 q 467 -35 530 -35 q 405 -31 444 -35 q 324 -19 366 -26 q 240 -5 281 -13 q 172 9 199 3 m 484 753 q 463 590 471 673 q 447 430 454 508 q 436 283 441 353 q 427 158 431 213 q 485 161 454 158 q 548 172 517 164 q 609 191 579 179 q 661 220 638 203 q 696 262 683 238 q 710 317 710 286 q 699 351 710 336 q 669 376 687 366 q 629 394 651 387 q 587 405 608 401 q 549 412 566 410 q 523 416 532 414 q 511 422 513 417 q 508 436 508 428 q 519 463 508 445 q 549 511 530 480 l 577 556 q 617 573 595 563 q 656 596 638 583 q 687 626 675 610 q 699 663 699 643 q 675 748 699 718 q 606 777 651 777 q 484 753 554 777 z " + }, + "C": { + "ha": 878, + "x_min": 85, + "x_max": 899, + "o": "m 85 458 q 103 602 85 533 q 155 730 121 671 q 235 839 188 790 q 340 923 282 888 q 467 977 399 958 q 610 996 535 996 q 715 988 670 996 q 794 968 760 980 q 850 942 827 956 q 888 915 873 928 q 887 910 887 915 q 889 873 887 900 q 893 812 890 846 q 897 745 895 779 q 899 686 899 711 q 896 676 899 682 q 882 671 893 671 q 867 673 876 671 q 831 692 853 677 q 781 723 810 707 q 716 751 752 739 q 639 764 681 764 q 538 742 583 764 q 460 681 492 720 q 411 589 429 642 q 393 473 393 536 q 406 367 393 417 q 443 278 418 316 q 504 217 467 240 q 590 195 541 195 q 659 197 629 195 q 714 206 688 200 q 765 222 740 212 q 818 248 789 233 q 825 249 819 249 q 831 245 829 249 q 833 230 833 241 l 833 149 q 832 105 833 126 q 832 66 832 83 q 831 39 831 50 q 830 28 831 28 q 763 -4 799 9 q 690 -23 728 -16 q 614 -33 652 -31 q 541 -35 576 -35 q 362 1 446 -35 q 217 102 279 37 q 121 258 156 168 q 85 458 85 349 z " + }, + "D": { + "ha": 1047, + "x_min": 79, + "x_max": 1055, + "o": "m 116 -35 q 105 -28 109 -34 q 102 -14 102 -22 q 104 -3 102 -8 l 188 156 q 190 177 189 159 q 194 223 192 195 q 198 284 196 250 q 203 351 201 318 q 209 415 206 385 q 213 465 211 445 q 215 490 214 473 q 218 528 216 507 q 221 575 219 550 q 225 625 223 600 l 235 767 q 197 759 216 763 q 158 751 177 754 q 123 745 140 747 q 94 743 106 743 q 83 749 87 743 q 79 764 79 756 q 83 777 79 772 l 256 937 q 297 968 277 956 q 347 987 318 980 q 419 996 376 994 q 526 998 461 998 q 724 975 637 998 q 869 909 810 952 q 948 846 913 879 q 1006 772 983 812 q 1042 683 1030 732 q 1055 574 1055 635 q 1020 368 1055 458 q 926 210 985 277 q 786 96 866 142 q 616 20 707 49 q 429 -22 526 -9 q 239 -35 332 -35 l 116 -35 m 510 781 l 502 699 q 493 608 498 656 q 485 510 489 559 q 477 411 481 460 q 469 321 473 363 q 463 245 466 279 q 458 193 460 212 l 456 172 q 595 199 538 172 q 687 272 652 225 q 738 380 722 318 q 754 515 754 443 q 740 614 754 566 q 699 705 726 662 q 632 770 671 749 q 543 792 593 792 q 526 788 535 792 q 510 781 516 785 z " + }, + "E": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 z " + }, + "F": { + "ha": 817, + "x_min": 90, + "x_max": 907, + "o": "m 770 557 q 760 528 767 547 q 743 485 752 509 q 722 434 733 461 q 699 382 710 408 q 688 365 694 370 q 662 359 682 359 l 464 355 l 439 54 q 436 42 439 48 q 428 33 433 37 q 412 25 426 32 q 377 10 398 19 q 331 -9 356 1 q 281 -27 306 -19 q 237 -42 257 -36 q 205 -47 216 -47 q 180 -42 191 -47 q 169 -22 169 -37 q 171 53 168 12 q 176 137 173 94 q 184 220 180 179 q 191 295 188 261 l 195 352 q 114 355 138 352 q 90 371 90 358 q 100 394 90 380 q 132 441 114 414 q 166 496 150 469 q 196 543 183 522 q 212 568 208 563 l 224 754 q 202 754 212 754 q 185 753 193 753 q 170 752 177 753 q 156 752 163 752 q 110 756 123 752 q 96 770 96 760 q 104 789 96 774 q 124 824 112 804 q 150 864 136 843 q 174 904 163 886 l 215 973 l 524 984 q 642 988 583 986 q 751 993 701 991 q 837 997 801 995 q 887 999 873 998 q 891 999 888 999 q 897 997 894 998 q 904 991 901 995 q 907 979 907 987 q 897 952 907 975 q 870 884 888 929 l 840 805 q 829 787 836 793 q 804 781 823 781 q 757 779 781 780 q 708 776 733 777 l 502 766 q 496 726 498 739 q 494 705 494 713 q 494 688 494 696 q 493 661 494 680 q 491 640 492 648 q 489 622 490 631 q 487 604 488 614 q 486 581 486 595 q 573 583 530 582 q 653 586 616 585 q 715 588 689 587 q 753 589 742 589 q 765 585 758 589 q 772 570 772 581 q 770 557 772 564 z " + }, + "G": { + "ha": 992, + "x_min": 85, + "x_max": 1027, + "o": "m 85 438 q 103 588 85 516 q 154 721 121 659 q 234 834 187 783 q 340 921 281 884 q 466 977 398 957 q 610 996 534 996 q 714 988 669 996 q 793 969 760 981 q 849 943 827 957 q 887 916 872 928 q 886 911 886 916 q 888 873 886 901 q 892 813 890 846 q 896 746 895 780 q 898 686 898 711 q 895 677 898 682 q 882 672 892 672 q 867 674 876 672 q 831 693 852 678 q 780 723 809 707 q 716 752 751 739 q 638 764 680 764 q 537 741 583 764 q 460 676 492 718 q 410 577 428 634 q 393 452 393 519 q 404 342 393 393 q 436 253 414 290 q 491 193 458 215 q 568 172 524 172 q 665 190 610 172 l 680 286 l 551 286 q 576 414 566 368 q 597 485 587 461 q 616 515 606 509 q 640 521 627 521 l 997 521 q 1020 519 1013 521 q 1027 505 1027 517 q 1025 490 1027 498 q 1022 469 1024 481 l 988 355 q 978 322 982 334 q 968 304 974 310 q 951 297 962 298 q 921 296 941 296 q 908 288 910 296 q 905 269 906 279 q 897 210 901 239 q 889 155 892 180 q 881 110 885 130 q 873 82 877 91 q 789 31 827 53 q 714 -6 751 9 q 635 -28 677 -20 q 541 -35 594 -35 q 361 -1 445 -35 q 217 97 278 34 q 120 246 155 159 q 85 438 85 333 z " + }, + "H": { + "ha": 1010, + "x_min": 55, + "x_max": 1013, + "o": "m 722 893 q 727 927 722 918 q 745 939 731 936 l 978 996 q 996 998 987 998 q 1009 992 1006 998 q 1013 969 1013 987 l 1004 863 q 997 770 1001 816 q 985 689 993 724 l 972 593 q 962 500 967 550 q 952 389 958 450 l 920 54 q 918 42 920 48 q 909 33 916 36 q 887 24 904 31 q 846 9 869 18 q 795 -10 822 0 q 743 -28 768 -20 q 700 -41 719 -36 q 674 -47 681 -47 q 657 -42 665 -47 q 650 -22 650 -37 q 660 145 650 60 q 677 316 669 231 q 678 334 678 326 q 679 351 677 342 l 431 351 l 403 54 q 400 42 402 48 q 391 33 399 37 q 365 23 389 32 q 309 4 340 15 q 247 -19 277 -7 q 199 -36 216 -30 q 179 -43 191 -39 q 156 -47 168 -47 q 139 -41 146 -47 q 132 -22 132 -36 q 135 53 132 11 q 140 140 137 94 q 149 234 144 186 q 158 329 153 282 l 159 349 q 80 352 104 349 q 55 368 55 355 q 65 391 55 376 q 97 436 79 410 q 131 489 115 463 q 161 536 148 515 q 178 563 174 557 l 194 726 q 195 747 194 728 q 199 792 197 766 q 203 846 201 818 q 208 893 205 873 q 211 926 209 917 q 227 939 214 936 l 458 996 q 476 998 467 998 q 489 992 486 998 q 492 969 492 987 q 485 880 488 920 q 479 806 482 840 q 473 740 476 771 q 463 676 469 709 l 450 571 l 696 574 q 709 727 702 664 q 719 827 717 790 q 722 876 721 861 q 722 893 722 891 z " + }, + "I": { + "ha": 453, + "x_min": 94, + "x_max": 450, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 z " + }, + "J": { + "ha": 517, + "x_min": 37, + "x_max": 517, + "o": "m 228 893 q 229 913 229 905 q 232 927 230 922 q 237 935 233 932 q 248 939 240 938 l 482 996 q 500 998 491 998 q 513 992 509 998 q 517 969 517 986 l 496 751 l 484 621 q 476 533 480 576 q 468 454 472 490 q 462 390 465 418 q 458 345 459 361 l 456 325 q 427 177 451 248 q 363 53 403 106 q 269 -33 323 -1 q 148 -65 214 -65 q 97 -64 114 -65 q 74 -59 81 -62 q 65 -51 66 -56 q 59 -39 63 -46 l 37 126 q 41 132 37 127 q 48 138 45 136 q 96 159 73 147 q 139 188 120 172 q 169 228 157 205 q 182 281 181 250 l 228 893 z " + }, + "K": { + "ha": 957, + "x_min": 96, + "x_max": 983, + "o": "m 448 970 q 445 938 446 960 q 441 888 444 916 q 437 827 439 859 q 432 765 435 795 q 428 709 430 734 q 424 669 425 684 l 458 705 l 555 824 q 590 868 572 846 q 622 906 608 889 q 644 935 635 924 q 654 948 653 947 l 956 973 q 962 974 957 974 l 967 974 q 983 966 983 974 q 978 955 983 961 q 965 938 974 948 q 933 901 954 925 q 888 848 913 877 q 838 788 864 819 q 789 731 812 758 q 751 686 767 705 q 731 662 735 667 l 688 621 q 577 518 645 580 q 622 471 598 496 q 668 422 646 446 q 708 380 690 399 q 735 351 726 361 l 945 109 q 954 102 948 104 q 964 97 959 99 q 974 93 970 95 q 977 87 977 90 q 968 74 977 83 q 948 58 963 68 q 913 35 933 47 q 870 10 893 23 q 826 -13 847 -3 q 786 -31 804 -24 q 759 -37 768 -37 q 729 -25 745 -37 q 692 13 713 -12 q 640 78 670 39 q 568 170 610 117 q 521 230 544 201 q 477 286 498 260 q 440 333 457 312 q 413 366 424 353 q 401 356 408 361 q 390 345 395 351 l 366 54 q 364 42 366 48 q 355 33 362 36 l 163 -36 q 143 -43 155 -39 q 119 -47 131 -47 q 102 -41 109 -47 q 96 -22 96 -36 q 104 82 99 18 q 115 228 109 146 q 127 406 121 309 q 141 609 134 503 l 160 895 q 161 915 161 907 q 163 928 162 923 q 168 937 165 934 q 178 941 172 939 l 413 997 q 431 1000 422 1000 q 444 994 441 1000 q 448 970 448 988 z " + }, + "L": { + "ha": 693, + "x_min": 57, + "x_max": 719, + "o": "m 454 972 q 452 931 454 956 q 447 881 450 907 q 442 826 445 854 q 436 776 439 799 q 433 736 434 753 q 431 715 431 720 q 426 637 429 673 q 419 566 422 601 q 412 493 416 530 q 404 410 408 455 q 395 309 400 364 q 385 181 390 253 l 665 210 q 696 212 684 212 q 715 207 711 212 q 719 192 719 202 q 718 188 719 191 q 715 179 718 186 q 708 159 713 172 q 696 123 704 146 l 682 79 q 668 40 674 57 q 652 10 661 22 q 628 -10 642 -3 q 591 -20 614 -18 q 553 -22 577 -21 q 496 -24 528 -23 q 428 -26 465 -25 q 352 -30 391 -28 q 275 -32 314 -31 q 202 -33 237 -32 q 138 -34 168 -33 q 90 -35 109 -35 q 65 -33 73 -35 q 57 -24 57 -30 q 62 -2 57 -16 q 74 29 66 12 q 91 65 81 46 q 109 101 100 83 l 127 343 q 138 469 132 405 q 147 592 143 532 l 159 772 q 164 864 163 836 q 165 896 165 893 q 168 916 167 908 q 170 930 168 924 q 175 938 172 935 q 185 942 178 941 l 423 998 q 441 1001 431 1001 q 452 997 449 1001 q 455 980 455 992 q 455 976 455 979 q 454 972 454 974 z " + }, + "M": { + "ha": 1173, + "x_min": 43, + "x_max": 1141, + "o": "m 975 998 q 981 999 978 999 q 987 1000 984 1000 q 1006 992 999 1000 q 1017 964 1014 984 q 1021 930 1018 952 q 1028 881 1024 908 q 1036 826 1031 854 q 1044 773 1040 798 q 1050 732 1047 749 q 1053 710 1053 715 q 1080 517 1068 590 q 1096 410 1091 445 l 1141 43 l 1135 22 q 1117 11 1133 18 q 1078 -4 1101 4 q 1027 -19 1055 -12 q 977 -33 1000 -26 q 938 -42 954 -39 q 922 -46 922 -46 q 896 -22 905 -46 q 880 38 886 1 q 870 118 874 75 q 862 201 867 161 q 854 269 858 235 q 847 338 851 302 l 821 536 l 569 75 q 546 52 557 59 q 524 45 534 45 q 501 54 509 45 q 489 75 492 62 q 467 148 476 118 q 450 201 458 177 q 435 249 442 226 q 420 302 429 272 q 401 373 412 332 q 376 474 391 414 l 355 376 l 257 -38 q 240 -46 255 -46 q 201 -41 226 -46 q 150 -27 176 -35 q 97 -11 123 -20 q 57 4 72 -3 q 48 11 54 5 q 43 26 43 16 q 45 38 43 29 q 51 59 47 47 q 57 83 54 71 q 64 104 61 95 q 80 159 73 127 q 94 222 87 190 q 105 283 100 254 q 115 332 110 312 l 198 721 l 223 831 q 232 872 228 852 q 240 908 237 892 q 245 935 243 924 q 248 946 248 945 l 417 998 q 434 997 427 998 q 443 985 441 995 l 578 551 l 763 941 l 975 998 z " + }, + "N": { + "ha": 935, + "x_min": 102, + "x_max": 909, + "o": "m 672 909 q 682 941 677 931 q 699 955 686 951 l 875 996 q 892 999 883 999 q 905 993 901 999 q 909 970 909 987 q 903 920 905 954 q 899 846 900 886 q 897 766 897 806 q 896 696 896 726 l 896 659 q 895 567 896 616 q 893 467 895 517 q 891 366 892 416 q 888 273 890 317 l 884 157 q 884 151 884 154 q 884 146 884 149 l 884 136 q 884 116 884 124 q 884 102 884 109 q 883 90 884 96 q 878 80 882 85 q 867 69 874 75 q 848 56 860 64 q 825 42 841 51 q 791 21 810 33 q 751 -3 772 9 q 714 -25 731 -15 q 687 -41 697 -35 q 676 -47 676 -47 l 361 437 l 333 41 q 331 29 332 35 q 321 20 329 23 q 300 11 315 17 q 266 -2 286 5 q 223 -17 245 -9 q 182 -32 201 -25 q 147 -42 162 -38 q 126 -46 132 -46 q 109 -41 116 -46 q 102 -21 102 -35 q 118 164 109 64 q 135 369 127 264 q 150 579 143 474 q 162 778 157 684 q 154 796 162 781 q 135 829 145 811 q 116 863 124 847 q 107 883 107 879 q 112 892 107 888 q 129 903 117 896 l 310 1005 q 316 1006 312 1005 q 322 1006 319 1006 q 334 1005 330 1006 q 343 994 338 1003 l 672 401 l 672 909 z " + }, + "O": { + "ha": 1096, + "x_min": 81, + "x_max": 1098, + "o": "m 380 463 q 391 365 380 414 q 426 276 401 315 q 490 212 450 237 q 589 188 530 188 q 669 207 635 191 q 728 248 703 222 q 769 306 753 273 q 795 375 785 339 q 808 450 804 412 q 812 523 812 488 q 799 616 812 571 q 760 696 786 661 q 696 751 734 730 q 607 772 658 772 q 535 758 567 772 q 477 718 503 743 q 434 661 452 693 q 405 594 416 629 q 387 526 393 560 q 380 463 381 492 m 513 -35 q 331 3 411 -35 q 196 105 251 41 q 111 255 140 170 q 81 435 81 340 q 112 632 81 542 q 196 791 142 722 q 325 908 250 861 q 490 979 400 956 q 502 992 498 979 q 510 1021 507 1005 q 518 1049 513 1036 q 532 1062 523 1062 q 768 1016 663 1048 q 947 922 873 983 q 1059 767 1020 861 q 1098 540 1098 674 q 1077 378 1098 454 q 1018 238 1056 302 q 927 123 980 174 q 808 37 873 73 q 668 -17 743 2 q 513 -35 593 -35 z " + }, + "P": { + "ha": 910, + "x_min": 92, + "x_max": 950, + "o": "m 950 705 q 939 600 949 646 q 909 517 928 554 q 862 451 890 480 q 800 395 835 421 q 651 322 732 347 q 489 297 570 297 q 462 298 475 297 q 435 300 449 298 l 413 52 l 401 31 q 378 20 400 28 q 324 1 357 12 l 281 -13 q 245 -26 261 -20 q 216 -37 230 -33 q 190 -44 203 -42 q 163 -47 177 -47 q 148 -42 153 -47 q 142 -24 142 -37 l 150 92 q 155 162 152 127 q 161 241 158 197 l 174 404 q 181 503 178 453 q 188 597 184 552 q 194 681 191 642 q 199 749 197 720 q 140 737 162 741 q 106 733 118 733 q 92 745 92 733 q 110 810 102 779 q 125 866 117 838 q 138 915 133 895 q 146 933 142 927 q 158 944 151 940 q 176 950 165 947 q 203 959 187 954 q 240 968 216 963 q 293 978 263 973 q 359 988 323 983 q 430 997 394 993 q 502 1003 467 1000 q 568 1005 537 1005 q 715 991 646 1005 q 836 942 784 977 q 919 850 888 908 q 950 705 950 792 m 454 501 q 457 488 454 494 q 472 482 460 482 q 517 491 491 482 q 569 516 543 499 q 618 558 595 533 q 652 617 640 583 q 660 652 656 631 q 663 701 663 673 q 630 771 663 749 q 544 793 597 793 q 524 792 535 793 q 503 789 513 791 q 486 782 493 786 q 479 772 479 779 l 477 758 q 475 729 477 749 q 470 683 473 709 q 466 629 468 657 q 461 574 463 600 q 457 529 458 549 q 454 501 455 509 z " + }, + "Q": { + "ha": 1096, + "x_min": 81, + "x_max": 1100, + "o": "m 513 -35 q 331 3 411 -35 q 196 105 251 41 q 111 255 140 170 q 81 435 81 340 q 112 632 81 542 q 196 791 142 722 q 325 908 250 861 q 490 979 400 956 q 502 990 498 979 q 508 1014 505 1000 q 514 1041 511 1028 q 524 1059 517 1054 q 844 985 763 1027 q 918 943 886 964 q 975 897 949 922 q 1020 839 1000 871 q 1061 761 1040 806 q 1092 643 1082 713 l 1093 635 q 1098 580 1096 609 q 1100 518 1100 551 q 1062 306 1100 401 q 956 140 1023 212 q 989 102 974 120 q 1017 71 1005 85 q 1035 46 1028 56 q 1042 31 1042 35 q 1030 13 1042 23 q 1000 -10 1018 2 q 961 -35 982 -22 q 922 -56 940 -47 q 890 -72 903 -66 q 876 -78 878 -78 q 866 -75 871 -78 q 859 -68 862 -72 l 775 15 q 657 -22 718 -9 q 529 -35 597 -35 l 513 -35 m 384 463 q 393 369 384 416 q 422 282 401 323 q 423 281 423 281 q 488 214 448 239 q 589 188 528 188 l 603 189 l 595 199 q 569 230 579 216 q 559 250 559 243 q 578 273 559 263 l 736 353 q 743 355 740 355 q 755 345 748 355 q 777 321 763 336 q 804 420 795 366 q 812 523 812 474 q 786 653 812 596 q 695 749 760 711 q 649 768 673 762 q 594 773 624 773 q 540 763 564 773 q 495 736 515 752 q 459 700 475 720 q 431 664 443 681 q 409 621 418 646 q 394 567 400 595 q 386 511 389 539 q 384 463 384 484 z " + }, + "R": { + "ha": 999, + "x_min": 83, + "x_max": 1026, + "o": "m 446 54 q 444 42 446 48 q 435 33 442 37 q 408 23 432 32 q 352 4 384 15 q 290 -19 321 -7 q 243 -36 259 -30 q 223 -43 235 -39 q 199 -47 211 -47 q 183 -42 190 -47 q 176 -24 176 -37 q 177 30 176 5 q 182 82 179 55 q 188 142 184 109 q 193 220 191 175 q 201 335 197 280 q 209 450 205 391 l 218 566 q 222 624 220 595 q 227 685 224 654 l 234 768 q 230 769 234 769 q 212 767 224 769 q 181 761 199 764 q 142 754 163 758 q 98 747 121 750 q 88 749 93 747 q 83 755 83 751 q 84 759 83 758 l 194 951 q 274 967 231 959 q 367 983 317 976 q 472 994 416 989 q 590 998 528 998 q 727 984 661 997 q 846 942 794 972 q 929 862 898 913 q 961 734 961 812 q 946 656 961 692 q 909 591 932 620 q 859 540 886 562 q 807 503 832 518 q 764 478 783 487 q 738 466 745 469 q 739 463 738 465 q 743 455 740 460 q 753 438 747 449 q 770 410 760 428 q 795 366 781 393 q 831 302 810 339 q 879 220 852 265 q 941 122 906 174 q 968 110 953 112 q 996 107 983 107 q 1017 105 1008 106 q 1026 97 1026 104 q 1015 84 1026 92 q 989 70 1004 77 q 959 56 974 62 q 935 45 943 49 q 901 29 919 37 q 863 13 884 22 q 818 -9 843 3 q 762 -38 793 -21 q 747 -44 754 -43 q 734 -46 740 -46 q 713 -40 722 -46 q 698 -26 704 -35 q 688 -10 692 -18 q 682 4 684 -1 l 479 430 l 446 54 m 488 513 q 559 531 522 513 q 627 576 597 549 q 677 640 658 604 q 697 711 697 675 q 579 809 697 809 q 542 806 557 809 q 515 802 527 803 l 488 513 z " + }, + "S": { + "ha": 745, + "x_min": 106, + "x_max": 732, + "o": "m 107 252 q 107 254 107 253 q 106 255 106 254 q 119 267 106 267 q 132 260 125 267 q 214 205 169 226 q 311 185 258 185 q 344 188 326 185 q 378 198 362 191 q 406 217 395 205 q 417 246 417 229 q 408 281 417 266 q 382 308 398 297 q 364 321 376 312 q 336 343 352 331 q 304 369 321 355 q 272 396 287 383 q 244 418 256 408 q 226 434 232 429 q 162 494 186 464 q 127 558 139 525 q 111 627 115 591 q 108 703 108 663 q 136 816 108 762 q 217 909 165 869 q 341 972 269 949 q 503 996 414 996 q 535 993 513 996 q 583 985 557 991 q 636 970 609 980 q 686 946 663 961 q 697 935 693 941 q 703 918 702 930 q 709 810 707 863 q 714 697 711 757 q 709 682 714 686 q 697 678 705 678 q 686 682 690 678 q 645 721 666 702 q 601 754 624 740 q 552 778 578 769 q 495 787 526 787 q 458 784 477 787 q 425 772 440 781 q 401 750 410 764 q 392 714 392 736 q 398 674 392 691 q 427 642 404 657 l 589 532 q 662 468 634 500 q 705 406 690 437 q 727 344 721 374 q 732 285 732 314 q 717 195 732 237 q 674 118 701 153 q 611 54 647 82 q 533 6 574 26 q 447 -25 492 -14 q 360 -35 403 -35 q 295 -34 327 -35 q 232 -29 262 -33 q 173 -15 201 -24 q 119 10 144 -6 l 107 252 z " + }, + "T": { + "ha": 786, + "x_min": 58, + "x_max": 886, + "o": "m 243 -22 q 250 74 246 28 q 256 163 253 120 l 280 484 q 287 550 284 520 q 291 609 290 581 q 295 668 293 638 q 298 733 296 697 q 251 734 276 734 q 199 734 227 734 l 68 734 q 59 740 61 734 q 58 750 58 745 l 58 755 l 132 951 l 132 954 q 136 959 132 957 q 156 963 140 962 q 337 974 250 968 q 511 983 425 979 q 683 991 597 987 q 859 998 768 995 q 877 996 867 998 q 886 981 886 994 q 885 979 886 980 q 884 976 884 977 q 864 923 873 948 q 846 873 855 898 q 828 824 838 849 q 808 771 819 799 q 806 765 808 770 q 800 752 804 759 q 789 739 795 745 q 772 732 782 732 q 765 732 768 732 q 758 732 762 732 l 576 732 q 570 659 573 698 q 563 584 567 621 l 513 54 q 511 42 513 48 q 502 33 509 36 l 310 -36 q 290 -43 302 -39 q 267 -47 278 -47 q 250 -42 257 -47 q 243 -24 243 -37 l 243 -22 z " + }, + "U": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 z " + }, + "V": { + "ha": 885, + "x_min": 94, + "x_max": 962, + "o": "m 596 60 q 590 43 593 51 q 582 28 587 35 q 570 16 577 21 q 552 7 563 10 q 467 -17 509 -7 q 382 -41 425 -26 q 368 -45 374 -43 q 357 -46 362 -46 q 339 -40 345 -46 q 331 -24 334 -34 l 99 898 q 94 927 94 916 q 103 952 94 942 q 134 968 113 962 q 189 980 155 975 q 271 992 222 985 q 311 997 295 996 q 339 999 328 999 q 365 992 359 999 q 377 972 372 986 l 507 412 l 689 927 q 696 945 693 938 q 708 957 699 951 q 733 967 716 962 q 783 979 751 972 q 867 994 834 989 q 921 999 901 999 q 955 993 947 999 q 962 975 962 987 q 961 965 963 970 q 956 953 958 960 l 596 60 z " + }, + "W": { + "ha": 1242, + "x_min": 80, + "x_max": 1301, + "o": "m 1052 920 q 1059 940 1055 932 q 1078 951 1063 948 l 1278 998 q 1281 998 1279 998 q 1284 999 1283 999 q 1298 991 1294 999 q 1301 975 1301 983 q 1301 970 1301 974 q 1300 964 1301 966 l 1019 111 q 1001 66 1008 83 q 987 38 994 49 q 971 19 980 26 q 946 4 962 12 q 908 -13 931 -3 q 850 -40 885 -23 q 831 -46 838 -46 q 814 -38 820 -46 q 806 -20 809 -30 l 690 393 l 545 94 q 529 73 541 86 q 502 45 517 59 q 471 17 486 31 q 446 -4 456 4 q 428 -19 437 -12 q 408 -33 418 -26 q 388 -42 397 -39 q 371 -46 379 -46 q 348 -26 353 -46 l 85 901 q 82 912 83 907 q 80 922 80 918 q 88 951 80 939 q 117 970 96 962 q 171 984 137 979 q 257 992 205 989 q 298 997 281 996 q 326 998 315 998 q 352 992 346 998 q 363 972 358 986 l 503 434 l 618 633 l 572 838 q 568 862 568 854 q 576 887 568 878 q 604 903 585 897 q 653 913 623 909 q 729 924 684 918 q 763 928 749 927 q 787 930 777 930 q 815 924 808 930 q 826 904 823 918 l 918 486 l 1052 920 z " + }, + "X": { + "ha": 844, + "x_min": 56, + "x_max": 880, + "o": "m 302 978 q 308 979 308 979 l 314 979 q 336 971 326 979 q 358 945 347 964 q 379 905 366 931 q 410 848 393 878 q 442 786 426 817 q 471 733 458 755 l 583 927 l 852 981 q 858 982 855 982 q 865 983 861 983 q 880 971 880 983 q 878 961 880 967 q 872 947 876 954 l 749 728 l 701 644 q 654 566 679 608 q 608 486 629 524 q 630 447 620 466 q 649 412 641 428 q 664 385 658 397 q 673 368 669 374 l 838 55 l 836 30 q 827 22 836 28 q 803 9 818 16 q 770 -7 789 1 q 731 -22 751 -15 l 683 -38 q 664 -42 674 -38 q 642 -47 653 -47 q 624 -42 632 -47 q 606 -24 615 -38 q 572 28 591 -2 q 531 98 553 59 l 456 230 l 349 49 l 328 28 q 316 22 328 26 q 286 11 304 18 q 244 -3 267 5 q 198 -17 221 -10 q 155 -30 176 -24 q 120 -39 134 -35 q 97 -43 112 -39 q 70 -47 81 -47 q 60 -44 64 -47 q 56 -37 56 -42 q 62 -16 56 -31 q 78 19 68 -1 q 101 63 88 39 q 126 111 113 87 q 151 156 139 134 q 172 194 163 178 q 243 326 205 258 l 320 465 l 84 886 q 77 909 77 897 q 83 919 77 917 l 302 978 z " + }, + "Y": { + "ha": 756, + "x_min": 73, + "x_max": 861, + "o": "m 81 903 q 95 910 85 906 q 118 920 104 915 q 146 932 131 926 q 177 945 161 939 l 256 976 q 272 980 264 980 q 284 977 277 980 q 298 966 290 974 l 455 662 l 459 672 l 526 796 q 552 842 539 819 q 574 882 564 864 q 591 910 584 899 q 599 921 597 921 l 831 979 q 845 981 840 981 q 861 967 861 981 q 859 958 860 964 q 855 945 859 952 l 656 576 q 626 520 641 548 q 596 468 610 492 q 571 422 582 443 q 553 389 559 402 l 548 367 l 510 113 q 507 94 508 101 q 503 81 505 86 q 497 73 500 77 q 486 66 493 70 l 294 -35 q 271 -43 283 -40 q 248 -47 259 -47 q 229 -42 237 -47 q 220 -25 220 -37 l 220 -22 q 230 67 223 16 q 245 168 237 119 l 283 419 q 255 480 273 442 q 217 562 237 518 q 175 653 197 606 q 133 742 153 700 q 99 815 114 783 q 76 862 83 847 q 73 883 73 872 q 74 895 73 889 q 81 903 76 901 z " + }, + "Z": { + "ha": 847, + "x_min": 70, + "x_max": 854, + "o": "m 139 0 q 123 2 131 0 q 108 8 115 3 q 97 20 101 12 q 92 41 92 28 q 93 43 92 42 q 94 46 94 45 l 447 666 q 432 670 447 668 q 395 672 418 671 q 345 674 372 673 q 293 676 318 675 q 249 676 269 676 q 223 677 230 677 q 155 676 186 677 q 88 675 123 675 q 75 680 81 675 q 70 692 70 685 q 71 693 71 692 q 71 697 71 695 l 159 907 q 177 931 165 922 q 206 945 190 940 q 240 952 222 950 q 276 954 258 954 q 422 950 350 954 q 565 944 494 947 q 708 938 637 941 q 853 935 780 935 l 853 907 l 523 290 q 523 288 523 288 q 522 286 522 288 q 529 278 522 279 q 541 277 536 277 l 675 277 q 754 282 716 277 q 833 287 792 287 q 848 282 841 287 q 854 269 854 277 q 853 263 854 270 l 744 46 q 724 20 739 30 q 689 6 709 11 q 643 0 668 1 q 591 -1 618 -1 q 375 -1 485 -1 q 139 0 266 0 z " + }, + "[": { + "ha": 545, + "x_min": 85, + "x_max": 616, + "o": "m 153 897 q 157 922 154 913 q 165 936 161 931 q 174 942 169 941 q 182 944 178 943 l 597 983 q 601 983 597 983 q 607 981 604 983 q 613 975 610 979 q 616 964 616 971 q 607 929 616 960 q 580 852 597 898 l 550 774 q 542 759 547 764 q 524 750 537 753 q 488 745 511 747 q 422 739 464 743 q 418 697 421 730 q 410 616 414 664 q 400 513 406 569 q 388 401 394 456 l 365 120 l 496 121 q 504 114 504 121 q 502 104 504 110 q 496 94 499 99 q 489 75 495 92 q 474 35 482 58 q 456 -11 465 12 q 441 -49 447 -35 q 418 -72 431 -66 q 389 -77 406 -77 q 367 -76 378 -77 q 342 -74 355 -75 q 278 -71 311 -72 q 213 -67 245 -69 q 151 -63 181 -66 q 96 -54 121 -60 q 86 -46 88 -53 q 85 -31 85 -39 q 86 -2 85 -28 q 91 65 88 24 q 98 161 94 107 q 106 273 102 214 q 115 393 111 332 q 124 509 120 454 l 153 897 z " + }, + "\\": { + "ha": 742, + "x_min": 111, + "x_max": 719, + "o": "m 456 640 q 532 473 498 549 q 596 329 566 397 q 655 198 627 261 q 715 68 684 134 q 719 56 719 60 q 710 40 719 47 q 691 28 702 33 q 668 19 680 23 q 652 14 657 16 l 584 -15 q 566 -20 574 -20 q 554 -11 556 -20 q 547 10 551 -1 l 112 969 q 111 971 111 970 q 115 977 111 975 q 122 980 118 979 q 194 1004 158 987 q 266 1036 229 1021 l 270 1036 q 284 1028 279 1036 q 298 1001 290 1021 q 377 819 339 908 q 456 640 415 729 z " + }, + "]": { + "ha": 547, + "x_min": 20, + "x_max": 542, + "o": "m 470 1 q 465 -24 469 -15 q 458 -38 462 -33 q 449 -44 454 -43 q 441 -46 445 -45 l 258 -52 q 187 -55 222 -54 q 122 -57 152 -56 q 71 -58 92 -58 q 40 -58 49 -58 q 36 -58 39 -58 q 30 -56 33 -58 q 23 -50 26 -54 q 20 -39 20 -46 q 27 -11 20 -37 q 50 61 34 16 l 73 131 q 84 149 77 142 q 110 155 90 155 l 215 159 q 217 182 216 165 q 220 224 218 199 q 224 281 222 250 q 229 349 227 313 q 235 422 232 385 q 241 496 238 460 l 261 778 l 130 777 q 121 780 123 777 q 119 789 119 783 q 120 796 119 792 q 120 804 120 800 q 120 821 120 806 q 121 858 121 837 q 122 900 121 878 q 122 933 122 921 q 133 961 122 955 q 161 967 143 967 q 185 966 172 967 q 212 965 198 965 l 531 965 q 541 957 539 965 q 542 943 542 949 q 541 925 542 939 q 539 893 541 911 q 537 852 538 874 q 533 812 535 831 q 530 763 532 795 q 524 690 528 730 q 517 606 521 650 q 510 521 513 562 q 503 445 506 479 q 499 389 500 410 l 470 1 z " + }, + "^": { + "ha": 807, + "x_min": 28, + "x_max": 768, + "o": "m 507 62 q 496 98 503 73 q 481 153 490 122 q 463 216 472 183 q 446 279 454 250 q 432 329 437 308 q 424 358 426 351 l 267 60 q 250 52 264 52 q 199 62 226 58 q 145 69 172 66 q 90 74 117 72 q 37 78 62 76 q 28 87 28 80 q 39 119 28 95 q 92 219 64 167 q 147 324 119 271 q 202 427 175 376 q 252 522 229 478 q 296 602 276 566 q 332 659 317 637 q 335 664 332 661 q 341 667 338 667 l 346 668 q 347 668 347 668 q 349 667 348 667 q 457 658 404 661 q 562 654 511 655 q 572 652 568 654 q 580 646 576 650 q 587 635 584 642 q 594 617 590 628 l 765 145 q 767 133 766 138 q 768 123 768 127 q 753 102 768 107 q 637 83 696 93 q 522 58 579 73 q 513 57 517 57 q 507 62 508 57 z " + }, + "_": { + "ha": 706, + "x_min": 14, + "x_max": 672, + "o": "m 113 -39 q 37 -36 60 -39 q 14 -23 14 -33 q 18 -6 14 -18 q 30 18 22 5 q 47 46 37 31 q 67 72 56 60 q 89 92 78 84 q 111 100 100 100 l 349 107 q 383 107 359 107 q 439 108 408 108 q 505 109 471 109 q 568 110 538 110 q 617 111 597 111 q 640 111 637 111 q 656 108 648 111 q 667 101 662 105 q 672 87 672 96 q 670 61 672 79 q 665 26 668 43 l 659 -20 q 653 -31 658 -27 q 626 -35 648 -35 l 519 -35 q 474 -35 498 -35 q 428 -36 451 -35 q 385 -36 406 -36 q 347 -36 363 -36 l 113 -39 z " + }, + "`": { + "ha": 380, + "x_min": 17, + "x_max": 341, + "o": "m 117 968 q 90 1015 101 996 q 70 1051 79 1034 q 50 1087 60 1068 q 24 1135 39 1107 q 19 1149 20 1143 q 17 1162 18 1156 q 20 1174 17 1169 q 31 1181 24 1178 q 83 1210 56 1196 q 138 1240 111 1225 q 194 1272 166 1255 q 246 1305 221 1288 q 256 1309 253 1309 q 261 1303 260 1309 l 337 907 q 340 892 338 898 q 341 881 341 886 q 339 869 341 875 q 330 861 337 864 q 304 849 316 854 q 280 838 292 844 q 255 825 268 832 q 225 808 241 819 q 218 806 222 806 q 204 815 212 806 q 185 843 197 824 q 158 893 174 863 q 117 968 141 924 z " + }, + "a": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 z " + }, + "b": { + "ha": 895, + "x_min": 79, + "x_max": 888, + "o": "m 368 221 q 387 194 374 208 q 416 168 399 180 q 452 150 433 157 q 493 142 472 142 q 548 165 522 142 q 593 226 574 188 q 623 314 612 264 q 634 416 634 363 q 628 477 634 448 q 610 528 623 507 q 579 563 598 550 q 532 576 559 576 q 452 550 488 576 q 390 484 415 524 l 368 221 m 115 -33 q 100 -35 106 -35 q 79 -9 79 -35 q 79 -3 79 -6 q 80 5 79 1 l 159 964 q 159 968 159 966 q 159 972 159 970 q 159 977 159 975 q 159 980 159 979 q 163 990 159 986 q 186 998 168 994 l 376 1044 q 396 1050 384 1045 q 420 1055 408 1055 q 436 1050 429 1055 q 444 1035 444 1046 q 444 1032 444 1034 q 444 1029 444 1031 q 440 1000 443 1026 q 434 936 437 973 q 427 855 431 898 q 419 774 422 812 q 413 710 416 736 q 410 680 410 684 q 512 735 459 713 q 618 757 565 757 q 739 728 688 757 q 823 651 789 699 q 872 541 856 604 q 888 412 888 479 q 878 305 888 359 q 849 201 868 251 q 803 109 831 152 q 738 33 774 65 q 657 -17 702 1 q 560 -35 612 -35 q 445 -7 497 -35 q 348 64 393 20 q 346 39 347 48 q 341 24 345 29 q 330 16 338 19 q 308 9 323 13 q 282 3 300 7 q 237 -7 263 -1 q 179 -20 211 -13 q 115 -33 148 -26 z " + }, + "c": { + "ha": 714, + "x_min": 68, + "x_max": 736, + "o": "m 68 331 q 101 497 68 420 q 192 633 134 575 q 329 724 250 690 q 500 757 408 757 q 608 747 564 757 q 682 724 653 736 q 723 701 710 711 q 736 690 736 690 q 733 667 736 686 q 727 624 730 648 q 720 573 723 599 q 717 528 717 547 q 715 520 717 524 q 704 516 713 516 q 680 522 696 516 q 642 534 665 528 q 590 547 619 541 q 524 553 560 553 q 444 537 479 553 q 382 493 408 521 q 342 426 356 465 q 328 342 328 387 q 343 263 328 296 q 382 209 357 230 q 438 179 406 189 q 505 170 470 170 q 587 181 546 170 q 665 212 629 192 q 671 213 667 213 q 676 210 674 213 q 677 197 677 206 q 677 144 677 175 q 677 85 677 113 q 676 37 677 57 q 675 18 675 18 q 622 -9 650 1 q 563 -25 593 -19 q 503 -33 533 -31 q 444 -35 472 -35 q 296 -9 364 -35 q 177 66 227 18 q 97 181 126 114 q 68 331 68 249 z " + }, + "d": { + "ha": 901, + "x_min": 71, + "x_max": 920, + "o": "m 595 386 q 599 431 597 408 q 605 481 602 455 q 561 547 589 522 q 490 572 532 572 q 418 548 448 572 q 372 489 389 524 q 346 415 354 454 q 338 346 338 376 q 343 273 338 309 q 357 208 347 237 q 383 161 367 179 q 424 143 399 143 q 521 170 484 145 q 581 231 558 195 l 595 386 m 831 68 q 831 65 831 67 q 831 62 831 64 q 831 57 831 59 q 831 53 831 55 q 827 43 831 47 q 804 35 823 39 l 623 -24 q 603 -31 615 -27 q 579 -35 591 -35 q 563 -31 570 -35 q 555 -13 555 -26 q 556 7 555 -1 q 556 21 556 14 q 558 35 557 27 q 560 54 559 42 q 455 -11 509 14 q 342 -35 401 -35 q 226 -4 277 -35 q 141 78 176 26 q 89 197 106 130 q 71 336 71 264 q 101 508 71 431 q 185 641 132 586 q 309 727 238 696 q 458 757 379 757 q 540 734 501 757 q 614 677 579 712 q 618 749 616 710 q 624 828 621 788 q 629 908 627 869 q 634 980 632 947 q 638 990 634 986 q 661 998 643 994 l 852 1044 q 871 1050 859 1045 q 895 1055 884 1055 q 912 1050 904 1055 q 920 1035 920 1046 q 919 1029 920 1034 q 919 1023 919 1025 l 831 68 z " + }, + "e": { + "ha": 774, + "x_min": 68, + "x_max": 771, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 z " + }, + "f": { + "ha": 617, + "x_min": 47, + "x_max": 783, + "o": "m 157 -10 q 160 22 158 -3 q 166 85 163 47 q 174 172 170 123 q 184 278 178 221 q 195 396 189 334 q 207 521 201 458 l 117 521 q 64 524 80 521 q 47 536 47 527 q 54 551 47 539 q 72 579 61 563 q 94 615 82 596 q 118 651 106 634 q 137 680 129 667 q 147 694 145 692 l 230 694 l 243 802 q 278 946 253 888 q 339 1040 302 1004 q 424 1092 376 1076 q 526 1107 471 1107 q 638 1094 580 1107 q 757 1058 697 1081 q 776 1049 770 1053 q 783 1039 783 1045 q 778 1025 783 1034 q 746 957 756 983 q 729 916 735 931 q 719 896 723 901 q 707 891 715 891 q 698 893 703 891 q 688 898 694 895 q 640 914 664 907 q 594 921 616 921 q 542 902 564 921 q 507 830 519 883 q 494 764 499 800 q 486 694 490 729 l 663 694 q 674 691 669 694 q 679 678 679 687 q 671 654 679 675 q 648 599 663 633 l 623 544 q 613 529 618 534 q 593 523 608 524 l 475 523 q 461 383 468 452 q 448 253 454 314 q 437 145 442 193 q 430 73 433 98 q 429 65 429 71 q 430 60 429 62 q 430 57 430 58 q 426 47 430 51 q 403 39 421 43 l 224 -24 q 205 -31 217 -27 q 181 -35 193 -35 q 163 -30 170 -35 q 157 -10 157 -24 z " + }, + "g": { + "ha": 878, + "x_min": 69, + "x_max": 871, + "o": "m 576 484 q 557 515 569 499 q 530 545 545 532 q 496 567 514 559 q 457 576 477 576 q 405 553 429 576 q 362 491 380 530 q 333 404 344 453 q 323 302 323 355 q 329 240 323 269 q 347 190 334 212 q 378 155 359 168 q 425 142 397 142 q 499 168 465 142 q 557 234 533 193 q 559 268 557 248 q 562 316 560 288 q 567 382 564 344 q 575 473 570 420 l 576 484 m 800 74 q 770 -52 793 7 q 710 -158 746 -110 q 627 -245 673 -207 q 529 -310 581 -283 q 422 -350 477 -336 q 313 -364 367 -364 q 255 -362 285 -364 q 197 -354 225 -360 q 142 -338 168 -349 q 96 -311 117 -328 l 96 -311 q 101 -287 96 -307 q 114 -239 106 -267 q 129 -182 121 -212 q 142 -132 136 -153 q 147 -119 144 -123 q 153 -116 150 -116 q 158 -117 157 -116 q 195 -138 178 -130 q 232 -152 212 -146 q 274 -159 251 -157 q 328 -161 297 -161 q 412 -142 375 -161 q 475 -95 448 -124 q 517 -31 501 -66 q 539 38 533 4 q 440 -15 490 5 q 339 -35 390 -35 q 218 -7 269 -35 q 134 68 168 21 q 85 177 101 116 q 69 306 69 239 q 79 413 69 359 q 108 517 89 467 q 156 611 127 567 q 222 687 184 654 q 304 738 259 720 q 404 757 350 757 q 512 731 462 757 q 602 667 562 705 q 604 687 604 680 q 608 698 605 694 q 617 704 610 702 q 635 709 623 706 q 663 714 643 710 q 709 725 682 719 q 769 738 736 731 q 835 752 802 745 q 850 754 843 754 q 871 727 871 754 q 871 721 871 724 q 870 714 871 718 l 800 74 z " + }, + "h": { + "ha": 901, + "x_min": 81, + "x_max": 878, + "o": "m 159 986 q 158 994 159 990 q 158 1000 158 997 q 163 1011 158 1006 q 186 1020 168 1015 l 376 1065 q 396 1072 384 1067 q 420 1076 408 1076 q 444 1055 444 1076 q 441 1027 444 1053 q 436 961 439 1000 q 429 875 433 922 q 420 787 425 829 q 413 713 416 745 q 408 672 410 682 q 474 705 439 690 q 544 732 509 721 q 613 750 578 744 q 681 757 648 757 q 758 745 722 757 q 821 706 794 733 q 863 634 848 678 q 878 525 878 589 q 878 491 878 509 q 875 456 877 474 l 846 178 q 842 119 842 150 q 841 56 841 88 q 841 46 841 50 q 842 40 842 43 q 837 31 842 34 q 814 22 831 27 l 648 -24 q 628 -31 640 -26 q 604 -35 616 -35 q 587 -30 593 -35 q 581 -10 581 -25 q 588 72 585 41 q 593 122 591 103 q 596 150 595 140 q 598 168 597 160 q 600 185 599 176 q 603 214 602 195 q 608 264 605 233 q 616 347 611 296 q 622 400 618 374 q 625 450 625 427 q 622 502 625 478 q 611 542 618 525 q 589 568 603 558 q 555 577 576 577 q 512 570 539 577 q 459 548 486 564 q 411 507 432 532 q 388 444 391 481 q 379 359 382 392 q 374 304 376 326 q 371 269 372 282 q 370 245 370 256 q 368 229 369 242 q 364 197 366 216 q 360 158 362 179 q 356 119 358 138 q 354 88 355 101 q 353 71 353 75 q 353 62 353 66 q 353 55 353 58 q 348 46 353 50 q 326 37 343 42 l 149 -24 q 129 -31 141 -27 q 105 -35 117 -35 q 88 -30 95 -35 q 81 -10 81 -24 l 159 986 z " + }, + "i": { + "ha": 415, + "x_min": 73, + "x_max": 439, + "o": "m 99 288 q 117 471 108 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 91 q 342 60 345 68 q 328 49 340 52 q 211 9 270 31 q 86 -35 152 -12 q 80 -35 84 -35 q 73 -28 73 -35 q 74 -10 73 -28 q 78 35 75 7 q 83 98 80 63 q 89 167 85 132 q 94 233 92 201 q 99 288 97 265 m 150 829 q 153 873 152 858 q 155 897 154 889 q 155 907 155 905 q 156 910 155 909 q 156 914 156 911 q 157 927 157 917 q 159 956 158 937 q 163 1008 161 975 q 168 1039 165 1030 q 183 1050 171 1047 q 304 1074 241 1061 q 426 1106 367 1086 q 432 1107 428 1107 q 437 1101 434 1103 l 439 1100 l 425 914 q 419 883 422 892 q 405 872 416 874 q 288 848 347 861 q 163 823 229 836 q 157 822 161 822 q 150 829 150 822 z " + }, + "j": { + "ha": 420, + "x_min": -193, + "x_max": 441, + "o": "m 152 829 q 155 873 154 858 q 157 897 156 889 q 157 907 157 905 q 158 910 157 909 q 158 914 158 911 q 159 927 159 917 q 161 956 160 937 q 165 1008 163 975 q 170 1039 167 1030 q 185 1050 173 1047 q 306 1074 243 1061 q 428 1106 369 1086 q 434 1107 430 1107 q 439 1101 436 1103 l 441 1100 l 427 914 q 421 883 425 892 q 407 872 418 874 q 290 848 349 861 q 165 823 231 836 q 159 822 163 822 q 152 829 152 822 m 77 60 q 90 185 83 115 q 105 335 98 256 q 121 496 113 414 q 134 658 128 579 q 139 689 136 680 q 154 700 142 697 q 275 724 212 711 q 397 756 338 736 q 403 757 399 757 q 410 751 410 757 l 360 196 l 347 88 q 257 -146 329 -69 q 47 -223 184 -223 q 11 -219 34 -223 q -39 -209 -12 -216 q -93 -195 -65 -203 q -142 -178 -120 -186 q -178 -162 -164 -170 q -193 -149 -193 -155 q -188 -134 -193 -143 q -157 -69 -168 -95 q -140 -28 -146 -43 q -131 -7 -134 -13 q -119 -1 -127 -1 q -98 -7 -113 -1 q -51 -24 -74 -17 q -4 -31 -27 -31 q 23 -26 9 -31 q 47 -12 36 -22 q 65 16 58 -1 q 77 60 73 34 z " + }, + "k": { + "ha": 853, + "x_min": 77, + "x_max": 881, + "o": "m 447 1029 l 422 794 q 413 698 418 747 q 403 604 408 649 q 394 522 398 559 q 387 459 390 484 l 434 511 l 510 600 q 538 632 524 616 q 562 661 551 648 q 580 682 573 673 q 588 692 587 690 l 861 711 q 865 711 863 711 q 869 711 867 711 q 881 705 881 711 q 867 684 881 701 l 654 447 q 621 411 640 431 q 578 365 602 390 l 677 247 l 848 63 q 854 58 850 60 q 862 55 858 56 q 868 52 865 54 q 871 47 871 50 q 870 43 871 44 q 864 37 869 41 q 843 24 858 33 q 807 4 827 15 q 764 -18 787 -7 q 719 -38 741 -29 q 679 -53 697 -47 q 650 -59 661 -59 q 634 -55 642 -59 q 617 -40 627 -51 q 585 -1 604 -25 q 543 52 566 23 q 497 112 521 81 q 453 171 474 142 q 414 221 431 199 q 387 258 397 244 q 380 253 384 254 q 374 248 377 251 q 369 242 371 245 q 367 231 367 238 q 366 216 367 229 q 362 186 364 203 q 359 149 361 169 q 355 112 357 130 q 352 82 353 95 q 351 67 351 70 q 351 59 351 62 q 351 54 351 56 q 346 42 351 46 q 321 33 341 39 l 144 -24 q 124 -31 136 -27 q 100 -35 112 -35 q 84 -31 91 -35 q 77 -13 77 -26 l 162 964 q 161 971 162 968 q 161 977 161 974 q 166 988 161 983 q 189 998 171 994 l 380 1044 q 399 1050 387 1045 q 423 1055 412 1055 q 440 1050 432 1055 q 448 1035 448 1046 q 447 1032 448 1034 q 447 1029 447 1031 z " + }, + "l": { + "ha": 422, + "x_min": 79, + "x_max": 439, + "o": "m 128 574 q 141 758 136 667 q 153 944 146 848 q 157 975 154 966 q 172 986 160 983 q 299 1009 235 998 q 426 1041 362 1021 q 432 1042 428 1042 q 439 1035 439 1042 l 351 66 l 351 55 q 347 45 351 49 q 324 37 342 41 l 146 -28 q 127 -36 139 -32 q 103 -39 115 -39 q 86 -35 94 -39 q 79 -18 79 -30 l 79 -14 q 90 120 85 54 q 101 255 95 185 q 113 403 106 325 q 128 574 120 481 z " + }, + "m": { + "ha": 1322, + "x_min": 73, + "x_max": 1301, + "o": "m 1068 -24 q 1048 -31 1061 -26 q 1025 -35 1036 -35 q 1008 -31 1015 -35 q 1001 -14 1001 -26 l 1001 -10 q 1009 70 1006 39 q 1015 120 1013 101 q 1018 149 1017 139 q 1021 167 1019 159 q 1023 186 1022 176 q 1026 215 1024 196 q 1031 264 1028 233 q 1039 345 1034 295 q 1045 399 1042 372 q 1048 448 1048 426 q 1045 500 1048 477 q 1034 540 1042 524 q 1013 566 1026 557 q 978 576 999 576 q 944 570 965 576 q 903 551 924 565 q 865 514 882 537 q 843 457 848 492 l 843 454 q 828 320 835 377 q 817 223 822 264 q 809 155 812 182 q 804 110 806 128 q 801 79 802 91 q 800 56 800 66 q 801 46 800 50 q 801 40 801 43 q 796 31 801 35 q 773 22 791 27 l 614 -24 q 594 -31 606 -26 q 570 -35 582 -35 q 554 -31 561 -35 q 547 -14 547 -26 l 547 -10 q 554 70 551 39 q 560 120 557 101 q 564 149 562 139 q 566 167 565 159 q 568 186 567 176 q 571 215 570 196 q 576 264 573 233 q 584 345 579 295 q 590 399 587 372 q 593 448 593 426 q 590 500 593 477 q 580 540 587 524 q 561 566 573 557 q 530 576 549 576 q 493 569 516 576 q 446 547 469 562 q 405 506 422 531 q 384 443 387 480 q 375 360 378 393 q 370 308 372 328 q 367 274 368 287 q 365 251 366 262 q 363 236 364 248 q 360 206 361 223 q 356 169 358 189 q 353 131 355 149 q 351 101 352 114 q 350 86 350 89 l 350 74 q 349 62 350 67 q 345 54 349 58 q 335 46 342 50 q 317 39 329 43 l 140 -24 q 120 -31 132 -27 q 96 -35 108 -35 q 80 -31 87 -35 q 73 -14 73 -26 l 134 677 q 139 708 136 700 q 154 720 142 717 q 280 744 216 732 q 406 777 345 757 q 413 775 412 777 q 414 770 414 773 l 405 671 q 461 699 430 684 q 527 727 492 714 q 596 748 561 740 q 666 757 632 757 q 752 735 713 757 q 818 665 792 714 q 883 701 848 684 q 952 730 917 718 q 1024 749 988 743 q 1094 756 1060 756 q 1175 744 1137 756 q 1241 704 1213 732 q 1285 632 1269 676 q 1301 524 1301 587 q 1301 490 1301 507 q 1298 454 1300 473 l 1267 164 q 1263 112 1263 136 q 1262 56 1262 87 q 1262 46 1262 50 q 1263 40 1263 43 q 1258 31 1263 34 q 1235 22 1253 27 l 1068 -24 z " + }, + "n": { + "ha": 897, + "x_min": 73, + "x_max": 876, + "o": "m 644 -24 q 625 -31 637 -26 q 601 -35 612 -35 q 584 -31 591 -35 q 577 -14 577 -26 l 577 -10 q 584 52 581 26 q 589 98 587 77 q 593 139 591 119 q 597 188 595 160 q 604 253 600 215 q 613 347 608 290 q 619 417 616 381 q 622 484 622 453 q 619 521 622 504 q 608 551 616 538 q 587 570 600 564 q 552 577 573 577 q 509 570 536 577 q 454 548 481 564 q 406 507 427 532 q 382 444 385 481 q 375 358 378 394 q 369 298 372 323 q 365 258 366 273 q 362 231 363 242 q 360 216 361 229 q 357 187 359 204 q 354 151 355 170 q 350 115 352 132 q 348 86 349 98 q 347 71 347 74 q 347 62 347 66 q 347 55 347 58 q 342 46 347 50 q 319 37 337 42 l 140 -24 q 120 -31 132 -27 q 96 -35 108 -35 q 80 -31 87 -35 q 73 -14 73 -26 l 134 679 q 139 710 136 701 q 155 721 142 718 q 281 746 216 733 q 408 778 347 758 q 415 776 414 778 q 416 771 416 774 q 415 767 416 770 q 413 755 415 764 q 409 726 412 745 q 400 672 406 707 q 488 716 447 699 q 565 742 529 732 q 633 754 602 751 q 693 757 665 757 q 832 699 789 757 q 876 525 876 641 q 875 491 876 509 q 872 456 874 474 l 841 164 q 837 112 838 136 q 836 56 836 87 q 837 46 836 50 q 837 40 837 43 q 832 31 837 34 q 809 22 827 27 l 644 -24 z " + }, + "o": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 z " + }, + "p": { + "ha": 899, + "x_min": 50, + "x_max": 895, + "o": "m 385 473 q 377 368 380 408 q 372 301 374 327 q 369 261 370 276 q 367 234 368 247 q 413 168 382 193 q 486 142 444 142 q 552 166 524 142 q 598 227 580 189 q 627 310 617 264 q 636 401 636 356 q 630 471 636 439 q 610 526 623 503 q 578 563 597 549 q 532 576 559 576 q 489 567 511 576 q 448 546 468 559 q 413 516 429 532 q 387 484 397 499 l 385 473 m 398 672 q 517 734 455 711 q 648 757 580 757 q 758 731 711 757 q 835 660 804 705 q 880 558 865 616 q 895 437 895 500 q 884 326 895 382 q 852 217 873 270 q 800 119 831 165 q 728 38 768 73 q 638 -16 688 4 q 531 -35 589 -35 q 434 -15 480 -35 q 347 38 387 5 q 345 13 347 35 q 340 -39 343 -8 q 334 -104 338 -69 q 328 -170 331 -139 q 324 -221 326 -200 q 322 -246 322 -243 q 322 -255 322 -252 q 323 -262 323 -259 q 318 -272 323 -268 q 295 -280 313 -275 l 117 -326 q 97 -332 109 -328 q 73 -336 85 -336 q 57 -332 64 -336 q 50 -314 50 -327 l 132 679 q 137 710 134 701 q 153 721 140 718 q 279 746 214 733 q 406 778 345 758 q 413 776 412 778 q 414 771 414 774 q 413 767 414 770 q 411 755 413 764 q 407 726 410 745 q 398 672 404 707 z " + }, + "q": { + "ha": 884, + "x_min": 70, + "x_max": 871, + "o": "m 576 487 q 558 519 570 503 q 530 549 546 535 q 496 571 515 562 q 458 579 478 579 q 406 556 430 579 q 363 495 381 533 q 334 407 345 456 q 323 305 323 358 q 329 244 323 273 q 347 193 335 215 q 379 159 359 172 q 425 146 398 146 q 500 171 466 146 q 557 237 534 197 q 559 271 558 252 q 563 319 561 291 q 568 386 565 347 q 576 477 571 424 l 576 487 m 836 755 q 850 758 844 758 q 871 731 871 758 q 871 725 871 728 q 871 718 871 722 l 783 -222 q 783 -230 783 -224 q 783 -235 783 -233 q 783 -238 783 -237 q 779 -248 783 -244 q 756 -256 774 -252 l 576 -322 q 557 -329 569 -324 q 533 -333 545 -333 q 516 -329 524 -333 q 509 -314 509 -325 q 509 -308 509 -310 q 511 -289 510 -305 q 515 -249 513 -273 q 519 -195 517 -225 q 525 -133 522 -165 q 530 -72 527 -102 q 534 -17 532 -41 q 538 23 536 7 q 540 41 539 39 q 441 -13 491 9 q 340 -35 391 -35 q 219 -7 270 -35 q 135 70 168 22 q 86 179 102 117 q 70 309 70 241 q 80 416 70 362 q 109 520 90 470 q 157 613 128 570 q 222 688 185 656 q 305 739 260 720 q 404 757 351 757 q 513 733 464 757 q 603 671 562 709 q 605 690 604 683 q 608 701 606 697 q 617 707 611 705 q 636 712 624 709 q 663 718 644 713 q 710 728 683 722 q 770 741 737 734 q 836 755 802 748 z " + }, + "r": { + "ha": 613, + "x_min": 73, + "x_max": 678, + "o": "m 636 552 q 604 555 622 553 q 567 557 587 557 q 506 552 537 557 q 449 532 475 547 q 404 491 422 517 q 379 423 385 465 q 371 341 374 373 q 367 288 368 309 q 365 255 366 267 q 363 231 363 242 q 362 216 363 229 q 359 188 361 204 q 355 153 357 172 q 351 117 353 134 q 348 89 349 101 q 347 74 347 77 q 348 65 347 68 q 348 58 348 61 q 343 48 348 52 q 320 40 338 45 l 140 -24 q 121 -31 133 -27 q 97 -35 109 -35 q 80 -31 87 -35 q 73 -14 73 -26 l 132 658 q 137 689 134 680 q 152 700 140 697 q 279 725 214 712 q 407 757 345 737 q 413 755 412 757 q 414 750 414 753 q 414 746 414 749 q 412 734 414 743 q 407 705 410 724 q 399 651 404 686 q 468 696 430 675 q 543 731 505 716 q 616 754 581 745 q 678 762 652 762 l 636 552 z " + }, + "s": { + "ha": 584, + "x_min": 84, + "x_max": 568, + "o": "m 85 187 q 84 188 85 188 q 84 189 84 189 q 94 198 84 198 q 104 193 100 198 q 166 151 132 168 q 242 135 201 135 q 267 137 253 135 q 294 145 281 140 q 316 160 307 151 q 324 182 324 169 q 317 210 324 197 q 297 231 309 222 q 273 247 290 235 q 237 273 256 259 q 200 298 218 286 q 176 316 183 311 q 128 363 146 339 q 100 412 110 387 q 88 465 91 437 q 85 524 85 493 q 111 614 85 572 q 177 688 136 656 q 270 738 218 720 q 376 757 322 757 q 465 743 421 757 q 545 697 508 730 q 550 613 549 654 q 554 526 551 572 q 550 514 554 517 q 541 511 546 511 q 532 515 536 511 q 500 544 517 530 q 466 570 484 559 q 428 588 448 581 q 385 595 408 595 q 356 593 370 595 q 330 584 342 591 q 312 567 319 578 q 304 539 304 556 q 309 505 304 519 q 332 477 314 491 l 458 392 q 514 346 492 368 q 547 300 535 323 q 564 256 559 278 q 568 212 568 234 q 540 105 568 151 q 467 27 512 58 q 366 -19 422 -3 q 252 -35 309 -35 q 169 -26 209 -35 q 94 2 128 -17 l 85 187 z " + }, + "t": { + "ha": 633, + "x_min": 51, + "x_max": 659, + "o": "m 447 523 q 429 381 437 452 q 418 248 420 310 q 422 204 418 222 q 434 176 427 186 q 453 162 442 166 q 477 157 464 157 q 525 164 499 157 q 576 180 551 170 q 590 185 585 184 q 600 187 596 187 q 609 181 607 187 q 614 160 612 175 q 621 119 616 144 q 637 54 626 93 q 638 48 637 50 q 638 43 638 45 q 631 30 638 34 q 608 20 624 26 q 549 1 580 11 q 488 -17 518 -9 q 430 -30 458 -25 q 381 -35 402 -35 q 216 21 268 -35 q 164 193 164 77 q 165 233 164 212 q 169 276 167 254 q 181 405 176 344 q 191 521 186 465 l 120 521 q 67 524 83 521 q 51 536 51 527 q 58 551 51 539 q 75 579 64 563 q 98 615 85 596 q 121 651 110 634 q 140 680 132 667 q 151 694 149 692 l 199 694 q 209 781 205 746 q 215 839 212 816 q 219 874 217 862 q 223 893 221 887 q 230 901 226 899 q 239 904 233 903 q 298 915 270 910 q 355 926 327 920 q 413 938 384 931 q 475 955 443 945 q 477 955 477 955 q 480 956 478 956 q 488 950 488 956 l 488 949 q 478 845 485 911 q 462 694 471 779 l 644 694 q 654 691 649 694 q 659 678 659 687 q 651 654 659 675 q 628 599 643 633 l 604 544 q 594 529 599 534 q 573 523 589 524 l 447 523 z " + }, + "u": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 z " + }, + "v": { + "ha": 760, + "x_min": 69, + "x_max": 819, + "o": "m 580 750 q 590 757 583 757 q 593 756 591 757 q 595 756 594 756 q 700 724 646 736 q 806 700 753 711 q 819 685 819 697 q 811 658 819 676 l 532 64 q 522 44 526 52 q 514 32 518 37 q 505 25 510 27 q 494 22 500 23 q 389 -2 442 12 q 279 -35 336 -15 q 277 -35 278 -35 q 276 -35 277 -35 l 271 -35 q 266 -33 268 -35 q 263 -28 264 -31 q 240 47 253 1 q 211 150 227 93 q 178 271 195 207 q 144 402 161 336 q 109 534 126 469 q 75 658 92 600 q 69 688 69 679 q 80 700 69 697 q 190 724 133 711 q 298 756 247 736 q 304 757 300 757 q 313 750 313 757 l 403 294 q 414 324 406 302 q 434 375 422 345 q 459 441 446 405 q 488 514 473 477 q 517 588 503 551 q 544 657 531 625 q 566 713 556 689 q 580 750 575 738 z " + }, + "w": { + "ha": 1147, + "x_min": 77, + "x_max": 1185, + "o": "m 503 690 q 515 700 505 698 q 627 725 570 712 q 738 757 685 738 q 747 750 747 757 l 817 288 l 946 750 q 956 757 949 757 q 959 756 958 757 q 962 756 960 756 q 1066 724 1013 736 q 1173 700 1120 711 q 1185 685 1185 697 q 1177 658 1185 676 l 959 53 q 949 34 954 41 q 941 21 945 26 q 932 15 937 17 q 921 11 928 12 q 817 -13 869 0 q 707 -45 764 -26 q 705 -46 705 -45 q 703 -46 704 -46 l 699 -46 q 693 -44 695 -46 q 690 -39 691 -41 q 673 33 683 -10 q 653 130 664 77 q 630 243 642 182 q 605 367 618 303 l 519 64 q 512 44 515 52 q 503 32 508 36 q 493 25 498 27 q 481 22 488 23 q 376 -2 429 12 q 267 -35 323 -15 q 264 -35 265 -35 q 263 -35 264 -35 l 258 -35 q 253 -33 255 -35 q 250 -28 250 -31 q 229 47 241 1 q 202 150 216 93 q 174 271 189 206 q 143 401 159 335 q 112 533 127 468 q 83 658 97 599 q 77 676 78 669 q 77 688 77 683 q 87 700 77 697 q 197 724 140 711 q 305 756 254 736 q 311 757 307 757 q 321 750 320 757 l 397 281 l 503 690 z " + }, + "x": { + "ha": 726, + "x_min": 15, + "x_max": 773, + "o": "m 475 -39 q 465 -21 474 -39 q 441 23 456 -4 q 409 85 427 51 q 372 153 391 119 q 321 85 347 119 q 277 23 296 51 q 244 -21 257 -4 q 231 -39 231 -39 q 218 -46 226 -46 q 212 -45 214 -46 q 168 -27 192 -35 q 120 -13 144 -20 q 70 -1 95 -7 q 22 11 45 5 q 15 18 15 12 q 20 30 15 22 q 37 53 26 37 l 262 360 q 181 508 221 435 q 100 658 142 581 q 88 679 92 671 q 84 692 84 686 q 93 700 84 698 q 197 724 144 711 q 296 756 250 736 q 302 757 298 757 q 313 750 311 757 l 415 566 l 557 750 q 570 757 562 757 q 575 756 574 757 q 668 724 616 736 q 766 700 719 711 q 773 694 773 698 q 768 681 773 688 q 751 658 762 673 q 636 508 692 581 q 526 360 581 436 l 688 53 q 699 33 695 40 q 703 20 703 25 q 695 11 703 13 q 591 -13 644 -1 q 492 -45 538 -26 q 486 -46 490 -46 q 475 -39 479 -46 z " + }, + "y": { + "ha": 763, + "x_min": 77, + "x_max": 807, + "o": "m 370 -309 q 367 -314 369 -312 q 361 -315 366 -315 q 355 -315 357 -315 q 309 -300 331 -306 q 266 -289 288 -294 q 222 -279 245 -283 q 175 -269 200 -275 q 164 -265 168 -268 q 161 -254 161 -262 q 163 -244 161 -252 q 175 -213 165 -237 q 204 -143 184 -189 q 258 -20 224 -97 q 235 62 248 14 q 208 165 222 109 q 178 284 193 222 q 146 411 162 347 q 114 538 130 475 q 83 658 98 601 q 78 676 79 669 q 77 688 77 683 q 88 700 77 697 q 198 724 141 711 q 306 756 255 736 q 312 757 308 757 q 321 750 321 757 l 404 294 q 415 324 407 302 q 433 375 422 345 q 456 441 444 405 q 483 514 469 477 q 510 588 496 551 q 534 657 523 625 q 554 713 546 689 q 568 750 563 738 q 578 757 570 757 q 581 756 580 757 q 583 756 581 756 q 688 724 634 736 q 794 700 741 711 q 807 685 807 697 q 799 658 807 676 l 519 26 l 370 -309 z " + }, + "z": { + "ha": 673, + "x_min": 57, + "x_max": 667, + "o": "m 173 0 q 144 1 159 0 q 116 6 129 3 q 95 16 104 10 q 87 33 87 22 q 87 35 87 33 q 88 37 88 36 l 328 528 q 264 527 299 528 q 193 525 229 526 q 125 524 157 524 q 71 523 93 523 q 60 527 64 523 q 57 537 57 532 l 57 540 l 125 703 q 146 727 131 722 q 176 732 161 732 q 410 728 295 732 q 644 724 525 724 q 659 720 651 724 q 667 708 667 715 q 665 703 667 707 l 450 234 q 454 232 450 233 q 464 231 458 231 q 475 230 469 230 q 485 230 481 230 q 568 232 528 230 q 648 234 608 234 q 661 231 656 234 q 667 217 667 227 q 665 216 667 216 l 580 37 q 558 17 574 24 q 520 6 543 9 q 470 1 498 2 q 414 0 443 0 l 173 0 z " + }, + "{": { + "ha": 571, + "x_min": 39, + "x_max": 624, + "o": "m 113 630 q 178 657 151 636 q 227 709 205 678 q 265 776 248 739 q 302 851 283 813 q 347 927 322 890 q 407 993 372 964 q 492 1043 443 1023 q 609 1067 541 1063 l 612 1067 q 621 1065 618 1067 q 624 1055 624 1063 q 616 988 618 1017 q 610 932 613 959 q 604 876 608 905 q 591 808 600 846 q 583 783 589 789 q 569 775 576 776 q 516 750 535 771 q 486 701 498 730 q 464 638 473 671 q 437 574 454 604 q 392 522 420 545 q 316 493 364 500 q 409 418 380 458 q 439 330 439 377 q 438 312 439 321 q 438 294 438 302 q 458 181 438 222 q 513 123 478 141 q 522 118 518 122 q 526 103 526 113 q 525 97 526 100 q 524 90 525 94 q 512 40 518 62 q 501 -3 507 18 q 490 -45 496 -23 q 477 -96 484 -67 q 472 -106 475 -103 q 463 -109 468 -109 q 460 -108 461 -109 q 458 -108 460 -108 q 349 -73 404 -99 q 251 -1 294 -47 q 180 111 208 45 q 153 267 153 177 q 154 286 153 276 q 154 304 154 295 q 146 350 154 330 q 126 384 139 370 q 96 406 113 397 q 58 418 78 414 q 44 422 49 419 q 39 437 39 425 q 41 466 39 449 q 46 503 43 484 q 53 540 50 522 q 60 574 57 559 q 65 598 63 589 q 67 608 67 608 q 82 624 71 621 q 113 630 92 627 z " + }, + "|": { + "ha": 404, + "x_min": 82, + "x_max": 415, + "o": "m 131 574 q 148 771 141 673 q 162 971 155 869 q 166 1002 163 994 q 182 1013 170 1010 q 294 1022 239 1017 q 408 1035 350 1027 q 414 1033 413 1035 q 415 1028 415 1031 l 327 9 l 327 -6 q 322 -16 327 -12 q 300 -24 318 -20 l 149 -62 q 130 -69 142 -65 q 106 -73 117 -73 q 89 -68 96 -73 q 82 -52 82 -64 l 82 -48 q 92 94 87 25 q 102 236 97 163 q 115 391 108 309 q 131 574 122 473 z " + }, + "}": { + "ha": 571, + "x_min": 44, + "x_max": 616, + "o": "m 576 616 q 606 611 596 614 q 616 594 616 608 q 615 584 616 594 q 613 557 614 573 q 610 519 612 541 q 606 477 608 498 q 600 435 603 455 q 592 400 596 415 q 584 382 589 387 q 567 377 578 378 q 498 325 528 366 q 452 208 469 284 q 420 114 442 156 q 367 38 397 71 q 298 -21 336 4 q 220 -65 260 -47 q 139 -93 180 -83 q 60 -108 98 -104 q 58 -108 58 -108 q 56 -109 57 -109 q 44 -96 44 -109 q 44 -70 44 -82 q 45 -47 45 -58 q 44 -9 45 -26 q 44 31 44 7 q 45 60 44 45 q 48 89 45 75 q 55 112 50 102 q 67 123 59 121 q 111 155 90 138 q 148 197 131 173 q 179 256 165 222 q 202 336 193 290 q 245 424 214 383 q 336 500 277 465 q 275 520 298 505 q 239 556 252 535 q 222 601 227 576 q 215 652 217 626 q 211 702 213 677 q 202 746 210 726 q 181 779 195 766 q 138 795 166 792 q 129 801 133 797 q 124 817 124 806 q 124 823 124 820 q 125 829 125 825 q 131 890 127 863 q 139 942 134 917 q 148 994 143 967 q 159 1055 153 1021 q 164 1065 160 1063 q 174 1067 168 1067 l 177 1067 q 294 1042 248 1063 q 369 991 340 1021 q 413 922 397 960 q 439 845 429 884 q 457 767 450 805 q 479 697 465 728 q 515 644 492 666 q 576 616 537 623 z " + }, + "~": { + "ha": 735, + "x_min": 77, + "x_max": 753, + "o": "m 490 591 q 514 566 503 573 q 541 558 526 558 q 571 569 562 558 q 584 594 580 581 q 589 620 587 608 q 597 631 591 631 q 609 631 599 631 q 633 632 618 631 q 664 633 647 633 q 696 635 681 634 q 722 636 711 636 q 736 637 733 637 q 747 634 741 637 q 753 624 753 631 q 736 525 753 575 q 687 435 719 475 q 612 369 656 394 q 514 343 568 343 q 471 347 492 343 q 428 362 450 351 q 385 394 407 374 q 341 446 363 414 q 317 467 330 463 q 287 471 304 471 q 260 465 271 471 q 244 447 250 458 q 236 423 238 436 q 233 397 233 410 q 228 386 233 391 q 214 381 222 381 q 179 381 192 381 q 154 380 165 380 q 131 379 143 380 q 98 379 118 379 q 84 382 90 379 q 77 391 77 385 q 96 507 77 454 q 145 597 114 559 q 219 655 177 634 q 309 675 261 675 q 360 673 336 675 q 405 663 383 671 q 448 638 427 655 q 490 591 469 621 z " + }, + "¡": { + "ha": 373, + "x_min": 48, + "x_max": 415, + "o": "m 102 247 q 144 458 123 353 q 182 671 165 564 q 187 702 184 693 q 202 713 190 711 q 254 724 229 718 q 309 736 280 729 q 315 735 314 736 q 316 730 316 733 l 323 60 q 318 28 321 37 q 303 18 315 20 q 186 -13 245 4 q 62 -45 127 -29 q 56 -46 60 -46 q 48 -39 48 -46 q 51 -23 48 -39 q 58 18 54 -7 q 68 75 62 43 q 79 137 73 106 q 91 198 85 169 q 102 247 97 227 m 125 808 q 129 853 127 838 q 130 877 130 869 q 131 886 131 884 q 131 889 131 888 q 132 893 132 890 q 133 906 132 897 q 135 935 134 916 q 139 987 136 954 q 143 1018 140 1010 q 159 1029 146 1027 q 279 1054 216 1041 q 401 1086 342 1066 q 408 1086 404 1086 q 413 1081 410 1082 l 415 1080 l 400 894 q 395 863 398 871 q 380 852 392 854 q 264 828 323 841 q 139 802 205 815 q 133 802 137 802 q 125 808 125 802 z " + }, + "¢": { + "ha": 737, + "x_min": 68, + "x_max": 737, + "o": "m 68 331 q 98 491 68 416 q 179 624 127 566 q 302 718 231 682 q 458 763 374 755 l 465 840 q 468 871 466 863 q 477 883 469 880 q 506 883 493 883 q 530 884 518 884 q 555 885 542 884 q 585 885 568 885 q 598 880 592 885 q 605 857 605 875 l 596 757 q 660 740 633 750 q 704 721 686 730 q 729 705 721 711 q 737 697 737 698 q 734 674 737 693 q 727 631 731 656 q 721 580 724 606 q 718 535 718 554 q 715 528 718 532 q 705 524 713 524 q 687 527 698 524 q 660 536 675 531 q 624 547 644 542 q 579 556 604 553 l 545 146 q 605 159 578 150 q 666 188 632 169 q 671 189 667 189 q 676 185 675 189 q 678 172 678 182 q 678 120 678 151 q 678 61 678 89 q 677 13 677 33 q 675 -7 676 -7 q 606 -39 643 -28 q 528 -55 568 -50 l 522 -132 q 521 -145 521 -140 q 520 -154 521 -150 q 516 -160 519 -157 q 505 -166 513 -163 l 450 -171 l 399 -171 q 390 -166 393 -171 q 386 -151 386 -162 q 389 -102 386 -125 q 393 -56 391 -79 q 263 -16 323 -47 q 161 67 204 16 q 93 184 117 117 q 68 331 68 252 m 443 543 q 359 465 389 518 q 329 342 329 412 q 349 236 329 283 q 411 163 370 188 q 416 224 413 191 q 421 294 418 258 q 428 367 425 330 q 434 439 431 404 l 443 543 z " + }, + "£": { + "ha": 755, + "x_min": 59, + "x_max": 789, + "o": "m 194 -39 q 189 -39 190 -39 q 161 -39 173 -39 q 140 -39 149 -39 q 79 -36 99 -39 q 59 -20 59 -32 q 72 1 59 -15 q 103 38 84 18 q 143 81 121 59 q 183 119 165 102 q 198 271 189 186 q 217 446 207 356 l 147 446 q 94 449 111 446 q 78 462 78 452 q 83 476 78 465 q 97 505 89 488 q 116 541 106 522 q 134 576 125 559 q 150 605 143 593 q 157 620 156 617 l 240 620 l 250 762 q 276 883 257 826 q 330 982 295 939 q 419 1048 365 1024 q 549 1073 473 1073 q 640 1063 596 1073 q 716 1041 683 1053 q 769 1016 749 1028 q 789 998 789 1003 q 787 992 789 995 q 784 984 786 989 q 755 922 766 948 q 738 879 744 896 q 727 854 731 862 q 718 846 723 846 q 715 848 719 846 q 658 871 686 862 q 600 880 629 880 q 548 861 570 880 q 513 789 526 842 q 504 707 508 751 q 496 620 499 663 l 673 620 q 684 616 679 620 q 689 604 689 612 q 681 579 689 600 q 658 524 673 558 l 633 469 q 624 454 629 459 q 603 448 618 448 l 486 448 q 467 289 475 366 q 452 151 458 211 q 539 155 498 153 q 615 156 581 156 q 673 157 649 157 q 709 157 698 157 q 721 153 714 157 q 728 138 728 149 q 722 118 728 132 q 705 85 715 104 q 682 41 695 65 q 655 -9 669 17 q 644 -27 650 -21 q 618 -33 637 -33 q 593 -33 616 -33 q 534 -34 570 -33 q 453 -36 498 -35 q 362 -37 408 -37 q 272 -39 316 -38 q 194 -39 228 -39 z " + }, + "¤": { + "ha": 866, + "x_min": 119, + "x_max": 833, + "o": "m 119 555 q 139 669 119 617 q 197 763 160 720 l 172 790 q 153 812 160 803 q 146 829 146 821 q 155 842 146 837 q 170 854 157 844 q 198 878 182 865 q 227 903 213 891 q 245 918 241 914 q 259 924 252 924 q 269 921 265 924 l 273 918 l 327 859 q 412 888 368 878 q 503 898 456 898 q 583 889 546 898 q 652 863 620 880 l 703 918 q 717 924 709 924 q 730 918 724 924 q 749 903 735 914 q 778 878 763 891 q 806 854 793 865 q 821 842 819 844 q 829 829 829 837 q 813 800 829 817 l 802 788 q 774 758 791 777 q 815 673 801 718 q 829 580 829 629 q 813 468 829 518 q 768 376 798 417 l 822 317 q 830 304 827 311 q 833 292 833 298 q 828 277 833 282 q 800 248 815 262 q 772 224 785 235 q 751 208 760 214 q 741 202 742 202 q 709 221 730 202 q 653 277 688 239 q 450 229 565 229 q 372 237 408 229 q 304 258 336 244 l 295 248 q 235 202 253 203 q 227 205 230 202 q 209 218 222 208 q 181 243 196 229 q 155 270 166 256 q 143 292 143 283 q 146 304 143 298 q 154 317 149 311 l 184 351 q 135 446 151 394 q 119 555 119 498 m 483 725 q 422 710 449 725 q 378 670 396 694 q 350 615 359 646 q 340 553 340 584 q 349 497 340 524 q 373 450 357 471 q 416 417 390 430 q 476 405 441 405 q 537 419 511 405 q 581 458 563 434 q 607 512 598 481 q 616 572 616 542 q 608 629 616 602 q 584 678 600 656 q 542 712 567 699 q 483 725 517 725 z " + }, + "¥": { + "ha": 857, + "x_min": 120, + "x_max": 907, + "o": "m 296 491 q 266 558 282 522 q 232 632 249 595 q 197 705 214 669 q 166 772 180 741 q 140 826 151 803 q 122 862 128 850 l 121 867 q 120 878 120 869 q 121 890 120 884 q 127 903 123 897 q 141 910 132 906 q 164 920 151 915 q 193 932 178 926 q 224 945 208 939 l 302 976 q 318 980 311 980 q 330 977 323 980 q 345 966 336 974 l 501 662 l 505 672 l 572 796 q 598 842 585 819 q 620 882 610 864 q 637 910 630 899 q 645 921 644 921 l 878 979 q 891 981 886 981 q 907 967 907 981 q 905 958 906 964 q 901 945 905 952 l 776 711 l 661 500 q 702 501 684 500 q 728 501 720 501 q 745 498 737 500 q 756 491 751 495 q 761 477 761 486 q 759 451 761 469 q 754 416 757 433 l 748 370 q 746 363 747 366 q 742 359 745 361 q 732 356 739 357 q 715 355 726 355 q 664 353 693 353 q 591 353 634 353 l 582 296 q 654 298 623 297 q 696 298 684 298 q 712 295 705 298 q 723 288 718 292 q 728 275 728 283 q 726 248 728 266 q 722 213 724 231 l 715 168 q 714 161 715 163 q 709 156 713 158 q 700 153 706 154 q 682 152 693 152 q 630 151 661 151 q 556 151 600 151 l 543 71 q 539 52 541 59 q 535 40 537 45 q 529 31 532 35 q 519 24 525 28 l 340 -22 q 317 -31 329 -28 q 294 -35 305 -35 q 275 -30 283 -35 q 267 -13 267 -24 l 267 -10 q 274 64 269 22 q 286 148 279 105 l 231 148 q 155 151 179 148 q 131 164 131 155 q 135 181 131 170 q 147 205 140 192 q 164 233 155 218 q 184 259 174 248 q 207 279 195 271 q 228 288 218 287 l 310 289 q 311 294 310 293 l 319 351 l 263 351 q 188 354 212 351 q 163 367 163 357 q 168 384 163 372 q 180 408 172 395 q 197 436 187 421 q 217 462 206 450 q 239 482 228 474 q 260 490 250 490 l 296 491 z " + }, + "¦": { + "ha": 387, + "x_min": 70, + "x_max": 407, + "o": "m 122 566 q 129 664 126 628 q 133 720 132 701 q 135 748 134 740 q 136 761 136 755 q 138 775 137 767 q 141 805 139 784 q 145 866 142 827 q 154 971 149 905 q 158 1002 155 993 q 174 1013 161 1011 q 286 1032 231 1023 q 399 1055 342 1041 l 407 1048 l 367 616 l 367 601 q 362 591 367 595 q 340 583 357 587 l 192 549 q 171 544 184 548 q 146 541 157 541 q 129 545 136 541 q 122 562 122 550 l 122 566 m 70 -57 q 77 42 74 5 q 81 98 79 78 q 83 125 82 117 q 84 139 83 133 q 86 153 85 144 q 89 183 87 161 q 93 243 90 204 q 102 349 96 282 q 106 379 103 371 q 121 391 109 388 q 234 412 178 401 q 347 439 290 422 l 355 433 l 315 7 l 315 -8 q 310 -18 315 -15 q 288 -26 306 -21 l 137 -71 q 118 -78 130 -73 q 96 -82 107 -82 q 77 -78 85 -82 q 70 -61 70 -74 l 70 -57 z " + }, + "§": { + "ha": 720, + "x_min": 82, + "x_max": 713, + "o": "m 136 96 q 135 97 136 96 q 134 100 134 98 q 142 106 134 106 q 155 102 148 106 q 235 60 192 76 q 326 43 279 43 q 348 45 336 43 q 371 52 361 47 q 389 64 382 56 q 397 83 397 71 q 386 114 397 99 q 358 139 374 130 q 261 194 310 165 q 172 259 212 223 q 107 343 132 296 q 82 454 82 390 q 116 575 82 524 q 214 648 151 627 q 200 680 207 663 q 188 716 193 698 q 179 754 182 735 q 176 790 176 772 q 193 863 176 829 q 244 922 211 897 q 325 961 277 947 q 433 975 373 975 q 449 974 437 975 q 479 971 462 973 q 517 964 497 968 q 554 953 536 960 q 585 937 572 946 q 602 916 597 928 q 625 831 614 873 q 650 745 636 790 q 650 739 650 743 q 640 730 650 730 q 631 733 634 730 q 592 763 612 748 q 552 788 572 777 q 509 807 531 800 q 464 814 487 814 q 439 812 452 814 q 417 805 427 810 q 401 792 408 800 q 395 771 395 784 q 437 702 395 727 q 547 628 501 662 q 624 565 593 594 q 672 512 654 536 q 699 466 690 487 q 710 426 708 445 q 713 391 713 408 q 702 324 713 355 q 673 269 692 293 q 631 227 655 244 q 579 199 606 209 q 610 135 601 167 q 618 75 618 103 q 598 -8 618 28 q 545 -72 578 -45 q 470 -113 512 -98 q 382 -127 427 -127 q 282 -122 330 -127 q 189 -92 234 -117 l 136 96 m 423 477 q 384 492 404 486 q 342 498 364 498 q 318 496 330 498 q 296 489 305 494 q 280 476 286 484 q 274 456 274 468 q 278 429 274 443 q 290 406 283 416 q 355 372 321 385 q 426 359 389 359 q 449 361 436 359 q 472 368 461 363 q 490 380 483 372 q 498 399 498 388 q 491 424 498 412 q 474 444 484 435 l 456 456 l 423 477 z " + }, + "¨": { + "ha": 806, + "x_min": 60, + "x_max": 747, + "o": "m 60 833 q 63 889 62 871 q 64 913 64 906 q 64 924 64 921 q 65 936 64 926 q 66 966 65 945 q 69 1031 67 987 q 74 1060 71 1052 q 87 1069 77 1067 q 200 1069 140 1069 q 310 1070 259 1070 q 319 1066 317 1070 q 322 1053 322 1063 l 305 864 q 300 837 303 844 q 288 827 298 829 q 181 826 236 827 q 67 826 127 826 q 62 827 64 826 q 60 831 60 827 l 60 833 m 485 831 q 488 895 487 874 q 489 924 489 916 q 489 936 490 933 q 489 946 489 939 q 491 972 490 954 q 494 1031 492 991 q 498 1058 496 1050 q 511 1067 501 1066 q 624 1068 565 1067 q 734 1069 683 1068 q 743 1066 739 1069 q 747 1055 747 1063 l 730 863 q 725 836 728 843 q 711 827 723 828 q 604 825 659 826 q 488 825 549 825 q 486 825 486 825 q 485 829 485 825 l 485 831 z " + }, + "©": { + "ha": 1112, + "x_min": 90, + "x_max": 1105, + "o": "m 559 70 q 675 84 619 70 q 780 123 731 98 q 868 186 828 149 q 937 271 908 224 q 981 374 965 318 q 996 494 996 430 q 971 633 996 568 q 900 746 945 698 q 790 822 854 794 q 650 850 726 850 q 484 821 563 850 q 347 738 406 791 q 253 609 288 684 q 218 442 218 534 q 241 300 218 368 q 309 181 264 233 q 416 100 353 130 q 559 70 479 70 m 534 -43 q 349 -3 431 -43 q 209 103 267 36 q 121 258 151 170 q 90 443 90 346 q 136 661 90 563 q 258 828 181 758 q 438 936 336 898 q 654 974 541 974 q 837 938 754 974 q 979 839 920 901 q 1072 691 1039 776 q 1105 510 1105 606 q 1085 354 1105 427 q 1027 219 1065 280 q 938 109 990 157 q 823 27 886 60 q 686 -25 759 -7 q 534 -43 613 -43 m 299 438 q 325 576 299 512 q 396 689 351 641 q 505 764 442 736 q 640 791 567 791 q 708 786 679 791 q 760 773 738 781 q 796 756 781 766 q 820 739 810 747 l 820 736 q 821 712 820 729 q 824 673 822 694 q 826 629 825 651 q 827 590 827 606 q 825 584 827 587 q 813 581 823 581 q 791 590 805 581 q 758 611 777 600 q 713 631 739 622 q 659 641 688 641 q 593 626 623 641 q 543 586 564 612 q 510 525 522 560 q 499 447 499 489 q 507 383 499 413 q 531 330 515 353 q 571 293 547 307 q 627 279 595 279 q 671 281 652 279 q 707 287 690 283 q 740 298 724 291 q 774 315 756 304 q 782 313 780 315 q 785 302 785 312 q 785 264 785 286 q 784 221 785 241 q 784 186 784 201 q 783 172 783 172 q 739 151 762 159 q 692 138 716 142 q 643 132 667 134 q 595 130 618 130 q 478 153 532 130 q 385 216 425 175 q 322 313 345 256 q 299 438 299 370 z " + }, + "ª": { + "ha": 669, + "x_min": 92, + "x_max": 694, + "o": "m 639 505 q 643 480 639 490 q 652 464 647 470 q 661 453 657 457 q 665 447 665 450 q 650 433 665 439 l 509 372 q 505 372 508 372 q 496 379 500 372 q 488 398 492 387 q 480 422 484 409 q 474 446 477 435 q 402 390 441 408 q 326 372 363 372 q 239 392 281 372 q 164 447 197 411 q 112 535 132 484 q 92 650 92 586 q 114 787 92 725 q 173 894 136 850 q 260 963 211 939 q 366 988 310 988 q 440 972 406 988 q 508 931 475 956 q 511 957 511 947 q 514 971 512 966 q 522 978 516 977 q 539 981 527 980 q 615 991 578 987 q 694 998 652 996 l 639 505 m 481 705 q 487 742 484 724 q 490 776 489 760 l 492 810 q 474 823 484 816 q 452 836 464 830 q 428 846 441 842 q 402 850 415 850 q 354 831 374 850 q 323 786 335 812 q 306 729 311 759 q 301 675 301 699 q 323 553 301 592 q 385 515 345 515 q 419 524 400 515 q 459 548 439 532 l 481 705 z " + }, + "«": { + "ha": 1378, + "x_min": 73, + "x_max": 1361, + "o": "m 681 220 q 686 212 686 216 q 684 204 686 209 q 634 99 654 153 q 591 -7 613 45 q 574 -20 587 -20 q 563 -18 568 -20 q 548 -12 557 -16 l 114 248 q 85 265 92 258 q 79 286 79 272 q 78 390 79 337 q 74 500 78 443 q 74 502 74 501 q 73 503 73 503 l 75 508 q 78 514 75 511 q 83 516 81 516 q 142 542 106 527 q 223 573 178 556 q 319 609 268 590 q 424 647 371 628 q 530 686 477 667 q 631 724 583 706 q 662 730 652 730 q 673 720 671 730 q 676 610 674 667 q 690 502 679 553 q 687 490 690 494 q 681 486 685 487 l 378 376 q 408 361 386 372 q 460 334 430 349 q 524 301 490 318 q 589 267 558 283 q 645 238 621 251 q 681 220 669 226 m 1352 220 q 1358 212 1358 216 q 1356 204 1358 209 q 1305 99 1326 153 q 1263 -7 1284 45 q 1245 -20 1258 -20 q 1234 -18 1240 -20 q 1219 -12 1229 -16 l 785 248 q 757 265 764 258 q 750 286 750 272 q 750 390 750 337 q 745 500 749 443 q 745 502 745 501 q 745 503 745 503 l 746 508 q 749 514 747 511 q 754 516 752 516 q 813 542 777 527 q 895 573 850 556 q 991 609 939 590 q 1096 647 1042 628 q 1202 686 1149 667 q 1303 724 1255 706 q 1333 730 1324 730 q 1344 720 1342 730 q 1348 610 1345 667 q 1361 502 1350 553 q 1359 490 1361 494 q 1352 486 1356 487 l 1050 376 q 1079 361 1057 372 q 1131 334 1101 349 q 1195 301 1161 318 q 1261 267 1230 283 q 1316 238 1292 251 q 1352 220 1341 226 z " + }, + "¬": { + "ha": 718, + "x_min": 74, + "x_max": 671, + "o": "m 174 351 q 97 354 121 351 q 74 367 74 357 q 78 384 74 372 q 90 408 83 395 q 107 436 98 421 q 127 462 117 450 q 150 482 138 474 q 171 490 161 490 l 410 497 q 480 499 446 498 q 542 500 514 500 q 590 502 570 501 q 620 503 610 503 q 660 499 648 503 q 671 479 671 496 q 671 470 671 474 q 671 462 671 465 l 667 386 q 666 358 667 378 q 664 315 665 339 q 661 263 663 290 q 657 214 659 237 q 655 175 656 191 q 654 157 654 159 q 650 140 652 148 q 642 129 647 134 q 628 124 637 124 q 608 125 620 124 q 582 128 596 126 q 552 132 568 130 q 522 137 536 134 q 511 142 515 138 q 508 159 508 145 q 510 196 507 176 q 513 234 512 215 l 522 355 q 461 355 492 355 q 408 354 430 355 l 174 351 z " + }, + "®": { + "ha": 1112, + "x_min": 90, + "x_max": 1105, + "o": "m 553 242 q 551 235 552 239 q 546 230 550 231 q 529 223 541 227 q 502 213 517 218 q 471 203 487 208 q 441 193 455 197 q 419 186 428 189 q 410 183 410 183 q 400 186 404 183 q 395 196 395 189 q 397 235 395 208 q 401 299 399 262 q 407 378 404 335 q 415 467 411 422 q 422 556 418 512 q 429 640 426 601 q 427 641 429 641 q 399 637 420 641 q 351 628 377 633 q 342 633 342 628 q 342 635 342 634 l 406 746 q 507 765 451 755 q 635 774 564 774 q 715 766 676 774 q 784 739 754 758 q 832 691 814 721 q 850 616 850 661 q 842 573 850 593 q 820 538 833 554 q 791 510 807 522 q 761 489 775 498 q 736 475 747 480 q 722 467 725 469 q 723 464 722 467 q 728 455 724 462 q 740 435 732 448 q 761 400 748 422 q 793 346 774 378 q 839 267 812 313 q 854 260 846 262 q 870 259 863 259 q 883 258 878 258 q 888 253 888 257 q 882 245 888 250 q 867 237 876 241 q 849 229 858 233 q 836 222 840 224 q 816 214 826 218 q 794 204 806 210 q 768 192 783 199 q 735 175 753 184 q 726 171 730 172 q 719 170 722 170 q 706 174 711 170 q 698 181 701 177 q 692 191 694 186 q 689 199 690 195 l 572 446 l 553 242 m 576 494 q 618 505 597 494 q 658 531 640 515 q 687 564 675 546 q 698 596 698 581 q 629 664 698 664 q 615 663 622 664 q 604 662 609 663 l 592 660 l 576 494 m 559 70 q 675 84 619 70 q 780 123 731 98 q 868 186 828 149 q 937 271 908 224 q 981 374 965 318 q 996 494 996 430 q 971 633 996 568 q 900 746 945 698 q 790 822 854 794 q 650 850 726 850 q 484 821 563 850 q 347 738 406 791 q 253 609 288 684 q 218 442 218 534 q 241 300 218 368 q 309 181 264 233 q 416 100 353 130 q 559 70 479 70 m 534 -43 q 349 -3 431 -43 q 209 103 267 36 q 121 258 151 170 q 90 443 90 346 q 136 661 90 563 q 258 828 181 758 q 438 936 336 898 q 654 974 541 974 q 837 938 754 974 q 979 839 920 901 q 1072 691 1039 776 q 1105 510 1105 606 q 1085 354 1105 427 q 1027 219 1065 280 q 938 109 990 157 q 823 27 886 60 q 686 -25 759 -7 q 534 -43 613 -43 z " + }, + "°": { + "ha": 683, + "x_min": 89, + "x_max": 712, + "o": "m 398 378 q 276 398 333 378 q 178 455 220 418 q 113 548 136 493 q 89 671 89 602 q 114 791 89 734 q 183 890 139 848 q 287 957 227 932 q 418 982 347 982 q 533 962 479 982 q 627 905 587 942 q 689 817 667 869 q 712 702 712 765 q 690 584 712 642 q 628 480 669 526 q 529 406 587 434 q 398 378 471 378 m 513 672 q 514 684 513 677 q 514 696 514 690 q 509 739 514 716 q 494 780 505 762 q 465 811 483 799 q 420 823 447 823 q 357 810 384 823 q 314 775 331 797 q 288 728 296 754 q 279 674 279 701 q 283 630 279 653 q 299 587 288 606 q 329 554 310 567 q 378 540 348 541 q 434 552 410 540 q 475 583 458 564 q 500 625 491 602 q 513 672 509 648 z " + }, + "±": { + "ha": 709, + "x_min": 62, + "x_max": 684, + "o": "m 287 448 q 226 447 252 447 q 184 447 200 447 q 124 451 144 447 q 104 466 104 454 q 107 491 104 472 q 116 535 111 511 q 126 584 121 559 q 135 625 132 608 q 145 640 136 633 q 170 648 155 647 l 306 650 l 307 664 q 312 743 311 710 q 316 798 313 777 q 326 828 319 819 q 347 838 333 838 q 350 838 349 838 q 354 838 352 838 q 367 838 358 838 q 386 839 375 838 q 410 839 397 839 q 435 840 422 840 l 499 842 q 513 834 509 839 q 518 818 518 830 q 518 816 518 817 q 518 811 518 815 q 517 798 517 807 q 515 772 516 789 q 511 725 513 754 q 505 653 509 696 l 517 654 q 566 655 542 654 q 610 656 590 656 q 645 657 630 656 q 665 657 659 657 q 677 653 671 657 q 684 637 684 649 q 681 611 684 636 q 673 543 678 586 l 666 477 q 658 463 662 467 q 647 456 654 458 q 626 454 640 454 q 588 453 612 454 l 489 451 l 480 352 q 475 310 478 326 q 468 283 472 293 q 458 270 464 274 q 445 267 452 267 q 414 267 433 267 q 372 268 394 267 q 328 269 350 269 q 292 269 307 269 q 278 277 283 271 q 273 298 273 284 l 273 302 l 287 448 m 162 5 q 86 8 109 5 q 62 21 62 11 q 67 38 62 26 q 79 62 71 49 q 96 90 86 75 q 116 116 105 104 q 138 136 127 128 q 159 144 149 144 l 398 151 q 426 152 406 151 q 469 152 445 152 q 520 153 494 153 q 570 154 547 154 q 609 155 593 155 q 627 155 625 155 q 644 152 636 155 q 655 145 650 149 q 660 132 660 140 q 658 105 660 123 q 653 70 656 87 l 647 24 q 645 18 646 20 q 641 13 644 15 q 631 10 637 11 q 614 9 625 9 l 531 9 q 496 9 514 9 q 460 8 477 9 q 426 8 442 8 q 396 8 409 8 l 162 5 z " + }, + "²": { + "ha": 498, + "x_min": 90, + "x_max": 519, + "o": "m 472 764 q 461 742 469 756 q 442 710 453 728 q 418 672 431 692 q 393 631 406 651 l 326 526 q 327 525 326 525 l 328 525 q 356 527 335 525 q 404 532 378 529 q 456 536 430 534 q 498 538 482 538 q 519 529 519 538 q 516 517 519 524 q 513 511 513 511 l 452 397 q 451 395 452 397 q 450 393 451 393 q 435 384 442 385 q 422 382 428 383 q 333 381 369 382 q 269 380 297 380 q 217 380 241 380 q 162 380 193 380 q 105 386 121 380 q 90 405 90 393 q 95 424 90 413 q 110 448 101 434 l 190 561 q 286 699 251 644 q 321 783 321 753 q 314 813 321 800 q 294 835 307 826 q 266 849 282 844 q 230 854 249 854 q 181 846 206 854 q 132 823 155 839 q 118 818 122 818 q 112 822 113 818 q 111 829 111 826 q 115 868 111 846 q 124 910 119 890 q 134 945 130 930 q 138 962 138 960 q 149 969 139 962 q 182 987 159 977 q 240 1005 204 997 q 328 1013 275 1013 q 389 1000 358 1013 q 444 965 419 987 q 483 914 468 943 q 498 850 498 884 q 492 808 498 829 q 472 764 486 786 z " + }, + "³": { + "ha": 507, + "x_min": 101, + "x_max": 503, + "o": "m 328 368 q 295 366 311 366 q 252 369 272 366 q 212 377 232 372 q 172 391 192 382 q 128 408 151 399 q 117 424 121 410 q 112 455 114 437 q 107 495 109 474 q 101 534 105 517 q 114 550 101 550 q 117 549 117 550 q 147 531 132 541 q 179 513 162 521 q 217 499 197 505 q 260 494 237 494 q 307 507 291 494 q 322 551 322 520 q 320 569 322 559 q 312 586 318 578 q 293 600 305 594 q 261 605 281 605 l 187 605 q 189 616 187 607 q 192 639 190 626 q 196 667 193 652 q 200 695 198 682 q 203 717 201 708 q 204 727 204 726 q 209 745 207 741 q 214 750 212 750 q 233 748 224 750 q 251 746 241 746 q 291 763 277 746 q 305 805 305 779 q 293 841 305 825 q 254 857 281 857 q 193 850 224 857 q 139 836 163 844 q 140 845 140 840 q 140 857 140 850 q 140 870 140 863 q 140 884 140 877 q 141 909 140 897 q 144 933 142 922 q 153 952 147 944 q 168 963 158 960 q 207 974 191 970 q 238 980 224 978 q 267 983 253 983 q 300 984 281 984 q 368 975 334 984 q 431 947 403 966 q 477 899 459 928 q 494 831 494 870 q 487 783 494 804 q 469 745 480 762 q 446 716 458 728 q 422 694 433 703 q 450 678 435 688 q 476 652 464 667 q 495 614 488 636 q 503 563 503 592 q 490 494 503 528 q 453 433 477 460 q 398 388 430 406 q 328 368 366 370 z " + }, + "´": { + "ha": 431, + "x_min": 32, + "x_max": 415, + "o": "m 255 944 q 205 876 225 903 q 172 833 185 849 q 152 812 159 818 q 140 806 144 806 q 133 808 136 806 q 107 825 119 818 q 85 838 96 832 q 65 849 75 844 q 41 861 54 854 q 32 875 32 866 q 34 888 33 880 q 40 907 36 895 l 165 1271 q 172 1284 169 1280 q 180 1287 176 1287 q 186 1285 183 1287 q 193 1282 190 1284 q 299 1220 246 1246 q 403 1168 351 1195 q 415 1149 415 1160 q 404 1122 415 1139 q 360 1071 377 1091 q 327 1033 342 1051 q 296 994 312 1015 q 255 944 279 974 z " + }, + "µ": { + "ha": 888, + "x_min": 55, + "x_max": 890, + "o": "m 108 235 q 138 544 128 438 q 149 658 148 650 q 154 688 151 680 q 169 700 157 697 q 230 711 201 706 q 287 722 258 716 q 344 734 315 728 q 406 751 373 741 q 408 751 407 751 q 411 751 410 751 q 419 745 419 751 q 414 701 416 722 q 409 660 412 681 q 405 614 407 639 q 399 557 402 589 q 391 483 395 525 q 380 385 387 441 q 373 315 376 351 q 368 248 370 279 q 388 175 368 197 q 439 153 408 153 q 486 163 461 153 q 531 191 510 174 q 565 232 551 209 q 581 281 579 255 q 590 393 586 348 q 596 469 593 438 q 601 521 599 500 q 605 561 603 542 q 608 603 606 581 q 610 658 609 625 q 615 689 612 680 q 631 700 618 697 q 757 725 692 712 q 883 757 821 737 q 889 755 888 757 q 890 750 890 753 l 827 73 q 824 54 825 62 q 821 42 823 47 q 816 36 819 38 q 806 31 812 33 q 747 15 778 23 q 686 -2 717 7 q 624 -21 654 -11 q 564 -45 593 -32 q 558 -46 563 -46 q 552 -44 553 -46 q 551 -39 551 -42 q 551 -35 551 -38 q 552 -21 551 -31 q 555 8 553 -11 q 563 60 558 27 q 529 33 549 45 q 487 12 509 21 q 441 -1 465 3 q 397 -6 418 -6 q 351 2 370 -6 q 326 30 332 11 l 302 -168 q 301 -177 301 -170 l 301 -183 q 297 -193 301 -189 q 275 -201 293 -196 l 118 -222 q 99 -224 111 -223 q 77 -225 87 -225 q 62 -221 68 -225 q 55 -206 55 -217 q 56 -201 55 -202 q 82 12 68 -95 q 108 235 96 119 z " + }, + "μ": { + "ha": 888, + "x_min": 55, + "x_max": 890, + "o": "m 108 235 q 138 544 128 438 q 149 658 148 650 q 154 688 151 680 q 169 700 157 697 q 230 711 201 706 q 287 722 258 716 q 344 734 315 728 q 406 751 373 741 q 408 751 407 751 q 411 751 410 751 q 419 745 419 751 q 414 701 416 722 q 409 660 412 681 q 405 614 407 639 q 399 557 402 589 q 391 483 395 525 q 380 385 387 441 q 373 315 376 351 q 368 248 370 279 q 388 175 368 197 q 439 153 408 153 q 486 163 461 153 q 531 191 510 174 q 565 232 551 209 q 581 281 579 255 q 590 393 586 348 q 596 469 593 438 q 601 521 599 500 q 605 561 603 542 q 608 603 606 581 q 610 658 609 625 q 615 689 612 680 q 631 700 618 697 q 757 725 692 712 q 883 757 821 737 q 889 755 888 757 q 890 750 890 753 l 827 73 q 824 54 825 62 q 821 42 823 47 q 816 36 819 38 q 806 31 812 33 q 747 15 778 23 q 686 -2 717 7 q 624 -21 654 -11 q 564 -45 593 -32 q 558 -46 563 -46 q 552 -44 553 -46 q 551 -39 551 -42 q 551 -35 551 -38 q 552 -21 551 -31 q 555 8 553 -11 q 563 60 558 27 q 529 33 549 45 q 487 12 509 21 q 441 -1 465 3 q 397 -6 418 -6 q 351 2 370 -6 q 326 30 332 11 l 302 -168 q 301 -177 301 -170 l 301 -183 q 297 -193 301 -189 q 275 -201 293 -196 l 118 -222 q 99 -224 111 -223 q 77 -225 87 -225 q 62 -221 68 -225 q 55 -206 55 -217 q 56 -201 55 -202 q 82 12 68 -95 q 108 235 96 119 z " + }, + "¶": { + "ha": 896, + "x_min": 102, + "x_max": 920, + "o": "m 441 14 q 430 -4 439 1 q 399 -15 421 -9 q 386 -17 397 -16 q 361 -19 376 -18 q 329 -23 346 -21 q 296 -26 311 -24 q 270 -28 281 -27 q 255 -29 258 -29 q 237 -25 246 -29 q 229 -8 229 -21 q 229 -3 229 -4 l 306 479 q 221 518 259 495 q 157 570 183 541 q 116 635 130 599 q 102 713 102 671 q 119 803 102 764 q 165 872 136 843 q 233 923 194 902 q 317 956 273 943 q 410 975 362 969 q 505 981 458 981 q 606 977 552 981 q 711 968 661 973 q 801 958 761 963 q 859 948 841 952 q 885 940 874 943 q 902 933 895 937 q 911 923 908 929 q 914 904 913 916 q 916 873 915 891 q 917 837 916 856 q 918 800 917 818 q 919 767 918 781 q 919 743 919 752 q 920 734 920 734 q 903 722 916 722 q 814 724 881 722 q 791 585 804 663 q 762 404 778 506 q 740 250 750 323 q 731 185 736 218 q 722 121 726 152 q 714 62 718 90 q 707 14 710 34 q 696 -4 706 2 q 666 -15 687 -10 q 642 -18 663 -16 q 598 -23 622 -20 q 554 -27 574 -25 q 528 -29 533 -29 q 511 -24 519 -29 q 503 -6 503 -20 l 577 449 q 550 447 564 448 q 523 446 536 446 l 509 446 q 492 346 499 395 q 477 250 484 296 q 468 185 473 218 q 457 120 463 152 q 448 61 452 89 q 441 14 444 34 z " + }, + "·": { + "ha": 380, + "x_min": 106, + "x_max": 397, + "o": "m 106 553 q 108 603 107 585 q 109 632 109 622 q 109 646 109 642 q 109 654 109 650 q 110 663 109 658 q 111 681 110 669 q 113 716 111 694 q 116 774 114 738 q 120 805 117 797 q 136 816 123 814 q 260 816 195 816 q 383 817 326 817 q 393 814 389 817 q 397 801 397 810 l 378 588 q 373 557 376 566 q 358 547 371 549 q 240 546 300 547 q 113 545 179 545 q 107 546 109 545 q 106 550 106 547 l 106 553 z " + }, + "¸": { + "ha": 370, + "x_min": 43, + "x_max": 339, + "o": "m 336 -262 q 313 -320 330 -294 q 273 -365 296 -346 q 219 -394 249 -383 q 159 -404 190 -404 q 99 -400 130 -404 q 43 -382 67 -396 l 45 -383 q 44 -378 44 -382 q 45 -357 44 -370 q 48 -330 46 -345 q 52 -302 50 -315 q 57 -279 54 -288 l 60 -279 q 64 -281 64 -279 l 64 -281 q 84 -289 75 -286 q 104 -294 94 -292 q 127 -296 115 -296 q 157 -297 140 -297 q 201 -280 185 -297 q 216 -241 216 -263 q 204 -208 216 -221 q 170 -195 191 -195 q 106 -192 132 -195 q 69 -189 79 -189 q 63 -186 64 -189 q 62 -180 62 -184 q 70 -159 65 -174 q 79 -126 74 -144 q 88 -89 83 -109 q 96 -53 92 -69 q 102 -25 100 -36 q 104 -12 104 -14 l 117 -12 q 147 -12 129 -12 q 183 -12 165 -12 l 226 -12 l 193 -108 q 215 -105 205 -106 q 233 -104 224 -104 q 289 -116 268 -104 q 320 -144 309 -127 q 335 -184 332 -162 q 339 -230 339 -207 q 338 -246 339 -238 q 336 -262 338 -254 z " + }, + "¹": { + "ha": 349, + "x_min": 130, + "x_max": 338, + "o": "m 155 915 q 158 936 156 930 q 168 944 159 942 l 317 980 q 323 981 320 981 q 328 981 326 981 q 336 978 334 981 q 338 963 338 974 l 313 432 q 308 405 312 412 q 292 395 303 398 l 158 355 q 152 354 155 354 q 148 353 149 353 q 135 357 140 353 q 130 373 130 361 q 130 378 130 374 q 130 398 130 382 q 133 449 131 415 q 137 543 134 483 q 144 694 140 603 q 155 915 149 785 z " + }, + "º": { + "ha": 698, + "x_min": 88, + "x_max": 726, + "o": "m 390 370 q 259 395 315 370 q 164 459 202 419 q 107 552 126 500 q 88 662 88 605 q 107 770 88 719 q 159 862 125 821 q 238 929 192 902 q 341 966 285 956 q 349 970 349 966 q 348 981 349 975 q 347 989 347 986 q 349 997 347 995 q 354 999 351 999 q 525 976 454 999 q 641 913 596 953 q 705 820 685 873 q 726 704 726 766 q 699 563 726 625 q 627 458 673 500 q 520 393 581 415 q 390 370 459 370 m 292 668 q 298 611 292 640 q 318 561 304 583 q 355 524 332 538 q 412 510 378 510 q 471 532 448 512 q 507 581 494 553 q 525 642 520 610 q 530 699 530 673 q 522 753 530 726 q 498 800 514 779 q 459 832 482 820 q 404 845 435 845 q 351 826 372 845 q 316 780 330 807 q 297 722 303 752 q 292 668 292 691 z " + }, + "»": { + "ha": 1364, + "x_min": 78, + "x_max": 1374, + "o": "m 755 220 q 792 239 766 226 q 849 268 817 252 q 916 301 881 283 q 981 334 950 319 q 1034 361 1011 349 q 1064 376 1057 372 l 766 486 q 758 502 758 487 q 775 610 770 553 q 784 720 780 667 q 795 730 786 730 q 825 724 804 730 q 925 687 873 706 q 1030 648 977 667 q 1133 609 1082 628 q 1227 573 1183 591 q 1307 542 1272 556 q 1365 516 1343 527 q 1370 514 1367 516 q 1373 508 1373 511 l 1374 503 q 1374 502 1374 503 q 1373 500 1373 501 q 1364 390 1367 443 q 1360 286 1362 337 q 1352 265 1360 272 q 1323 248 1345 258 l 878 -12 q 863 -18 869 -16 q 852 -20 858 -20 q 842 -17 845 -20 q 836 -7 838 -14 q 797 100 816 46 q 751 204 777 153 q 749 212 749 208 q 750 217 749 215 q 755 220 751 218 m 83 220 q 120 239 95 226 q 178 268 146 252 q 244 301 210 283 q 309 334 279 319 q 362 361 340 349 q 393 376 385 372 l 94 486 q 86 502 86 487 q 103 610 98 553 q 113 720 109 667 q 123 730 115 730 q 154 724 132 730 q 254 687 201 706 q 358 648 306 667 q 461 609 411 628 q 556 573 512 591 q 636 542 600 556 q 694 516 671 527 q 699 514 696 516 q 702 508 701 511 l 703 503 q 702 502 703 503 q 702 500 702 501 q 693 390 696 443 q 688 286 690 337 q 681 265 688 272 q 652 248 673 258 l 207 -12 q 192 -18 197 -16 q 181 -20 186 -20 q 170 -17 174 -20 q 164 -7 167 -14 q 125 100 144 46 q 79 204 106 153 q 78 212 78 208 q 79 217 78 215 q 83 220 80 218 z " + }, + "¼": { + "ha": 1161, + "x_min": 121, + "x_max": 1151, + "o": "m 147 915 q 150 936 148 930 q 159 944 151 942 l 309 980 q 315 981 312 981 q 319 981 317 981 q 328 978 326 981 q 330 963 330 974 l 305 432 q 299 405 304 412 q 284 395 295 398 l 150 355 q 144 354 146 354 q 140 353 141 353 q 127 357 132 353 q 121 373 121 361 q 121 378 121 374 q 122 398 121 382 q 124 449 123 415 q 129 543 126 483 q 136 694 132 603 q 147 915 140 785 m 502 640 q 581 819 543 729 q 660 1001 619 908 q 673 1028 669 1021 q 688 1036 678 1036 l 692 1036 q 765 1004 729 1021 q 836 980 800 987 q 843 977 840 979 q 846 971 846 975 l 846 969 l 412 10 q 408 -1 409 4 q 405 -11 406 -7 q 400 -17 403 -15 q 392 -20 397 -20 q 374 -15 384 -20 l 306 14 q 290 19 300 16 q 268 28 279 23 q 248 40 256 33 q 239 56 239 47 q 243 68 239 62 q 303 198 274 134 q 362 329 332 261 q 426 473 392 397 q 502 640 460 549 m 1138 197 q 1128 147 1133 173 q 1126 137 1126 140 q 1120 133 1126 134 q 1098 132 1114 132 q 1051 132 1082 132 l 1043 52 q 1042 45 1043 49 q 1036 39 1040 41 q 1022 34 1033 38 q 996 23 1011 29 q 963 12 981 18 q 930 0 946 5 q 903 -9 915 -5 q 886 -12 890 -12 q 876 -9 880 -12 q 871 3 871 -6 q 873 56 871 24 q 879 130 876 88 q 806 129 842 130 q 739 129 770 129 q 685 129 708 129 q 650 128 661 128 q 595 131 611 128 q 578 143 578 134 q 579 148 578 145 q 583 155 580 151 q 597 179 587 161 q 621 220 607 196 q 653 272 635 243 q 688 329 670 300 l 777 477 q 806 531 793 505 q 835 579 819 557 q 872 617 850 601 q 928 640 894 633 l 1064 675 q 1070 676 1067 676 q 1076 677 1073 677 q 1084 674 1081 677 q 1086 663 1086 671 l 1086 659 q 1081 554 1084 599 q 1076 469 1078 510 q 1070 382 1073 427 q 1065 278 1067 338 q 1113 279 1094 279 q 1135 279 1132 279 q 1146 276 1141 279 q 1151 264 1151 272 q 1150 259 1151 260 l 1138 197 m 890 272 q 899 335 895 302 q 906 402 903 368 q 911 470 909 436 q 914 536 913 504 l 757 272 l 890 272 z " + }, + "½": { + "ha": 1185, + "x_min": 123, + "x_max": 1166, + "o": "m 149 915 q 151 936 149 930 q 161 944 153 942 l 310 980 q 316 981 313 981 q 321 981 319 981 q 329 978 327 981 q 332 963 332 974 l 307 432 q 301 405 305 412 q 286 395 296 398 l 151 355 q 145 354 148 354 q 141 353 142 353 q 128 357 134 353 q 123 373 123 361 q 123 378 123 374 q 123 398 123 382 q 126 449 124 415 q 130 543 127 483 q 137 694 133 603 q 149 915 142 785 m 503 640 q 583 819 545 729 q 661 1001 621 908 q 675 1028 670 1021 q 689 1036 680 1036 l 694 1036 q 766 1004 730 1021 q 838 980 802 987 q 845 977 842 979 q 848 971 848 975 l 848 969 l 413 10 q 409 -1 410 4 q 406 -11 408 -7 q 401 -17 404 -15 q 393 -20 399 -20 q 376 -15 385 -20 l 307 14 q 291 19 302 16 q 269 28 280 23 q 249 40 258 33 q 241 56 241 47 q 244 68 241 62 q 304 198 275 134 q 363 329 333 261 q 427 473 393 397 q 503 640 461 549 m 1118 382 q 1107 360 1115 374 q 1088 329 1099 347 q 1065 290 1078 311 q 1039 249 1052 269 l 972 144 q 973 143 972 143 l 975 143 q 1003 145 981 143 q 1050 150 1024 147 q 1102 155 1076 153 q 1144 157 1128 157 q 1166 147 1166 157 q 1163 136 1166 142 q 1160 129 1160 130 l 1099 15 q 1098 13 1098 15 q 1097 12 1097 12 q 1081 2 1088 3 q 1069 1 1074 1 q 979 -1 1015 0 q 916 -2 943 -1 q 863 -2 888 -2 q 808 -2 839 -2 q 752 4 768 -2 q 736 23 736 11 q 742 42 736 31 q 756 66 747 52 l 836 179 q 932 317 897 262 q 967 401 967 371 q 960 431 967 418 q 941 453 953 444 q 912 467 928 463 q 876 472 895 472 q 827 465 852 472 q 778 441 802 457 q 764 436 768 436 q 758 440 760 436 q 757 448 757 444 q 761 486 757 464 q 771 528 766 508 q 780 563 776 548 q 785 580 785 578 q 795 588 785 580 q 828 605 806 595 q 886 623 850 615 q 974 631 922 631 q 1035 618 1004 631 q 1090 584 1065 606 q 1129 532 1114 562 q 1145 468 1145 503 q 1138 426 1145 448 q 1118 382 1132 404 z " + }, + "¾": { + "ha": 1305, + "x_min": 92, + "x_max": 1294, + "o": "m 319 368 q 286 366 301 366 q 242 369 262 366 q 202 377 222 372 q 162 391 182 382 q 119 408 142 399 q 108 424 111 410 q 102 455 104 437 q 98 495 100 474 q 92 534 96 517 q 104 550 92 550 q 108 549 107 550 q 137 531 122 541 q 170 513 153 521 q 207 499 187 505 q 251 494 227 494 q 297 507 281 494 q 313 551 313 520 q 311 569 313 559 q 302 586 309 578 q 284 600 296 594 q 252 605 272 605 l 178 605 q 179 616 178 607 q 182 639 180 626 q 186 667 184 652 q 190 695 189 682 q 193 717 192 708 q 195 727 195 726 q 200 745 197 741 q 205 750 202 750 q 223 748 214 750 q 241 746 232 746 q 281 763 267 746 q 296 805 296 779 q 284 841 296 825 q 245 857 272 857 q 184 850 214 857 q 130 836 153 844 q 131 845 130 840 q 131 857 131 850 q 131 870 131 863 q 130 884 130 877 q 131 909 130 897 q 135 933 132 922 q 143 952 138 944 q 158 963 149 960 q 198 974 181 970 q 229 980 214 978 q 258 983 243 983 q 290 984 272 984 q 359 975 324 984 q 421 947 393 966 q 467 899 450 928 q 485 831 485 870 q 478 783 485 804 q 460 745 471 762 q 436 716 449 728 q 412 694 423 703 q 440 678 426 688 q 466 652 454 667 q 486 614 478 636 q 494 563 494 592 q 480 494 494 528 q 444 433 467 460 q 388 388 420 406 q 319 368 356 370 m 645 640 q 724 819 686 729 q 803 1001 762 908 q 817 1028 812 1021 q 831 1036 821 1036 l 836 1036 q 908 1004 872 1021 q 979 980 943 987 q 986 977 983 979 q 989 971 989 975 l 989 969 l 555 10 q 551 -1 552 4 q 548 -11 549 -7 q 543 -17 546 -15 q 535 -20 541 -20 q 517 -15 527 -20 l 449 14 q 433 19 444 16 q 411 28 422 23 q 391 40 399 33 q 382 56 382 47 q 386 68 382 62 q 446 198 417 134 q 505 329 475 261 q 569 473 535 397 q 645 640 603 549 m 1281 197 q 1271 147 1276 173 q 1269 137 1270 140 q 1263 133 1269 134 q 1241 132 1257 132 q 1194 132 1225 132 l 1186 52 q 1185 45 1186 49 q 1179 39 1183 41 q 1165 34 1176 38 q 1139 23 1154 29 q 1106 12 1124 18 q 1074 0 1089 5 q 1046 -9 1058 -5 q 1029 -12 1034 -12 q 1019 -9 1023 -12 q 1015 3 1015 -6 q 1017 56 1015 24 q 1022 130 1019 88 q 949 129 985 130 q 882 129 913 129 q 828 129 851 129 q 793 128 804 128 q 738 131 754 128 q 722 143 722 134 q 722 148 722 145 q 726 155 723 151 q 740 179 730 161 q 764 220 750 196 q 796 272 779 243 q 831 329 813 300 l 920 477 q 949 531 937 505 q 978 579 962 557 q 1015 617 994 601 q 1071 640 1037 633 l 1207 675 q 1213 676 1211 676 q 1219 677 1216 677 q 1227 674 1224 677 q 1230 663 1230 671 l 1230 659 q 1224 554 1227 599 q 1219 469 1221 510 q 1213 382 1216 427 q 1208 278 1211 338 q 1256 279 1237 279 q 1278 279 1275 279 q 1289 276 1284 279 q 1294 264 1294 272 q 1293 259 1294 260 l 1281 197 m 1034 272 q 1042 335 1038 302 q 1049 402 1046 368 q 1054 470 1052 436 q 1057 536 1056 504 l 900 272 l 1034 272 z " + }, + "¿": { + "ha": 791, + "x_min": 73, + "x_max": 776, + "o": "m 580 541 q 491 479 533 516 q 416 399 448 441 q 366 314 385 356 q 347 236 347 271 q 368 174 347 198 q 437 151 389 151 q 482 157 465 151 q 510 174 500 164 q 525 196 521 184 q 529 220 529 208 q 524 248 529 235 q 520 265 520 261 q 529 273 520 271 q 560 278 539 275 q 617 288 581 282 q 703 309 652 294 q 734 316 723 314 q 751 318 745 318 q 763 308 758 318 q 771 284 768 298 q 775 255 774 270 q 776 231 776 240 q 745 119 776 168 q 665 36 714 70 q 553 -16 615 2 q 429 -34 491 -34 q 283 -9 348 -34 q 171 55 217 16 q 99 142 124 94 q 74 237 74 191 q 87 314 74 276 q 123 387 101 351 q 176 457 146 423 q 240 522 206 491 q 307 582 273 553 q 373 636 341 610 q 378 640 374 638 q 386 642 382 642 q 391 641 388 642 q 395 639 394 640 q 442 617 421 627 q 482 600 463 608 q 523 585 502 591 q 568 573 543 579 q 582 567 578 571 q 587 558 587 562 q 581 549 581 554 q 580 541 580 544 m 577 973 q 575 923 576 941 q 574 894 574 904 q 574 880 574 884 q 574 872 574 876 q 573 863 574 868 q 572 845 573 857 q 570 810 572 832 q 567 751 569 788 q 563 721 566 729 q 547 710 560 712 q 423 710 488 710 q 300 709 357 709 q 289 713 292 709 q 286 729 286 718 l 305 938 q 310 969 307 960 q 325 979 312 977 q 443 980 383 979 q 570 981 504 981 q 576 980 575 981 q 577 973 577 979 z " + }, + "À": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 437 1194 q 410 1242 421 1223 q 390 1278 399 1261 q 370 1314 380 1295 q 344 1362 359 1333 q 339 1376 340 1369 q 337 1388 338 1383 q 340 1400 337 1396 q 351 1408 344 1404 q 403 1437 376 1422 q 458 1467 431 1451 q 514 1498 486 1482 q 566 1532 541 1514 q 576 1535 573 1535 q 581 1530 580 1535 l 657 1133 q 660 1119 659 1124 q 661 1107 661 1113 q 659 1096 661 1101 q 650 1087 657 1090 q 624 1076 636 1081 q 600 1064 612 1070 q 575 1052 588 1059 q 545 1035 562 1045 q 538 1032 543 1032 q 524 1041 532 1032 q 506 1070 517 1050 q 478 1120 494 1089 q 437 1194 461 1151 z " + }, + "Á": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 529 1171 q 479 1102 499 1129 q 446 1060 459 1076 q 426 1038 433 1044 q 414 1032 418 1032 q 407 1035 410 1032 q 381 1052 393 1044 q 359 1064 370 1059 q 339 1076 349 1070 q 315 1087 328 1081 q 306 1101 306 1093 q 308 1114 307 1107 q 314 1133 310 1122 l 439 1497 q 446 1510 443 1507 q 454 1514 450 1514 q 460 1512 457 1514 q 467 1508 464 1510 q 573 1447 520 1472 q 677 1394 625 1421 q 689 1376 689 1387 q 677 1348 689 1365 q 634 1298 651 1318 q 601 1259 616 1278 q 570 1221 586 1241 q 529 1171 553 1200 z " + }, + "Â": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 695 1354 q 721 1321 710 1335 q 739 1295 731 1307 q 754 1273 747 1284 q 769 1251 761 1263 q 782 1232 773 1244 q 801 1206 791 1220 q 817 1179 810 1192 q 824 1156 824 1165 q 811 1137 824 1147 q 779 1119 798 1128 q 742 1103 761 1110 q 713 1091 724 1095 q 692 1082 703 1088 q 670 1071 681 1076 q 649 1062 659 1065 q 635 1058 640 1058 q 624 1064 630 1058 q 610 1084 618 1069 l 522 1271 q 440 1167 481 1218 q 357 1064 399 1117 q 349 1059 355 1059 q 333 1065 345 1059 q 303 1079 320 1071 q 269 1095 286 1086 q 237 1110 251 1104 q 219 1119 230 1114 q 197 1129 208 1124 q 180 1140 187 1135 q 173 1150 173 1145 q 179 1166 173 1157 q 199 1187 185 1175 l 301 1303 q 376 1386 340 1345 q 450 1470 413 1427 q 468 1480 459 1479 q 485 1481 477 1481 l 588 1481 q 604 1480 596 1481 q 618 1469 612 1478 q 627 1455 618 1468 q 646 1426 635 1443 q 671 1389 658 1409 q 695 1354 684 1369 z " + }, + "Ã": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 574 1297 q 598 1272 587 1280 q 624 1265 609 1265 q 654 1276 645 1265 q 667 1301 663 1287 q 673 1326 671 1315 q 681 1337 675 1337 q 692 1338 682 1337 q 716 1339 702 1338 q 747 1340 730 1339 q 779 1342 764 1341 q 805 1343 794 1343 q 819 1343 817 1343 q 831 1341 825 1343 q 837 1331 837 1338 q 820 1232 837 1282 q 771 1141 802 1182 q 695 1075 739 1101 q 597 1050 651 1050 q 554 1054 576 1050 q 512 1069 533 1058 q 469 1100 490 1080 q 425 1153 447 1120 q 400 1174 413 1169 q 370 1178 388 1178 q 344 1171 354 1178 q 328 1154 334 1164 q 319 1129 321 1143 q 317 1103 317 1116 q 311 1093 317 1098 q 297 1088 306 1088 q 262 1087 275 1088 q 238 1087 249 1087 q 214 1086 227 1086 q 181 1086 201 1086 q 167 1088 174 1086 q 161 1097 161 1091 q 179 1214 161 1161 q 229 1303 197 1266 q 302 1361 260 1341 q 393 1382 345 1382 q 443 1380 420 1382 q 489 1370 467 1378 q 531 1345 511 1362 q 574 1297 552 1328 z " + }, + "Ä": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 155 1059 q 158 1115 157 1098 q 159 1140 159 1133 q 159 1150 159 1147 q 160 1162 159 1153 q 161 1193 160 1172 q 164 1258 162 1214 q 169 1286 165 1278 q 182 1295 172 1294 q 295 1296 235 1295 q 405 1297 354 1297 q 414 1293 412 1297 q 417 1279 417 1289 l 400 1090 q 395 1063 398 1071 q 382 1054 393 1055 q 276 1053 331 1053 q 162 1053 222 1053 q 157 1053 159 1053 q 155 1057 155 1054 l 155 1059 m 580 1058 q 583 1121 582 1101 q 584 1151 584 1142 q 584 1162 585 1160 q 584 1173 584 1165 q 586 1199 585 1181 q 589 1257 587 1217 q 593 1285 591 1277 q 606 1294 596 1293 q 719 1294 660 1294 q 829 1295 778 1295 q 838 1293 834 1295 q 842 1282 842 1290 l 825 1090 q 820 1062 823 1069 q 806 1053 818 1055 q 699 1052 754 1053 q 583 1051 644 1051 q 581 1052 581 1051 q 580 1055 580 1052 l 580 1058 z " + }, + "Å": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 496 1035 q 422 1047 456 1035 q 362 1082 387 1059 q 322 1139 336 1105 q 307 1215 307 1172 q 323 1288 307 1253 q 366 1348 338 1322 q 430 1389 393 1374 q 509 1404 467 1404 q 579 1392 547 1404 q 637 1358 612 1380 q 675 1303 661 1335 q 689 1233 689 1272 q 675 1161 689 1196 q 637 1097 662 1125 q 577 1052 612 1069 q 496 1035 541 1035 m 567 1215 q 568 1222 568 1219 q 568 1229 568 1225 q 565 1255 568 1241 q 555 1281 562 1269 q 538 1300 549 1293 q 511 1308 527 1308 q 472 1299 488 1308 q 445 1277 455 1291 q 429 1247 434 1264 q 424 1214 424 1231 q 427 1187 424 1201 q 436 1162 429 1173 q 454 1142 443 1150 q 484 1134 466 1135 q 519 1141 505 1134 q 544 1160 534 1148 q 559 1186 553 1172 q 567 1215 565 1201 z " + }, + "Æ": { + "ha": 1179, + "x_min": 10, + "x_max": 1197, + "o": "m 1063 549 q 1058 522 1061 539 q 1049 484 1054 505 q 1038 437 1044 462 q 1025 387 1031 412 q 1021 373 1023 377 q 1011 366 1018 368 q 991 364 1004 364 q 954 363 978 363 l 860 361 l 882 168 l 1188 189 q 1197 175 1197 189 q 1194 161 1197 167 q 1187 143 1194 160 q 1171 103 1181 126 q 1152 56 1162 80 q 1135 19 1141 33 q 1122 0 1130 7 q 1104 -11 1114 -7 q 1081 -15 1094 -14 q 1052 -16 1067 -16 q 946 -17 1001 -16 q 837 -19 891 -18 q 730 -22 782 -20 q 634 -26 678 -24 q 624 -28 629 -26 q 612 -29 619 -29 q 600 -23 603 -29 q 597 -12 597 -18 q 588 63 592 28 q 578 137 584 97 q 572 143 577 140 q 558 146 566 145 q 541 148 550 148 q 523 149 531 149 q 491 148 503 149 q 469 148 479 148 q 450 147 459 147 q 429 146 441 146 q 389 147 409 146 q 351 148 368 147 l 313 149 q 294 119 303 134 q 275 89 284 104 q 254 54 266 75 q 229 13 241 33 q 203 -22 216 -7 q 183 -36 191 -36 q 178 -35 179 -36 q 79 12 119 -12 q 10 56 39 35 q 37 96 23 74 q 63 137 52 118 l 90 182 q 65 248 77 210 q 54 326 54 285 q 57 353 54 342 q 68 356 57 355 q 95 357 79 357 q 129 358 111 358 q 164 359 148 359 q 191 359 180 359 q 201 360 201 359 q 217 388 201 359 q 259 464 233 417 q 319 570 286 511 q 388 691 352 629 q 457 812 423 753 q 519 916 492 871 q 531 929 524 923 q 547 939 539 935 q 563 945 555 943 q 575 949 570 948 q 596 951 578 949 q 637 956 614 954 q 684 960 661 958 q 722 962 708 962 l 901 972 q 970 977 936 975 q 1035 980 1005 979 q 1086 982 1064 981 q 1116 983 1107 983 q 1121 983 1117 983 q 1129 980 1125 983 q 1137 970 1133 977 q 1140 951 1140 964 q 1139 936 1140 946 q 1135 911 1137 925 q 1131 883 1133 898 q 1125 852 1128 867 l 1112 774 q 1105 756 1109 762 q 1080 749 1101 750 q 967 745 1029 748 q 822 739 904 742 l 838 573 q 905 576 872 574 q 964 578 937 577 q 1011 580 991 579 q 1039 581 1031 581 q 1043 580 1039 581 q 1051 577 1046 580 q 1059 568 1055 574 q 1063 549 1063 561 m 503 508 q 479 458 491 483 q 458 413 468 433 q 442 377 449 392 q 433 358 435 363 q 430 347 433 353 l 566 347 q 565 413 566 378 q 565 486 565 448 q 564 562 564 524 q 564 637 564 600 l 503 508 z " + }, + "Ç": { + "ha": 879, + "x_min": 87, + "x_max": 900, + "o": "m 87 458 q 105 602 87 533 q 156 730 123 671 q 236 839 189 790 q 342 923 283 888 q 468 977 400 958 q 612 996 536 996 q 716 988 671 996 q 795 968 762 980 q 851 942 829 956 q 889 915 874 928 q 888 910 888 915 q 890 873 888 900 q 894 812 892 846 q 898 745 897 779 q 900 686 900 711 q 897 676 900 682 q 884 671 895 671 q 869 673 878 671 q 833 692 854 677 q 782 723 811 707 q 718 751 753 739 q 640 764 682 764 q 539 742 585 764 q 462 681 494 720 q 412 589 430 642 q 395 473 395 536 q 407 367 395 417 q 444 278 419 316 q 506 217 469 240 q 591 195 543 195 q 660 197 630 195 q 715 206 690 200 q 766 222 741 212 q 819 248 791 233 q 826 249 821 249 q 832 245 831 249 q 834 230 834 241 l 834 149 q 834 105 834 126 q 833 66 833 83 q 832 39 833 50 q 831 28 832 28 q 765 -4 800 9 q 691 -23 729 -16 q 616 -33 654 -31 q 543 -35 578 -35 q 363 1 447 -35 q 219 102 280 37 q 122 258 157 168 q 87 458 87 349 m 643 -262 q 620 -320 637 -294 q 579 -365 603 -346 q 526 -394 555 -383 q 465 -404 496 -404 q 405 -400 437 -404 q 350 -382 374 -396 l 351 -383 q 351 -378 351 -382 q 352 -357 351 -370 q 354 -330 353 -345 q 358 -302 356 -315 q 363 -279 361 -288 l 367 -279 q 371 -281 371 -279 l 370 -281 q 391 -289 381 -286 q 411 -294 400 -292 q 434 -296 421 -296 q 463 -297 446 -297 q 507 -280 492 -297 q 523 -241 523 -263 q 510 -208 523 -221 q 476 -195 498 -195 q 412 -192 439 -195 q 376 -189 386 -189 q 369 -186 370 -189 q 368 -180 368 -184 q 376 -159 372 -174 q 385 -126 380 -144 q 394 -89 390 -109 q 402 -53 399 -69 q 409 -25 406 -36 q 411 -12 411 -14 l 424 -12 q 454 -12 435 -12 q 490 -12 472 -12 l 532 -12 l 500 -108 q 521 -105 511 -106 q 540 -104 531 -104 q 595 -116 574 -104 q 627 -144 616 -127 q 642 -184 638 -162 q 646 -230 646 -207 q 645 -246 646 -238 q 643 -262 644 -254 z " + }, + "È": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 m 411 1194 q 385 1242 395 1223 q 364 1278 374 1261 q 344 1314 355 1295 q 318 1362 333 1333 q 313 1376 314 1369 q 311 1388 312 1383 q 315 1400 311 1396 q 325 1408 318 1404 q 377 1437 350 1422 q 433 1467 405 1451 q 488 1498 460 1482 q 541 1532 515 1514 q 550 1535 547 1535 q 555 1530 554 1535 l 631 1133 q 634 1119 633 1124 q 635 1107 635 1113 q 633 1096 635 1101 q 625 1087 631 1090 q 598 1076 610 1081 q 574 1064 586 1070 q 549 1052 562 1059 q 519 1035 536 1045 q 512 1032 517 1032 q 498 1041 506 1032 q 480 1070 491 1050 q 452 1120 469 1089 q 411 1194 435 1151 z " + }, + "É": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 m 504 1171 q 454 1102 474 1129 q 421 1060 434 1076 q 401 1038 408 1044 q 389 1032 393 1032 q 382 1035 385 1032 q 356 1052 368 1044 q 334 1064 345 1059 q 314 1076 324 1070 q 290 1087 303 1081 q 281 1101 281 1093 q 283 1114 281 1107 q 289 1133 285 1122 l 414 1497 q 421 1510 418 1507 q 429 1514 425 1514 q 435 1512 432 1514 q 442 1508 439 1510 q 548 1447 495 1472 q 652 1394 600 1421 q 664 1376 664 1387 q 652 1348 664 1365 q 609 1298 626 1318 q 576 1259 591 1278 q 545 1221 561 1241 q 504 1171 528 1200 z " + }, + "Ê": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 m 670 1354 q 695 1321 685 1335 q 714 1295 706 1307 q 729 1273 722 1284 q 744 1251 736 1263 q 757 1232 748 1244 q 775 1206 766 1220 q 792 1179 785 1192 q 799 1156 799 1165 q 786 1137 799 1147 q 754 1119 772 1128 q 717 1103 736 1110 q 688 1091 699 1095 q 667 1082 678 1088 q 645 1071 656 1076 q 624 1062 633 1065 q 610 1058 615 1058 q 599 1064 605 1058 q 585 1084 593 1069 l 497 1271 q 415 1167 456 1218 q 332 1064 374 1117 q 323 1059 330 1059 q 308 1065 320 1059 q 278 1079 295 1071 q 243 1095 261 1086 q 212 1110 226 1104 q 194 1119 205 1114 q 172 1129 182 1124 q 155 1140 162 1135 q 148 1150 148 1145 q 154 1166 148 1157 q 174 1187 160 1175 l 276 1303 q 351 1386 315 1345 q 425 1470 388 1427 q 443 1480 434 1479 q 460 1481 452 1481 l 563 1481 q 579 1480 571 1481 q 593 1469 587 1478 q 602 1455 593 1468 q 621 1426 610 1443 q 646 1389 633 1409 q 670 1354 659 1369 z " + }, + "Ë": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 m 130 1059 q 132 1115 132 1098 q 133 1140 133 1133 q 134 1150 134 1147 q 134 1162 134 1153 q 135 1193 134 1172 q 138 1258 136 1214 q 143 1286 140 1278 q 157 1295 146 1294 q 269 1296 210 1295 q 379 1297 328 1297 q 389 1293 386 1297 q 391 1279 391 1289 l 374 1090 q 370 1063 372 1071 q 357 1054 367 1055 q 251 1053 305 1053 q 136 1053 196 1053 q 131 1053 133 1053 q 130 1057 130 1054 l 130 1059 m 554 1058 q 557 1121 556 1101 q 558 1151 558 1142 q 558 1162 559 1160 q 558 1173 558 1165 q 560 1199 559 1181 q 564 1257 561 1217 q 568 1285 565 1277 q 581 1294 570 1293 q 693 1294 634 1294 q 803 1295 752 1295 q 812 1293 808 1295 q 816 1282 816 1290 l 800 1090 q 794 1062 797 1069 q 781 1053 792 1055 q 673 1052 728 1053 q 557 1051 618 1051 q 555 1052 555 1051 q 554 1055 554 1052 l 554 1058 z " + }, + "Ì": { + "ha": 453, + "x_min": 94, + "x_max": 450, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m 207 1194 q 180 1242 191 1223 q 160 1278 170 1261 q 140 1314 151 1295 q 114 1362 129 1333 q 109 1376 110 1369 q 107 1388 108 1383 q 111 1400 107 1396 q 121 1408 114 1404 q 173 1437 146 1422 q 229 1467 201 1451 q 284 1498 256 1482 q 336 1532 311 1514 q 346 1535 343 1535 q 351 1530 350 1535 l 427 1133 q 430 1119 429 1124 q 431 1107 431 1113 q 429 1096 431 1101 q 420 1087 427 1090 q 394 1076 406 1081 q 370 1064 382 1070 q 345 1052 358 1059 q 315 1035 332 1045 q 308 1032 313 1032 q 294 1041 302 1032 q 276 1070 287 1050 q 248 1120 264 1089 q 207 1194 231 1151 z " + }, + "Í": { + "ha": 453, + "x_min": 77, + "x_max": 460, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m 300 1171 q 250 1102 270 1129 q 217 1060 230 1076 q 197 1038 204 1044 q 184 1032 189 1032 q 178 1035 180 1032 q 152 1052 163 1044 q 130 1064 140 1059 q 110 1076 120 1070 q 86 1087 99 1081 q 77 1101 77 1093 q 79 1114 77 1107 q 85 1133 81 1122 l 210 1497 q 217 1510 214 1507 q 224 1514 220 1514 q 231 1512 228 1514 q 238 1508 235 1510 q 343 1447 291 1472 q 448 1394 396 1421 q 460 1376 460 1387 q 448 1348 460 1365 q 405 1298 422 1318 q 372 1259 387 1278 q 340 1221 357 1241 q 300 1171 324 1200 z " + }, + "Î": { + "ha": 453, + "x_min": -54, + "x_max": 597, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m 468 1354 q 493 1321 483 1335 q 512 1295 504 1307 q 527 1273 520 1284 q 542 1251 534 1263 q 555 1232 546 1244 q 573 1206 564 1220 q 590 1179 583 1192 q 597 1156 597 1165 q 584 1137 597 1147 q 552 1119 570 1128 q 515 1103 534 1110 q 486 1091 496 1095 q 465 1082 476 1088 q 443 1071 454 1076 q 422 1062 431 1065 q 408 1058 413 1058 q 397 1064 403 1058 q 382 1084 391 1069 l 295 1271 q 213 1167 254 1218 q 130 1064 172 1117 q 121 1059 127 1059 q 105 1065 118 1059 q 76 1079 93 1071 q 41 1095 59 1086 q 10 1110 24 1104 q -8 1119 3 1114 q -30 1129 -20 1124 q -47 1140 -40 1135 q -54 1150 -54 1145 q -48 1166 -54 1157 q -28 1187 -42 1175 l 74 1303 q 149 1386 113 1345 q 222 1470 186 1427 q 241 1480 232 1479 q 258 1481 250 1481 l 361 1481 q 377 1480 369 1481 q 391 1469 385 1478 q 399 1455 391 1468 q 419 1426 408 1443 q 444 1389 431 1409 q 468 1354 457 1369 z " + }, + "Ï": { + "ha": 453, + "x_min": -73, + "x_max": 614, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m -73 1059 q -70 1115 -71 1098 q -69 1140 -69 1133 q -68 1150 -68 1147 q -68 1162 -68 1153 q -67 1193 -68 1172 q -64 1258 -66 1214 q -59 1286 -62 1278 q -45 1295 -56 1294 q 67 1296 7 1295 q 177 1297 126 1297 q 186 1293 184 1297 q 189 1279 189 1289 l 172 1090 q 168 1063 170 1071 q 155 1054 165 1055 q 48 1053 103 1053 q -66 1053 -6 1053 q -71 1053 -69 1053 q -73 1057 -73 1054 l -73 1059 m 352 1058 q 355 1121 354 1101 q 356 1151 356 1142 q 356 1162 357 1160 q 356 1173 356 1165 q 358 1199 357 1181 q 361 1257 359 1217 q 366 1285 363 1277 q 378 1294 368 1293 q 491 1294 432 1294 q 601 1295 550 1295 q 610 1293 606 1295 q 614 1282 614 1290 l 597 1090 q 592 1062 595 1069 q 578 1053 590 1055 q 471 1052 526 1053 q 355 1051 416 1051 q 353 1052 353 1051 q 352 1055 352 1052 l 352 1058 z " + }, + "Ð": { + "ha": 1050, + "x_min": 39, + "x_max": 1058, + "o": "m 224 564 l 233 689 q 236 721 235 705 q 238 752 237 736 q 200 745 220 748 q 162 739 180 741 q 126 734 143 736 q 98 732 110 732 q 86 739 90 732 q 83 753 83 746 q 87 766 83 762 l 259 922 q 299 952 279 941 q 347 971 319 964 q 414 980 375 978 q 514 983 454 983 q 640 975 576 983 q 765 949 705 968 q 879 901 825 931 q 972 825 932 871 q 1035 718 1012 780 q 1058 574 1058 656 q 1023 369 1058 458 q 929 214 989 280 q 790 103 869 149 q 620 30 710 58 q 432 -13 529 1 q 243 -31 336 -27 l 119 -35 q 109 -27 112 -33 q 105 -14 105 -21 q 108 -3 105 -7 l 191 156 q 194 178 192 159 q 197 225 195 197 q 202 287 199 253 q 208 355 205 321 q 156 355 178 355 q 119 355 133 355 q 59 358 79 355 q 39 374 39 362 q 47 393 39 377 q 69 429 56 408 q 100 473 83 450 q 134 516 117 496 q 167 549 151 536 q 193 562 182 562 l 224 564 m 513 766 q 505 686 510 736 q 494 574 500 636 q 570 577 537 576 q 614 578 602 578 q 626 574 619 578 q 633 559 633 570 q 626 536 633 553 q 610 497 620 520 q 587 445 600 473 q 560 385 574 416 q 554 370 557 375 q 544 363 551 366 q 521 361 536 361 q 476 360 505 361 l 460 173 q 598 199 542 173 q 690 272 655 226 q 742 380 726 318 q 758 515 758 443 q 743 614 758 566 q 702 698 729 662 q 635 755 675 734 q 547 777 596 777 q 529 773 538 777 q 513 766 519 770 z " + }, + "Ñ": { + "ha": 935, + "x_min": 102, + "x_max": 909, + "o": "m 672 909 q 682 941 677 931 q 699 955 686 951 l 875 996 q 892 999 883 999 q 905 993 901 999 q 909 970 909 987 q 903 920 905 954 q 899 846 900 886 q 897 766 897 806 q 896 696 896 726 l 896 659 q 895 567 896 616 q 893 467 895 517 q 891 366 892 416 q 888 273 890 317 l 884 157 q 884 151 884 154 q 884 146 884 149 l 884 136 q 884 116 884 124 q 884 102 884 109 q 883 90 884 96 q 878 80 882 85 q 867 69 874 75 q 848 56 860 64 q 825 42 841 51 q 791 21 810 33 q 751 -3 772 9 q 714 -25 731 -15 q 687 -41 697 -35 q 676 -47 676 -47 l 361 437 l 333 41 q 331 29 332 35 q 321 20 329 23 q 300 11 315 17 q 266 -2 286 5 q 223 -17 245 -9 q 182 -32 201 -25 q 147 -42 162 -38 q 126 -46 132 -46 q 109 -41 116 -46 q 102 -21 102 -35 q 118 164 109 64 q 135 369 127 264 q 150 579 143 474 q 162 778 157 684 q 154 796 162 781 q 135 829 145 811 q 116 863 124 847 q 107 883 107 879 q 112 892 107 888 q 129 903 117 896 l 310 1005 q 316 1006 312 1005 q 322 1006 319 1006 q 334 1005 330 1006 q 343 994 338 1003 l 672 401 l 672 909 m 583 1297 q 607 1272 595 1280 q 633 1265 618 1265 q 663 1276 654 1265 q 676 1301 672 1287 q 682 1326 680 1315 q 690 1337 684 1337 q 701 1338 691 1337 q 725 1339 711 1338 q 756 1340 739 1339 q 788 1342 773 1341 q 814 1343 803 1343 q 828 1343 825 1343 q 840 1341 833 1343 q 846 1331 846 1338 q 828 1232 846 1282 q 780 1141 811 1182 q 704 1075 748 1101 q 606 1050 660 1050 q 563 1054 585 1050 q 520 1069 542 1058 q 477 1100 499 1080 q 433 1153 456 1120 q 409 1174 422 1169 q 379 1178 397 1178 q 353 1171 363 1178 q 336 1154 342 1164 q 328 1129 330 1143 q 326 1103 326 1116 q 320 1093 326 1098 q 306 1088 315 1088 q 271 1087 284 1088 q 247 1087 258 1087 q 223 1086 235 1086 q 190 1086 210 1086 q 176 1088 182 1086 q 170 1097 170 1091 q 188 1214 170 1161 q 238 1303 206 1266 q 311 1361 269 1341 q 401 1382 353 1382 q 452 1380 429 1382 q 497 1370 475 1378 q 540 1345 519 1362 q 583 1297 561 1328 z " + }, + "Ò": { + "ha": 1103, + "x_min": 85, + "x_max": 1101, + "o": "m 384 463 q 394 365 384 414 q 429 276 405 315 q 494 212 454 237 q 593 188 534 188 q 672 207 638 191 q 731 248 707 222 q 772 306 756 273 q 798 375 789 339 q 811 450 808 412 q 815 523 815 488 q 802 616 815 571 q 764 696 789 661 q 700 751 738 730 q 610 772 661 772 q 538 758 570 772 q 481 718 506 743 q 438 661 456 693 q 408 594 420 629 q 390 526 396 560 q 384 463 385 492 m 516 -35 q 334 3 414 -35 q 199 105 254 41 q 114 255 144 170 q 85 435 85 340 q 115 632 85 542 q 199 791 145 722 q 329 908 254 861 q 493 979 404 956 q 506 992 501 979 q 513 1021 510 1005 q 522 1049 517 1036 q 535 1062 526 1062 q 772 1016 667 1048 q 950 922 877 983 q 1062 767 1023 861 q 1101 540 1101 674 q 1080 378 1101 454 q 1021 238 1059 302 q 930 123 983 174 q 812 37 877 73 q 672 -17 747 2 q 516 -35 597 -35 m 534 1194 q 507 1242 518 1223 q 487 1278 496 1261 q 467 1314 477 1295 q 441 1362 456 1333 q 436 1376 437 1369 q 434 1388 435 1383 q 437 1400 434 1396 q 448 1408 441 1404 q 500 1437 473 1422 q 555 1467 528 1451 q 611 1498 583 1482 q 663 1532 638 1514 q 673 1535 670 1535 q 678 1530 677 1535 l 754 1133 q 757 1119 755 1124 q 758 1107 758 1113 q 756 1096 758 1101 q 747 1087 754 1090 q 721 1076 733 1081 q 697 1064 709 1070 q 672 1052 685 1059 q 642 1035 659 1045 q 635 1032 640 1032 q 621 1041 629 1032 q 603 1070 614 1050 q 575 1120 591 1089 q 534 1194 558 1151 z " + }, + "Ó": { + "ha": 1103, + "x_min": 85, + "x_max": 1101, + "o": "m 384 463 q 394 365 384 414 q 429 276 405 315 q 494 212 454 237 q 593 188 534 188 q 672 207 638 191 q 731 248 707 222 q 772 306 756 273 q 798 375 789 339 q 811 450 808 412 q 815 523 815 488 q 802 616 815 571 q 764 696 789 661 q 700 751 738 730 q 610 772 661 772 q 538 758 570 772 q 481 718 506 743 q 438 661 456 693 q 408 594 420 629 q 390 526 396 560 q 384 463 385 492 m 516 -35 q 334 3 414 -35 q 199 105 254 41 q 114 255 144 170 q 85 435 85 340 q 115 632 85 542 q 199 791 145 722 q 329 908 254 861 q 493 979 404 956 q 506 992 501 979 q 513 1021 510 1005 q 522 1049 517 1036 q 535 1062 526 1062 q 772 1016 667 1048 q 950 922 877 983 q 1062 767 1023 861 q 1101 540 1101 674 q 1080 378 1101 454 q 1021 238 1059 302 q 930 123 983 174 q 812 37 877 73 q 672 -17 747 2 q 516 -35 597 -35 m 626 1171 q 576 1102 596 1129 q 543 1060 556 1076 q 523 1038 530 1044 q 511 1032 515 1032 q 504 1035 507 1032 q 478 1052 490 1044 q 456 1064 467 1059 q 436 1076 446 1070 q 412 1087 425 1081 q 403 1101 403 1093 q 405 1114 404 1107 q 411 1133 407 1122 l 536 1497 q 543 1510 540 1507 q 551 1514 547 1514 q 557 1512 554 1514 q 564 1508 561 1510 q 670 1447 617 1472 q 774 1394 722 1421 q 786 1376 786 1387 q 774 1348 786 1365 q 731 1298 748 1318 q 698 1259 713 1278 q 667 1221 683 1241 q 626 1171 650 1200 z " + }, + "Ô": { + "ha": 1096, + "x_min": 81, + "x_max": 1098, + "o": "m 380 463 q 391 365 380 414 q 426 276 401 315 q 490 212 450 237 q 589 188 530 188 q 669 207 635 191 q 728 248 703 222 q 769 306 753 273 q 795 375 785 339 q 808 450 804 412 q 812 523 812 488 q 799 616 812 571 q 760 696 786 661 q 696 751 734 730 q 607 772 658 772 q 535 758 567 772 q 477 718 503 743 q 434 661 452 693 q 405 594 416 629 q 387 526 393 560 q 380 463 381 492 m 513 -35 q 331 3 411 -35 q 196 105 251 41 q 111 255 140 170 q 81 435 81 340 q 112 632 81 542 q 196 791 142 722 q 325 908 250 861 q 490 979 400 956 q 502 992 498 979 q 510 1021 507 1005 q 518 1049 513 1036 q 532 1062 523 1062 q 768 1016 663 1048 q 947 922 873 983 q 1059 767 1020 861 q 1098 540 1098 674 q 1077 378 1098 454 q 1018 238 1056 302 q 927 123 980 174 q 808 37 873 73 q 668 -17 743 2 q 513 -35 593 -35 m 789 1354 q 814 1321 804 1335 q 833 1295 825 1307 q 848 1273 841 1284 q 863 1251 854 1263 q 876 1232 867 1244 q 894 1206 885 1220 q 910 1179 903 1192 q 918 1156 918 1165 q 904 1137 918 1147 q 873 1119 891 1128 q 836 1103 854 1110 q 806 1091 817 1095 q 786 1082 797 1088 q 763 1071 774 1076 q 743 1062 752 1065 q 729 1058 734 1058 q 718 1064 724 1058 q 703 1084 712 1069 l 616 1271 q 533 1167 574 1218 q 451 1064 492 1117 q 442 1059 448 1059 q 426 1065 439 1059 q 397 1079 414 1071 q 362 1095 380 1086 q 331 1110 345 1104 q 312 1119 323 1114 q 291 1129 301 1124 q 274 1140 281 1135 q 267 1150 267 1145 q 273 1166 267 1157 q 292 1187 279 1175 l 395 1303 q 470 1386 433 1345 q 543 1470 507 1427 q 562 1480 553 1479 q 578 1481 571 1481 l 682 1481 q 698 1480 690 1481 q 711 1469 706 1478 q 720 1455 712 1468 q 740 1426 728 1443 q 765 1389 751 1409 q 789 1354 778 1369 z " + }, + "Õ": { + "ha": 1096, + "x_min": 81, + "x_max": 1098, + "o": "m 380 463 q 391 365 380 414 q 426 276 401 315 q 490 212 450 237 q 589 188 530 188 q 669 207 635 191 q 728 248 703 222 q 769 306 753 273 q 795 375 785 339 q 808 450 804 412 q 812 523 812 488 q 799 616 812 571 q 760 696 786 661 q 696 751 734 730 q 607 772 658 772 q 535 758 567 772 q 477 718 503 743 q 434 661 452 693 q 405 594 416 629 q 387 526 393 560 q 380 463 381 492 m 513 -35 q 331 3 411 -35 q 196 105 251 41 q 111 255 140 170 q 81 435 81 340 q 112 632 81 542 q 196 791 142 722 q 325 908 250 861 q 490 979 400 956 q 502 992 498 979 q 510 1021 507 1005 q 518 1049 513 1036 q 532 1062 523 1062 q 768 1016 663 1048 q 947 922 873 983 q 1059 767 1020 861 q 1098 540 1098 674 q 1077 378 1098 454 q 1018 238 1056 302 q 927 123 980 174 q 808 37 873 73 q 668 -17 743 2 q 513 -35 593 -35 m 667 1297 q 691 1272 680 1280 q 718 1265 703 1265 q 748 1276 739 1265 q 761 1301 757 1287 q 766 1326 764 1315 q 774 1337 768 1337 q 786 1338 776 1337 q 810 1339 795 1338 q 841 1340 824 1339 q 873 1342 858 1341 q 899 1343 888 1343 q 913 1343 910 1343 q 924 1341 918 1343 q 930 1331 930 1338 q 913 1232 930 1282 q 864 1141 896 1182 q 789 1075 833 1101 q 691 1050 745 1050 q 648 1054 669 1050 q 605 1069 627 1058 q 562 1100 584 1080 q 518 1153 541 1120 q 494 1174 507 1169 q 464 1178 481 1178 q 437 1171 448 1178 q 421 1154 427 1164 q 413 1129 415 1143 q 410 1103 410 1116 q 405 1093 410 1098 q 391 1088 399 1088 q 356 1087 369 1088 q 331 1087 342 1087 q 308 1086 320 1086 q 275 1086 295 1086 q 261 1088 267 1086 q 254 1097 254 1091 q 273 1214 254 1161 q 322 1303 291 1266 q 396 1361 354 1341 q 486 1382 438 1382 q 537 1380 513 1382 q 582 1370 560 1378 q 625 1345 604 1362 q 667 1297 646 1328 z " + }, + "Ö": { + "ha": 1096, + "x_min": 81, + "x_max": 1098, + "o": "m 380 463 q 391 365 380 414 q 426 276 401 315 q 490 212 450 237 q 589 188 530 188 q 669 207 635 191 q 728 248 703 222 q 769 306 753 273 q 795 375 785 339 q 808 450 804 412 q 812 523 812 488 q 799 616 812 571 q 760 696 786 661 q 696 751 734 730 q 607 772 658 772 q 535 758 567 772 q 477 718 503 743 q 434 661 452 693 q 405 594 416 629 q 387 526 393 560 q 380 463 381 492 m 513 -35 q 331 3 411 -35 q 196 105 251 41 q 111 255 140 170 q 81 435 81 340 q 112 632 81 542 q 196 791 142 722 q 325 908 250 861 q 490 979 400 956 q 502 992 498 979 q 510 1021 507 1005 q 518 1049 513 1036 q 532 1062 523 1062 q 768 1016 663 1048 q 947 922 873 983 q 1059 767 1020 861 q 1098 540 1098 674 q 1077 378 1098 454 q 1018 238 1056 302 q 927 123 980 174 q 808 37 873 73 q 668 -17 743 2 q 513 -35 593 -35 m 249 1059 q 252 1115 251 1098 q 253 1140 252 1133 q 253 1150 253 1147 q 253 1162 253 1153 q 255 1193 254 1172 q 258 1258 256 1214 q 262 1286 259 1278 q 276 1295 266 1294 q 388 1296 329 1295 q 498 1297 448 1297 q 508 1293 505 1297 q 511 1279 511 1289 l 494 1090 q 489 1063 492 1071 q 476 1054 486 1055 q 370 1053 425 1053 q 256 1053 315 1053 q 251 1053 252 1053 q 249 1057 249 1054 l 249 1059 m 673 1058 q 676 1121 675 1101 q 678 1151 677 1142 q 678 1162 678 1160 q 678 1173 677 1165 q 679 1199 678 1181 q 683 1257 680 1217 q 687 1285 684 1277 q 700 1294 690 1293 q 812 1294 753 1294 q 922 1295 871 1295 q 931 1293 928 1295 q 935 1282 935 1290 l 919 1090 q 914 1062 916 1069 q 900 1053 911 1055 q 792 1052 848 1053 q 677 1051 737 1051 q 674 1052 675 1051 q 673 1055 673 1052 l 673 1058 z " + }, + "×": { + "ha": 671, + "x_min": 106, + "x_max": 648, + "o": "m 372 313 q 306 244 332 272 q 262 198 279 216 q 233 173 244 180 q 213 165 222 165 q 205 168 208 165 q 194 181 202 170 q 176 207 186 192 q 154 240 165 222 q 133 273 142 257 q 117 300 123 288 q 111 316 111 312 q 114 329 111 322 q 123 343 116 336 l 242 465 l 233 476 q 139 584 172 546 q 106 633 106 623 q 109 643 106 637 q 120 657 113 648 q 141 679 127 665 q 175 714 155 692 l 219 761 q 233 766 229 766 q 244 762 239 766 q 257 749 250 758 l 292 711 q 332 668 309 692 q 380 612 354 643 l 388 621 q 485 722 450 685 q 529 758 519 758 q 538 754 534 758 q 549 742 543 751 q 561 727 553 737 q 579 704 569 717 q 601 676 590 690 q 622 651 612 663 q 637 633 631 640 q 643 625 643 625 q 646 616 645 621 q 648 608 648 612 q 632 582 648 598 q 598 547 616 566 l 512 458 l 600 357 q 632 314 622 332 q 644 286 643 296 q 641 278 644 281 q 630 267 639 276 q 608 244 621 257 q 580 217 595 231 q 551 190 565 202 q 527 170 538 178 q 513 162 517 162 q 501 165 507 162 q 489 176 495 168 l 372 313 z " + }, + "Ø": { + "ha": 1101, + "x_min": 83, + "x_max": 1099, + "o": "m 528 -35 q 452 -28 489 -35 l 408 -121 q 400 -141 402 -132 q 388 -151 398 -151 q 370 -146 380 -151 l 302 -117 q 286 -112 296 -115 q 263 -103 275 -108 q 244 -91 252 -98 q 235 -75 235 -84 q 240 -57 235 -69 q 253 -29 245 -45 q 269 3 260 -14 q 286 36 278 20 q 200 113 237 68 q 136 212 162 158 q 97 326 111 265 q 83 451 83 387 q 129 669 83 571 q 252 836 174 766 q 431 944 329 906 q 648 982 534 982 q 742 972 694 982 q 759 1008 751 989 q 775 1041 767 1026 q 790 1066 783 1056 q 803 1076 797 1076 l 808 1076 q 880 1043 844 1061 q 951 1019 916 1026 q 959 1016 956 1018 q 962 1010 962 1014 l 962 1008 l 909 901 q 988 829 953 870 q 1048 739 1023 787 q 1085 634 1072 690 q 1099 518 1099 578 q 1078 362 1099 435 q 1021 227 1058 288 q 931 117 983 165 q 816 35 880 68 q 679 -17 752 1 q 528 -35 606 -35 m 382 463 q 390 376 382 419 q 415 295 397 334 q 458 384 439 343 q 495 465 477 425 q 533 546 513 505 q 576 638 552 588 l 646 779 q 623 782 635 781 q 600 783 612 783 q 527 768 559 783 q 470 726 494 752 q 429 667 446 701 q 402 598 412 634 q 387 527 392 562 q 382 463 382 492 m 812 513 q 806 585 812 549 q 786 651 800 620 l 554 178 q 599 174 574 174 q 675 193 642 177 q 731 235 707 210 q 771 294 755 261 q 796 363 787 327 q 809 438 805 400 q 812 513 812 476 z " + }, + "Ù": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 m 452 1194 q 426 1242 437 1223 q 406 1278 415 1261 q 385 1314 396 1295 q 359 1362 374 1333 q 354 1376 355 1369 q 353 1388 353 1383 q 356 1400 353 1396 q 366 1408 359 1404 q 419 1437 391 1422 q 474 1467 446 1451 q 529 1498 502 1482 q 582 1532 557 1514 q 591 1535 589 1535 q 597 1530 595 1535 l 673 1133 q 675 1119 674 1124 q 677 1107 677 1113 q 675 1096 677 1101 q 666 1087 673 1090 q 640 1076 652 1081 q 615 1064 627 1070 q 590 1052 604 1059 q 561 1035 577 1045 q 553 1032 558 1032 q 540 1041 547 1032 q 521 1070 532 1050 q 493 1120 510 1089 q 452 1194 477 1151 z " + }, + "Ú": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 m 545 1171 q 495 1102 515 1129 q 463 1060 475 1076 q 442 1038 450 1044 q 430 1032 435 1032 q 423 1035 426 1032 q 397 1052 409 1044 q 376 1064 386 1059 q 355 1076 366 1070 q 332 1087 345 1081 q 322 1101 322 1093 q 325 1114 323 1107 q 330 1133 326 1122 l 456 1497 q 463 1510 459 1507 q 470 1514 466 1514 q 477 1512 473 1514 q 484 1508 480 1510 q 589 1447 536 1472 q 693 1394 642 1421 q 705 1376 705 1387 q 694 1348 705 1365 q 650 1298 667 1318 q 617 1259 633 1278 q 586 1221 602 1241 q 545 1171 570 1200 z " + }, + "Û": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 m 711 1354 q 737 1321 726 1335 q 755 1295 747 1307 q 770 1273 764 1284 q 785 1251 777 1263 q 799 1232 789 1244 q 817 1206 808 1220 q 833 1179 826 1192 q 840 1156 840 1165 q 827 1137 840 1147 q 795 1119 814 1128 q 759 1103 777 1110 q 729 1091 740 1095 q 708 1082 720 1088 q 686 1071 697 1076 q 666 1062 675 1065 q 652 1058 656 1058 q 641 1064 646 1058 q 626 1084 635 1069 l 538 1271 q 456 1167 497 1218 q 374 1064 415 1117 q 365 1059 371 1059 q 349 1065 361 1059 q 319 1079 336 1071 q 285 1095 302 1086 q 254 1110 267 1104 q 235 1119 246 1114 q 214 1129 224 1124 q 196 1140 203 1135 q 189 1150 189 1145 q 195 1166 189 1157 q 215 1187 201 1175 l 317 1303 q 393 1386 356 1345 q 466 1470 429 1427 q 485 1480 475 1479 q 501 1481 494 1481 l 604 1481 q 621 1480 612 1481 q 634 1469 629 1478 q 643 1455 635 1468 q 663 1426 651 1443 q 687 1389 674 1409 q 711 1354 701 1369 z " + }, + "Ü": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 m 171 1059 q 174 1115 173 1098 q 175 1140 174 1133 q 175 1150 175 1147 q 175 1162 175 1153 q 177 1193 176 1172 q 180 1258 178 1214 q 184 1286 181 1278 q 198 1295 188 1294 q 310 1296 251 1295 q 420 1297 370 1297 q 430 1293 427 1297 q 433 1279 433 1289 l 416 1090 q 411 1063 414 1071 q 398 1054 408 1055 q 292 1053 347 1053 q 178 1053 237 1053 q 173 1053 174 1053 q 171 1057 171 1054 l 171 1059 m 595 1058 q 598 1121 597 1101 q 600 1151 600 1142 q 600 1162 600 1160 q 600 1173 600 1165 q 601 1199 600 1181 q 605 1257 602 1217 q 609 1285 606 1277 q 622 1294 612 1293 q 734 1294 675 1294 q 844 1295 793 1295 q 853 1293 850 1295 q 857 1282 857 1290 l 841 1090 q 836 1062 838 1069 q 822 1053 833 1055 q 714 1052 770 1053 q 599 1051 659 1051 q 596 1052 597 1051 q 595 1055 595 1052 l 595 1058 z " + }, + "Ý": { + "ha": 756, + "x_min": 73, + "x_max": 861, + "o": "m 81 903 q 95 910 85 906 q 118 920 104 915 q 146 932 131 926 q 177 945 161 939 l 256 976 q 272 980 264 980 q 284 977 277 980 q 298 966 290 974 l 455 662 l 459 672 l 526 796 q 552 842 539 819 q 574 882 564 864 q 591 910 584 899 q 599 921 597 921 l 831 979 q 845 981 840 981 q 861 967 861 981 q 859 958 860 964 q 855 945 859 952 l 656 576 q 626 520 641 548 q 596 468 610 492 q 571 422 582 443 q 553 389 559 402 l 548 367 l 510 113 q 507 94 508 101 q 503 81 505 86 q 497 73 500 77 q 486 66 493 70 l 294 -35 q 271 -43 283 -40 q 248 -47 259 -47 q 229 -42 237 -47 q 220 -25 220 -37 l 220 -22 q 230 67 223 16 q 245 168 237 119 l 283 419 q 255 480 273 442 q 217 562 237 518 q 175 653 197 606 q 133 742 153 700 q 99 815 114 783 q 76 862 83 847 q 73 883 73 872 q 74 895 73 889 q 81 903 76 901 m 476 1171 q 426 1102 446 1129 q 393 1060 406 1076 q 373 1038 380 1044 q 361 1032 366 1032 q 354 1035 357 1032 q 328 1052 340 1044 q 307 1064 317 1059 q 286 1076 296 1070 q 262 1087 275 1081 q 253 1101 253 1093 q 255 1114 254 1107 q 261 1133 257 1122 l 387 1497 q 393 1510 390 1507 q 401 1514 397 1514 q 408 1512 404 1514 q 414 1508 411 1510 q 520 1447 467 1472 q 624 1394 572 1421 q 636 1376 636 1387 q 625 1348 636 1365 q 581 1298 598 1318 q 548 1259 564 1278 q 517 1221 533 1241 q 476 1171 500 1200 z " + }, + "Þ": { + "ha": 872, + "x_min": 104, + "x_max": 907, + "o": "m 907 630 q 888 515 907 568 q 836 420 869 463 q 758 345 802 378 q 662 291 713 313 q 555 258 610 269 q 447 247 500 247 q 420 248 433 247 q 393 250 407 248 l 375 64 l 363 43 q 352 36 363 41 q 324 26 341 32 q 286 13 307 20 q 244 -2 265 5 q 204 -15 223 -9 q 172 -26 184 -22 q 149 -33 162 -30 q 125 -35 136 -35 q 110 -31 115 -35 q 104 -12 104 -26 q 108 39 106 11 q 112 96 110 66 q 116 157 114 126 q 121 216 119 188 l 132 353 q 146 534 140 456 q 156 674 152 613 q 132 672 136 672 q 117 684 117 672 q 122 703 117 684 q 135 749 127 722 q 150 805 142 776 q 163 854 158 834 q 183 878 164 867 l 198 1016 l 210 1037 q 220 1043 210 1039 q 248 1054 231 1048 q 287 1066 265 1059 q 329 1081 308 1074 q 370 1095 351 1088 q 401 1106 389 1101 q 424 1112 410 1109 q 448 1115 437 1115 q 463 1110 458 1115 q 469 1092 469 1105 q 466 1055 467 1072 q 463 1020 465 1038 q 460 980 462 1002 q 456 928 458 958 q 526 930 495 930 q 672 916 603 930 q 793 868 741 902 q 877 775 846 833 q 907 630 907 718 m 412 451 q 415 438 412 444 q 430 432 418 432 q 465 437 445 432 q 506 453 485 443 q 548 480 528 464 q 584 518 568 496 q 610 567 600 540 q 620 627 620 594 q 587 696 620 674 q 501 719 554 719 q 480 718 492 719 q 460 714 469 717 q 444 708 450 711 q 436 698 437 704 q 435 683 436 698 q 431 646 433 669 q 427 596 429 624 q 421 541 424 568 q 416 490 418 513 q 412 451 414 466 z " + }, + "ß": { + "ha": 926, + "x_min": 83, + "x_max": 920, + "o": "m 91 115 q 102 287 98 203 q 109 449 105 371 q 121 596 113 526 q 146 725 130 665 q 192 831 163 784 q 267 912 221 879 q 378 964 313 946 q 534 982 444 982 q 674 963 609 982 q 786 910 739 943 q 861 833 834 877 q 888 742 888 790 q 855 638 888 691 q 745 538 821 586 q 818 494 786 514 q 873 452 850 474 q 907 404 895 431 q 920 338 920 376 q 902 225 920 279 q 852 125 884 172 q 777 42 821 79 q 680 -21 732 5 q 569 -62 628 -47 q 449 -76 510 -76 q 431 -71 437 -76 q 423 -55 426 -66 q 420 -23 420 -43 q 417 26 419 -3 q 414 63 416 47 q 412 92 412 79 q 418 120 412 110 q 441 133 424 130 q 524 152 484 140 q 593 185 563 163 q 641 238 623 206 q 659 317 659 271 q 646 352 659 337 q 613 377 633 366 q 569 394 593 387 q 522 405 545 401 q 480 412 499 410 q 451 416 460 414 q 439 422 442 417 q 436 435 436 428 q 447 460 436 445 q 477 503 457 475 l 505 543 q 545 559 524 549 q 585 583 566 570 q 615 613 603 596 q 627 649 627 629 q 603 734 627 705 q 534 764 579 764 q 499 759 521 764 q 456 740 477 753 q 417 703 434 726 q 397 642 400 679 q 381 507 387 571 q 373 385 376 443 q 368 274 370 327 q 361 172 366 221 l 349 -8 q 338 -22 348 -18 q 321 -26 329 -26 q 209 -10 265 -18 q 106 9 152 -2 q 88 20 93 12 q 83 35 83 27 q 86 75 83 59 q 91 115 89 92 z " + }, + "à": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 395 968 q 368 1015 379 996 q 348 1051 357 1034 q 328 1087 338 1068 q 302 1135 317 1107 q 297 1149 298 1143 q 295 1162 296 1156 q 298 1174 295 1169 q 309 1181 302 1178 q 361 1210 334 1196 q 416 1240 389 1225 q 472 1272 444 1255 q 524 1305 499 1288 q 534 1309 531 1309 q 539 1303 538 1309 l 615 907 q 618 892 616 898 q 619 881 619 886 q 617 869 619 875 q 608 861 615 864 q 582 849 594 854 q 558 838 570 844 q 533 825 546 832 q 503 808 519 819 q 496 806 500 806 q 482 815 490 806 q 464 843 475 824 q 436 893 452 863 q 395 968 419 924 z " + }, + "á": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 488 944 q 438 876 458 903 q 405 833 418 849 q 385 812 392 818 q 372 806 377 806 q 366 808 368 806 q 340 825 351 818 q 318 838 328 832 q 297 849 308 844 q 274 861 287 854 q 264 875 264 866 q 267 888 265 880 q 273 907 269 895 l 398 1271 q 405 1284 401 1280 q 412 1287 408 1287 q 419 1285 416 1287 q 426 1282 422 1284 q 531 1220 479 1246 q 635 1168 584 1195 q 648 1149 648 1160 q 636 1122 648 1139 q 592 1071 610 1091 q 560 1033 575 1051 q 528 994 545 1015 q 488 944 512 974 z " + }, + "â": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 654 1127 q 679 1095 669 1109 q 698 1069 690 1080 q 713 1047 706 1057 q 728 1024 720 1036 q 741 1006 732 1018 q 759 980 750 994 q 775 952 768 966 q 783 930 783 939 q 769 911 783 920 q 738 892 756 901 q 701 876 720 884 q 671 865 682 869 q 651 856 662 861 q 628 844 640 850 q 608 835 617 839 q 594 831 599 831 q 583 837 589 831 q 568 858 577 843 l 481 1044 q 398 941 439 991 q 316 838 357 890 q 307 833 313 833 q 291 839 304 833 q 262 852 279 844 q 227 869 245 860 q 196 884 210 878 q 177 892 189 887 q 156 903 166 897 q 139 913 146 908 q 132 924 132 919 q 138 939 132 930 q 157 961 144 948 l 260 1076 q 335 1160 298 1119 q 408 1243 372 1200 q 427 1254 418 1253 q 444 1255 436 1255 l 547 1255 q 563 1253 555 1255 q 576 1242 571 1252 q 585 1229 577 1241 q 605 1199 593 1217 q 630 1162 616 1182 q 654 1127 643 1143 z " + }, + "ã": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 532 1071 q 556 1046 545 1053 q 582 1038 567 1038 q 612 1049 603 1038 q 625 1075 621 1061 q 631 1100 629 1088 q 639 1111 633 1111 q 650 1111 640 1111 q 674 1112 660 1112 q 705 1114 688 1113 q 737 1115 722 1114 q 763 1117 752 1116 q 777 1117 774 1117 q 789 1114 783 1117 q 795 1104 795 1112 q 778 1005 795 1055 q 729 915 760 956 q 653 849 697 874 q 555 823 609 823 q 512 827 534 823 q 470 843 491 831 q 427 874 448 854 q 382 926 405 894 q 358 947 371 943 q 328 951 346 951 q 302 945 312 951 q 286 927 292 938 q 277 903 279 916 q 275 877 275 890 q 269 866 275 871 q 255 861 264 861 q 220 861 233 861 q 196 860 207 861 q 172 860 184 860 q 139 859 159 859 q 125 862 132 859 q 119 871 119 865 q 137 987 119 935 q 187 1077 155 1040 q 260 1135 218 1114 q 351 1156 302 1156 q 401 1154 378 1156 q 447 1143 425 1152 q 489 1118 469 1135 q 532 1071 510 1101 z " + }, + "ä": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 113 833 q 116 889 115 871 q 117 913 117 906 q 117 924 117 921 q 118 936 117 926 q 119 966 118 945 q 122 1031 120 987 q 127 1060 123 1052 q 140 1069 130 1067 q 253 1069 193 1069 q 363 1070 312 1070 q 372 1066 370 1070 q 375 1053 375 1063 l 358 864 q 353 837 356 844 q 340 827 351 829 q 234 826 289 827 q 120 826 180 826 q 115 827 117 826 q 113 831 113 827 l 113 833 m 538 831 q 541 895 540 874 q 542 924 542 916 q 542 936 543 933 q 542 946 542 939 q 544 972 543 954 q 547 1031 545 991 q 551 1058 549 1050 q 564 1067 554 1066 q 677 1068 618 1067 q 787 1069 736 1068 q 796 1066 792 1069 q 800 1055 800 1063 l 783 863 q 778 836 781 843 q 764 827 776 828 q 657 825 712 826 q 541 825 602 825 q 538 825 539 825 q 538 829 538 825 l 538 831 z " + }, + "å": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 455 808 q 380 821 415 808 q 320 856 346 833 q 280 912 295 879 q 266 988 266 945 q 281 1061 266 1027 q 324 1122 297 1096 q 388 1163 351 1147 q 468 1178 425 1178 q 538 1166 505 1178 q 595 1131 571 1154 q 634 1077 620 1109 q 648 1006 648 1045 q 634 934 648 970 q 596 870 621 899 q 535 825 571 842 q 455 808 500 808 m 526 989 q 526 996 526 992 q 526 1002 526 999 q 524 1028 526 1015 q 514 1054 521 1042 q 496 1074 507 1066 q 469 1081 486 1081 q 430 1073 447 1081 q 403 1051 414 1064 q 388 1021 393 1038 q 382 987 382 1004 q 385 961 382 975 q 395 935 388 947 q 413 916 401 924 q 443 907 425 908 q 478 915 463 907 q 502 933 492 922 q 518 960 512 945 q 526 989 524 975 z " + }, + "æ": { + "ha": 1300, + "x_min": 70, + "x_max": 1299, + "o": "m 734 604 q 858 716 787 675 q 1015 758 928 758 q 1080 748 1046 758 q 1145 720 1114 738 q 1205 675 1177 701 q 1254 617 1233 649 q 1286 548 1274 585 q 1299 471 1299 511 q 1295 422 1299 447 q 1284 372 1291 397 q 1275 342 1284 351 q 1243 329 1265 334 q 1186 321 1221 325 q 1099 306 1151 317 q 1033 294 1067 299 q 966 285 1000 290 q 899 275 932 280 q 833 261 865 269 q 851 208 836 231 q 892 171 867 186 q 947 150 916 157 q 1012 143 979 143 q 1075 150 1044 143 q 1133 166 1105 156 q 1186 189 1160 176 q 1235 215 1212 202 q 1245 217 1241 216 q 1254 218 1250 218 q 1254 207 1254 218 q 1253 175 1253 196 q 1253 125 1253 155 q 1252 61 1253 96 q 1255 49 1255 54 q 1252 40 1255 43 q 1244 34 1249 37 q 1190 8 1222 22 q 1120 -16 1158 -5 q 1044 -34 1083 -27 q 970 -41 1005 -41 q 889 -33 929 -41 q 812 -9 849 -25 q 742 29 775 6 q 682 82 709 52 q 606 27 646 50 q 527 -11 567 4 q 447 -34 486 -26 q 370 -41 407 -41 q 259 -16 313 -41 q 163 55 205 9 q 95 168 121 102 q 70 315 70 233 q 98 492 70 412 q 174 630 126 572 q 287 719 222 687 q 422 750 351 750 q 599 731 518 750 q 754 684 680 713 l 734 604 m 562 185 q 578 329 572 269 q 588 430 584 389 q 594 497 592 471 q 597 542 596 524 q 540 562 573 551 q 469 572 507 572 q 407 549 432 572 q 367 490 382 525 q 346 417 352 456 q 339 349 339 378 q 367 192 339 241 q 446 142 395 142 q 500 153 471 142 q 562 185 530 165 m 998 576 q 939 565 966 576 q 891 531 913 553 q 854 478 870 509 q 827 410 838 447 l 1105 465 q 1106 470 1105 467 q 1106 475 1106 473 q 1096 519 1106 500 q 1070 551 1086 538 q 1035 570 1055 564 q 998 576 1016 576 z " + }, + "ç": { + "ha": 716, + "x_min": 69, + "x_max": 738, + "o": "m 69 331 q 102 497 69 420 q 193 633 135 575 q 330 724 251 690 q 502 757 409 757 q 610 747 565 757 q 683 724 654 736 q 725 701 711 711 q 738 690 738 690 q 735 667 738 686 q 728 624 732 648 q 721 573 724 599 q 718 528 718 547 q 716 520 718 524 q 705 516 714 516 q 682 522 697 516 q 643 534 666 528 q 591 547 621 541 q 526 553 562 553 q 445 537 481 553 q 383 493 409 521 q 343 426 357 465 q 330 342 330 387 q 344 263 330 296 q 383 209 359 230 q 439 179 408 189 q 507 170 471 170 q 589 181 547 170 q 667 212 630 192 q 672 213 668 213 q 677 210 675 213 q 679 197 679 206 q 679 144 679 175 q 679 85 679 113 q 677 37 678 57 q 676 18 677 18 q 623 -9 652 1 q 565 -25 595 -19 q 504 -33 534 -31 q 445 -35 473 -35 q 297 -9 366 -35 q 178 66 229 18 q 98 181 127 114 q 69 331 69 249 m 552 -262 q 529 -320 546 -294 q 488 -365 512 -346 q 435 -394 465 -383 q 374 -404 406 -404 q 314 -400 346 -404 q 259 -382 283 -396 l 260 -383 q 260 -378 260 -382 q 261 -357 260 -370 q 263 -330 262 -345 q 268 -302 265 -315 q 273 -279 270 -288 l 276 -279 q 280 -281 280 -279 l 279 -281 q 300 -289 290 -286 q 320 -294 309 -292 q 343 -296 330 -296 q 372 -297 355 -297 q 416 -280 401 -297 q 432 -241 432 -263 q 419 -208 432 -221 q 385 -195 407 -195 q 321 -192 348 -195 q 285 -189 295 -189 q 278 -186 279 -189 q 277 -180 277 -184 q 285 -159 281 -174 q 294 -126 290 -144 q 303 -89 299 -109 q 312 -53 308 -69 q 318 -25 315 -36 q 320 -12 320 -14 l 333 -12 q 363 -12 345 -12 q 399 -12 381 -12 l 441 -12 l 409 -108 q 430 -105 420 -106 q 449 -104 440 -104 q 504 -116 484 -104 q 536 -144 525 -127 q 551 -184 547 -162 q 555 -230 555 -207 q 554 -246 555 -238 q 552 -262 553 -254 z " + }, + "è": { + "ha": 774, + "x_min": 68, + "x_max": 771, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 m 380 968 q 353 1015 364 996 q 333 1051 342 1034 q 313 1087 323 1068 q 287 1135 302 1107 q 282 1149 283 1143 q 280 1162 281 1156 q 283 1174 280 1169 q 294 1181 287 1178 q 346 1210 319 1196 q 401 1240 374 1225 q 457 1272 429 1255 q 509 1305 484 1288 q 519 1309 516 1309 q 524 1303 523 1309 l 600 907 q 603 892 602 898 q 604 881 604 886 q 602 869 604 875 q 593 861 600 864 q 567 849 579 854 q 543 838 555 844 q 518 825 531 832 q 488 808 505 819 q 481 806 486 806 q 467 815 475 806 q 449 843 460 824 q 421 893 437 863 q 380 968 404 924 z " + }, + "é": { + "ha": 774, + "x_min": 68, + "x_max": 771, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 m 473 944 q 423 876 443 903 q 390 833 403 849 q 370 812 377 818 q 357 806 362 806 q 351 808 353 806 q 325 825 336 818 q 303 838 313 832 q 282 849 293 844 q 259 861 272 854 q 250 875 250 866 q 252 888 250 880 q 258 907 254 895 l 383 1271 q 390 1284 387 1280 q 397 1287 393 1287 q 404 1285 401 1287 q 411 1282 408 1284 q 516 1220 464 1246 q 621 1168 569 1195 q 633 1149 633 1160 q 621 1122 633 1139 q 577 1071 595 1091 q 545 1033 560 1051 q 513 994 530 1015 q 473 944 497 974 z " + }, + "ê": { + "ha": 774, + "x_min": 68, + "x_max": 771, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 m 633 1127 q 658 1095 648 1109 q 677 1069 669 1080 q 692 1047 685 1057 q 707 1024 699 1036 q 720 1006 711 1018 q 738 980 729 994 q 754 952 747 966 q 762 930 762 939 q 748 911 762 920 q 717 892 735 901 q 680 876 699 884 q 650 865 661 869 q 630 856 641 861 q 607 844 618 850 q 587 835 596 839 q 573 831 578 831 q 562 837 568 831 q 547 858 556 843 l 460 1044 q 377 941 418 991 q 295 838 336 890 q 286 833 292 833 q 270 839 283 833 q 241 852 258 844 q 206 869 224 860 q 175 884 189 878 q 156 892 168 887 q 135 903 145 897 q 118 913 125 908 q 111 924 111 919 q 117 939 111 930 q 136 961 123 948 l 239 1076 q 314 1160 277 1119 q 387 1243 351 1200 q 406 1254 397 1253 q 422 1255 415 1255 l 526 1255 q 542 1253 534 1255 q 555 1242 550 1252 q 564 1229 556 1241 q 584 1199 572 1217 q 609 1162 595 1182 q 633 1127 622 1143 z " + }, + "ë": { + "ha": 774, + "x_min": 68, + "x_max": 785, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 m 98 833 q 101 889 100 871 q 102 913 102 906 q 102 924 102 921 q 103 936 102 926 q 104 966 103 945 q 107 1031 105 987 q 112 1060 109 1052 q 125 1069 115 1067 q 238 1069 178 1069 q 348 1070 297 1070 q 357 1066 355 1070 q 360 1053 360 1063 l 343 864 q 338 837 341 844 q 326 827 336 829 q 219 826 274 827 q 105 826 165 826 q 100 827 102 826 q 98 831 98 827 l 98 833 m 523 831 q 526 895 525 874 q 527 924 527 916 q 527 936 528 933 q 527 946 527 939 q 529 972 528 954 q 532 1031 530 991 q 536 1058 534 1050 q 549 1067 539 1066 q 662 1068 603 1067 q 772 1069 721 1068 q 781 1066 777 1069 q 785 1055 785 1063 l 768 863 q 763 836 766 843 q 749 827 761 828 q 642 825 697 826 q 526 825 587 825 q 524 825 524 825 q 523 829 523 825 l 523 831 z " + }, + "ì": { + "ha": 414, + "x_min": 73, + "x_max": 417, + "o": "m 99 288 q 117 471 109 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 328 38 340 41 q 211 -2 270 20 q 86 -45 152 -24 q 80 -46 84 -46 q 73 -39 73 -46 q 74 -20 73 -39 q 78 26 75 -2 q 83 91 80 55 q 89 163 85 127 q 94 232 92 199 q 99 288 97 264 m 193 968 q 166 1015 177 996 q 146 1051 155 1034 q 125 1087 136 1068 q 100 1135 115 1107 q 95 1149 96 1143 q 93 1162 94 1156 q 96 1174 93 1169 q 106 1181 100 1178 q 159 1210 132 1196 q 214 1240 186 1225 q 270 1272 242 1255 q 322 1305 297 1288 q 332 1309 329 1309 q 337 1303 336 1309 l 413 907 q 416 892 414 898 q 417 881 417 886 q 415 869 417 875 q 406 861 413 864 q 380 849 392 854 q 356 838 368 844 q 331 825 344 832 q 301 808 317 819 q 294 806 298 806 q 280 815 288 806 q 261 843 273 824 q 234 893 250 863 q 193 968 217 924 z " + }, + "í": { + "ha": 414, + "x_min": 62, + "x_max": 446, + "o": "m 99 288 q 117 471 109 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 328 38 340 41 q 211 -2 270 20 q 86 -45 152 -24 q 80 -46 84 -46 q 73 -39 73 -46 q 74 -20 73 -39 q 78 26 75 -2 q 83 91 80 55 q 89 163 85 127 q 94 232 92 199 q 99 288 97 264 m 286 944 q 236 876 256 903 q 203 833 216 849 q 182 812 190 818 q 170 806 175 806 q 163 808 166 806 q 138 825 149 818 q 116 838 126 832 q 95 849 106 844 q 72 861 85 854 q 62 875 62 866 q 65 888 63 880 q 71 907 66 895 l 196 1271 q 203 1284 199 1280 q 210 1287 206 1287 q 217 1285 214 1287 q 224 1282 220 1284 q 329 1220 277 1246 q 433 1168 382 1195 q 446 1149 446 1160 q 434 1122 446 1139 q 390 1071 408 1091 q 358 1033 373 1051 q 326 994 342 1015 q 286 944 310 974 z " + }, + "î": { + "ha": 414, + "x_min": -68, + "x_max": 583, + "o": "m 99 288 q 117 471 109 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 328 38 340 41 q 211 -2 270 20 q 86 -45 152 -24 q 80 -46 84 -46 q 73 -39 73 -46 q 74 -20 73 -39 q 78 26 75 -2 q 83 91 80 55 q 89 163 85 127 q 94 232 92 199 q 99 288 97 264 m 454 1127 q 479 1095 469 1109 q 498 1069 490 1080 q 513 1047 506 1057 q 528 1024 519 1036 q 541 1006 532 1018 q 559 980 550 994 q 575 952 568 966 q 583 930 583 939 q 569 911 583 920 q 538 892 556 901 q 501 876 519 884 q 471 865 482 869 q 451 856 462 861 q 428 844 439 850 q 408 835 417 839 q 394 831 399 831 q 383 837 389 831 q 368 858 377 843 l 281 1044 q 198 941 239 991 q 116 838 157 890 q 107 833 113 833 q 91 839 104 833 q 62 852 79 844 q 27 869 45 860 q -4 884 9 878 q -23 892 -12 887 q -44 903 -34 897 q -61 913 -54 908 q -68 924 -68 919 q -62 939 -68 930 q -43 961 -56 948 l 60 1076 q 135 1160 98 1119 q 208 1243 172 1200 q 227 1254 218 1253 q 243 1255 236 1255 l 347 1255 q 363 1253 355 1255 q 376 1242 371 1252 q 385 1229 377 1241 q 405 1199 393 1217 q 430 1162 416 1182 q 454 1127 443 1143 z " + }, + "ï": { + "ha": 414, + "x_min": -87, + "x_max": 600, + "o": "m 99 288 q 117 471 109 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 328 38 340 41 q 211 -2 270 20 q 86 -45 152 -24 q 80 -46 84 -46 q 73 -39 73 -46 q 74 -20 73 -39 q 78 26 75 -2 q 83 91 80 55 q 89 163 85 127 q 94 232 92 199 q 99 288 97 264 m -87 833 q -84 889 -85 871 q -83 913 -83 906 q -83 924 -83 921 q -82 936 -83 926 q -81 966 -82 945 q -78 1031 -80 987 q -73 1060 -77 1052 q -60 1069 -70 1067 q 53 1069 -7 1069 q 163 1070 112 1070 q 172 1066 170 1070 q 175 1053 175 1063 l 158 864 q 153 837 156 844 q 140 827 151 829 q 34 826 89 827 q -80 826 -20 826 q -85 827 -83 826 q -87 831 -87 827 l -87 833 m 338 831 q 341 895 340 874 q 342 924 342 916 q 342 936 342 933 q 342 946 342 939 q 343 972 342 954 q 347 1031 345 991 q 351 1058 349 1050 q 364 1067 354 1066 q 477 1068 418 1067 q 587 1069 536 1068 q 596 1066 592 1069 q 600 1055 600 1063 l 583 863 q 578 836 581 843 q 564 827 576 828 q 457 825 512 826 q 341 825 401 825 q 338 825 339 825 q 338 829 338 825 l 338 831 z " + }, + "ð": { + "ha": 899, + "x_min": 72, + "x_max": 888, + "o": "m 373 747 q 362 745 367 745 q 337 755 352 745 q 308 779 322 766 q 283 804 294 792 q 273 821 273 817 q 332 853 273 832 l 399 873 q 381 902 391 886 q 360 933 370 918 q 343 960 351 948 q 336 976 336 972 q 353 991 336 986 q 384 1001 370 996 q 411 1012 398 1006 q 438 1026 425 1018 q 468 1043 452 1034 q 477 1046 473 1046 q 486 1044 482 1046 q 491 1040 489 1042 q 537 987 513 1014 q 585 931 561 960 l 681 961 q 699 965 693 965 q 708 963 705 965 q 715 957 712 960 l 741 922 q 767 884 759 897 q 777 867 776 871 q 772 858 777 862 q 765 854 767 854 l 674 824 q 755 712 717 770 q 824 593 794 654 q 871 467 853 532 q 888 336 888 403 q 877 245 888 290 q 843 159 866 200 q 786 82 821 117 q 703 21 751 47 q 595 -20 656 -5 q 461 -35 535 -35 q 296 -1 368 -35 q 174 87 224 33 q 98 210 124 142 q 72 347 72 278 q 91 474 72 412 q 149 585 110 536 q 249 662 189 633 q 391 692 309 692 q 455 684 430 692 q 492 667 479 676 q 474 715 484 690 q 453 766 465 740 l 373 747 m 356 336 q 367 271 358 305 q 393 208 376 237 q 436 161 410 180 q 500 142 463 142 q 542 156 526 142 q 567 191 558 170 q 579 238 576 212 q 582 288 582 264 q 580 348 582 323 q 573 395 578 373 q 559 442 568 418 q 537 500 550 466 q 511 508 521 506 q 488 511 501 511 q 427 498 452 511 q 386 465 401 486 q 362 416 370 444 q 355 357 355 388 q 355 347 355 352 q 356 336 355 341 z " + }, + "ñ": { + "ha": 897, + "x_min": 73, + "x_max": 876, + "o": "m 644 -24 q 625 -31 637 -26 q 601 -35 612 -35 q 584 -31 591 -35 q 577 -14 577 -26 l 577 -10 q 584 52 581 26 q 589 98 587 77 q 593 139 591 119 q 597 188 595 160 q 604 253 600 215 q 613 347 608 290 q 619 417 616 381 q 622 484 622 453 q 619 521 622 504 q 608 551 616 538 q 587 570 600 564 q 552 577 573 577 q 509 570 536 577 q 454 548 481 564 q 406 507 427 532 q 382 444 385 481 q 375 358 378 394 q 369 298 372 323 q 365 258 366 273 q 362 231 363 242 q 360 216 361 229 q 357 187 359 204 q 354 151 355 170 q 350 115 352 132 q 348 86 349 98 q 347 71 347 74 q 347 62 347 66 q 347 55 347 58 q 342 46 347 50 q 319 37 337 42 l 140 -24 q 120 -31 132 -27 q 96 -35 108 -35 q 80 -31 87 -35 q 73 -14 73 -26 l 134 679 q 139 710 136 701 q 155 721 142 718 q 281 746 216 733 q 408 778 347 758 q 415 776 414 778 q 416 771 416 774 q 415 767 416 770 q 413 755 415 764 q 409 726 412 745 q 400 672 406 707 q 488 716 447 699 q 565 742 529 732 q 633 754 602 751 q 693 757 665 757 q 832 699 789 757 q 876 525 876 641 q 875 491 876 509 q 872 456 874 474 l 841 164 q 837 112 838 136 q 836 56 836 87 q 837 46 836 50 q 837 40 837 43 q 832 31 837 34 q 809 22 827 27 l 644 -24 m 547 1071 q 571 1046 560 1053 q 597 1038 583 1038 q 628 1049 618 1038 q 641 1075 637 1061 q 646 1100 644 1088 q 654 1111 648 1111 q 666 1111 656 1111 q 690 1112 675 1112 q 721 1114 704 1113 q 753 1115 738 1114 q 779 1117 768 1116 q 793 1117 790 1117 q 804 1114 798 1117 q 810 1104 810 1112 q 793 1005 810 1055 q 744 915 776 956 q 669 849 713 874 q 571 823 625 823 q 528 827 549 823 q 485 843 507 831 q 442 874 464 854 q 398 926 420 894 q 374 947 387 943 q 344 951 361 951 q 317 945 328 951 q 301 927 307 938 q 293 903 295 916 q 290 877 290 890 q 285 866 290 871 q 271 861 279 861 q 236 861 249 861 q 211 860 222 861 q 188 860 200 860 q 155 859 175 859 q 141 862 147 859 q 134 871 134 865 q 153 987 134 935 q 202 1077 171 1040 q 276 1135 234 1114 q 366 1156 318 1156 q 417 1154 393 1156 q 462 1143 440 1152 q 505 1118 484 1135 q 547 1071 526 1101 z " + }, + "ò": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 416 968 q 389 1015 400 996 q 369 1051 378 1034 q 349 1087 359 1068 q 323 1135 338 1107 q 318 1149 319 1143 q 316 1162 317 1156 q 319 1174 316 1169 q 330 1181 323 1178 q 382 1210 355 1196 q 437 1240 410 1225 q 493 1272 465 1255 q 545 1305 520 1288 q 555 1309 552 1309 q 560 1303 559 1309 l 636 907 q 639 892 637 898 q 640 881 640 886 q 638 869 640 875 q 629 861 636 864 q 603 849 615 854 q 579 838 591 844 q 554 825 567 832 q 524 808 541 819 q 517 806 522 806 q 503 815 511 806 q 485 843 496 824 q 457 893 473 863 q 416 968 440 924 z " + }, + "ó": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 508 944 q 458 876 478 903 q 425 833 438 849 q 405 812 412 818 q 393 806 397 806 q 386 808 389 806 q 360 825 372 818 q 338 838 349 832 q 318 849 328 844 q 294 861 307 854 q 285 875 285 866 q 287 888 286 880 q 293 907 289 895 l 418 1271 q 425 1284 422 1280 q 433 1287 429 1287 q 439 1285 436 1287 q 446 1282 443 1284 q 552 1220 499 1246 q 656 1168 604 1195 q 668 1149 668 1160 q 656 1122 668 1139 q 613 1071 630 1091 q 580 1033 595 1051 q 549 994 565 1015 q 508 944 532 974 z " + }, + "ô": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 672 1127 q 697 1095 687 1109 q 716 1069 708 1080 q 731 1047 724 1057 q 746 1024 738 1036 q 759 1006 750 1018 q 778 980 768 994 q 794 952 787 966 q 801 930 801 939 q 788 911 801 920 q 756 892 774 901 q 719 876 738 884 q 690 865 701 869 q 669 856 680 861 q 647 844 658 850 q 626 835 635 839 q 612 831 617 831 q 601 837 607 831 q 587 858 595 843 l 499 1044 q 417 941 458 991 q 334 838 376 890 q 326 833 332 833 q 310 839 322 833 q 280 852 297 844 q 245 869 263 860 q 214 884 228 878 q 196 892 207 887 q 174 903 184 897 q 157 913 164 908 q 150 924 150 919 q 156 939 150 930 q 176 961 162 948 l 278 1076 q 353 1160 317 1119 q 427 1243 390 1200 q 445 1254 436 1253 q 462 1255 454 1255 l 565 1255 q 581 1253 573 1255 q 595 1242 589 1252 q 604 1229 595 1241 q 623 1199 612 1217 q 648 1162 635 1182 q 672 1127 661 1143 z " + }, + "õ": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 553 1071 q 577 1046 566 1053 q 603 1038 588 1038 q 633 1049 624 1038 q 646 1075 642 1061 q 652 1100 650 1088 q 660 1111 654 1111 q 671 1111 661 1111 q 695 1112 681 1112 q 726 1114 709 1113 q 758 1115 743 1114 q 784 1117 773 1116 q 798 1117 795 1117 q 810 1114 804 1117 q 816 1104 816 1112 q 799 1005 816 1055 q 750 915 781 956 q 674 849 718 874 q 576 823 630 823 q 533 827 555 823 q 491 843 512 831 q 448 874 469 854 q 404 926 426 894 q 379 947 392 943 q 349 951 367 951 q 323 945 333 951 q 307 927 313 938 q 298 903 300 916 q 296 877 296 890 q 290 866 296 871 q 276 861 285 861 q 241 861 254 861 q 217 860 228 861 q 193 860 205 860 q 160 859 180 859 q 146 862 153 859 q 140 871 140 865 q 158 987 140 935 q 208 1077 176 1040 q 281 1135 239 1114 q 372 1156 323 1156 q 422 1154 399 1156 q 468 1143 446 1152 q 510 1118 490 1135 q 553 1071 531 1101 z " + }, + "ö": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 134 833 q 137 889 136 871 q 138 913 138 906 q 138 924 138 921 q 139 936 138 926 q 140 966 139 945 q 143 1031 141 987 q 148 1060 144 1052 q 161 1069 151 1067 q 274 1069 214 1069 q 384 1070 333 1070 q 393 1066 391 1070 q 396 1053 396 1063 l 379 864 q 374 837 377 844 q 361 827 372 829 q 255 826 310 827 q 141 826 201 826 q 136 827 138 826 q 134 831 134 827 l 134 833 m 559 831 q 562 895 561 874 q 563 924 563 916 q 563 936 564 933 q 563 946 563 939 q 565 972 564 954 q 568 1031 566 991 q 572 1058 570 1050 q 585 1067 575 1066 q 698 1068 639 1067 q 808 1069 757 1068 q 817 1066 813 1069 q 821 1055 821 1063 l 804 863 q 799 836 802 843 q 785 827 797 828 q 678 825 733 826 q 562 825 623 825 q 559 825 560 825 q 559 829 559 825 l 559 831 z " + }, + "÷": { + "ha": 924, + "x_min": 65, + "x_max": 928, + "o": "m 384 635 q 386 686 385 667 q 387 715 387 705 q 387 729 387 725 q 387 737 387 733 q 388 746 387 741 q 389 764 388 751 q 391 799 389 777 q 394 857 392 821 q 398 888 395 880 q 414 899 401 897 q 538 899 473 899 q 661 900 604 899 q 671 897 667 900 q 675 884 675 893 l 656 671 q 651 640 654 648 q 636 630 649 631 q 518 629 578 629 q 391 628 457 628 q 386 629 387 628 q 384 633 384 629 l 384 635 m 370 -26 q 372 25 372 6 q 373 54 373 43 q 374 68 374 64 q 374 76 374 72 q 374 85 374 79 q 375 103 374 90 q 377 137 376 115 q 380 196 378 159 q 385 227 382 218 q 400 237 388 235 q 525 238 460 237 q 648 239 590 238 q 658 235 654 239 q 662 222 662 232 l 643 9 q 638 -21 640 -13 q 623 -31 635 -30 q 504 -33 565 -32 q 378 -33 444 -33 q 372 -33 374 -33 q 370 -28 370 -32 l 370 -26 m 391 553 q 568 555 484 554 q 720 558 652 557 q 836 559 788 559 q 904 560 884 560 q 908 560 905 560 q 917 556 912 559 q 925 547 921 553 q 928 528 928 541 q 923 502 927 519 q 915 464 920 486 q 906 417 911 442 q 896 366 901 392 q 889 349 894 355 q 864 343 884 343 q 722 341 793 342 q 582 340 650 340 q 449 338 513 339 q 329 337 385 338 q 228 337 273 337 q 150 336 182 336 q 87 340 109 336 q 65 357 65 345 q 71 379 65 363 q 85 415 76 395 q 106 459 94 436 q 130 500 117 481 q 153 532 142 519 q 174 544 165 544 l 391 553 z " + }, + "ø": { + "ha": 888, + "x_min": 68, + "x_max": 886, + "o": "m 394 -39 q 379 -38 387 -39 q 365 -37 372 -38 l 318 -128 q 310 -147 313 -138 q 300 -156 307 -155 q 284 -150 294 -156 l 228 -115 q 215 -109 224 -113 q 197 -99 206 -104 q 182 -86 189 -93 q 175 -71 175 -79 q 179 -57 175 -64 l 217 15 q 153 76 180 41 q 106 153 125 111 q 78 242 87 195 q 68 338 68 290 q 105 507 68 431 q 204 637 142 583 q 349 721 267 691 q 524 750 432 750 q 592 744 559 750 l 629 817 q 644 842 638 835 q 655 850 649 849 q 661 849 659 850 q 690 830 675 840 q 720 811 705 821 q 749 794 734 802 q 779 781 764 786 q 784 778 782 780 q 787 773 787 777 q 786 771 787 772 q 786 770 786 771 l 741 683 q 801 627 774 659 q 847 558 828 595 q 876 479 866 522 q 886 392 886 437 q 868 267 886 325 q 818 160 850 208 q 741 76 785 113 q 641 13 696 39 q 524 -25 586 -12 q 394 -39 461 -39 m 330 346 q 332 300 330 323 q 342 256 335 278 q 381 333 360 293 q 424 413 402 373 q 466 493 445 454 q 505 569 487 533 q 474 572 488 572 q 406 548 433 572 q 361 489 378 524 q 337 415 345 454 q 330 346 330 376 m 635 386 q 624 459 635 422 l 460 144 q 467 143 463 143 q 473 143 470 143 q 532 155 507 143 q 575 186 557 167 q 604 231 593 206 q 623 283 616 256 q 633 337 630 311 q 635 386 635 363 z " + }, + "ù": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 m 406 968 q 379 1015 390 996 q 359 1051 368 1034 q 338 1087 349 1068 q 313 1135 328 1107 q 308 1149 309 1143 q 306 1162 307 1156 q 309 1174 306 1169 q 319 1181 313 1178 q 372 1210 345 1196 q 427 1240 399 1225 q 483 1272 455 1255 q 535 1305 510 1288 q 545 1309 542 1309 q 550 1303 549 1309 l 626 907 q 629 892 627 898 q 630 881 630 886 q 628 869 630 875 q 619 861 626 864 q 593 849 605 854 q 569 838 581 844 q 544 825 557 832 q 514 808 530 819 q 507 806 511 806 q 493 815 500 806 q 474 843 486 824 q 447 893 463 863 q 406 968 430 924 z " + }, + "ú": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 m 498 944 q 448 876 468 903 q 415 833 428 849 q 395 812 402 818 q 382 806 387 806 q 376 808 378 806 q 350 825 361 818 q 328 838 338 832 q 308 849 318 844 q 284 861 297 854 q 275 875 275 866 q 277 888 275 880 q 283 907 279 895 l 408 1271 q 415 1284 412 1280 q 422 1287 418 1287 q 429 1285 426 1287 q 436 1282 433 1284 q 542 1220 489 1246 q 646 1168 594 1195 q 658 1149 658 1160 q 646 1122 658 1139 q 603 1071 620 1091 q 570 1033 585 1051 q 538 994 555 1015 q 498 944 522 974 z " + }, + "û": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 m 664 1127 q 689 1095 679 1109 q 708 1069 700 1080 q 723 1047 716 1057 q 738 1024 730 1036 q 751 1006 742 1018 q 769 980 760 994 q 786 952 779 966 q 793 930 793 939 q 780 911 793 920 q 748 892 766 901 q 711 876 730 884 q 682 865 692 869 q 661 856 672 861 q 638 844 650 850 q 618 835 627 839 q 604 831 609 831 q 593 837 599 831 q 578 858 587 843 l 491 1044 q 409 941 450 991 q 326 838 368 890 q 317 833 323 833 q 301 839 314 833 q 272 852 289 844 q 237 869 255 860 q 206 884 220 878 q 188 892 199 887 q 166 903 176 897 q 149 913 156 908 q 142 924 142 919 q 148 939 142 930 q 168 961 154 948 l 270 1076 q 345 1160 309 1119 q 418 1243 382 1200 q 437 1254 428 1253 q 454 1255 446 1255 l 557 1255 q 573 1253 565 1255 q 587 1242 581 1252 q 595 1229 587 1241 q 615 1199 604 1217 q 640 1162 627 1182 q 664 1127 653 1143 z " + }, + "ü": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 m 124 833 q 127 889 126 871 q 128 913 127 906 q 128 924 128 921 q 129 936 128 926 q 130 966 129 945 q 133 1031 131 987 q 138 1060 134 1052 q 151 1069 141 1067 q 263 1069 204 1069 q 374 1070 323 1070 q 383 1066 380 1070 q 386 1053 386 1063 l 369 864 q 364 837 367 844 q 351 827 361 829 q 245 826 300 827 q 131 826 191 826 q 126 827 127 826 q 124 831 124 827 l 124 833 m 549 831 q 552 895 551 874 q 553 924 553 916 q 553 936 553 933 q 553 946 553 939 q 554 972 553 954 q 558 1031 555 991 q 562 1058 559 1050 q 575 1067 565 1066 q 688 1068 629 1067 q 798 1069 747 1068 q 807 1066 803 1069 q 810 1055 810 1063 l 794 863 q 789 836 791 843 q 775 827 787 828 q 668 825 723 826 q 552 825 612 825 q 549 825 550 825 q 549 829 549 825 l 549 831 z " + }, + "ý": { + "ha": 763, + "x_min": 77, + "x_max": 807, + "o": "m 370 -309 q 367 -314 369 -312 q 361 -315 366 -315 q 355 -315 357 -315 q 309 -300 331 -306 q 266 -289 288 -294 q 222 -279 245 -283 q 175 -269 200 -275 q 164 -265 168 -268 q 161 -254 161 -262 q 163 -244 161 -252 q 175 -213 165 -237 q 204 -143 184 -189 q 258 -20 224 -97 q 235 62 248 14 q 208 165 222 109 q 178 284 193 222 q 146 411 162 347 q 114 538 130 475 q 83 658 98 601 q 78 676 79 669 q 77 688 77 683 q 88 700 77 697 q 198 724 141 711 q 306 756 255 736 q 312 757 308 757 q 321 750 321 757 l 404 294 q 415 324 407 302 q 433 375 422 345 q 456 441 444 405 q 483 514 469 477 q 510 588 496 551 q 534 657 523 625 q 554 713 546 689 q 568 750 563 738 q 578 757 570 757 q 581 756 580 757 q 583 756 581 756 q 688 724 634 736 q 794 700 741 711 q 807 685 807 697 q 799 658 807 676 l 519 26 l 370 -309 m 460 944 q 411 876 431 903 q 378 833 391 849 q 357 812 365 818 q 345 806 350 806 q 338 808 341 806 q 313 825 324 818 q 291 838 301 832 q 270 849 281 844 q 247 861 260 854 q 237 875 237 866 q 240 888 238 880 q 245 907 241 895 l 371 1271 q 378 1284 374 1280 q 385 1287 381 1287 q 392 1285 389 1287 q 399 1282 395 1284 q 504 1220 452 1246 q 608 1168 557 1195 q 621 1149 621 1160 q 609 1122 621 1139 q 565 1071 583 1091 q 533 1033 548 1051 q 501 994 517 1015 q 460 944 485 974 z " + }, + "þ": { + "ha": 902, + "x_min": 53, + "x_max": 898, + "o": "m 388 471 q 381 365 384 406 q 376 299 378 324 q 372 259 374 273 q 370 231 371 244 q 416 165 386 191 q 490 140 447 140 q 555 163 527 140 q 602 224 583 186 q 630 307 621 261 q 640 399 640 353 q 633 468 640 436 q 614 523 627 500 q 582 560 601 547 q 536 573 562 573 q 493 565 514 573 q 452 543 471 556 q 416 513 432 530 q 390 481 400 496 l 388 471 m 406 665 q 523 727 463 703 q 651 751 583 751 q 761 725 715 751 q 838 656 808 699 q 883 555 869 612 q 898 435 898 498 q 887 323 898 380 q 855 215 876 267 q 803 116 834 162 q 731 36 772 70 q 642 -18 691 1 q 534 -38 592 -38 q 437 -18 484 -38 q 350 35 391 3 q 348 11 350 32 q 344 -41 347 -11 q 338 -107 341 -72 q 332 -172 334 -142 q 327 -224 329 -203 q 326 -249 326 -245 q 326 -258 326 -254 q 326 -265 326 -262 q 321 -274 326 -271 q 298 -283 316 -278 l 120 -328 q 100 -335 113 -330 q 77 -339 88 -339 q 60 -334 67 -339 q 53 -318 53 -330 l 134 658 q 136 679 134 661 q 140 722 138 696 q 145 775 142 747 q 151 829 149 804 q 155 871 153 854 q 157 892 157 889 q 156 902 157 898 q 155 908 155 905 q 160 918 155 914 q 184 926 165 922 l 362 972 q 382 978 370 974 q 406 983 394 982 q 422 978 415 983 q 429 962 429 973 l 429 958 l 406 665 z " + }, + "ÿ": { + "ha": 763, + "x_min": 77, + "x_max": 807, + "o": "m 370 -309 q 367 -314 369 -312 q 361 -315 366 -315 q 355 -315 357 -315 q 309 -300 331 -306 q 266 -289 288 -294 q 222 -279 245 -283 q 175 -269 200 -275 q 164 -265 168 -268 q 161 -254 161 -262 q 163 -244 161 -252 q 175 -213 165 -237 q 204 -143 184 -189 q 258 -20 224 -97 q 235 62 248 14 q 208 165 222 109 q 178 284 193 222 q 146 411 162 347 q 114 538 130 475 q 83 658 98 601 q 78 676 79 669 q 77 688 77 683 q 88 700 77 697 q 198 724 141 711 q 306 756 255 736 q 312 757 308 757 q 321 750 321 757 l 404 294 q 415 324 407 302 q 433 375 422 345 q 456 441 444 405 q 483 514 469 477 q 510 588 496 551 q 534 657 523 625 q 554 713 546 689 q 568 750 563 738 q 578 757 570 757 q 581 756 580 757 q 583 756 581 756 q 688 724 634 736 q 794 700 741 711 q 807 685 807 697 q 799 658 807 676 l 519 26 l 370 -309 m 87 833 q 90 889 89 871 q 91 913 90 906 q 91 924 91 921 q 91 936 91 926 q 93 966 92 945 q 96 1031 94 987 q 100 1060 97 1052 q 114 1069 104 1067 q 226 1069 167 1069 q 336 1070 286 1070 q 346 1066 343 1070 q 349 1053 349 1063 l 332 864 q 327 837 330 844 q 314 827 324 829 q 208 826 262 827 q 94 826 153 826 q 89 827 90 826 q 87 831 87 827 l 87 833 m 511 831 q 514 895 513 874 q 516 924 515 916 q 516 936 516 933 q 516 946 515 939 q 517 972 516 954 q 521 1031 518 991 q 525 1058 522 1050 q 538 1067 528 1066 q 650 1068 591 1067 q 760 1069 709 1068 q 769 1066 766 1069 q 773 1055 773 1063 l 757 863 q 752 836 754 843 q 738 827 749 828 q 630 825 686 826 q 515 825 575 825 q 512 825 513 825 q 511 829 511 825 l 511 831 z " + }, + "Ğ": { + "ha": 992, + "x_min": 85, + "x_max": 1027, + "o": "m 85 438 q 103 588 85 516 q 154 721 121 659 q 234 834 187 783 q 340 921 281 884 q 466 977 398 957 q 610 996 534 996 q 714 988 669 996 q 793 969 760 981 q 849 943 827 957 q 887 916 872 928 q 886 911 886 916 q 888 873 886 901 q 892 813 890 846 q 896 746 895 780 q 898 686 898 711 q 895 677 898 682 q 882 672 892 672 q 867 674 876 672 q 831 693 852 678 q 780 723 809 707 q 716 752 751 739 q 638 764 680 764 q 537 741 583 764 q 460 676 492 718 q 410 577 428 634 q 393 452 393 519 q 404 342 393 393 q 436 253 414 290 q 491 193 458 215 q 568 172 524 172 q 665 190 610 172 l 680 286 l 551 286 q 576 414 566 368 q 597 485 587 461 q 616 515 606 509 q 640 521 627 521 l 997 521 q 1020 519 1013 521 q 1027 505 1027 517 q 1025 490 1027 498 q 1022 469 1024 481 l 988 355 q 978 322 982 334 q 968 304 974 310 q 951 297 962 298 q 921 296 941 296 q 908 288 910 296 q 905 269 906 279 q 897 210 901 239 q 889 155 892 180 q 881 110 885 130 q 873 82 877 91 q 789 31 827 53 q 714 -6 751 9 q 635 -28 677 -20 q 541 -35 594 -35 q 361 -1 445 -35 q 217 97 278 34 q 120 246 155 159 q 85 438 85 333 m 633 1203 q 635 1211 633 1207 q 644 1216 637 1214 q 662 1220 650 1219 q 694 1221 674 1221 q 726 1220 714 1221 q 745 1217 739 1219 q 753 1212 751 1215 q 755 1204 755 1209 q 740 1140 755 1172 q 696 1083 724 1108 q 630 1043 668 1059 q 548 1027 592 1027 q 479 1036 512 1027 q 422 1060 447 1044 q 384 1098 398 1076 q 369 1151 369 1121 q 370 1164 369 1157 q 372 1180 371 1171 q 376 1197 374 1190 q 385 1209 378 1204 q 405 1216 391 1213 q 444 1218 418 1218 q 469 1217 460 1218 q 483 1214 478 1216 q 490 1210 488 1213 q 491 1204 491 1207 q 490 1198 491 1203 q 490 1187 490 1193 q 492 1168 490 1178 q 502 1151 495 1159 q 522 1138 509 1143 q 552 1133 534 1133 q 588 1139 572 1133 q 613 1154 603 1145 q 628 1174 623 1163 q 633 1194 633 1184 l 633 1203 l 633 1203 z " + }, + "ğ": { + "ha": 878, + "x_min": 69, + "x_max": 871, + "o": "m 576 484 q 557 515 569 499 q 530 545 545 532 q 496 567 514 559 q 457 576 477 576 q 405 553 429 576 q 362 491 380 530 q 333 404 344 453 q 323 302 323 355 q 329 240 323 269 q 347 190 334 212 q 378 155 359 168 q 425 142 397 142 q 499 168 465 142 q 557 234 533 193 q 559 268 557 248 q 562 316 560 288 q 567 382 564 344 q 575 473 570 420 l 576 484 m 800 74 q 770 -52 793 7 q 710 -158 746 -110 q 627 -245 673 -207 q 529 -310 581 -283 q 422 -350 477 -336 q 313 -364 367 -364 q 255 -362 285 -364 q 197 -354 225 -360 q 142 -338 168 -349 q 96 -311 117 -328 l 96 -311 q 101 -287 96 -307 q 114 -239 106 -267 q 129 -182 121 -212 q 142 -132 136 -153 q 147 -119 144 -123 q 153 -116 150 -116 q 158 -117 157 -116 q 195 -138 178 -130 q 232 -152 212 -146 q 274 -159 251 -157 q 328 -161 297 -161 q 412 -142 375 -161 q 475 -95 448 -124 q 517 -31 501 -66 q 539 38 533 4 q 440 -15 490 5 q 339 -35 390 -35 q 218 -7 269 -35 q 134 68 168 21 q 85 177 101 116 q 69 306 69 239 q 79 413 69 359 q 108 517 89 467 q 156 611 127 567 q 222 687 184 654 q 304 738 259 720 q 404 757 350 757 q 512 731 462 757 q 602 667 562 705 q 604 687 604 680 q 608 698 605 694 q 617 704 610 702 q 635 709 623 706 q 663 714 643 710 q 709 725 682 719 q 769 738 736 731 q 835 752 802 745 q 850 754 843 754 q 871 727 871 754 q 871 721 871 724 q 870 714 871 718 l 800 74 m 534 991 q 536 998 534 995 q 545 1004 538 1002 q 563 1008 551 1006 q 595 1009 575 1009 q 627 1008 615 1009 q 646 1005 640 1007 q 654 1000 652 1003 q 656 992 656 997 q 641 928 656 960 q 597 871 625 896 q 531 831 569 846 q 449 815 493 815 q 380 823 413 815 q 323 847 348 831 q 284 886 299 863 q 270 939 270 909 q 271 952 270 945 q 273 968 272 959 q 277 985 275 978 q 286 997 279 992 q 306 1003 292 1001 q 345 1006 319 1006 q 370 1005 361 1006 q 384 1002 379 1004 q 391 998 389 1000 q 392 992 392 995 q 391 986 392 991 q 391 975 391 981 q 393 956 391 966 q 403 939 396 947 q 422 926 410 931 q 453 921 435 921 q 489 927 473 921 q 514 942 504 932 q 529 961 524 951 q 534 982 534 972 l 534 991 l 534 991 z " + }, + "İ": { + "ha": 453, + "x_min": 94, + "x_max": 450, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m 123 1162 q 125 1212 124 1194 q 126 1241 125 1231 q 126 1255 126 1251 q 126 1263 126 1259 q 126 1272 126 1267 q 127 1290 127 1278 q 130 1325 128 1303 q 133 1383 131 1347 q 137 1414 134 1406 q 153 1425 140 1423 q 277 1425 212 1425 q 400 1426 342 1426 q 410 1423 406 1426 q 414 1410 414 1419 l 395 1197 q 390 1166 393 1175 q 375 1156 388 1158 q 257 1155 317 1156 q 130 1154 196 1154 q 124 1155 126 1154 q 123 1159 123 1156 l 123 1162 z " + }, + "ı": { + "ha": 414, + "x_min": 73, + "x_max": 408, + "o": "m 99 288 q 117 471 109 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 328 38 340 41 q 211 -2 270 20 q 86 -45 152 -24 q 80 -46 84 -46 q 73 -39 73 -46 q 74 -20 73 -39 q 78 26 75 -2 q 83 91 80 55 q 89 163 85 127 q 94 232 92 199 q 99 288 97 264 z " + }, + "Ł": { + "ha": 694, + "x_min": 57, + "x_max": 719, + "o": "m 454 972 q 452 931 454 956 q 447 881 450 907 q 442 826 445 854 q 436 776 439 799 q 433 736 434 753 q 431 715 431 720 q 426 637 429 673 q 419 566 422 601 q 412 493 416 530 q 404 410 408 455 q 395 309 400 364 q 385 181 390 253 l 665 210 q 696 212 684 212 q 715 207 711 212 q 719 192 719 202 q 718 188 719 191 q 715 179 718 186 q 708 159 713 172 q 696 123 704 146 l 682 79 q 668 40 674 57 q 652 10 661 22 q 628 -10 642 -3 q 591 -20 614 -18 q 553 -22 577 -21 q 496 -24 528 -23 q 428 -26 465 -25 q 352 -30 391 -28 q 275 -32 314 -31 q 202 -33 237 -32 q 138 -34 168 -33 q 90 -35 109 -35 q 65 -33 73 -35 q 57 -24 57 -30 q 62 -2 57 -16 q 74 29 66 12 q 91 65 81 46 q 109 101 100 83 l 127 343 q 138 469 132 405 q 147 592 143 532 l 159 772 q 164 864 163 836 q 165 896 165 893 q 168 916 167 908 q 170 930 168 924 q 175 938 172 935 q 185 942 178 941 l 423 998 q 441 1001 431 1001 q 452 997 449 1001 q 455 980 455 992 q 455 976 455 979 q 454 972 454 974 m 454 573 q 521 575 488 574 q 581 577 554 576 q 628 579 608 578 q 656 581 648 581 q 669 576 662 581 q 675 561 675 572 q 669 538 675 555 q 652 499 663 522 q 629 447 642 475 q 603 387 616 418 q 598 376 600 380 q 592 369 595 372 q 582 365 588 366 q 566 363 576 363 q 444 361 505 362 q 329 359 382 360 q 231 357 275 358 q 162 357 188 357 q 101 360 121 357 q 81 376 81 364 q 90 394 81 379 q 112 430 98 410 q 143 474 125 451 q 177 517 160 498 q 210 550 194 537 q 235 564 225 564 l 454 573 z " + }, + "ł": { + "ha": 585, + "x_min": 37, + "x_max": 631, + "o": "m 203 574 q 217 758 212 667 q 228 944 222 848 q 232 975 229 966 q 248 986 235 983 q 374 1009 311 998 q 501 1041 437 1021 q 507 1042 503 1042 q 515 1035 515 1042 l 427 66 l 427 55 q 422 45 427 49 q 399 37 417 41 l 222 -28 q 202 -36 214 -32 q 178 -39 190 -39 q 162 -35 169 -39 q 155 -18 155 -30 l 155 -14 q 165 120 160 54 q 176 255 170 185 q 189 403 182 325 q 203 574 195 481 m 410 573 q 477 575 444 574 q 537 577 510 576 q 584 579 564 578 q 612 581 604 581 q 625 576 618 581 q 631 561 631 572 q 625 538 631 555 q 608 499 618 522 q 585 447 598 475 q 559 387 572 418 q 554 376 556 380 q 548 369 551 372 q 538 365 544 366 q 522 363 532 363 q 399 361 460 362 q 284 359 338 360 q 187 357 231 358 q 118 357 144 357 q 57 360 77 357 q 37 376 37 364 q 46 394 37 379 q 68 430 54 410 q 99 474 81 451 q 133 517 116 498 q 165 550 150 537 q 191 564 181 564 l 410 573 z " + }, + "Œ": { + "ha": 1391, + "x_min": 86, + "x_max": 1438, + "o": "m 517 -35 q 336 5 416 -35 q 201 111 256 44 q 116 266 145 178 q 86 451 86 354 q 105 601 86 530 q 160 730 125 671 q 243 836 195 789 q 351 915 292 883 q 477 965 410 947 q 616 982 545 982 q 737 962 682 982 q 840 907 793 942 l 842 946 q 980 956 914 951 q 1106 964 1046 960 l 1419 983 q 1422 983 1419 983 q 1429 981 1426 983 q 1435 975 1432 979 q 1438 964 1438 971 q 1429 929 1438 960 q 1402 852 1419 898 l 1372 774 q 1361 756 1367 762 q 1335 749 1355 750 q 1239 745 1293 748 q 1116 739 1186 742 l 1097 573 q 1165 576 1132 574 q 1225 578 1198 577 q 1272 580 1252 579 q 1300 581 1292 581 q 1312 576 1305 581 q 1319 561 1319 572 q 1313 539 1319 555 q 1296 499 1306 523 q 1273 447 1286 476 q 1246 387 1260 418 q 1239 373 1242 377 q 1228 366 1236 368 q 1207 364 1221 364 q 1171 363 1194 363 l 1076 361 l 1058 169 l 1379 169 l 1379 141 q 1368 122 1378 140 q 1343 82 1358 105 q 1315 36 1329 60 q 1290 -1 1300 13 q 1274 -21 1283 -14 q 1255 -31 1265 -28 q 1232 -35 1244 -35 q 1204 -36 1219 -36 q 1095 -33 1152 -36 q 982 -26 1038 -31 q 873 -17 926 -22 q 775 -6 821 -12 q 767 -1 770 -5 q 763 10 763 4 q 764 16 763 11 l 772 56 q 651 -12 715 11 q 517 -35 587 -35 m 385 463 q 396 359 385 411 q 433 267 408 308 q 500 199 458 225 q 602 174 541 174 q 677 193 645 177 q 734 235 710 210 q 773 294 758 261 q 799 363 789 327 q 811 438 808 400 q 815 513 815 476 q 802 614 815 565 q 763 700 789 663 q 696 761 736 738 q 603 783 656 783 q 529 768 562 783 q 473 726 497 752 q 432 667 449 701 q 405 598 415 634 q 390 527 395 562 q 385 463 385 492 z " + }, + "œ": { + "ha": 1354, + "x_min": 69, + "x_max": 1358, + "o": "m 395 -39 q 254 -7 315 -39 q 152 77 193 24 q 90 198 111 130 q 69 338 69 266 q 105 507 69 431 q 201 637 140 583 q 341 721 261 691 q 511 750 422 750 q 648 720 585 750 q 758 636 710 689 q 889 730 815 696 q 1050 765 963 765 q 1167 742 1112 765 q 1265 679 1223 720 q 1333 581 1308 637 q 1358 457 1358 524 q 1344 358 1358 408 q 1335 325 1344 338 q 1304 305 1326 312 q 1247 295 1283 298 q 1159 292 1212 292 q 1023 288 1092 290 q 886 281 954 286 q 903 213 888 241 q 939 166 917 184 q 988 138 960 147 q 1044 130 1015 130 q 1109 137 1078 130 q 1169 155 1140 144 q 1224 181 1198 167 q 1274 208 1251 195 q 1276 209 1275 208 q 1277 209 1276 209 q 1284 195 1282 209 q 1288 161 1287 181 q 1290 120 1289 142 q 1290 82 1290 98 q 1289 46 1290 61 q 1284 27 1287 31 q 1234 -7 1265 9 q 1168 -36 1204 -24 q 1095 -55 1133 -47 q 1023 -62 1057 -62 q 930 -52 973 -62 q 851 -24 888 -43 q 782 20 814 -6 q 721 81 749 47 q 658 30 689 52 q 590 -8 627 7 q 506 -31 553 -23 q 395 -39 458 -39 m 330 346 q 338 273 330 309 q 364 208 346 237 q 411 161 382 179 q 484 143 440 143 q 533 157 512 144 q 570 190 555 170 q 596 235 586 210 q 612 287 606 260 q 620 339 618 313 q 623 386 623 365 q 613 455 623 421 q 585 515 604 489 q 538 556 566 541 q 475 572 511 572 q 406 548 434 572 q 362 489 378 524 q 338 415 345 454 q 330 346 330 376 m 1050 576 q 992 566 1019 576 q 944 536 966 555 q 907 490 923 517 q 880 430 890 463 l 1165 451 q 1165 456 1165 454 q 1166 462 1166 459 q 1155 512 1166 490 q 1127 548 1144 534 q 1090 569 1111 562 q 1050 576 1069 576 z " + }, + "Ş": { + "ha": 746, + "x_min": 106, + "x_max": 732, + "o": "m 107 252 q 107 254 107 253 q 106 255 106 254 q 119 267 106 267 q 132 260 125 267 q 214 205 169 226 q 311 185 258 185 q 344 188 326 185 q 378 198 362 191 q 406 217 395 205 q 417 246 417 229 q 408 281 417 266 q 382 308 398 297 q 364 321 376 312 q 336 343 352 331 q 304 369 321 355 q 272 396 287 383 q 244 418 256 408 q 226 434 232 429 q 162 494 186 464 q 127 558 139 525 q 111 627 115 591 q 108 703 108 663 q 136 816 108 762 q 217 909 165 869 q 341 972 269 949 q 503 996 414 996 q 535 993 513 996 q 583 985 557 991 q 636 970 609 980 q 686 946 663 961 q 697 935 693 941 q 703 918 702 930 q 709 810 707 863 q 714 697 711 757 q 709 682 714 686 q 697 678 705 678 q 686 682 690 678 q 645 721 666 702 q 601 754 624 740 q 552 778 578 769 q 495 787 526 787 q 458 784 477 787 q 425 772 440 781 q 401 750 410 764 q 392 714 392 736 q 398 674 392 691 q 427 642 404 657 l 589 532 q 662 468 634 500 q 705 406 690 437 q 727 344 721 374 q 732 285 732 314 q 717 195 732 237 q 674 118 701 153 q 611 54 647 82 q 533 6 574 26 q 447 -25 492 -14 q 360 -35 403 -35 q 295 -34 327 -35 q 232 -29 262 -33 q 173 -15 201 -24 q 119 10 144 -6 l 107 252 m 562 -262 q 539 -320 556 -294 q 498 -365 522 -346 q 445 -394 475 -383 q 385 -404 416 -404 q 325 -400 356 -404 q 269 -382 293 -396 l 271 -383 q 270 -378 270 -382 q 271 -357 270 -370 q 274 -330 272 -345 q 278 -302 275 -315 q 283 -279 280 -288 l 286 -279 q 290 -281 290 -279 l 290 -281 q 310 -289 300 -286 q 330 -294 319 -292 q 353 -296 340 -296 q 382 -297 366 -297 q 427 -280 411 -297 q 442 -241 442 -263 q 430 -208 442 -221 q 395 -195 417 -195 q 332 -192 358 -195 q 295 -189 305 -189 q 289 -186 290 -189 q 288 -180 288 -184 q 295 -159 291 -174 q 304 -126 300 -144 q 314 -89 309 -109 q 322 -53 318 -69 q 328 -25 326 -36 q 330 -12 330 -14 l 343 -12 q 373 -12 355 -12 q 409 -12 391 -12 l 452 -12 l 419 -108 q 440 -105 431 -106 q 459 -104 450 -104 q 514 -116 494 -104 q 546 -144 535 -127 q 561 -184 557 -162 q 565 -230 565 -207 q 564 -246 565 -238 q 562 -262 564 -254 z " + }, + "ş": { + "ha": 587, + "x_min": 85, + "x_max": 570, + "o": "m 86 187 q 86 188 86 188 q 85 189 85 189 q 95 198 85 198 q 106 193 101 198 q 168 151 133 168 q 243 135 203 135 q 269 137 254 135 q 296 145 283 140 q 317 160 309 151 q 326 182 326 169 q 318 210 326 197 q 298 231 311 222 q 275 247 292 235 q 238 273 258 259 q 202 298 219 286 q 178 316 184 311 q 129 363 147 339 q 102 412 111 387 q 90 465 92 437 q 87 524 87 493 q 112 614 87 572 q 178 688 137 656 q 271 738 219 720 q 378 757 323 757 q 466 743 422 757 q 547 697 509 730 q 551 613 550 654 q 555 526 553 572 q 551 514 555 517 q 542 511 547 511 q 534 515 537 511 q 502 544 518 530 q 468 570 486 559 q 429 588 450 581 q 386 595 409 595 q 357 593 372 595 q 332 584 343 591 q 313 567 320 578 q 306 539 306 556 q 311 505 306 519 q 333 477 315 491 l 459 392 q 515 346 494 368 q 549 300 536 323 q 565 256 561 278 q 570 212 570 234 q 542 105 570 151 q 469 27 513 58 q 367 -19 424 -3 q 254 -35 311 -35 q 170 -26 210 -35 q 95 2 130 -17 l 86 187 m 471 -262 q 448 -320 465 -294 q 407 -365 431 -346 q 354 -394 383 -383 q 293 -404 324 -404 q 233 -400 264 -404 q 178 -382 201 -396 l 179 -383 q 178 -378 178 -382 q 179 -357 178 -370 q 182 -330 180 -345 q 186 -302 184 -315 q 191 -279 189 -288 l 195 -279 q 199 -281 199 -279 l 198 -281 q 218 -289 209 -286 q 238 -294 228 -292 q 261 -296 249 -296 q 291 -297 274 -297 q 335 -280 319 -297 q 351 -241 351 -263 q 338 -208 351 -221 q 304 -195 326 -195 q 240 -192 267 -195 q 203 -189 214 -189 q 197 -186 198 -189 q 196 -180 196 -184 q 204 -159 199 -174 q 213 -126 208 -144 q 222 -89 218 -109 q 230 -53 227 -69 q 236 -25 234 -36 q 239 -12 239 -14 l 252 -12 q 281 -12 263 -12 q 317 -12 300 -12 l 360 -12 l 328 -108 q 349 -105 339 -106 q 368 -104 359 -104 q 423 -116 402 -104 q 455 -144 444 -127 q 470 -184 466 -162 q 473 -230 473 -207 q 473 -246 473 -238 q 471 -262 472 -254 z " + }, + "Š": { + "ha": 745, + "x_min": 92, + "x_max": 743, + "o": "m 107 252 q 107 254 107 253 q 106 255 106 254 q 119 267 106 267 q 132 260 125 267 q 214 205 169 226 q 311 185 258 185 q 344 188 326 185 q 378 198 362 191 q 406 217 395 205 q 417 246 417 229 q 408 281 417 266 q 382 308 398 297 q 364 321 376 312 q 336 343 352 331 q 304 369 321 355 q 272 396 287 383 q 244 418 256 408 q 226 434 232 429 q 162 494 186 464 q 127 558 139 525 q 111 627 115 591 q 108 703 108 663 q 136 816 108 762 q 217 909 165 869 q 341 972 269 949 q 503 996 414 996 q 535 993 513 996 q 583 985 557 991 q 636 970 609 980 q 686 946 663 961 q 697 935 693 941 q 703 918 702 930 q 709 810 707 863 q 714 697 711 757 q 709 682 714 686 q 697 678 705 678 q 686 682 690 678 q 645 721 666 702 q 601 754 624 740 q 552 778 578 769 q 495 787 526 787 q 458 784 477 787 q 425 772 440 781 q 401 750 410 764 q 392 714 392 736 q 398 674 392 691 q 427 642 404 657 l 589 532 q 662 468 634 500 q 705 406 690 437 q 727 344 721 374 q 732 285 732 314 q 717 195 732 237 q 674 118 701 153 q 611 54 647 82 q 533 6 574 26 q 447 -25 492 -14 q 360 -35 403 -35 q 295 -34 327 -35 q 232 -29 262 -33 q 173 -15 201 -24 q 119 10 144 -6 l 107 252 m 222 1194 q 186 1241 200 1223 q 161 1277 173 1260 q 137 1312 150 1293 q 104 1362 125 1331 q 95 1379 98 1371 q 92 1392 92 1386 q 114 1414 92 1401 q 166 1440 136 1427 q 226 1465 196 1453 q 273 1485 256 1476 q 280 1487 277 1487 q 290 1482 286 1487 q 301 1466 294 1477 l 400 1289 l 564 1482 q 570 1486 566 1486 q 582 1483 574 1485 q 605 1472 589 1480 q 671 1439 640 1453 q 736 1408 702 1426 q 743 1398 743 1405 q 737 1383 743 1392 q 718 1362 732 1374 q 590 1219 653 1290 q 465 1078 526 1147 q 450 1068 458 1070 q 437 1067 443 1067 l 427 1067 l 322 1067 q 310 1068 316 1067 q 300 1078 304 1070 q 291 1092 300 1079 q 271 1122 283 1104 q 246 1159 259 1139 q 222 1194 233 1179 z " + }, + "š": { + "ha": 585, + "x_min": 1, + "x_max": 652, + "o": "m 85 187 q 85 188 85 188 q 85 189 85 189 q 94 198 85 198 q 105 193 100 198 q 167 151 132 168 q 243 135 202 135 q 268 137 254 135 q 295 145 282 140 q 316 160 308 151 q 325 182 325 169 q 317 210 325 197 q 298 231 310 222 q 274 247 291 235 q 238 273 257 259 q 201 298 218 286 q 177 316 184 311 q 129 363 146 339 q 101 412 111 387 q 89 465 92 437 q 86 524 86 493 q 111 614 86 572 q 177 688 136 656 q 271 738 218 720 q 377 757 323 757 q 465 743 422 757 q 546 697 509 730 q 551 613 549 654 q 555 526 552 572 q 551 514 555 517 q 541 511 547 511 q 533 515 536 511 q 501 544 517 530 q 467 570 485 559 q 429 588 449 581 q 385 595 408 595 q 357 593 371 595 q 331 584 342 591 q 312 567 319 578 q 305 539 305 556 q 310 505 305 519 q 332 477 315 491 l 458 392 q 514 346 493 368 q 548 300 536 323 q 565 256 560 278 q 569 212 569 234 q 541 105 569 151 q 468 27 513 58 q 367 -19 423 -3 q 253 -35 310 -35 q 169 -26 210 -35 q 94 2 129 -17 l 85 187 m 132 968 q 96 1015 109 996 q 71 1050 82 1034 q 46 1086 59 1067 q 14 1135 34 1105 q 4 1152 7 1145 q 1 1165 1 1160 q 23 1187 1 1175 q 75 1213 45 1200 q 135 1238 105 1226 q 182 1258 165 1250 q 189 1260 186 1260 q 199 1255 195 1260 q 210 1239 203 1251 l 309 1063 l 473 1256 q 479 1259 475 1259 q 491 1256 483 1259 q 514 1245 498 1254 q 580 1213 549 1226 q 645 1181 611 1199 q 652 1171 652 1179 q 646 1156 652 1165 q 627 1135 641 1147 q 499 992 562 1063 q 374 851 435 921 q 359 842 367 844 q 346 840 352 840 l 336 840 l 231 840 q 219 842 225 840 q 210 851 214 844 q 200 865 209 852 q 180 895 192 878 q 155 932 168 913 q 132 968 142 952 z " + }, + "Ÿ": { + "ha": 756, + "x_min": 73, + "x_max": 861, + "o": "m 81 903 q 95 910 85 906 q 118 920 104 915 q 146 932 131 926 q 177 945 161 939 l 256 976 q 272 980 264 980 q 284 977 277 980 q 298 966 290 974 l 455 662 l 459 672 l 526 796 q 552 842 539 819 q 574 882 564 864 q 591 910 584 899 q 599 921 597 921 l 831 979 q 845 981 840 981 q 861 967 861 981 q 859 958 860 964 q 855 945 859 952 l 656 576 q 626 520 641 548 q 596 468 610 492 q 571 422 582 443 q 553 389 559 402 l 548 367 l 510 113 q 507 94 508 101 q 503 81 505 86 q 497 73 500 77 q 486 66 493 70 l 294 -35 q 271 -43 283 -40 q 248 -47 259 -47 q 229 -42 237 -47 q 220 -25 220 -37 l 220 -22 q 230 67 223 16 q 245 168 237 119 l 283 419 q 255 480 273 442 q 217 562 237 518 q 175 653 197 606 q 133 742 153 700 q 99 815 114 783 q 76 862 83 847 q 73 883 73 872 q 74 895 73 889 q 81 903 76 901 m 102 1059 q 105 1115 104 1098 q 106 1140 106 1133 q 106 1150 106 1147 q 107 1162 106 1153 q 108 1193 107 1172 q 111 1258 109 1214 q 116 1286 113 1278 q 130 1295 119 1294 q 242 1296 182 1295 q 352 1297 301 1297 q 361 1293 359 1297 q 364 1279 364 1289 l 347 1090 q 342 1063 345 1071 q 330 1054 340 1055 q 223 1053 278 1053 q 109 1053 169 1053 q 104 1053 106 1053 q 102 1057 102 1054 l 102 1059 m 527 1058 q 530 1121 529 1101 q 531 1151 531 1142 q 531 1162 532 1160 q 531 1173 531 1165 q 533 1199 532 1181 q 536 1257 534 1217 q 541 1285 538 1277 q 553 1294 543 1293 q 666 1294 607 1294 q 776 1295 725 1295 q 785 1293 781 1295 q 789 1282 789 1290 l 772 1090 q 767 1062 770 1069 q 753 1053 765 1055 q 646 1052 701 1053 q 530 1051 591 1051 q 528 1052 528 1051 q 527 1055 527 1052 l 527 1058 z " + }, + "Ž": { + "ha": 847, + "x_min": 70, + "x_max": 854, + "o": "m 139 0 q 123 2 131 0 q 108 8 115 3 q 97 20 101 12 q 92 41 92 28 q 93 43 92 42 q 94 46 94 45 l 447 666 q 432 670 447 668 q 395 672 418 671 q 345 674 372 673 q 293 676 318 675 q 249 676 269 676 q 223 677 230 677 q 155 676 186 677 q 88 675 123 675 q 75 680 81 675 q 70 692 70 685 q 71 693 71 692 q 71 697 71 695 l 159 907 q 177 931 165 922 q 206 945 190 940 q 240 952 222 950 q 276 954 258 954 q 422 950 350 954 q 565 944 494 947 q 708 938 637 941 q 853 935 780 935 l 853 907 l 523 290 q 523 288 523 288 q 522 286 522 288 q 529 278 522 279 q 541 277 536 277 l 675 277 q 754 282 716 277 q 833 287 792 287 q 848 282 841 287 q 854 269 854 277 q 853 263 854 270 l 744 46 q 724 20 739 30 q 689 6 709 11 q 643 0 668 1 q 591 -1 618 -1 q 375 -1 485 -1 q 139 0 266 0 m 261 1194 q 225 1241 239 1223 q 200 1277 212 1260 q 176 1312 189 1293 q 143 1362 163 1331 q 134 1379 136 1371 q 131 1392 131 1386 q 153 1414 131 1401 q 205 1440 175 1427 q 265 1465 235 1453 q 311 1485 295 1476 q 319 1487 315 1487 q 329 1482 324 1487 q 340 1466 333 1477 l 439 1289 l 602 1482 q 608 1486 604 1486 q 620 1483 612 1485 q 644 1472 628 1480 q 709 1439 678 1453 q 774 1408 741 1426 q 781 1398 781 1405 q 776 1383 781 1392 q 757 1362 770 1374 q 628 1219 692 1290 q 503 1078 565 1147 q 489 1068 496 1070 q 475 1067 481 1067 l 466 1067 l 361 1067 q 349 1068 355 1067 q 339 1078 343 1070 q 330 1092 338 1079 q 310 1122 321 1104 q 284 1159 298 1139 q 261 1194 271 1179 z " + }, + "ž": { + "ha": 673, + "x_min": 38, + "x_max": 688, + "o": "m 173 0 q 144 1 159 0 q 116 6 129 3 q 95 16 104 10 q 87 33 87 22 q 87 35 87 33 q 88 37 88 36 l 328 528 q 264 527 299 528 q 193 525 229 526 q 125 524 157 524 q 71 523 93 523 q 60 527 64 523 q 57 537 57 532 l 57 540 l 125 703 q 146 727 131 722 q 176 732 161 732 q 410 728 295 732 q 644 724 525 724 q 659 720 651 724 q 667 708 667 715 q 665 703 667 707 l 450 234 q 454 232 450 233 q 464 231 458 231 q 475 230 469 230 q 485 230 481 230 q 568 232 528 230 q 648 234 608 234 q 661 231 656 234 q 667 217 667 227 q 665 216 667 216 l 580 37 q 558 17 574 24 q 520 6 543 9 q 470 1 498 2 q 414 0 443 0 l 173 0 m 168 968 q 132 1015 146 996 q 107 1050 119 1034 q 83 1086 96 1067 q 50 1135 71 1105 q 41 1152 43 1145 q 38 1165 38 1160 q 60 1187 38 1175 q 112 1213 82 1200 q 172 1238 142 1226 q 218 1258 202 1250 q 226 1260 222 1260 q 236 1255 231 1260 q 247 1239 240 1251 l 346 1063 l 509 1256 q 515 1259 511 1259 q 527 1256 519 1259 q 551 1245 535 1254 q 616 1213 585 1226 q 682 1181 648 1199 q 688 1171 688 1179 q 683 1156 688 1165 q 664 1135 677 1147 q 535 992 599 1063 q 410 851 472 921 q 396 842 404 844 q 382 840 389 840 l 373 840 l 268 840 q 256 842 262 840 q 246 851 250 844 q 237 865 245 852 q 217 895 229 878 q 192 932 205 913 q 168 968 178 952 z " + }, + "ƒ": { + "ha": 623, + "x_min": -123, + "x_max": 785, + "o": "m 178 191 q 181 224 179 197 q 188 294 184 251 q 197 395 191 338 q 210 521 202 453 l 119 521 q 66 524 83 521 q 50 536 50 527 q 57 551 50 539 q 74 579 64 563 q 97 615 85 596 q 120 651 109 634 q 140 680 132 667 q 150 694 148 692 l 233 694 l 246 802 q 336 1037 264 960 q 546 1114 408 1114 q 582 1110 559 1114 q 631 1100 605 1106 q 685 1085 658 1093 q 735 1069 713 1077 q 771 1053 757 1061 q 785 1039 785 1045 q 781 1025 785 1034 q 749 958 760 984 q 732 917 739 932 q 722 897 726 902 q 710 891 718 891 q 702 893 707 890 q 690 898 698 895 q 643 914 667 907 q 597 921 618 921 q 544 902 566 921 q 509 830 522 883 q 497 764 502 800 q 489 694 493 729 l 666 694 q 676 691 671 694 q 682 678 682 687 q 673 654 682 675 q 650 599 665 633 l 626 544 q 616 529 621 534 q 595 523 611 524 l 478 523 q 453 311 465 420 q 425 81 441 202 q 392 -59 414 1 q 334 -157 370 -118 q 244 -216 297 -197 q 120 -235 192 -235 q 84 -231 107 -235 q 33 -221 60 -227 q -22 -207 6 -215 q -72 -190 -50 -198 q -109 -174 -95 -182 q -123 -159 -123 -165 q -119 -145 -123 -154 q -87 -79 -98 -105 q -71 -37 -77 -52 q -60 -15 -64 -21 q -49 -9 -56 -9 q -39 -15 -45 -14 q -26 -16 -34 -16 q 21 -32 -3 -25 q 68 -39 45 -39 q 110 -28 93 -39 q 138 2 127 -16 q 156 45 149 21 q 167 95 163 70 q 173 146 171 121 q 178 191 176 171 z " + }, + "Ȁ": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 613 1180 q 594 1227 602 1208 q 579 1264 586 1246 q 564 1300 572 1281 q 545 1348 556 1319 q 542 1359 543 1354 q 541 1369 541 1364 q 545 1384 541 1379 q 558 1394 549 1389 q 665 1452 609 1422 q 772 1518 721 1481 q 775 1519 772 1519 q 778 1520 778 1520 q 779 1521 779 1520 q 781 1521 780 1521 q 786 1516 786 1521 l 825 1119 q 825 1108 825 1113 q 825 1100 825 1103 q 823 1083 825 1088 q 811 1073 820 1078 q 783 1061 795 1067 q 757 1050 770 1056 q 730 1037 745 1044 q 698 1021 716 1030 q 690 1018 695 1018 q 677 1027 684 1018 q 663 1056 671 1036 q 643 1106 654 1075 q 613 1180 631 1137 m 274 1180 q 248 1227 258 1208 q 228 1263 237 1246 q 207 1300 218 1280 q 181 1348 196 1319 q 176 1362 177 1355 q 174 1374 174 1369 q 178 1386 174 1381 q 188 1394 181 1390 q 240 1422 213 1408 q 296 1452 268 1437 q 351 1484 323 1468 q 404 1518 378 1500 q 413 1521 410 1521 q 418 1516 417 1521 l 494 1119 q 497 1104 496 1110 q 498 1093 498 1099 q 496 1082 498 1087 q 488 1073 494 1076 q 461 1061 473 1067 q 437 1050 450 1056 q 412 1037 425 1044 q 382 1021 399 1030 q 375 1018 380 1018 q 361 1027 369 1018 q 343 1056 354 1036 q 315 1106 332 1075 q 274 1180 298 1137 z " + }, + "ȁ": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 576 968 q 556 1015 564 996 q 542 1052 549 1034 q 527 1088 534 1069 q 507 1135 519 1107 q 505 1147 506 1142 q 504 1156 504 1152 q 508 1172 504 1166 q 521 1181 512 1177 q 628 1239 572 1210 q 734 1305 684 1269 q 738 1307 734 1307 q 741 1308 741 1308 q 742 1309 741 1308 q 743 1309 743 1309 q 749 1303 749 1309 l 787 907 q 788 896 787 901 q 788 888 788 891 q 785 871 788 876 q 774 861 783 866 q 745 849 758 854 q 720 838 732 844 q 693 825 707 832 q 661 808 679 818 q 652 806 658 806 q 640 815 646 806 q 626 843 634 824 q 605 893 617 863 q 576 968 593 924 m 237 968 q 211 1015 221 996 q 190 1051 200 1034 q 170 1087 180 1068 q 144 1135 159 1107 q 138 1150 140 1143 q 137 1162 137 1157 q 140 1174 137 1169 q 151 1181 144 1178 q 203 1210 176 1196 q 258 1240 231 1225 q 314 1272 286 1255 q 366 1305 341 1288 q 376 1309 373 1309 q 381 1303 380 1309 l 457 907 q 460 892 458 898 q 461 881 461 886 q 459 869 461 875 q 450 861 457 864 q 424 849 436 854 q 400 838 412 844 q 375 825 388 832 q 345 808 361 818 q 338 806 342 806 q 324 815 332 806 q 306 843 317 824 q 278 893 294 863 q 237 968 261 924 z " + }, + "Ȃ": { + "ha": 940, + "x_min": 29, + "x_max": 965, + "o": "m 45 50 q 64 89 54 67 q 81 137 73 111 l 100 182 q 75 221 87 200 q 52 265 62 243 q 36 310 42 288 q 29 353 29 332 q 40 356 29 355 q 66 357 50 357 q 101 358 82 358 q 135 359 119 359 q 162 360 151 359 q 172 360 172 360 q 203 437 188 401 q 233 511 218 474 q 262 588 248 549 q 292 671 277 627 q 348 819 321 755 q 399 938 375 883 q 408 950 401 945 q 422 960 414 956 q 436 967 429 964 q 448 971 444 970 q 468 977 450 972 q 509 989 486 983 q 555 1000 532 995 q 591 1004 578 1004 l 604 1004 q 624 999 614 1004 q 637 979 634 994 q 707 797 670 896 q 779 580 744 699 q 817 459 798 516 q 853 348 835 402 q 890 239 871 294 q 928 127 909 185 q 941 89 935 103 q 953 66 947 75 q 962 55 958 58 q 965 47 965 51 q 952 39 965 43 l 806 -9 q 773 -21 790 -15 q 741 -32 756 -27 q 715 -40 726 -37 q 699 -43 704 -43 l 695 -43 q 682 -37 686 -43 q 676 -26 677 -31 q 663 15 669 -3 q 649 51 656 33 q 636 89 643 68 q 624 137 630 109 q 616 143 623 140 q 602 146 610 145 q 584 148 593 148 q 566 149 574 149 q 525 148 541 149 q 496 148 509 148 q 473 147 484 147 q 446 146 462 146 q 405 147 426 146 q 368 148 385 147 l 329 149 q 294 58 311 100 q 256 -31 276 15 q 249 -39 254 -36 q 239 -43 244 -43 q 234 -42 235 -43 q 123 5 168 -18 q 45 50 79 28 m 503 659 q 470 550 486 599 q 441 463 454 500 q 418 398 428 425 q 405 358 409 372 l 405 352 q 404 347 405 348 l 567 347 q 551 414 559 380 q 534 485 543 448 q 518 565 526 522 q 503 659 510 608 m 564 1046 q 565 1054 564 1049 q 566 1064 566 1059 q 549 1099 566 1082 q 496 1116 531 1116 q 459 1109 474 1116 q 436 1092 445 1102 q 424 1070 427 1082 q 420 1048 420 1059 q 421 1047 420 1048 q 421 1046 421 1047 q 419 1040 420 1043 q 412 1035 417 1037 q 397 1032 407 1033 q 371 1031 388 1031 q 335 1033 348 1031 q 316 1038 322 1034 q 308 1048 309 1042 q 307 1062 307 1054 q 322 1129 307 1100 q 367 1179 338 1159 q 432 1211 395 1200 q 513 1221 470 1221 q 592 1207 559 1221 q 646 1168 625 1192 q 676 1114 667 1144 q 686 1053 686 1084 q 685 1041 686 1046 q 676 1033 683 1036 q 656 1029 669 1030 q 621 1027 644 1027 q 591 1028 602 1027 q 573 1031 580 1029 q 565 1036 567 1033 q 563 1042 563 1038 q 563 1044 563 1043 q 564 1046 564 1044 z " + }, + "ȃ": { + "ha": 846, + "x_min": 71, + "x_max": 844, + "o": "m 774 136 q 773 131 773 134 q 773 127 773 129 q 779 99 773 110 q 790 80 784 87 q 802 69 797 73 q 808 61 808 64 q 787 43 808 52 l 605 -35 q 600 -35 604 -35 q 589 -26 595 -35 q 579 -2 584 -16 q 569 30 574 13 q 562 60 565 46 q 469 -12 519 11 q 371 -35 420 -35 q 260 -10 314 -35 q 163 61 205 15 q 96 174 121 108 q 71 321 71 239 q 99 498 71 418 q 175 636 127 578 q 287 725 223 694 q 423 757 351 757 q 519 736 474 757 q 606 683 565 715 q 610 715 610 703 q 613 734 611 728 q 623 744 616 741 q 645 747 629 746 q 742 760 694 754 q 844 768 791 766 l 774 136 m 571 392 q 580 465 578 431 q 584 528 583 499 q 561 545 573 536 q 533 561 548 553 q 503 574 519 569 q 469 578 487 578 q 408 555 433 578 q 368 496 383 531 q 346 423 353 462 q 340 355 340 385 q 368 198 340 248 q 446 148 396 148 q 492 159 467 148 q 543 191 517 171 l 571 392 m 526 833 q 527 842 526 837 q 528 852 528 846 q 511 887 528 870 q 458 903 493 903 q 421 897 436 903 q 398 880 407 890 q 386 858 389 869 q 382 836 382 846 q 383 835 382 836 q 383 834 383 835 q 381 828 382 831 q 374 823 379 825 q 359 820 369 821 q 333 819 350 819 q 297 820 310 819 q 278 826 284 822 q 270 836 271 830 q 269 850 269 842 q 284 917 269 888 q 329 967 300 947 q 394 998 357 987 q 475 1009 432 1009 q 554 995 521 1009 q 608 956 587 980 q 638 902 629 932 q 648 840 648 871 q 647 829 648 833 q 638 821 645 824 q 618 817 631 818 q 583 815 606 815 q 553 816 564 815 q 535 819 542 817 q 527 823 529 821 q 525 829 525 826 q 525 831 525 831 q 526 833 526 832 z " + }, + "Ȅ": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 m 589 1180 q 570 1227 578 1208 q 555 1264 562 1246 q 540 1300 548 1281 q 521 1348 532 1319 q 518 1359 519 1354 q 517 1369 517 1364 q 522 1384 517 1379 q 534 1394 526 1389 q 641 1452 585 1422 q 748 1518 697 1481 q 751 1519 748 1519 q 754 1520 754 1520 q 755 1521 755 1520 q 757 1521 756 1521 q 762 1516 762 1521 l 801 1119 q 801 1108 801 1113 q 802 1100 802 1103 q 799 1083 802 1088 q 787 1073 796 1078 q 759 1061 772 1067 q 733 1050 746 1056 q 707 1037 721 1044 q 674 1021 692 1030 q 666 1018 671 1018 q 654 1027 660 1018 q 639 1056 648 1036 q 619 1106 631 1075 q 589 1180 607 1137 m 250 1180 q 224 1227 235 1208 q 204 1263 214 1246 q 183 1300 194 1280 q 157 1348 172 1319 q 152 1362 153 1355 q 151 1374 151 1369 q 154 1386 151 1381 q 164 1394 157 1390 q 217 1422 189 1408 q 272 1452 244 1437 q 327 1484 300 1468 q 380 1518 355 1500 q 389 1521 387 1521 q 395 1516 393 1521 l 471 1119 q 473 1104 472 1110 q 475 1093 475 1099 q 473 1082 475 1087 q 464 1073 471 1076 q 438 1061 450 1067 q 414 1050 426 1056 q 388 1037 401 1044 q 359 1021 375 1030 q 351 1018 356 1018 q 338 1027 345 1018 q 319 1056 330 1036 q 291 1106 308 1075 q 250 1180 275 1137 z " + }, + "ȅ": { + "ha": 774, + "x_min": 68, + "x_max": 771, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 m 536 968 q 517 1015 525 996 q 502 1052 509 1034 q 487 1088 495 1069 q 468 1135 479 1107 q 466 1147 467 1142 q 465 1156 465 1152 q 469 1172 465 1166 q 481 1181 473 1177 q 588 1239 532 1210 q 695 1305 644 1269 q 698 1307 695 1307 q 701 1308 701 1308 q 703 1309 702 1308 q 704 1309 703 1309 q 709 1303 709 1309 l 748 907 q 748 896 748 901 q 749 888 749 891 q 746 871 749 876 q 734 861 743 866 q 706 849 719 854 q 681 838 693 844 q 654 825 668 832 q 621 808 640 818 q 613 806 618 806 q 601 815 607 806 q 586 843 595 824 q 566 893 578 863 q 536 968 554 924 m 197 968 q 171 1015 182 996 q 151 1051 161 1034 q 130 1087 141 1068 q 104 1135 119 1107 q 99 1150 100 1143 q 98 1162 98 1157 q 101 1174 98 1169 q 111 1181 104 1178 q 164 1210 136 1196 q 219 1240 191 1225 q 274 1272 247 1255 q 327 1305 302 1288 q 336 1309 334 1309 q 342 1303 340 1309 l 418 907 q 420 892 419 898 q 422 881 422 886 q 420 869 422 875 q 411 861 418 864 q 385 849 397 854 q 361 838 373 844 q 335 825 349 832 q 306 808 322 818 q 298 806 303 806 q 285 815 292 806 q 266 843 277 824 q 238 893 255 863 q 197 968 222 924 z " + }, + "Ȇ": { + "ha": 799, + "x_min": 92, + "x_max": 856, + "o": "m 694 563 q 684 536 691 553 q 666 497 676 519 q 645 451 656 475 q 623 401 634 426 q 616 387 619 391 q 605 380 612 382 q 584 378 597 378 q 547 377 571 378 l 453 375 l 434 168 l 782 168 l 782 141 q 770 123 781 140 q 747 83 760 106 q 718 37 733 60 q 693 -1 703 14 q 677 -20 686 -13 q 658 -31 669 -27 q 635 -35 648 -34 q 608 -35 623 -35 q 480 -33 547 -35 q 349 -26 414 -30 q 220 -17 283 -22 q 104 -6 157 -12 q 95 -1 99 -5 q 92 10 92 4 q 95 22 89 10 q 113 52 102 33 q 139 93 125 70 q 165 140 153 117 l 176 293 q 181 351 179 332 q 182 371 182 370 q 122 375 142 372 q 102 390 102 378 q 112 410 102 395 q 136 445 121 425 q 166 485 150 464 q 194 524 182 506 l 211 754 l 209 754 q 182 753 194 753 q 159 753 170 753 q 113 756 127 753 q 99 770 99 760 q 107 790 99 775 q 127 824 115 805 q 153 865 140 844 q 178 905 167 886 l 218 974 l 502 974 l 836 998 q 840 998 837 998 q 846 996 843 998 q 853 990 850 994 q 856 979 856 986 l 846 943 q 834 912 841 928 q 819 878 827 895 l 789 799 q 779 781 785 787 q 753 774 772 775 q 638 770 701 773 q 492 764 574 767 q 490 749 490 755 q 488 727 490 743 l 485 696 q 481 649 484 674 q 474 587 478 624 q 542 590 509 589 q 602 592 574 591 q 649 594 629 593 q 677 595 669 595 q 689 591 682 595 q 696 575 696 587 q 694 563 696 570 m 539 1046 q 541 1054 540 1049 q 542 1064 542 1059 q 524 1099 542 1082 q 471 1116 507 1116 q 435 1109 450 1116 q 412 1092 420 1102 q 399 1070 403 1082 q 396 1048 396 1059 q 396 1047 396 1048 q 397 1046 397 1047 q 394 1040 396 1043 q 388 1035 393 1037 q 373 1032 382 1033 q 347 1031 363 1031 q 311 1033 323 1031 q 291 1038 298 1034 q 283 1048 285 1042 q 282 1062 282 1054 q 298 1129 282 1100 q 342 1179 314 1159 q 408 1211 370 1200 q 488 1221 446 1221 q 567 1207 534 1221 q 621 1168 600 1192 q 652 1114 642 1144 q 662 1053 662 1084 q 660 1041 662 1046 q 652 1033 659 1036 q 632 1029 645 1030 q 596 1027 619 1027 q 567 1028 578 1027 q 549 1031 555 1029 q 541 1036 543 1033 q 538 1042 538 1038 q 539 1044 538 1043 q 539 1046 539 1044 z " + }, + "ȇ": { + "ha": 774, + "x_min": 68, + "x_max": 771, + "o": "m 486 757 q 603 733 550 757 q 693 670 655 709 q 751 585 730 632 q 771 490 771 537 q 745 405 771 448 q 673 329 720 363 q 562 274 627 295 q 418 253 496 253 q 333 261 378 253 q 350 211 336 232 q 386 176 364 190 q 435 156 408 163 q 492 150 463 150 q 554 156 524 150 q 612 171 585 161 q 666 192 640 180 q 715 215 692 203 q 725 217 721 216 q 734 218 730 218 q 733 208 734 218 q 733 177 733 197 q 733 130 733 157 q 732 68 732 102 q 734 61 734 64 q 735 56 735 58 q 720 38 735 47 q 681 18 705 28 q 626 -1 657 8 q 564 -19 595 -11 q 502 -31 532 -26 q 450 -35 472 -35 q 308 -10 376 -35 q 185 63 239 16 q 100 178 132 111 q 68 331 68 246 q 99 497 68 420 q 185 633 130 575 q 317 724 240 690 q 486 757 393 757 m 477 576 q 426 565 450 576 q 384 531 403 553 q 350 478 364 509 q 327 410 336 447 q 443 435 397 422 q 517 463 489 449 q 556 491 545 477 q 568 517 568 505 q 558 541 568 530 q 536 559 549 551 q 506 572 522 568 q 477 576 490 576 m 486 833 q 488 842 487 837 q 489 852 489 846 q 471 887 489 870 q 418 903 454 903 q 382 897 397 903 q 359 880 368 890 q 347 858 350 869 q 343 836 343 846 q 343 835 343 836 q 344 834 344 835 q 341 828 343 831 q 335 823 340 825 q 320 820 330 821 q 294 819 311 819 q 258 820 271 819 q 238 826 245 822 q 231 836 232 830 q 229 850 229 842 q 245 917 229 888 q 289 967 261 947 q 355 998 317 987 q 435 1009 393 1009 q 514 995 481 1009 q 568 956 547 980 q 599 902 589 932 q 609 840 609 871 q 607 829 609 833 q 599 821 606 824 q 579 817 592 818 q 543 815 566 815 q 514 816 525 815 q 496 819 503 817 q 488 823 490 821 q 486 829 486 826 q 486 831 486 831 q 486 833 486 832 z " + }, + "Ȉ": { + "ha": 452, + "x_min": -54, + "x_max": 597, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m 385 1180 q 366 1227 374 1208 q 351 1264 358 1246 q 336 1300 344 1281 q 317 1348 328 1319 q 314 1359 315 1354 q 313 1369 313 1364 q 317 1384 313 1379 q 330 1394 321 1389 q 437 1452 381 1422 q 544 1518 493 1481 q 547 1519 544 1519 q 550 1520 550 1520 q 551 1521 551 1520 q 553 1521 552 1521 q 558 1516 558 1521 l 597 1119 q 597 1108 597 1113 q 597 1100 597 1103 q 595 1083 597 1088 q 583 1073 592 1078 q 555 1061 568 1067 q 529 1050 542 1056 q 503 1037 517 1044 q 470 1021 488 1030 q 462 1018 467 1018 q 450 1027 456 1018 q 435 1056 444 1036 q 415 1106 427 1075 q 385 1180 403 1137 m 46 1180 q 20 1227 31 1208 q 0 1263 9 1246 q -21 1300 -10 1280 q -47 1348 -32 1319 q -52 1362 -51 1355 q -54 1374 -54 1369 q -50 1386 -54 1381 q -40 1394 -47 1390 q 13 1422 -15 1408 q 68 1452 40 1437 q 123 1484 96 1468 q 176 1518 151 1500 q 185 1521 182 1521 q 191 1516 189 1521 l 267 1119 q 269 1104 268 1110 q 271 1093 271 1099 q 269 1082 271 1087 q 260 1073 267 1076 q 234 1061 245 1067 q 210 1050 222 1056 q 184 1037 197 1044 q 155 1021 171 1030 q 147 1018 152 1018 q 134 1027 141 1018 q 115 1056 126 1036 q 87 1106 104 1075 q 46 1180 71 1137 z " + }, + "ȉ": { + "ha": 415, + "x_min": -66, + "x_max": 585, + "o": "m 99 288 q 117 471 109 380 q 132 658 125 562 q 137 689 134 680 q 152 700 140 697 q 273 724 210 711 q 395 756 336 736 q 401 757 397 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 328 38 340 41 q 211 -2 270 20 q 86 -45 152 -24 q 80 -46 84 -46 q 73 -39 73 -46 q 74 -20 73 -39 q 78 26 75 -2 q 83 91 80 55 q 89 163 85 127 q 94 232 92 199 q 99 288 97 264 m 372 968 q 353 1015 361 996 q 338 1052 345 1034 q 323 1088 331 1069 q 304 1135 315 1107 q 301 1147 302 1142 q 300 1156 300 1152 q 304 1172 300 1166 q 317 1181 309 1177 q 424 1239 368 1210 q 531 1305 480 1269 q 534 1307 531 1307 q 537 1308 537 1308 q 538 1309 538 1308 q 540 1309 539 1309 q 545 1303 545 1309 l 584 907 q 584 896 584 901 q 585 888 585 891 q 582 871 585 876 q 570 861 579 866 q 542 849 555 854 q 516 838 529 844 q 490 825 504 832 q 457 808 475 818 q 449 806 454 806 q 437 815 443 806 q 422 843 431 824 q 402 893 414 863 q 372 968 390 924 m 33 968 q 7 1015 18 996 q -13 1051 -3 1034 q -34 1087 -23 1068 q -60 1135 -45 1107 q -65 1150 -64 1143 q -66 1162 -66 1157 q -63 1174 -66 1169 q -53 1181 -60 1178 q 0 1210 -28 1196 q 55 1240 27 1225 q 110 1272 83 1255 q 163 1305 138 1288 q 172 1309 170 1309 q 178 1303 176 1309 l 254 907 q 256 892 255 898 q 258 881 258 886 q 256 869 258 875 q 247 861 254 864 q 221 849 233 854 q 197 838 209 844 q 171 825 184 832 q 142 808 158 818 q 134 806 139 806 q 121 815 128 806 q 102 843 113 824 q 74 893 91 863 q 33 968 58 924 z " + }, + "Ȋ": { + "ha": 452, + "x_min": 76, + "x_max": 456, + "o": "m 94 -22 l 128 442 q 153 785 145 675 q 161 893 161 894 q 163 913 162 905 q 165 927 163 922 q 171 935 168 932 q 181 939 174 938 l 416 996 q 434 998 425 998 q 447 992 444 998 q 450 969 450 987 q 443 890 446 927 q 438 821 440 854 q 435 756 437 787 q 432 695 434 726 q 425 629 428 662 q 419 560 422 595 q 413 485 416 524 q 406 398 410 445 l 376 41 q 374 29 376 35 q 365 20 372 23 q 357 17 364 20 q 337 10 349 14 q 309 1 325 5 q 274 -8 292 -4 q 221 -22 243 -15 q 180 -34 198 -28 q 148 -43 163 -40 q 118 -47 133 -47 q 101 -41 108 -47 q 94 -22 94 -36 m 333 1046 q 335 1054 334 1049 q 336 1064 336 1059 q 318 1099 336 1082 q 265 1116 300 1116 q 229 1109 243 1116 q 205 1092 214 1102 q 193 1070 197 1082 q 190 1048 190 1059 q 190 1047 190 1048 q 191 1046 191 1047 q 188 1040 190 1043 q 181 1035 186 1037 q 167 1032 176 1033 q 140 1031 157 1031 q 104 1033 117 1031 q 85 1038 92 1034 q 77 1048 79 1042 q 76 1062 76 1054 q 92 1129 76 1100 q 136 1179 108 1159 q 202 1211 164 1200 q 282 1221 239 1221 q 361 1207 328 1221 q 415 1168 394 1192 q 446 1114 436 1144 q 456 1053 456 1084 q 454 1041 456 1046 q 446 1033 452 1036 q 426 1029 439 1030 q 390 1027 413 1027 q 360 1028 372 1027 q 343 1031 349 1029 q 334 1036 336 1033 q 332 1042 332 1038 q 333 1044 332 1043 q 333 1046 333 1044 z " + }, + "ȋ": { + "ha": 414, + "x_min": 52, + "x_max": 432, + "o": "m 98 288 q 116 471 108 380 q 132 658 124 562 q 136 689 133 680 q 151 700 139 697 q 272 724 209 711 q 394 756 335 736 q 400 757 396 757 q 408 751 408 757 l 347 80 q 342 49 345 58 q 327 38 339 41 q 210 -2 269 20 q 85 -45 151 -24 q 79 -46 83 -46 q 72 -39 72 -46 q 73 -20 72 -39 q 77 26 75 -2 q 82 91 79 55 q 88 163 85 127 q 94 232 91 199 q 98 288 96 264 m 309 833 q 311 842 310 837 q 312 852 312 846 q 294 887 312 870 q 241 903 277 903 q 205 897 220 903 q 182 880 191 890 q 170 858 173 869 q 166 836 166 846 q 166 835 166 836 q 167 834 167 835 q 164 828 166 831 q 158 823 163 825 q 143 820 153 821 q 117 819 134 819 q 78 821 91 819 q 59 828 64 823 q 53 839 54 832 q 52 857 52 846 q 69 921 52 893 q 115 969 86 949 q 180 999 143 989 q 258 1009 218 1009 q 337 995 304 1009 q 391 956 370 980 q 422 902 412 932 q 432 840 432 871 q 430 829 432 833 q 422 821 429 824 q 402 817 415 818 q 366 815 389 815 q 337 816 348 815 q 319 819 326 817 q 311 823 313 821 q 309 829 309 826 q 309 831 309 831 q 309 833 309 832 z " + }, + "Ȍ": { + "ha": 1103, + "x_min": 85, + "x_max": 1101, + "o": "m 384 463 q 394 365 384 414 q 429 276 405 315 q 494 212 454 237 q 593 188 534 188 q 672 207 638 191 q 731 248 707 222 q 772 306 756 273 q 798 375 789 339 q 811 450 808 412 q 815 523 815 488 q 802 616 815 571 q 764 696 789 661 q 700 751 738 730 q 610 772 661 772 q 538 758 570 772 q 481 718 506 743 q 438 661 456 693 q 408 594 420 629 q 390 526 396 560 q 384 463 385 492 m 516 -35 q 334 3 414 -35 q 199 105 254 41 q 114 255 144 170 q 85 435 85 340 q 115 632 85 542 q 199 791 145 722 q 329 908 254 861 q 493 979 404 956 q 506 992 501 979 q 513 1021 510 1005 q 522 1049 517 1036 q 535 1062 526 1062 q 772 1016 667 1048 q 950 922 877 983 q 1062 767 1023 861 q 1101 540 1101 674 q 1080 378 1101 454 q 1021 238 1059 302 q 930 123 983 174 q 812 37 877 73 q 672 -17 747 2 q 516 -35 597 -35 m 709 1180 q 689 1227 697 1208 q 674 1264 682 1246 q 660 1300 667 1281 q 640 1348 652 1319 q 638 1359 639 1354 q 637 1369 637 1364 q 641 1384 637 1379 q 654 1394 645 1389 q 761 1452 705 1422 q 867 1518 817 1481 q 870 1519 867 1519 q 873 1520 873 1520 q 875 1521 874 1520 q 876 1521 876 1521 q 882 1516 882 1521 l 920 1119 q 921 1108 920 1113 q 921 1100 921 1103 q 918 1083 921 1088 q 907 1073 916 1078 q 878 1061 891 1067 q 853 1050 865 1056 q 826 1037 840 1044 q 793 1021 812 1030 q 785 1018 791 1018 q 773 1027 779 1018 q 759 1056 767 1036 q 738 1106 750 1075 q 709 1180 726 1137 m 370 1180 q 343 1227 354 1208 q 323 1263 333 1246 q 302 1300 313 1280 q 277 1348 292 1319 q 271 1362 273 1355 q 270 1374 270 1369 q 273 1386 270 1381 q 283 1394 277 1390 q 336 1422 309 1408 q 391 1452 363 1437 q 447 1484 419 1468 q 499 1518 474 1500 q 509 1521 506 1521 q 514 1516 513 1521 l 590 1119 q 593 1104 591 1110 q 594 1093 594 1099 q 592 1082 594 1087 q 583 1073 590 1076 q 557 1061 569 1067 q 533 1050 545 1056 q 508 1037 521 1044 q 478 1021 494 1030 q 471 1018 475 1018 q 457 1027 465 1018 q 438 1056 450 1036 q 411 1106 427 1075 q 370 1180 394 1137 z " + }, + "ȍ": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 592 968 q 573 1015 581 996 q 558 1052 565 1034 q 543 1088 551 1069 q 524 1135 535 1107 q 521 1147 522 1142 q 520 1156 520 1152 q 524 1172 520 1166 q 537 1181 528 1177 q 644 1239 588 1210 q 751 1305 700 1269 q 754 1307 751 1307 q 757 1308 757 1308 q 758 1309 758 1308 q 760 1309 759 1309 q 765 1303 765 1309 l 804 907 q 804 896 804 901 q 804 888 804 891 q 802 871 804 876 q 790 861 799 866 q 762 849 774 854 q 736 838 749 844 q 709 825 724 832 q 677 808 695 818 q 669 806 674 806 q 656 815 663 806 q 642 843 650 824 q 622 893 633 863 q 592 968 610 924 m 253 968 q 227 1015 237 996 q 207 1051 216 1034 q 186 1087 197 1068 q 160 1135 175 1107 q 155 1150 156 1143 q 153 1162 153 1157 q 157 1174 153 1169 q 167 1181 160 1178 q 219 1210 192 1196 q 275 1240 247 1225 q 330 1272 302 1255 q 382 1305 357 1288 q 392 1309 389 1309 q 397 1303 396 1309 l 473 907 q 476 892 475 898 q 477 881 477 886 q 475 869 477 875 q 467 861 473 864 q 440 849 452 854 q 416 838 429 844 q 391 825 404 832 q 361 808 378 818 q 354 806 359 806 q 340 815 348 806 q 322 843 333 824 q 294 893 311 863 q 253 968 277 924 z " + }, + "Ȏ": { + "ha": 1103, + "x_min": 85, + "x_max": 1101, + "o": "m 384 463 q 394 365 384 414 q 429 276 405 315 q 494 212 454 237 q 593 188 534 188 q 672 207 638 191 q 731 248 707 222 q 772 306 756 273 q 798 375 789 339 q 811 450 808 412 q 815 523 815 488 q 802 616 815 571 q 764 696 789 661 q 700 751 738 730 q 610 772 661 772 q 538 758 570 772 q 481 718 506 743 q 438 661 456 693 q 408 594 420 629 q 390 526 396 560 q 384 463 385 492 m 516 -35 q 334 3 414 -35 q 199 105 254 41 q 114 255 144 170 q 85 435 85 340 q 115 632 85 542 q 199 791 145 722 q 329 908 254 861 q 493 979 404 956 q 506 992 501 979 q 513 1021 510 1005 q 522 1049 517 1036 q 535 1062 526 1062 q 772 1016 667 1048 q 950 922 877 983 q 1062 767 1023 861 q 1101 540 1101 674 q 1080 378 1101 454 q 1021 238 1059 302 q 930 123 983 174 q 812 37 877 73 q 672 -17 747 2 q 516 -35 597 -35 m 659 1046 q 660 1054 659 1049 q 661 1064 661 1059 q 644 1099 661 1082 q 591 1116 626 1116 q 554 1109 569 1116 q 531 1092 540 1102 q 519 1070 522 1082 q 515 1048 515 1059 q 516 1047 515 1048 q 516 1046 516 1047 q 514 1040 515 1043 q 507 1035 512 1037 q 492 1032 502 1033 q 466 1031 483 1031 q 430 1033 443 1031 q 411 1038 417 1034 q 403 1048 404 1042 q 401 1062 401 1054 q 417 1129 401 1100 q 461 1179 433 1159 q 527 1211 490 1200 q 608 1221 565 1221 q 687 1207 654 1221 q 741 1168 720 1192 q 771 1114 762 1144 q 781 1053 781 1084 q 780 1041 781 1046 q 771 1033 778 1036 q 751 1029 764 1030 q 715 1027 739 1027 q 686 1028 697 1027 q 668 1031 675 1029 q 660 1036 662 1033 q 658 1042 658 1038 q 658 1044 658 1043 q 659 1046 659 1044 z " + }, + "ȏ": { + "ha": 890, + "x_min": 68, + "x_max": 885, + "o": "m 454 -35 q 287 -4 359 -35 q 165 79 214 27 q 92 198 117 131 q 68 338 68 266 q 92 476 68 411 q 158 594 115 542 q 260 681 201 646 q 392 728 320 715 q 403 732 403 728 q 401 746 403 740 q 399 758 399 753 q 402 768 399 765 q 408 770 405 770 q 629 741 538 770 q 777 661 720 711 q 859 541 833 610 q 885 392 885 471 q 851 212 885 291 q 759 77 817 132 q 622 -6 700 22 q 454 -35 543 -35 m 329 346 q 337 273 329 309 q 362 208 345 237 q 410 161 380 179 q 482 143 439 143 q 537 157 513 144 q 577 190 560 170 q 605 235 594 210 q 623 287 616 260 q 632 339 629 313 q 635 386 635 365 q 624 455 635 421 q 593 515 614 489 q 543 556 573 541 q 473 572 513 572 q 405 548 433 572 q 360 489 377 524 q 336 415 344 454 q 329 346 329 376 m 542 833 q 544 842 543 837 q 545 852 545 846 q 527 887 545 870 q 474 903 509 903 q 438 897 452 903 q 414 880 423 890 q 402 858 406 869 q 399 836 399 846 q 399 835 399 836 q 399 834 399 835 q 397 828 399 831 q 390 823 395 825 q 376 820 385 821 q 349 819 366 819 q 313 820 326 819 q 294 826 300 822 q 286 836 288 830 q 285 850 285 842 q 301 917 285 888 q 345 967 317 947 q 411 998 373 987 q 491 1009 448 1009 q 570 995 537 1009 q 624 956 603 980 q 655 902 645 932 q 665 840 665 871 q 663 829 665 833 q 654 821 661 824 q 635 817 648 818 q 599 815 622 815 q 569 816 581 815 q 552 819 558 817 q 543 823 545 821 q 541 829 541 826 q 542 831 541 831 q 542 833 542 832 z " + }, + "Ȑ": { + "ha": 999, + "x_min": 83, + "x_max": 1026, + "o": "m 446 54 q 444 42 446 48 q 435 33 442 37 q 408 23 432 32 q 352 4 384 15 q 290 -19 321 -7 q 243 -36 259 -30 q 223 -43 235 -39 q 199 -47 211 -47 q 183 -42 190 -47 q 176 -24 176 -37 q 177 30 176 5 q 182 82 179 55 q 188 142 184 109 q 193 220 191 175 q 201 335 197 280 q 209 450 205 391 l 218 566 q 222 624 220 595 q 227 685 224 654 l 234 768 q 230 769 234 769 q 212 767 224 769 q 181 761 199 764 q 142 754 163 758 q 98 747 121 750 q 88 749 93 747 q 83 755 83 751 q 84 759 83 758 l 194 951 q 274 967 231 959 q 367 983 317 976 q 472 994 416 989 q 590 998 528 998 q 727 984 661 997 q 846 942 794 972 q 929 862 898 913 q 961 734 961 812 q 946 656 961 692 q 909 591 932 620 q 859 540 886 562 q 807 503 832 518 q 764 478 783 487 q 738 466 745 469 q 739 463 738 465 q 743 455 740 460 q 753 438 747 449 q 770 410 760 428 q 795 366 781 393 q 831 302 810 339 q 879 220 852 265 q 941 122 906 174 q 968 110 953 112 q 996 107 983 107 q 1017 105 1008 106 q 1026 97 1026 104 q 1015 84 1026 92 q 989 70 1004 77 q 959 56 974 62 q 935 45 943 49 q 901 29 919 37 q 863 13 884 22 q 818 -9 843 3 q 762 -38 793 -21 q 747 -44 754 -43 q 734 -46 740 -46 q 713 -40 722 -46 q 698 -26 704 -35 q 688 -10 692 -18 q 682 4 684 -1 l 479 430 l 446 54 m 488 513 q 559 531 522 513 q 627 576 597 549 q 677 640 658 604 q 697 711 697 675 q 579 809 697 809 q 542 806 557 809 q 515 802 527 803 l 488 513 m 668 1180 q 649 1227 656 1208 q 634 1264 641 1246 q 619 1300 627 1281 q 600 1348 611 1319 q 597 1359 598 1354 q 596 1369 596 1364 q 600 1384 596 1379 q 613 1394 604 1389 q 720 1452 664 1422 q 827 1518 776 1481 q 830 1519 827 1519 q 833 1520 833 1520 q 834 1521 833 1520 q 836 1521 835 1521 q 841 1516 841 1521 l 880 1119 q 880 1108 880 1113 q 880 1100 880 1103 q 878 1083 880 1088 q 866 1073 875 1078 q 838 1061 850 1067 q 812 1050 825 1056 q 785 1037 800 1044 q 753 1021 771 1030 q 745 1018 750 1018 q 732 1027 739 1018 q 718 1056 726 1036 q 697 1106 709 1075 q 668 1180 686 1137 m 329 1180 q 303 1227 313 1208 q 282 1263 292 1246 q 262 1300 273 1280 q 236 1348 251 1319 q 231 1362 232 1355 q 229 1374 229 1369 q 233 1386 229 1381 q 243 1394 236 1390 q 295 1422 268 1408 q 351 1452 323 1437 q 406 1484 378 1468 q 458 1518 433 1500 q 468 1521 465 1521 q 473 1516 472 1521 l 549 1119 q 552 1104 551 1110 q 553 1093 553 1099 q 551 1082 553 1087 q 543 1073 549 1076 q 516 1061 528 1067 q 492 1050 505 1056 q 467 1037 480 1044 q 437 1021 454 1030 q 430 1018 435 1018 q 416 1027 424 1018 q 398 1056 409 1036 q 370 1106 387 1075 q 329 1180 353 1137 z " + }, + "ȑ": { + "ha": 613, + "x_min": 32, + "x_max": 683, + "o": "m 636 552 q 604 555 622 553 q 567 557 587 557 q 506 552 537 557 q 449 532 475 547 q 404 491 422 517 q 379 423 385 465 q 371 341 374 373 q 367 288 368 309 q 365 255 366 267 q 363 231 363 242 q 362 216 363 229 q 359 188 361 204 q 355 153 357 172 q 351 117 353 134 q 348 89 349 101 q 347 74 347 77 q 348 65 347 68 q 348 58 348 61 q 343 48 348 52 q 320 40 338 45 l 140 -24 q 121 -31 133 -27 q 97 -35 109 -35 q 80 -31 87 -35 q 73 -14 73 -26 l 132 658 q 137 689 134 680 q 152 700 140 697 q 279 725 214 712 q 407 757 345 737 q 413 755 412 757 q 414 750 414 753 q 414 746 414 749 q 412 734 414 743 q 407 705 410 724 q 399 651 404 686 q 468 696 430 675 q 543 731 505 716 q 616 754 581 745 q 678 762 652 762 l 636 552 m 471 968 q 451 1015 459 996 q 436 1052 444 1034 q 421 1088 429 1069 q 402 1135 414 1107 q 400 1147 401 1142 q 399 1156 399 1152 q 403 1172 399 1166 q 416 1181 407 1177 q 523 1239 467 1210 q 629 1305 578 1269 q 632 1307 629 1307 q 635 1308 635 1308 q 637 1309 636 1308 q 638 1309 637 1309 q 644 1303 644 1309 l 682 907 q 683 896 682 901 q 683 888 683 891 q 680 871 683 876 q 669 861 677 866 q 640 849 653 854 q 615 838 627 844 q 588 825 602 832 q 555 808 574 818 q 547 806 553 806 q 535 815 541 806 q 520 843 529 824 q 500 893 512 863 q 471 968 488 924 m 132 968 q 105 1015 116 996 q 85 1051 95 1034 q 64 1087 75 1068 q 39 1135 54 1107 q 33 1150 35 1143 q 32 1162 32 1157 q 35 1174 32 1169 q 45 1181 39 1178 q 98 1210 71 1196 q 153 1240 125 1225 q 209 1272 181 1255 q 261 1305 236 1288 q 271 1309 268 1309 q 276 1303 275 1309 l 352 907 q 355 892 353 898 q 356 881 356 886 q 354 869 356 875 q 345 861 352 864 q 319 849 331 854 q 295 838 307 844 q 270 825 283 832 q 240 808 256 818 q 233 806 237 806 q 219 815 227 806 q 200 843 212 824 q 173 893 189 863 q 132 968 156 924 z " + }, + "Ȓ": { + "ha": 999, + "x_min": 83, + "x_max": 1026, + "o": "m 446 54 q 444 42 446 48 q 435 33 442 37 q 408 23 432 32 q 352 4 384 15 q 290 -19 321 -7 q 243 -36 259 -30 q 223 -43 235 -39 q 199 -47 211 -47 q 183 -42 190 -47 q 176 -24 176 -37 q 177 30 176 5 q 182 82 179 55 q 188 142 184 109 q 193 220 191 175 q 201 335 197 280 q 209 450 205 391 l 218 566 q 222 624 220 595 q 227 685 224 654 l 234 768 q 230 769 234 769 q 212 767 224 769 q 181 761 199 764 q 142 754 163 758 q 98 747 121 750 q 88 749 93 747 q 83 755 83 751 q 84 759 83 758 l 194 951 q 274 967 231 959 q 367 983 317 976 q 472 994 416 989 q 590 998 528 998 q 727 984 661 997 q 846 942 794 972 q 929 862 898 913 q 961 734 961 812 q 946 656 961 692 q 909 591 932 620 q 859 540 886 562 q 807 503 832 518 q 764 478 783 487 q 738 466 745 469 q 739 463 738 465 q 743 455 740 460 q 753 438 747 449 q 770 410 760 428 q 795 366 781 393 q 831 302 810 339 q 879 220 852 265 q 941 122 906 174 q 968 110 953 112 q 996 107 983 107 q 1017 105 1008 106 q 1026 97 1026 104 q 1015 84 1026 92 q 989 70 1004 77 q 959 56 974 62 q 935 45 943 49 q 901 29 919 37 q 863 13 884 22 q 818 -9 843 3 q 762 -38 793 -21 q 747 -44 754 -43 q 734 -46 740 -46 q 713 -40 722 -46 q 698 -26 704 -35 q 688 -10 692 -18 q 682 4 684 -1 l 479 430 l 446 54 m 488 513 q 559 531 522 513 q 627 576 597 549 q 677 640 658 604 q 697 711 697 675 q 579 809 697 809 q 542 806 557 809 q 515 802 527 803 l 488 513 m 618 1046 q 620 1054 619 1049 q 621 1064 621 1059 q 604 1099 621 1082 q 551 1116 586 1116 q 514 1109 529 1116 q 491 1092 500 1102 q 479 1070 482 1082 q 475 1048 475 1059 q 476 1047 475 1048 q 476 1046 476 1047 q 474 1040 475 1043 q 467 1035 472 1037 q 452 1032 462 1033 q 426 1031 443 1031 q 390 1033 403 1031 q 371 1038 377 1034 q 363 1048 364 1042 q 361 1062 361 1054 q 377 1129 361 1100 q 421 1179 393 1159 q 487 1211 450 1200 q 568 1221 525 1221 q 647 1207 614 1221 q 701 1168 680 1192 q 731 1114 722 1144 q 741 1053 741 1084 q 740 1041 741 1046 q 731 1033 738 1036 q 711 1029 724 1030 q 675 1027 699 1027 q 646 1028 657 1027 q 628 1031 635 1029 q 620 1036 622 1033 q 618 1042 618 1038 q 618 1044 618 1043 q 618 1046 618 1044 z " + }, + "ȓ": { + "ha": 613, + "x_min": 73, + "x_max": 678, + "o": "m 636 552 q 604 555 622 553 q 567 557 587 557 q 506 552 537 557 q 449 532 475 547 q 404 491 422 517 q 379 423 385 465 q 371 341 374 373 q 367 288 368 309 q 365 255 366 267 q 363 231 363 242 q 362 216 363 229 q 359 188 361 204 q 355 153 357 172 q 351 117 353 134 q 348 89 349 101 q 347 74 347 77 q 348 65 347 68 q 348 58 348 61 q 343 48 348 52 q 320 40 338 45 l 140 -24 q 121 -31 133 -27 q 97 -35 109 -35 q 80 -31 87 -35 q 73 -14 73 -26 l 132 658 q 137 689 134 680 q 152 700 140 697 q 279 725 214 712 q 407 757 345 737 q 413 755 412 757 q 414 750 414 753 q 414 746 414 749 q 412 734 414 743 q 407 705 410 724 q 399 651 404 686 q 468 696 430 675 q 543 731 505 716 q 616 754 581 745 q 678 762 652 762 l 636 552 m 420 833 q 422 842 421 837 q 423 852 423 846 q 406 887 423 870 q 353 903 388 903 q 316 897 331 903 q 293 880 302 890 q 281 858 284 869 q 277 836 277 846 q 278 835 277 836 q 278 834 278 835 q 276 828 277 831 q 269 823 274 825 q 254 820 264 821 q 228 819 245 819 q 192 820 205 819 q 173 826 179 822 q 165 836 166 830 q 163 850 163 842 q 179 917 163 888 q 223 967 195 947 q 289 998 252 987 q 370 1009 327 1009 q 449 995 416 1009 q 503 956 481 980 q 533 902 524 932 q 543 840 543 871 q 542 829 543 833 q 533 821 540 824 q 513 817 526 818 q 477 815 500 815 q 448 816 459 815 q 430 819 437 817 q 422 823 424 821 q 420 829 420 826 q 420 831 420 831 q 420 833 420 832 z " + }, + "Ȕ": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 m 629 1180 q 610 1227 618 1208 q 595 1264 602 1246 q 580 1300 588 1281 q 561 1348 572 1319 q 558 1359 559 1354 q 557 1369 557 1364 q 562 1384 557 1379 q 574 1394 566 1389 q 681 1452 625 1422 q 788 1518 737 1481 q 791 1519 788 1519 q 794 1520 794 1520 q 795 1521 795 1520 q 797 1521 796 1521 q 802 1516 802 1521 l 841 1119 q 841 1108 841 1113 q 842 1100 842 1103 q 839 1083 842 1088 q 827 1073 836 1078 q 799 1061 812 1067 q 773 1050 786 1056 q 747 1037 761 1044 q 714 1021 732 1030 q 706 1018 711 1018 q 694 1027 700 1018 q 679 1056 688 1036 q 659 1106 671 1075 q 629 1180 647 1137 m 290 1180 q 264 1227 275 1208 q 244 1263 254 1246 q 223 1300 234 1280 q 197 1348 212 1319 q 192 1362 193 1355 q 191 1374 191 1369 q 194 1386 191 1381 q 204 1394 197 1390 q 257 1422 229 1408 q 312 1452 284 1437 q 367 1484 340 1468 q 420 1518 395 1500 q 429 1521 427 1521 q 435 1516 433 1521 l 511 1119 q 513 1104 512 1110 q 515 1093 515 1099 q 513 1082 515 1087 q 504 1073 511 1076 q 478 1061 490 1067 q 454 1050 466 1056 q 428 1037 441 1044 q 399 1021 415 1030 q 391 1018 396 1018 q 378 1027 385 1018 q 359 1056 370 1036 q 331 1106 348 1075 q 290 1180 315 1137 z " + }, + "ȕ": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 m 582 968 q 563 1015 570 996 q 548 1052 555 1034 q 533 1088 541 1069 q 513 1135 525 1107 q 511 1147 512 1142 q 510 1156 510 1152 q 514 1172 510 1166 q 527 1181 518 1177 q 634 1239 578 1210 q 741 1305 690 1269 q 744 1307 741 1307 q 747 1308 747 1308 q 748 1309 747 1308 q 749 1309 749 1309 q 755 1303 755 1309 l 793 907 q 794 896 793 901 q 794 888 794 891 q 791 871 794 876 q 780 861 789 866 q 751 849 764 854 q 726 838 739 844 q 699 825 713 832 q 667 808 685 818 q 659 806 664 806 q 646 815 652 806 q 632 843 640 824 q 611 893 623 863 q 582 968 600 924 m 243 968 q 217 1015 227 996 q 196 1051 206 1034 q 176 1087 186 1068 q 150 1135 165 1107 q 144 1150 146 1143 q 143 1162 143 1157 q 146 1174 143 1169 q 157 1181 150 1178 q 209 1210 182 1196 q 264 1240 237 1225 q 320 1272 292 1255 q 372 1305 347 1288 q 382 1309 379 1309 q 387 1303 386 1309 l 463 907 q 466 892 465 898 q 467 881 467 886 q 465 869 467 875 q 456 861 463 864 q 430 849 442 854 q 406 838 418 844 q 381 825 394 832 q 351 808 368 818 q 344 806 349 806 q 330 815 338 806 q 312 843 323 824 q 284 893 300 863 q 243 968 267 924 z " + }, + "Ȗ": { + "ha": 935, + "x_min": 103, + "x_max": 937, + "o": "m 937 970 q 932 916 935 951 q 924 835 928 880 q 915 741 920 790 q 906 646 910 691 q 898 564 901 601 q 893 509 895 528 q 845 261 879 365 q 755 92 810 158 q 622 -4 699 26 q 442 -35 544 -35 q 311 -10 372 -35 q 203 62 249 16 q 130 173 157 108 q 103 317 103 237 q 105 354 103 327 q 110 419 106 380 q 116 507 113 458 q 124 612 120 556 l 132 724 q 138 827 136 786 q 143 893 140 868 q 150 930 145 918 q 165 945 155 941 l 396 996 q 414 999 405 999 q 429 993 425 999 q 433 970 433 987 l 381 435 q 376 384 380 412 q 373 327 373 356 q 377 288 373 309 q 393 249 382 267 q 425 217 405 230 q 477 205 445 205 q 539 221 515 205 q 577 266 563 238 q 598 328 591 293 q 608 399 604 363 q 616 500 612 442 q 626 623 621 557 l 646 894 q 647 914 647 906 q 649 928 648 922 q 655 936 650 933 q 669 940 659 939 l 900 996 q 918 999 909 999 q 932 993 928 999 q 937 970 937 987 m 579 1046 q 581 1054 580 1049 q 582 1064 582 1059 q 564 1099 582 1082 q 511 1116 547 1116 q 475 1109 490 1116 q 452 1092 460 1102 q 439 1070 443 1082 q 436 1048 436 1059 q 436 1047 436 1048 q 437 1046 437 1047 q 434 1040 436 1043 q 428 1035 433 1037 q 413 1032 422 1033 q 387 1031 404 1031 q 351 1033 363 1031 q 331 1038 338 1034 q 323 1048 325 1042 q 322 1062 322 1054 q 338 1129 322 1100 q 382 1179 354 1159 q 448 1211 410 1200 q 528 1221 486 1221 q 607 1207 574 1221 q 661 1168 640 1192 q 692 1114 682 1144 q 702 1053 702 1084 q 700 1041 702 1046 q 692 1033 699 1036 q 672 1029 685 1030 q 636 1027 659 1027 q 607 1028 618 1027 q 589 1031 595 1029 q 581 1036 583 1033 q 578 1042 578 1038 q 579 1044 578 1043 q 579 1046 579 1044 z " + }, + "ȗ": { + "ha": 868, + "x_min": 79, + "x_max": 862, + "o": "m 798 84 q 793 53 796 62 q 778 42 789 45 q 657 9 720 26 q 536 -35 595 -7 q 530 -35 534 -35 q 523 -33 524 -35 q 522 -26 522 -30 q 523 -22 522 -25 q 524 -9 523 -18 q 528 16 526 0 q 534 60 530 33 q 400 -13 458 10 q 296 -35 341 -35 q 131 19 182 -35 q 79 182 79 74 q 79 223 79 202 q 83 265 80 243 q 96 402 91 345 q 105 500 102 460 q 111 565 109 540 q 115 608 113 591 q 118 636 117 625 q 120 658 119 647 q 125 688 123 680 q 140 700 127 697 q 201 711 172 706 q 258 722 230 716 q 315 734 286 728 q 377 751 345 741 q 380 751 378 751 q 382 751 381 751 q 391 746 391 751 l 391 745 q 386 701 388 722 q 382 660 384 681 q 377 614 380 638 q 372 557 375 589 q 363 483 368 525 q 352 385 359 441 q 344 304 348 345 q 340 229 340 264 q 342 194 340 210 q 352 167 345 178 q 371 150 359 156 q 401 144 382 144 q 439 150 415 144 q 488 173 464 157 q 531 215 513 189 q 553 281 550 241 q 561 393 557 348 q 568 469 565 438 q 572 521 570 500 q 576 561 574 542 q 579 603 578 581 q 582 658 581 625 q 587 689 584 680 q 602 700 590 697 q 728 725 664 712 q 854 757 793 737 q 861 755 860 757 q 862 750 862 753 l 798 84 m 532 833 q 534 842 533 837 q 535 852 535 846 q 517 887 535 870 q 465 903 500 903 q 428 897 443 903 q 405 880 414 890 q 393 858 396 869 q 389 836 389 846 q 390 835 389 836 q 390 834 390 835 q 388 828 389 831 q 381 823 386 825 q 366 820 376 821 q 340 819 357 819 q 304 820 317 819 q 284 826 291 822 q 277 836 278 830 q 275 850 275 842 q 291 917 275 888 q 335 967 307 947 q 401 998 363 987 q 481 1009 439 1009 q 561 995 528 1009 q 614 956 593 980 q 645 902 635 932 q 655 840 655 871 q 653 829 655 833 q 645 821 652 824 q 625 817 638 818 q 589 815 612 815 q 560 816 571 815 q 542 819 549 817 q 534 823 536 821 q 532 829 532 826 q 532 831 532 831 q 532 833 532 832 z " + }, + "Ș": { + "ha": 745, + "x_min": 106, + "x_max": 732, + "o": "m 107 252 q 107 254 107 253 q 106 255 106 254 q 119 267 106 267 q 132 260 125 267 q 214 205 169 226 q 311 185 258 185 q 344 188 326 185 q 378 198 362 191 q 406 217 395 205 q 417 246 417 229 q 408 281 417 266 q 382 308 398 297 q 364 321 376 312 q 336 343 352 331 q 304 369 321 355 q 272 396 287 383 q 244 418 256 408 q 226 434 232 429 q 162 494 186 464 q 127 558 139 525 q 111 627 115 591 q 108 703 108 663 q 136 816 108 762 q 217 909 165 869 q 341 972 269 949 q 503 996 414 996 q 535 993 513 996 q 583 985 557 991 q 636 970 609 980 q 686 946 663 961 q 697 935 693 941 q 703 918 702 930 q 709 810 707 863 q 714 697 711 757 q 709 682 714 686 q 697 678 705 678 q 686 682 690 678 q 645 721 666 702 q 601 754 624 740 q 552 778 578 769 q 495 787 526 787 q 458 784 477 787 q 425 772 440 781 q 401 750 410 764 q 392 714 392 736 q 398 674 392 691 q 427 642 404 657 l 589 532 q 662 468 634 500 q 705 406 690 437 q 727 344 721 374 q 732 285 732 314 q 717 195 732 237 q 674 118 701 153 q 611 54 647 82 q 533 6 574 26 q 447 -25 492 -14 q 360 -35 403 -35 q 295 -34 327 -35 q 232 -29 262 -33 q 173 -15 201 -24 q 119 10 144 -6 l 107 252 m 342 -602 q 330 -596 334 -598 q 318 -591 326 -595 q 295 -579 310 -587 q 250 -555 279 -571 q 229 -536 229 -543 q 231 -530 229 -534 q 272 -466 250 -500 q 313 -398 293 -433 l 255 -397 l 243 -397 q 227 -396 233 -397 q 221 -391 221 -395 l 221 -389 q 225 -343 224 -360 q 228 -318 227 -327 q 229 -304 229 -309 q 230 -297 229 -300 q 230 -289 230 -294 q 231 -273 231 -284 q 234 -241 232 -261 q 238 -188 235 -221 q 242 -157 239 -165 q 258 -146 245 -148 q 321 -146 288 -146 q 388 -145 355 -146 q 453 -145 420 -145 q 515 -145 485 -145 q 526 -161 526 -145 l 514 -327 q 510 -353 512 -342 q 507 -371 509 -363 q 503 -388 505 -380 q 497 -407 500 -396 q 484 -432 493 -416 q 462 -465 474 -447 q 436 -500 450 -482 q 412 -534 423 -518 q 391 -562 400 -550 q 380 -577 382 -573 q 375 -584 378 -580 q 368 -593 372 -589 q 359 -601 363 -597 q 349 -604 354 -604 q 342 -602 346 -604 z " + }, + "ș": { + "ha": 584, + "x_min": 84, + "x_max": 568, + "o": "m 85 187 q 84 188 85 188 q 84 189 84 189 q 94 198 84 198 q 104 193 100 198 q 166 151 132 168 q 242 135 201 135 q 267 137 253 135 q 294 145 281 140 q 316 160 307 151 q 324 182 324 169 q 317 210 324 197 q 297 231 309 222 q 273 247 290 235 q 237 273 256 259 q 200 298 218 286 q 176 316 183 311 q 128 363 146 339 q 100 412 110 387 q 88 465 91 437 q 85 524 85 493 q 111 614 85 572 q 177 688 136 656 q 270 738 218 720 q 376 757 322 757 q 465 743 421 757 q 545 697 508 730 q 550 613 549 654 q 554 526 551 572 q 550 514 554 517 q 541 511 546 511 q 532 515 536 511 q 500 544 517 530 q 466 570 484 559 q 428 588 448 581 q 385 595 408 595 q 356 593 370 595 q 330 584 342 591 q 312 567 319 578 q 304 539 304 556 q 309 505 304 519 q 332 477 314 491 l 458 392 q 514 346 492 368 q 547 300 535 323 q 564 256 559 278 q 568 212 568 234 q 540 105 568 151 q 467 27 512 58 q 366 -19 422 -3 q 252 -35 309 -35 q 169 -26 209 -35 q 94 2 128 -17 l 85 187 m 260 -602 q 248 -596 252 -598 q 236 -591 243 -595 q 213 -579 228 -587 q 168 -555 197 -571 q 146 -536 146 -543 q 149 -530 146 -534 q 190 -466 168 -500 q 231 -398 211 -433 l 173 -397 l 161 -397 q 145 -396 151 -397 q 139 -391 139 -395 l 139 -389 q 143 -343 142 -360 q 146 -318 145 -327 q 147 -304 146 -309 q 148 -297 147 -300 q 148 -289 148 -294 q 149 -273 149 -284 q 152 -241 150 -261 q 156 -188 153 -221 q 160 -157 157 -165 q 176 -146 163 -148 q 239 -146 206 -146 q 306 -145 273 -146 q 371 -145 338 -145 q 433 -145 403 -145 q 444 -161 444 -145 l 432 -327 q 428 -353 430 -342 q 425 -371 427 -363 q 421 -388 423 -380 q 415 -407 418 -396 q 401 -432 411 -416 q 380 -465 392 -447 q 354 -500 368 -482 q 330 -534 341 -518 q 309 -562 318 -550 q 298 -577 300 -573 q 293 -584 296 -580 q 286 -593 290 -589 q 277 -601 281 -597 q 267 -604 272 -604 q 260 -602 264 -604 z " + }, + "Ț": { + "ha": 786, + "x_min": 58, + "x_max": 886, + "o": "m 243 -22 q 250 74 246 28 q 256 163 253 120 l 280 484 q 287 550 284 520 q 291 609 290 581 q 295 668 293 638 q 298 733 296 697 q 251 734 276 734 q 199 734 227 734 l 68 734 q 59 740 61 734 q 58 750 58 745 l 58 755 l 132 951 l 132 954 q 136 959 132 957 q 156 963 140 962 q 337 974 250 968 q 511 983 425 979 q 683 991 597 987 q 859 998 768 995 q 877 996 867 998 q 886 981 886 994 q 885 979 886 980 q 884 976 884 977 q 864 923 873 948 q 846 873 855 898 q 828 824 838 849 q 808 771 819 799 q 806 765 808 770 q 800 752 804 759 q 789 739 795 745 q 772 732 782 732 q 765 732 768 732 q 758 732 762 732 l 576 732 q 570 659 573 698 q 563 584 567 621 l 513 54 q 511 42 513 48 q 502 33 509 36 l 310 -36 q 290 -43 302 -39 q 267 -47 278 -47 q 250 -42 257 -47 q 243 -24 243 -37 l 243 -22 m 432 -602 q 420 -596 425 -598 q 408 -591 416 -595 q 385 -579 400 -587 q 340 -555 370 -571 q 319 -536 319 -543 q 321 -530 319 -534 q 362 -466 340 -500 q 403 -398 383 -433 l 345 -397 l 333 -397 q 317 -396 323 -397 q 311 -391 311 -395 l 311 -389 q 316 -343 314 -360 q 318 -318 317 -327 q 319 -304 319 -309 q 320 -297 319 -300 q 320 -289 320 -294 q 321 -273 321 -284 q 324 -241 322 -261 q 328 -188 326 -221 q 333 -157 330 -165 q 348 -146 336 -148 q 412 -146 378 -146 q 478 -145 445 -146 q 543 -145 511 -145 q 605 -145 575 -145 q 616 -161 616 -145 l 604 -327 q 601 -353 602 -342 q 597 -371 599 -363 q 593 -388 595 -380 q 587 -407 591 -396 q 574 -432 583 -416 q 552 -465 564 -447 q 527 -500 540 -482 q 502 -534 513 -518 q 481 -562 490 -550 q 470 -577 473 -573 q 465 -584 468 -580 q 458 -593 462 -589 q 449 -601 454 -597 q 439 -604 444 -604 q 432 -602 436 -604 z " + }, + "ț": { + "ha": 633, + "x_min": 51, + "x_max": 659, + "o": "m 447 523 q 429 381 437 452 q 418 248 420 310 q 422 204 418 222 q 434 176 427 186 q 453 162 442 166 q 477 157 464 157 q 525 164 499 157 q 576 180 551 170 q 590 185 585 184 q 600 187 596 187 q 609 181 607 187 q 614 160 612 175 q 621 119 616 144 q 637 54 626 93 q 638 48 637 50 q 638 43 638 45 q 631 30 638 34 q 608 20 624 26 q 549 1 580 11 q 488 -17 518 -9 q 430 -30 458 -25 q 381 -35 402 -35 q 216 21 268 -35 q 164 193 164 77 q 165 233 164 212 q 169 276 167 254 q 181 405 176 344 q 191 521 186 465 l 120 521 q 67 524 83 521 q 51 536 51 527 q 58 551 51 539 q 75 579 64 563 q 98 615 85 596 q 121 651 110 634 q 140 680 132 667 q 151 694 149 692 l 199 694 q 209 781 205 746 q 215 839 212 816 q 219 874 217 862 q 223 893 221 887 q 230 901 226 899 q 239 904 233 903 q 298 915 270 910 q 355 926 327 920 q 413 938 384 931 q 475 955 443 945 q 477 955 477 955 q 480 956 478 956 q 488 950 488 956 l 488 949 q 478 845 485 911 q 462 694 471 779 l 644 694 q 654 691 649 694 q 659 678 659 687 q 651 654 659 675 q 628 599 643 633 l 604 544 q 594 529 599 534 q 573 523 589 524 l 447 523 m 327 -602 q 315 -596 319 -598 q 303 -591 311 -595 q 280 -579 295 -587 q 235 -555 264 -571 q 214 -536 214 -543 q 216 -530 214 -534 q 257 -466 235 -500 q 298 -398 278 -433 l 240 -397 l 228 -397 q 212 -396 218 -397 q 206 -391 206 -395 l 206 -389 q 211 -343 209 -360 q 213 -318 212 -327 q 214 -304 214 -309 q 215 -297 214 -300 q 215 -289 215 -294 q 216 -273 216 -284 q 219 -241 217 -261 q 223 -188 220 -221 q 228 -157 224 -165 q 243 -146 231 -148 q 307 -146 273 -146 q 373 -145 340 -146 q 438 -145 406 -145 q 500 -145 470 -145 q 511 -161 511 -145 l 499 -327 q 495 -353 497 -342 q 492 -371 494 -363 q 488 -388 490 -380 q 482 -407 486 -396 q 469 -432 478 -416 q 447 -465 459 -447 q 421 -500 435 -482 q 397 -534 408 -518 q 376 -562 385 -550 q 365 -577 368 -573 q 360 -584 363 -580 q 353 -593 357 -589 q 344 -601 349 -597 q 334 -604 339 -604 q 327 -602 331 -604 z " + }, + "ˆ": { + "ha": 716, + "x_min": 27, + "x_max": 678, + "o": "m 549 1127 q 575 1095 564 1109 q 593 1069 585 1080 q 608 1047 602 1057 q 623 1024 615 1036 q 636 1006 627 1018 q 655 980 646 994 q 671 952 664 966 q 678 930 678 939 q 665 911 678 920 q 633 892 652 901 q 596 876 615 884 q 567 865 578 869 q 546 856 557 861 q 524 844 535 850 q 504 835 513 839 q 490 831 494 831 q 478 837 484 831 q 464 858 473 843 l 376 1044 q 294 941 335 991 q 212 838 253 890 q 203 833 209 833 q 187 839 199 833 q 157 852 174 844 q 123 869 140 860 q 92 884 105 878 q 73 892 84 887 q 52 903 62 897 q 34 913 41 908 q 27 924 27 919 q 33 939 27 930 q 53 961 39 948 l 155 1076 q 231 1160 194 1119 q 304 1243 267 1200 q 322 1254 313 1253 q 339 1255 332 1255 l 442 1255 q 458 1253 450 1255 q 472 1242 467 1252 q 481 1229 473 1241 q 500 1199 489 1217 q 525 1162 512 1182 q 549 1127 538 1143 z " + }, + "ˇ": { + "ha": 718, + "x_min": 38, + "x_max": 688, + "o": "m 168 968 q 132 1015 146 996 q 107 1050 119 1034 q 83 1086 96 1067 q 50 1135 71 1105 q 41 1152 43 1145 q 38 1165 38 1160 q 60 1187 38 1175 q 112 1213 82 1200 q 172 1238 142 1226 q 218 1258 202 1250 q 226 1260 222 1260 q 236 1255 231 1260 q 247 1239 240 1251 l 346 1063 l 509 1256 q 515 1259 511 1259 q 527 1256 519 1259 q 551 1245 535 1254 q 616 1213 585 1226 q 682 1181 648 1199 q 688 1171 688 1179 q 683 1156 688 1165 q 664 1135 677 1147 q 535 992 599 1063 q 410 851 472 921 q 396 842 404 844 q 382 840 389 840 l 373 840 l 268 840 q 256 842 262 840 q 246 851 250 844 q 237 865 245 852 q 217 895 229 878 q 192 932 205 913 q 168 968 178 952 z " + }, + "˘": { + "ha": 481, + "x_min": 51, + "x_max": 437, + "o": "m 315 991 q 317 998 315 995 q 326 1004 319 1002 q 344 1008 332 1006 q 376 1009 356 1009 q 408 1008 396 1009 q 427 1005 420 1007 q 435 1000 433 1003 q 437 992 437 997 q 421 928 437 960 q 378 871 406 896 q 312 831 350 846 q 230 815 274 815 q 161 823 194 815 q 104 847 129 831 q 65 886 80 863 q 51 939 51 909 q 52 952 51 945 q 54 968 53 959 q 58 985 56 978 q 66 997 60 992 q 87 1003 73 1001 q 126 1006 100 1006 q 151 1005 142 1006 q 165 1002 160 1004 q 172 998 170 1000 q 173 992 173 995 q 172 986 173 991 q 172 975 172 981 q 174 956 172 966 q 184 939 177 947 q 203 926 191 931 q 234 921 216 921 q 270 927 254 921 q 295 942 285 932 q 310 961 305 951 q 315 982 315 972 l 315 991 l 315 991 z " + }, + "˙": { + "ha": 393, + "x_min": 50, + "x_max": 341, + "o": "m 50 949 q 52 1000 51 981 q 53 1029 52 1019 q 53 1043 53 1039 q 53 1051 53 1047 q 53 1060 53 1055 q 54 1078 54 1065 q 56 1113 55 1090 q 60 1171 58 1135 q 64 1202 61 1194 q 79 1213 67 1211 q 204 1213 139 1213 q 327 1214 269 1213 q 337 1211 333 1214 q 341 1198 341 1207 l 322 985 q 317 954 319 962 q 302 944 315 945 q 183 943 244 943 q 57 942 123 942 q 51 943 53 942 q 50 947 50 943 l 50 949 z " + }, + "˚": { + "ha": 481, + "x_min": 50, + "x_max": 431, + "o": "m 239 808 q 164 821 199 808 q 104 856 130 833 q 64 912 79 879 q 50 988 50 945 q 65 1061 50 1027 q 108 1122 81 1096 q 172 1163 135 1147 q 252 1178 209 1178 q 322 1166 289 1178 q 379 1131 355 1154 q 417 1077 404 1109 q 431 1006 431 1045 q 418 934 431 970 q 379 870 404 899 q 319 825 355 842 q 239 808 283 808 m 309 989 q 310 996 310 992 q 310 1002 310 999 q 307 1028 310 1015 q 298 1054 304 1042 q 280 1074 291 1066 q 253 1081 269 1081 q 214 1073 231 1081 q 187 1051 197 1064 q 171 1021 176 1038 q 166 987 166 1004 q 169 961 166 975 q 178 935 172 947 q 197 916 185 924 q 227 907 208 908 q 261 915 247 907 q 286 933 276 922 q 301 960 296 945 q 309 989 307 975 z " + }, + "˛": { + "ha": 369, + "x_min": 34, + "x_max": 328, + "o": "m 34 -262 q 52 -185 37 -221 q 92 -117 68 -149 q 145 -60 115 -86 q 206 -12 175 -33 l 263 -12 q 304 -12 285 -12 q 328 -12 323 -12 q 279 -61 304 -36 q 230 -113 253 -85 q 187 -173 206 -141 q 159 -241 168 -204 q 157 -256 157 -249 q 169 -285 157 -273 q 205 -297 181 -297 q 236 -296 223 -297 q 261 -293 250 -295 q 284 -288 273 -291 q 309 -279 295 -284 q 307 -306 309 -289 q 304 -339 306 -322 q 299 -368 302 -355 q 295 -383 297 -381 l 296 -382 q 237 -400 270 -396 q 176 -404 204 -404 q 116 -394 142 -404 q 72 -365 90 -383 q 44 -320 54 -346 q 34 -262 34 -294 z " + }, + "˜": { + "ha": 760, + "x_min": 43, + "x_max": 719, + "o": "m 456 1071 q 480 1046 469 1053 q 506 1038 491 1038 q 536 1049 527 1038 q 549 1075 545 1061 q 555 1100 553 1088 q 563 1111 557 1111 q 574 1111 564 1111 q 598 1112 584 1112 q 629 1114 612 1113 q 661 1115 646 1114 q 687 1117 676 1116 q 701 1117 699 1117 q 713 1114 707 1117 q 719 1104 719 1112 q 702 1005 719 1055 q 653 915 684 956 q 577 849 621 874 q 479 823 533 823 q 436 827 458 823 q 394 843 415 831 q 351 874 372 854 q 307 926 329 894 q 282 947 295 943 q 252 951 270 951 q 226 945 236 951 q 210 927 216 938 q 201 903 203 916 q 199 877 199 890 q 193 866 199 871 q 179 861 188 861 q 144 861 157 861 q 120 860 131 861 q 96 860 109 860 q 63 859 83 859 q 49 862 56 859 q 43 871 43 865 q 61 987 43 935 q 111 1077 79 1040 q 184 1135 142 1114 q 275 1156 227 1156 q 325 1154 302 1156 q 371 1143 349 1152 q 413 1118 393 1135 q 456 1071 434 1101 z " + }, + "˝": { + "ha": 861, + "x_min": 32, + "x_max": 864, + "o": "m 255 944 q 205 876 225 903 q 172 833 185 849 q 152 812 159 818 q 140 806 144 806 q 133 808 136 806 q 107 825 119 818 q 85 838 96 832 q 65 849 75 844 q 41 861 54 854 q 32 875 32 866 q 34 888 33 880 q 40 907 36 895 l 165 1271 q 172 1284 169 1280 q 180 1287 176 1287 q 186 1285 183 1287 q 193 1282 190 1284 q 299 1221 246 1246 q 403 1168 351 1195 q 415 1149 415 1160 q 404 1122 415 1139 q 360 1071 377 1091 q 327 1033 342 1051 q 296 994 312 1015 q 255 944 279 974 m 667 944 q 605 876 630 903 q 564 833 580 849 q 540 812 549 818 q 527 806 532 806 q 521 808 523 806 q 494 827 506 819 q 472 842 482 836 q 450 854 461 848 q 425 867 439 861 q 418 879 418 873 q 421 892 418 884 q 432 913 425 900 l 608 1284 q 618 1297 613 1294 q 626 1301 623 1301 q 638 1295 631 1301 q 746 1227 690 1257 q 854 1168 802 1198 q 861 1161 859 1164 q 864 1153 864 1158 q 860 1139 864 1147 q 847 1122 857 1131 q 795 1070 816 1091 q 755 1032 774 1050 q 717 994 737 1014 q 667 944 696 974 z " + }, + "̏": { + "ha": 725, + "x_min": 30, + "x_max": 681, + "o": "m 469 968 q 449 1015 457 996 q 434 1052 441 1034 q 419 1088 427 1069 q 400 1135 412 1107 q 398 1147 399 1142 q 397 1156 397 1152 q 401 1172 397 1166 q 414 1181 405 1177 q 520 1239 465 1210 q 627 1305 576 1269 q 630 1307 627 1307 q 633 1308 633 1308 q 635 1309 634 1308 q 636 1309 635 1309 q 642 1303 642 1309 l 680 907 q 681 896 680 901 q 681 888 681 891 q 678 871 681 876 q 667 861 675 866 q 638 849 651 854 q 613 838 625 844 q 586 825 600 832 q 553 808 572 818 q 545 806 551 806 q 533 815 539 806 q 518 843 527 824 q 498 893 510 863 q 469 968 486 924 m 130 968 q 103 1015 114 996 q 83 1051 93 1034 q 62 1087 73 1068 q 37 1135 52 1107 q 31 1150 33 1143 q 30 1162 30 1157 q 33 1174 30 1169 q 43 1181 37 1178 q 96 1210 68 1196 q 151 1240 123 1225 q 207 1272 179 1255 q 259 1305 234 1288 q 269 1309 266 1309 q 274 1303 273 1309 l 350 907 q 353 892 351 898 q 354 881 354 886 q 352 869 354 875 q 343 861 350 864 q 317 849 329 854 q 293 838 305 844 q 268 825 281 832 q 238 808 254 818 q 231 806 235 806 q 217 815 224 806 q 198 843 210 824 q 171 893 187 863 q 130 968 154 924 z " + }, + "̑": { + "ha": 462, + "x_min": 39, + "x_max": 418, + "o": "m 296 833 q 297 842 296 837 q 298 852 298 846 q 281 887 298 870 q 228 903 263 903 q 192 897 206 903 q 168 880 177 890 q 156 858 159 869 q 153 836 153 846 q 153 835 153 836 q 153 834 153 835 q 151 828 153 831 q 144 823 149 825 q 130 820 139 821 q 103 819 120 819 q 67 820 80 819 q 48 826 54 822 q 40 836 41 830 q 39 850 39 842 q 55 917 39 888 q 99 967 71 947 q 164 998 127 987 q 245 1009 202 1009 q 324 995 291 1009 q 378 956 357 980 q 409 902 399 932 q 418 840 418 871 q 417 829 418 833 q 408 821 415 824 q 389 817 401 818 q 353 815 376 815 q 323 816 334 815 q 306 819 312 817 q 297 823 299 821 q 295 829 295 826 q 295 831 295 831 q 296 833 296 832 z " + }, + "̦": { + "ha": 379, + "x_min": 43, + "x_max": 349, + "o": "m 164 -602 q 152 -596 157 -598 q 140 -591 148 -595 q 117 -579 132 -587 q 73 -555 102 -571 q 51 -536 51 -543 q 53 -530 51 -534 q 94 -466 73 -500 q 135 -398 115 -433 l 77 -397 l 65 -397 q 50 -396 56 -397 q 43 -391 43 -395 l 43 -389 q 48 -343 46 -360 q 50 -318 50 -327 q 51 -304 51 -309 q 52 -297 52 -300 q 53 -289 52 -294 q 54 -273 53 -284 q 56 -241 54 -261 q 60 -188 58 -221 q 65 -157 62 -165 q 80 -146 68 -148 q 144 -146 111 -146 q 210 -145 177 -146 q 275 -145 243 -145 q 337 -145 307 -145 q 349 -161 349 -145 l 336 -327 q 333 -353 334 -342 q 329 -371 331 -363 q 325 -388 328 -380 q 319 -407 323 -396 q 306 -432 315 -416 q 284 -465 296 -447 q 259 -500 272 -482 q 234 -534 245 -518 q 214 -562 222 -550 q 202 -577 205 -573 q 197 -584 200 -580 q 190 -593 194 -589 q 181 -601 186 -597 q 172 -604 176 -604 q 164 -602 168 -604 z " + }, + "Δ": { + "ha": 868, + "x_min": 56, + "x_max": 838, + "o": "m 71 88 q 83 126 74 99 q 107 193 93 154 q 139 281 121 232 q 177 382 157 330 q 218 489 197 435 q 259 595 239 544 q 297 691 279 646 q 331 770 316 736 q 340 782 334 777 q 354 792 347 788 q 369 799 361 796 q 380 803 376 802 q 404 809 384 804 q 447 821 423 814 q 495 831 471 827 q 530 836 518 836 q 553 832 541 836 q 570 811 566 829 q 640 623 605 720 q 709 427 675 526 q 776 231 743 328 q 838 41 808 133 l 838 43 q 838 35 838 41 q 837 21 838 28 q 834 8 836 14 q 830 2 833 2 q 595 1 706 2 q 391 0 485 1 q 221 -2 297 -1 q 92 -4 146 -3 q 64 4 73 -2 q 56 25 56 10 q 71 88 56 46 m 435 532 l 389 384 q 371 327 380 355 q 355 275 362 299 q 343 235 348 252 q 337 212 338 218 q 337 206 337 208 q 336 200 337 203 l 499 200 q 483 276 492 235 q 466 360 475 317 q 450 447 458 404 q 435 532 441 491 z " + }, + "∆": { + "ha": 868, + "x_min": 56, + "x_max": 838, + "o": "m 71 88 q 83 126 74 99 q 107 193 93 154 q 139 281 121 232 q 177 382 157 330 q 218 489 197 435 q 259 595 239 544 q 297 691 279 646 q 331 770 316 736 q 340 782 334 777 q 354 792 347 788 q 369 799 361 796 q 380 803 376 802 q 404 809 384 804 q 447 821 423 814 q 495 831 471 827 q 530 836 518 836 q 553 832 541 836 q 570 811 566 829 q 640 623 605 720 q 709 427 675 526 q 776 231 743 328 q 838 41 808 133 l 838 43 q 838 35 838 41 q 837 21 838 28 q 834 8 836 14 q 830 2 833 2 q 595 1 706 2 q 391 0 485 1 q 221 -2 297 -1 q 92 -4 146 -3 q 64 4 73 -2 q 56 25 56 10 q 71 88 56 46 m 435 532 l 389 384 q 371 327 380 355 q 355 275 362 299 q 343 235 348 252 q 337 212 338 218 q 337 206 337 208 q 336 200 337 203 l 499 200 q 483 276 492 235 q 466 360 475 317 q 450 447 458 404 q 435 532 441 491 z " + }, + "–": { + "ha": 683, + "x_min": 76, + "x_max": 673, + "o": "m 176 351 q 99 354 123 351 q 76 367 76 357 q 80 384 76 372 q 92 408 85 395 q 109 436 100 421 q 130 462 119 450 q 152 482 140 474 q 173 490 163 490 l 412 497 q 439 497 420 497 q 483 498 458 498 q 534 499 507 498 q 584 500 561 500 q 622 501 606 500 q 641 501 638 501 q 657 498 650 500 q 668 491 663 495 q 673 477 673 486 q 671 451 673 469 q 667 416 669 433 l 661 370 q 659 363 660 366 q 654 359 658 361 q 645 356 651 357 q 627 355 638 355 l 545 355 q 509 355 528 355 q 473 354 491 355 q 439 354 456 354 q 410 354 422 354 l 176 351 z " + }, + "—": { + "ha": 926, + "x_min": 76, + "x_max": 917, + "o": "m 176 351 q 99 354 123 351 q 76 368 76 358 q 80 384 76 373 q 92 409 85 395 q 109 436 100 422 q 130 463 119 451 q 152 483 140 475 q 173 491 163 490 l 498 497 q 537 497 506 497 q 610 498 568 498 q 699 499 652 499 q 786 500 746 500 q 854 502 827 501 q 884 502 882 502 q 901 498 893 501 q 912 491 907 496 q 917 478 917 487 q 915 452 917 469 q 910 416 913 434 l 904 371 q 902 364 903 367 q 898 359 901 361 q 888 356 895 357 q 871 355 882 355 l 709 355 q 645 355 677 355 q 584 355 613 355 q 532 354 555 355 q 496 354 509 354 l 176 351 z " + }, + "‘": { + "ha": 389, + "x_min": 107, + "x_max": 412, + "o": "m 292 1056 q 303 1049 299 1051 q 315 1044 307 1047 q 338 1033 323 1040 q 383 1008 353 1025 q 405 989 405 997 q 403 984 405 987 q 362 920 383 954 q 321 852 340 886 l 378 851 q 401 851 391 851 q 412 844 412 850 l 412 843 q 407 787 409 804 q 405 762 406 769 q 404 751 404 754 q 403 739 404 748 q 400 707 402 729 q 395 642 399 686 q 390 610 393 619 q 376 600 387 602 q 312 600 345 600 q 246 599 279 600 q 180 599 213 599 q 119 599 148 599 q 107 615 107 599 l 119 781 q 123 806 121 796 q 126 825 125 817 q 131 842 128 833 q 136 861 133 850 q 150 885 140 870 q 171 918 159 901 q 196 954 183 936 q 221 988 209 972 q 241 1016 233 1004 q 254 1031 250 1027 q 259 1038 256 1033 q 266 1047 262 1042 q 274 1055 269 1051 q 284 1058 279 1058 q 292 1056 288 1058 z " + }, + "’": { + "ha": 370, + "x_min": 109, + "x_max": 414, + "o": "m 230 524 q 218 530 222 528 q 206 536 214 532 q 183 548 198 540 q 138 572 168 555 q 117 591 117 583 q 119 596 117 593 q 160 660 138 627 q 201 728 181 694 l 143 729 l 131 729 q 115 730 121 729 q 109 736 109 731 l 109 737 q 114 783 112 766 q 116 809 115 800 q 117 822 117 818 q 118 829 117 826 q 118 838 118 833 q 119 854 119 842 q 122 885 120 865 q 126 939 123 905 q 131 970 127 961 q 146 980 134 979 q 210 980 176 980 q 276 981 243 981 q 341 981 309 981 q 403 981 373 981 q 414 965 414 981 l 402 800 q 398 774 400 784 q 395 755 397 764 q 391 739 393 747 q 385 720 389 730 q 372 695 381 710 q 350 662 362 680 q 325 626 338 644 q 300 592 311 608 q 279 565 288 576 q 268 549 271 553 q 263 542 266 547 q 256 533 260 538 q 247 526 252 529 q 237 522 242 522 q 230 524 234 522 z " + }, + "‚": { + "ha": 398, + "x_min": 56, + "x_max": 361, + "o": "m 176 -225 q 164 -219 169 -221 q 152 -214 160 -218 q 129 -202 144 -210 q 85 -178 114 -194 q 63 -159 63 -166 q 65 -153 63 -157 q 106 -89 85 -123 q 147 -21 127 -56 l 90 -20 l 77 -20 q 62 -19 68 -20 q 56 -14 56 -18 l 56 -12 q 60 34 58 17 q 62 59 62 50 q 63 73 63 68 q 64 80 64 77 q 65 88 64 83 q 66 104 65 93 q 68 136 66 116 q 73 189 70 156 q 77 220 74 212 q 92 231 80 229 q 156 231 123 231 q 222 232 189 231 q 287 232 255 232 q 349 232 319 232 q 361 216 361 232 l 349 50 q 345 24 347 35 q 341 6 343 14 q 337 -11 340 -3 q 332 -30 335 -19 q 318 -55 328 -39 q 296 -87 309 -70 q 271 -123 284 -105 q 246 -157 258 -141 q 226 -184 235 -173 q 214 -200 217 -196 q 209 -207 212 -203 q 202 -216 206 -212 q 193 -224 198 -220 q 184 -227 189 -227 q 176 -225 180 -227 z " + }, + "“": { + "ha": 747, + "x_min": 107, + "x_max": 770, + "o": "m 649 1056 q 661 1049 656 1051 q 672 1044 665 1047 q 695 1033 680 1040 q 741 1008 711 1025 q 762 989 762 997 q 760 984 762 987 q 719 920 741 954 q 678 852 698 886 l 736 851 q 759 851 748 851 q 770 844 770 850 l 770 843 q 765 787 766 804 q 762 762 763 769 q 761 751 762 754 q 760 739 761 748 q 758 707 760 729 q 753 642 756 686 q 748 610 751 619 q 733 600 745 602 q 669 600 703 600 q 603 599 636 600 q 538 599 570 599 q 476 599 505 599 q 465 615 465 599 l 477 781 q 480 806 479 796 q 484 825 482 817 q 488 842 486 833 q 494 861 490 850 q 507 885 498 870 q 529 918 517 901 q 553 954 541 936 q 578 988 566 972 q 599 1016 590 1004 q 611 1031 608 1027 q 616 1038 613 1033 q 623 1047 619 1042 q 631 1055 627 1051 q 642 1058 636 1058 q 649 1056 645 1058 m 292 1056 q 303 1049 299 1051 q 315 1044 307 1047 q 338 1033 323 1040 q 383 1008 353 1025 q 405 989 405 997 q 403 984 405 987 q 362 920 383 954 q 321 852 340 886 l 378 851 q 401 851 391 851 q 412 844 412 850 l 412 843 q 407 787 409 804 q 405 762 406 769 q 404 751 404 754 q 403 739 404 748 q 400 707 402 729 q 395 642 399 686 q 390 610 393 619 q 376 600 387 602 q 312 600 345 600 q 246 599 279 600 q 180 599 213 599 q 119 599 148 599 q 107 615 107 599 l 119 781 q 123 806 121 796 q 126 825 125 817 q 131 842 128 833 q 136 861 133 850 q 150 885 140 870 q 171 918 159 901 q 196 954 183 936 q 221 988 209 972 q 241 1016 233 1004 q 254 1031 250 1027 q 259 1038 256 1033 q 266 1047 262 1042 q 274 1055 269 1051 q 284 1058 279 1058 q 292 1056 288 1058 z " + }, + "”": { + "ha": 727, + "x_min": 109, + "x_max": 771, + "o": "m 587 524 q 575 530 579 528 q 563 536 570 532 q 539 548 555 540 q 495 572 524 555 q 473 591 473 583 q 475 596 473 593 q 516 660 495 627 q 557 728 538 694 l 500 729 l 488 729 q 472 730 478 729 q 466 736 466 731 l 466 737 q 470 783 469 766 q 473 809 472 800 q 474 822 473 818 q 474 829 474 826 q 475 838 475 833 q 476 854 475 842 q 478 885 477 865 q 483 939 480 905 q 487 970 484 961 q 503 980 490 979 q 566 980 533 980 q 632 981 600 981 q 697 981 665 981 q 760 981 730 981 q 771 965 771 981 l 759 800 q 755 774 757 784 q 752 755 753 764 q 748 739 750 747 q 742 720 745 730 q 728 695 738 710 q 707 662 719 680 q 681 626 694 644 q 656 592 668 608 q 636 565 645 576 q 625 549 627 553 q 620 542 623 547 q 612 533 616 538 q 604 526 608 529 q 594 522 599 522 q 587 524 591 522 m 230 524 q 218 530 222 528 q 206 536 214 532 q 183 548 198 540 q 138 572 168 555 q 117 591 117 583 q 119 596 117 593 q 160 660 138 627 q 201 728 181 694 l 143 729 l 131 729 q 115 730 121 729 q 109 736 109 731 l 109 737 q 114 783 112 766 q 116 809 115 800 q 117 822 117 818 q 118 829 117 826 q 118 838 118 833 q 119 854 119 842 q 122 885 120 865 q 126 939 123 905 q 131 970 127 961 q 146 980 134 979 q 210 980 176 980 q 276 981 243 981 q 341 981 309 981 q 403 981 373 981 q 414 965 414 981 l 402 800 q 398 774 400 784 q 395 755 397 764 q 391 739 393 747 q 385 720 389 730 q 372 695 381 710 q 350 662 362 680 q 325 626 338 644 q 300 592 311 608 q 279 565 288 576 q 268 549 271 553 q 263 542 266 547 q 256 533 260 538 q 247 526 252 529 q 237 522 242 522 q 230 524 234 522 z " + }, + "„": { + "ha": 781, + "x_min": 56, + "x_max": 745, + "o": "m 560 -225 q 548 -219 553 -221 q 536 -214 544 -218 q 513 -202 528 -210 q 469 -178 498 -194 q 447 -159 447 -166 q 449 -153 447 -157 q 490 -89 469 -123 q 531 -21 511 -56 l 473 -20 l 461 -20 q 446 -19 452 -20 q 439 -14 439 -18 l 439 -12 q 444 34 442 17 q 446 59 446 50 q 447 73 447 68 q 448 80 448 77 q 449 88 448 83 q 450 104 449 93 q 452 136 450 116 q 456 189 454 156 q 461 220 458 212 q 476 231 464 229 q 540 231 507 231 q 606 232 573 231 q 671 232 639 232 q 733 232 703 232 q 745 216 745 232 l 732 50 q 729 24 730 35 q 725 6 727 14 q 721 -11 724 -3 q 715 -30 719 -19 q 702 -55 711 -39 q 680 -87 692 -70 q 655 -123 668 -105 q 630 -157 642 -141 q 610 -184 618 -173 q 598 -200 601 -196 q 593 -207 596 -203 q 586 -216 590 -212 q 577 -224 582 -220 q 568 -227 572 -227 q 560 -225 564 -227 m 176 -225 q 164 -219 169 -221 q 152 -214 160 -218 q 129 -202 144 -210 q 85 -178 114 -194 q 63 -159 63 -166 q 65 -153 63 -157 q 106 -89 85 -123 q 147 -21 127 -56 l 90 -20 l 77 -20 q 62 -19 68 -20 q 56 -14 56 -18 l 56 -12 q 60 34 58 17 q 62 59 62 50 q 63 73 63 68 q 64 80 64 77 q 65 88 64 83 q 66 104 65 93 q 68 136 66 116 q 73 189 70 156 q 77 220 74 212 q 92 231 80 229 q 156 231 123 231 q 222 232 189 231 q 287 232 255 232 q 349 232 319 232 q 361 216 361 232 l 349 50 q 345 24 347 35 q 341 6 343 14 q 337 -11 340 -3 q 332 -30 335 -19 q 318 -55 328 -39 q 296 -87 309 -70 q 271 -123 284 -105 q 246 -157 258 -141 q 226 -184 235 -173 q 214 -200 217 -196 q 209 -207 212 -203 q 202 -216 206 -212 q 193 -224 198 -220 q 184 -227 189 -227 q 176 -225 180 -227 z " + }, + "†": { + "ha": 612, + "x_min": 91, + "x_max": 640, + "o": "m 437 623 l 406 96 q 406 87 406 91 q 406 81 406 83 q 403 73 406 75 q 389 66 400 70 l 309 68 q 299 67 307 68 q 283 66 292 67 q 267 65 275 66 q 255 65 258 65 q 245 69 250 65 q 241 84 241 73 l 241 87 q 244 144 242 117 q 248 199 245 172 q 252 253 250 226 q 256 311 254 281 q 261 382 258 342 q 265 465 263 422 q 270 547 267 507 q 274 621 272 588 l 190 621 l 172 621 q 110 624 128 621 q 91 638 91 627 q 93 659 91 646 q 97 687 94 672 q 102 718 99 703 q 109 745 106 733 q 119 754 115 750 q 140 760 123 759 l 222 762 q 238 763 222 762 q 279 764 254 764 l 292 933 q 295 961 293 954 q 304 971 296 968 q 451 979 380 975 q 454 980 452 980 q 459 974 459 980 l 446 768 q 551 769 513 768 q 608 771 589 771 q 624 768 616 770 q 635 761 630 765 q 640 747 640 756 q 638 721 640 739 q 633 686 636 703 l 627 640 q 626 633 627 636 q 621 629 625 631 q 611 626 618 627 q 594 625 605 625 q 585 624 594 625 q 558 624 575 624 q 518 624 541 624 q 468 623 495 623 l 437 623 z " + }, + "‡": { + "ha": 684, + "x_min": 68, + "x_max": 692, + "o": "m 465 236 l 457 109 q 457 101 457 104 q 458 94 458 97 q 455 86 458 89 q 441 79 452 83 l 333 20 q 321 15 328 18 q 307 11 314 11 q 297 15 301 11 q 292 30 292 19 q 296 135 292 86 q 304 235 300 184 l 168 234 q 92 237 115 233 q 68 250 68 241 q 73 267 68 256 q 85 291 77 278 q 102 319 92 304 q 122 346 111 334 q 144 365 133 357 q 165 374 155 373 l 248 375 q 265 376 248 376 q 311 378 283 377 q 318 503 314 439 q 326 621 322 566 l 194 621 q 118 624 141 620 q 94 637 94 627 q 99 653 94 642 q 111 678 103 665 q 127 706 118 691 q 148 732 137 720 q 170 752 159 744 q 191 760 181 760 l 273 762 q 289 763 273 762 q 331 764 305 764 l 336 892 q 339 920 338 912 q 349 930 341 928 q 426 951 387 941 q 503 979 464 962 q 506 980 504 980 q 511 974 511 980 l 498 768 q 603 769 565 768 q 659 771 640 771 q 675 768 668 770 q 687 761 682 765 q 692 747 692 756 q 690 721 692 739 q 685 686 688 703 l 679 640 q 677 633 678 636 q 673 629 676 631 q 663 626 669 627 q 646 625 656 625 q 636 624 646 625 q 610 624 627 624 q 570 624 593 624 q 519 623 547 624 l 489 623 l 474 381 q 577 384 539 382 q 633 385 615 385 q 650 381 642 384 q 661 374 656 378 q 666 361 666 370 q 664 334 666 352 q 659 299 662 317 l 653 254 q 651 247 652 250 q 647 242 650 244 q 637 239 644 240 q 620 238 631 238 q 610 238 620 238 q 584 237 601 237 q 544 237 567 237 q 494 236 521 237 l 465 236 z " + }, + "•": { + "ha": 663, + "x_min": 90, + "x_max": 656, + "o": "m 349 203 q 236 225 285 203 q 155 283 188 247 q 106 367 123 319 q 90 465 90 414 q 115 581 90 528 q 184 671 140 634 q 284 729 227 709 q 406 750 342 750 q 507 731 460 750 q 586 678 553 711 q 638 599 619 644 q 656 502 656 553 q 632 376 656 431 q 566 282 608 321 q 468 223 524 243 q 349 203 412 203 z " + }, + "…": { + "ha": 1174, + "x_min": 61, + "x_max": 1138, + "o": "m 61 -26 q 63 25 62 6 q 64 54 64 43 q 64 68 64 64 q 64 76 64 72 q 65 85 64 79 q 66 103 65 90 q 68 137 66 115 q 71 196 69 159 q 76 227 73 218 q 91 237 79 235 q 216 238 151 237 q 338 239 281 238 q 349 235 345 239 q 353 222 353 232 l 334 9 q 329 -21 331 -13 q 313 -31 326 -30 q 195 -33 256 -32 q 68 -33 134 -33 q 63 -33 64 -33 q 61 -28 61 -32 l 61 -26 m 454 -26 q 456 25 455 6 q 457 54 456 43 q 457 68 457 64 q 457 76 457 72 q 457 85 457 79 q 458 103 458 90 q 460 137 459 115 q 464 196 462 159 q 468 227 465 218 q 484 237 471 235 q 608 238 543 237 q 731 239 673 238 q 741 235 737 239 q 745 222 745 232 l 726 9 q 721 -21 724 -13 q 706 -31 719 -30 q 588 -33 648 -32 q 461 -33 527 -33 q 455 -33 457 -33 q 454 -28 454 -32 l 454 -26 m 846 -26 q 848 25 848 6 q 849 54 849 43 q 850 68 850 64 q 850 76 850 72 q 850 85 850 79 q 851 103 850 90 q 853 137 852 115 q 857 196 854 159 q 861 227 858 218 q 876 237 864 235 q 1001 238 936 237 q 1124 239 1066 238 q 1134 235 1130 239 q 1138 222 1138 232 l 1119 9 q 1114 -21 1116 -13 q 1099 -31 1112 -30 q 980 -33 1041 -32 q 854 -33 920 -33 q 848 -33 850 -33 q 846 -28 846 -32 l 846 -26 z " + }, + "‰": { + "ha": 2116, + "x_min": 103, + "x_max": 2104, + "o": "m 717 586 q 827 720 770 652 q 938 857 883 789 q 950 871 946 867 q 961 876 954 876 q 967 876 966 876 q 1032 825 1000 849 q 1096 782 1063 802 q 1110 775 1101 780 q 1118 766 1118 771 q 1116 761 1118 763 l 582 113 q 575 103 576 106 q 568 100 574 100 q 561 101 565 100 q 549 104 556 102 q 487 153 510 136 q 450 180 463 171 q 431 193 437 190 q 422 200 426 197 q 416 209 417 203 q 416 222 416 214 q 493 312 456 268 q 566 400 530 356 q 639 491 602 445 q 717 586 676 536 m 103 764 q 121 889 103 831 q 175 989 139 947 q 265 1056 211 1031 q 391 1080 319 1080 q 508 1056 458 1080 q 591 991 558 1031 q 639 899 623 951 q 654 791 654 846 q 634 661 654 718 q 576 564 614 604 q 487 503 539 524 q 372 482 435 482 q 263 502 313 482 q 178 559 214 522 q 123 648 142 595 q 103 764 103 700 m 277 778 q 281 724 277 751 q 296 675 285 697 q 326 639 307 653 q 375 625 345 625 q 428 641 407 625 q 461 682 449 657 q 479 735 474 706 q 484 793 484 765 q 479 842 484 816 q 463 890 474 868 q 433 927 452 913 q 387 942 414 942 q 334 927 355 942 q 300 888 313 912 q 282 836 287 865 q 277 778 277 806 m 924 247 q 942 372 924 314 q 996 472 960 429 q 1086 538 1032 514 q 1212 563 1140 563 q 1330 538 1280 563 q 1412 474 1379 514 q 1460 381 1444 433 q 1476 273 1476 329 q 1455 143 1476 201 q 1398 46 1435 86 q 1308 -14 1360 7 q 1193 -35 1256 -35 q 1084 -15 1134 -35 q 999 41 1035 5 q 944 130 964 78 q 924 247 924 182 m 1098 260 q 1102 207 1098 234 q 1117 158 1106 180 q 1147 122 1128 136 q 1196 108 1166 108 q 1249 124 1228 108 q 1283 164 1270 140 q 1300 218 1295 189 q 1305 275 1305 248 q 1300 325 1305 298 q 1284 373 1295 351 q 1254 410 1273 395 q 1208 425 1236 425 q 1155 410 1176 425 q 1121 371 1134 395 q 1103 318 1108 347 q 1098 260 1098 289 m 1553 247 q 1571 372 1553 314 q 1625 472 1589 429 q 1715 538 1661 514 q 1841 563 1769 563 q 1958 538 1908 563 q 2041 474 2008 514 q 2089 381 2073 433 q 2104 273 2104 329 q 2084 143 2104 201 q 2026 46 2064 86 q 1937 -14 1989 7 q 1822 -35 1885 -35 q 1713 -15 1763 -35 q 1628 41 1664 5 q 1573 130 1592 78 q 1553 247 1553 182 m 1727 260 q 1731 207 1727 234 q 1746 158 1735 180 q 1776 122 1757 136 q 1825 108 1795 108 q 1878 124 1857 108 q 1911 164 1899 140 q 1929 218 1924 189 q 1934 275 1934 248 q 1929 325 1934 298 q 1913 373 1924 351 q 1883 410 1902 395 q 1837 425 1864 425 q 1784 410 1805 425 q 1750 371 1763 395 q 1732 318 1737 347 q 1727 260 1727 289 z " + }, + "‹": { + "ha": 712, + "x_min": 70, + "x_max": 694, + "o": "m 689 220 q 694 212 694 216 q 693 204 694 208 q 646 100 665 153 q 608 -7 627 46 q 591 -20 604 -20 q 581 -18 586 -20 q 566 -12 575 -16 l 121 248 q 92 265 99 258 q 84 286 84 272 q 79 390 82 337 q 71 500 77 443 q 70 502 71 501 q 70 503 70 503 l 71 508 q 74 514 71 511 q 79 516 77 516 q 137 542 101 527 q 216 573 172 556 q 311 609 260 591 q 414 648 361 628 q 519 687 467 667 q 618 724 571 706 q 649 730 640 730 q 660 720 658 730 q 669 610 664 667 q 686 502 674 553 q 678 486 686 487 l 380 376 q 410 361 387 372 q 463 334 433 349 q 528 301 494 319 q 595 268 563 283 q 652 239 627 252 q 689 220 677 226 z " + }, + "›": { + "ha": 692, + "x_min": 78, + "x_max": 703, + "o": "m 83 220 q 120 239 95 226 q 178 268 146 252 q 244 301 210 283 q 309 334 279 319 q 362 361 340 349 q 393 376 385 372 l 94 486 q 86 502 86 487 q 103 610 98 553 q 113 720 109 667 q 123 730 115 730 q 154 724 132 730 q 254 687 201 706 q 358 648 306 667 q 461 609 411 628 q 556 573 512 591 q 636 542 600 556 q 694 516 671 527 q 699 514 696 516 q 702 508 701 511 l 703 503 q 702 502 703 503 q 702 500 702 501 q 693 390 696 443 q 688 286 690 337 q 681 265 688 272 q 652 248 673 258 l 207 -12 q 192 -18 197 -16 q 181 -20 186 -20 q 170 -17 174 -20 q 164 -7 167 -14 q 125 100 144 46 q 79 204 106 153 q 78 212 78 208 q 79 217 78 215 q 83 220 80 218 z " + }, + "⁄": { + "ha": 503, + "x_min": -6, + "x_max": 601, + "o": "m 256 640 q 336 819 298 729 q 414 1001 374 908 q 428 1028 423 1021 q 442 1036 433 1036 l 447 1036 q 519 1004 484 1021 q 591 980 555 987 q 598 977 595 979 q 601 971 601 975 l 601 969 l 166 10 q 162 -1 163 4 q 159 -11 161 -7 q 155 -17 157 -15 q 146 -20 152 -20 q 129 -15 138 -20 l 60 14 q 44 19 55 16 q 22 28 33 23 q 2 40 11 33 q -6 56 -6 47 q -3 68 -6 62 q 57 198 28 134 q 116 329 86 261 q 180 473 146 397 q 256 640 214 549 z " + }, + "⁴": { + "ha": 599, + "x_min": 43, + "x_max": 615, + "o": "m 602 562 q 592 513 597 538 q 590 503 591 506 q 584 499 590 500 q 562 498 578 498 q 515 497 547 498 l 507 418 q 506 410 507 414 q 500 405 505 406 q 486 399 497 404 q 460 389 475 395 q 428 377 445 383 q 395 366 410 371 q 367 357 379 360 q 351 353 355 353 q 340 356 345 353 q 336 369 336 359 q 338 421 336 389 q 343 495 340 454 q 270 495 307 495 q 203 494 234 494 q 149 494 172 494 q 114 494 125 494 q 59 496 75 494 q 43 509 43 499 q 43 514 43 511 q 47 520 44 517 q 61 544 51 527 q 85 585 71 562 q 117 637 100 609 q 152 694 134 666 l 241 843 q 271 897 258 870 q 299 945 283 923 q 336 982 315 966 q 392 1005 358 998 l 528 1041 q 534 1042 532 1042 q 540 1042 537 1042 q 548 1040 545 1042 q 551 1029 551 1037 l 551 1024 q 545 920 548 964 q 540 834 543 876 q 534 748 537 793 q 529 644 532 703 q 577 645 558 644 q 600 645 596 645 q 610 641 605 645 q 615 629 615 637 q 614 625 615 626 l 602 562 m 355 637 q 363 701 359 667 q 370 768 367 734 q 375 836 373 802 q 378 901 377 869 l 221 637 l 355 637 z " + }, + "€": { + "ha": 985, + "x_min": 72, + "x_max": 991, + "o": "m 659 638 q 670 637 666 638 q 675 633 673 635 q 677 629 677 631 q 679 624 677 627 q 687 593 684 610 q 689 558 689 575 q 686 525 689 536 q 671 509 682 514 q 639 503 660 503 q 583 502 618 502 l 488 502 q 486 478 487 490 q 486 452 486 465 l 659 447 q 675 438 669 447 q 685 418 682 430 q 688 392 688 406 q 689 367 689 378 q 686 334 689 345 q 671 317 682 323 q 639 311 660 312 q 583 311 618 311 l 513 311 q 578 226 536 258 q 682 195 621 195 q 751 197 721 195 q 806 206 781 200 q 857 222 832 212 q 910 248 882 233 q 917 249 912 249 q 923 245 922 249 q 925 230 925 241 q 925 171 925 205 q 925 105 925 136 q 924 51 924 73 q 922 28 923 28 q 856 -4 891 9 q 782 -23 820 -16 q 707 -33 745 -31 q 633 -35 669 -35 q 480 -11 552 -35 q 350 60 408 14 q 251 170 292 106 q 191 314 210 235 q 133 314 156 314 q 96 315 110 313 q 77 321 83 317 q 72 336 72 326 q 73 366 72 338 q 79 437 74 393 q 91 449 84 445 q 105 455 98 453 q 116 457 112 457 q 121 458 121 458 q 143 458 128 458 q 178 457 158 458 q 179 481 178 469 q 181 505 180 494 q 134 505 153 505 q 105 507 115 505 q 90 514 94 509 q 85 530 85 519 q 88 555 85 542 l 90 587 q 91 612 91 600 q 93 628 92 624 q 105 640 98 636 q 119 646 112 644 q 130 649 125 648 q 134 649 134 649 q 165 649 144 649 q 216 648 186 649 q 293 784 245 722 q 403 889 340 845 q 542 958 466 934 q 703 983 618 983 q 807 975 762 983 q 886 955 852 967 q 942 929 920 943 q 980 902 965 915 q 979 897 979 902 q 981 860 979 887 q 985 800 983 833 q 989 732 987 766 q 991 673 991 698 q 988 664 991 669 q 975 659 985 659 q 960 661 968 659 q 924 679 945 665 q 873 710 902 694 q 809 738 844 726 q 731 751 773 751 q 617 722 666 751 q 535 642 568 692 l 659 638 z " + }, + "™": { + "ha": 1327, + "x_min": 149, + "x_max": 1298, + "o": "m 262 376 l 296 822 q 235 823 269 823 q 155 823 202 823 q 150 826 151 823 q 149 833 149 830 l 149 836 l 195 948 q 194 949 194 948 l 194 950 q 210 955 194 952 q 252 960 226 958 q 314 965 279 963 q 387 970 349 968 q 463 975 425 972 q 536 979 502 977 q 596 982 570 981 q 638 984 623 983 q 649 983 644 984 q 654 974 654 981 q 654 972 654 972 q 654 970 654 971 q 631 908 640 937 q 608 845 621 879 q 606 842 607 845 q 602 834 604 838 q 595 826 600 829 q 585 821 591 822 q 576 822 581 822 q 565 822 572 822 l 466 822 q 462 782 464 801 q 458 740 460 762 q 453 691 456 718 q 447 629 450 665 l 427 422 q 426 415 427 419 q 420 410 425 412 l 303 368 q 291 363 298 366 q 277 361 283 361 q 267 364 271 361 q 262 374 262 367 l 262 376 m 1196 985 q 1200 986 1198 985 q 1204 986 1202 986 q 1216 981 1211 986 q 1222 964 1221 975 q 1226 929 1223 953 q 1233 880 1230 905 q 1240 834 1237 854 q 1244 809 1243 813 l 1257 730 q 1261 699 1259 714 q 1265 671 1263 684 q 1268 648 1267 658 q 1270 634 1270 638 l 1298 422 l 1294 410 q 1284 402 1293 407 q 1260 392 1274 397 q 1230 381 1246 387 q 1200 371 1214 376 q 1176 364 1186 367 q 1164 361 1166 361 q 1154 365 1158 361 q 1147 376 1149 368 q 1138 422 1142 397 q 1130 475 1134 447 q 1124 532 1126 503 q 1118 590 1121 562 l 1102 711 l 949 430 q 921 411 936 411 q 908 416 913 411 q 900 430 903 422 q 887 474 892 456 q 876 507 881 492 q 867 536 871 522 q 858 568 863 550 q 846 612 852 587 q 831 673 840 637 q 823 635 826 652 q 818 614 819 619 l 760 368 q 750 363 760 363 q 725 367 741 363 q 694 377 710 372 q 663 390 678 383 q 638 400 648 396 q 632 404 635 401 q 629 414 629 407 q 632 431 629 419 q 640 458 635 443 q 648 489 644 473 q 656 520 652 505 q 663 552 659 536 q 671 587 667 569 l 722 816 q 733 863 728 840 q 742 905 738 886 q 749 937 747 924 q 752 954 752 950 l 856 985 q 866 984 861 985 q 871 977 870 983 l 974 719 l 1067 950 l 1196 985 z " + }, + "−": { + "ha": 739, + "x_min": 102, + "x_max": 700, + "o": "m 202 351 q 126 354 149 351 q 102 367 102 357 q 107 384 102 372 q 119 408 111 395 q 136 436 126 421 q 156 462 145 450 q 178 482 167 474 q 199 490 189 490 l 438 497 q 466 497 446 497 q 509 498 485 498 q 561 499 534 498 q 610 500 587 500 q 649 501 633 500 q 667 501 665 501 q 684 498 676 500 q 695 491 690 495 q 700 477 700 486 q 698 451 700 469 q 693 416 696 433 l 687 370 q 685 363 686 366 q 681 359 684 361 q 671 356 677 357 q 654 355 665 355 l 571 355 q 536 355 554 355 q 500 354 517 355 q 466 354 482 354 q 436 354 449 354 l 202 351 z " + }, + "fi": { + "ha": 1046, + "x_min": 47, + "x_max": 1078, + "o": "m 751 523 l 475 523 q 461 382 468 452 q 448 251 454 313 q 437 142 442 190 q 430 68 433 94 q 429 61 429 66 q 430 56 429 58 q 430 53 430 54 q 426 43 430 47 q 403 35 421 39 l 224 -28 q 205 -35 217 -31 q 181 -39 193 -39 q 164 -35 172 -39 q 157 -18 157 -30 l 157 -14 q 160 18 158 -7 q 166 82 163 43 q 174 170 170 120 q 184 277 178 220 q 195 396 189 334 q 207 521 201 458 l 117 521 q 64 524 80 521 q 47 536 47 527 q 54 551 47 539 q 72 579 61 563 q 94 615 82 596 q 118 651 106 634 q 137 680 129 667 q 147 694 145 692 l 230 694 l 243 802 q 333 1037 261 960 q 543 1114 405 1114 q 572 1109 553 1114 q 611 1098 590 1105 q 655 1083 633 1092 q 695 1066 677 1075 q 724 1051 713 1058 q 735 1039 735 1044 q 730 1025 735 1034 q 703 963 712 987 q 688 924 693 939 q 679 904 682 909 q 669 898 675 898 q 659 904 664 903 q 647 905 654 905 q 620 916 633 911 q 594 921 608 921 q 542 902 564 921 q 507 830 519 883 q 495 764 499 800 q 486 694 490 729 l 796 694 q 910 708 851 694 q 1024 736 969 721 q 1030 736 1026 736 q 1036 731 1032 732 l 1038 730 l 977 80 q 971 49 974 58 q 957 38 969 41 q 837 -3 897 20 q 709 -46 777 -25 q 703 -44 704 -46 q 702 -39 702 -43 q 703 -20 702 -39 q 707 26 705 -2 q 712 91 709 55 q 718 163 715 127 q 724 232 721 199 q 728 288 726 264 l 751 523 m 1078 1120 q 1075 1076 1076 1091 q 1074 1052 1074 1060 q 1073 1042 1073 1044 q 1073 1039 1073 1040 q 1072 1035 1072 1038 q 1071 1022 1072 1032 q 1069 994 1070 1013 q 1065 941 1067 975 q 1060 910 1063 919 q 1045 899 1057 902 q 921 873 985 887 q 796 842 856 860 l 789 849 l 804 1035 q 809 1066 806 1057 q 823 1077 811 1074 q 943 1104 882 1088 q 1071 1134 1003 1120 q 1077 1130 1076 1134 q 1078 1120 1078 1126 z " + }, + "fl": { + "ha": 1181, + "x_min": 47, + "x_max": 1198, + "o": "m 157 -10 q 160 22 158 -3 q 166 85 163 47 q 174 172 170 123 q 184 278 178 221 q 195 396 189 334 q 207 521 201 458 l 117 521 q 64 524 80 521 q 47 536 47 527 q 54 551 47 539 q 72 579 61 563 q 94 615 82 596 q 118 651 106 634 q 137 680 129 667 q 147 694 145 692 l 230 694 l 243 802 q 278 946 253 888 q 339 1040 302 1004 q 424 1092 376 1076 q 526 1107 471 1107 q 638 1094 580 1107 q 757 1058 697 1081 q 776 1049 770 1053 q 783 1039 783 1045 q 778 1025 783 1034 q 746 957 756 983 q 729 916 735 931 q 719 896 723 901 q 707 891 715 891 q 698 893 703 891 q 688 898 694 895 q 640 914 664 907 q 594 921 616 921 q 542 902 564 921 q 507 830 519 883 q 494 764 499 800 q 486 694 490 729 l 663 694 q 674 691 669 694 q 679 678 679 687 q 671 654 679 675 q 648 599 663 633 l 623 544 q 613 529 618 534 q 593 523 608 524 l 475 523 q 461 383 468 452 q 448 253 454 314 q 437 145 442 193 q 430 73 433 98 q 429 65 429 71 q 430 60 429 62 q 430 57 430 58 q 426 47 430 51 q 403 39 421 43 l 224 -24 q 205 -31 217 -27 q 181 -35 193 -35 q 163 -30 170 -35 q 157 -10 157 -24 m 887 574 q 900 758 895 667 q 911 944 905 848 q 916 975 913 966 q 931 986 919 983 q 1058 1009 994 998 q 1185 1041 1121 1021 q 1191 1042 1187 1042 q 1198 1035 1198 1042 l 1110 66 l 1110 55 q 1105 45 1110 49 q 1083 37 1101 41 l 905 -28 q 886 -36 898 -32 q 862 -39 873 -39 q 845 -35 852 -39 q 838 -18 838 -30 l 838 -14 q 849 120 844 54 q 860 255 854 185 q 872 403 865 325 q 887 574 879 481 z " + } + }, + "familyName": "Carter One", + "ascender": 1535, + "descender": -604, + "underlinePosition": -104, + "underlineThickness": 69, + "boundingBox": { + "yMin": -604, + "xMin": -193, + "yMax": 1535, + "xMax": 2104 + }, + "resolution": 1000, + "original_font_information": { + "format": 0, + "copyright": "Copyright (c) 2011 by vernon adams. All rights reserved.", + "fontFamily": "Carter One", + "fontSubfamily": "Regular", + "uniqueID": "vernonadams: Carter One: 2011", + "fullName": "Carter One", + "version": "Version 1.000", + "postScriptName": "CarterOne", + "trademark": "Carter One is a trademark of vernon adams.", + "manufacturer": "vernon adams", + "designer": "vernon adams", + "description": "Copyright (c) 2011 by vernon adams. All rights reserved.", + "licenceURL": "http://scripts.sil.org/OFL" + }, + "cssFontWeight": "normal", + "cssFontStyle": "normal" +} \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/gifs/2D_CANVAS_DEMO.webp b/frontend/apps/artcraft/app/public/resources/gifs/2D_CANVAS_DEMO.webp new file mode 100644 index 0000000000..975d642dcc Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/gifs/2D_CANVAS_DEMO.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/gifs/3D_CANVAS_DEMO.webp b/frontend/apps/artcraft/app/public/resources/gifs/3D_CANVAS_DEMO.webp new file mode 100644 index 0000000000..0c2b11291c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/gifs/3D_CANVAS_DEMO.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/gifs/artcraft-logo-load.gif b/frontend/apps/artcraft/app/public/resources/gifs/artcraft-logo-load.gif new file mode 100644 index 0000000000..6d5e2bcac2 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/gifs/artcraft-logo-load.gif differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/0.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/0.webp new file mode 100644 index 0000000000..22d64316ab Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/0.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/1.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/1.webp new file mode 100644 index 0000000000..39e12d0bb4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/1.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/10.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/10.webp new file mode 100644 index 0000000000..1bdfaba961 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/10.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/11.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/11.webp new file mode 100644 index 0000000000..925c954737 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/11.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/12.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/12.webp new file mode 100644 index 0000000000..927cb714c4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/12.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/13.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/13.webp new file mode 100644 index 0000000000..68c2ab838f Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/13.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/14.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/14.webp new file mode 100644 index 0000000000..84582b89c9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/14.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/15.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/15.webp new file mode 100644 index 0000000000..d56fb04a7d Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/15.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/16.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/16.webp new file mode 100644 index 0000000000..6b280616c2 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/16.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/17.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/17.webp new file mode 100644 index 0000000000..d13b86daa6 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/17.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/18.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/18.webp new file mode 100644 index 0000000000..29f751a168 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/18.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/19.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/19.webp new file mode 100644 index 0000000000..7bed881ef0 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/19.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/2.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/2.webp new file mode 100644 index 0000000000..9715e6bcd6 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/2.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/20.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/20.webp new file mode 100644 index 0000000000..1968e9f039 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/20.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/21.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/21.webp new file mode 100644 index 0000000000..ea1f1b77f4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/21.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/22.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/22.webp new file mode 100644 index 0000000000..97fd2b89ed Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/22.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/23.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/23.webp new file mode 100644 index 0000000000..9e4a6bce1c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/23.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/24.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/24.webp new file mode 100644 index 0000000000..72bb9ff41f Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/24.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/3.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/3.webp new file mode 100644 index 0000000000..089141a371 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/3.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/4.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/4.webp new file mode 100644 index 0000000000..9f4c72b6fb Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/4.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/5.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/5.webp new file mode 100644 index 0000000000..7c0501f52c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/5.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/6.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/6.webp new file mode 100644 index 0000000000..d0a990bbaa Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/6.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/7.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/7.webp new file mode 100644 index 0000000000..c1a2b9f3f4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/7.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/8.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/8.webp new file mode 100644 index 0000000000..b5ac96d226 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/8.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/default-covers/9.webp b/frontend/apps/artcraft/app/public/resources/images/default-covers/9.webp new file mode 100644 index 0000000000..68627ee8e2 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/default-covers/9.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/digital-mountains.webp b/frontend/apps/artcraft/app/public/resources/images/digital-mountains.webp new file mode 100644 index 0000000000..5fc9663153 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/digital-mountains.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/floating-cubes.webp b/frontend/apps/artcraft/app/public/resources/images/floating-cubes.webp new file mode 100644 index 0000000000..93249331a5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/floating-cubes.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/forest-trees.webp b/frontend/apps/artcraft/app/public/resources/images/forest-trees.webp new file mode 100644 index 0000000000..07935fa76e Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/forest-trees.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/room-of-items.webp b/frontend/apps/artcraft/app/public/resources/images/room-of-items.webp new file mode 100644 index 0000000000..9e3c089235 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/room-of-items.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/images/services/alibaba.svg b/frontend/apps/artcraft/app/public/resources/images/services/alibaba.svg new file mode 100644 index 0000000000..a4bb382a63 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/alibaba.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/artcraft.svg b/frontend/apps/artcraft/app/public/resources/images/services/artcraft.svg new file mode 100644 index 0000000000..3fefcd113e --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/artcraft.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/blackforestlabs.svg b/frontend/apps/artcraft/app/public/resources/images/services/blackforestlabs.svg new file mode 100644 index 0000000000..12a4ba1d76 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/blackforestlabs.svg @@ -0,0 +1 @@ +Flux \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/bytedance.svg b/frontend/apps/artcraft/app/public/resources/images/services/bytedance.svg new file mode 100644 index 0000000000..dec27435c1 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/bytedance.svg @@ -0,0 +1 @@ +ByteDance \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/alibaba.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/alibaba.svg new file mode 100644 index 0000000000..bb458d7ed0 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/alibaba.svg @@ -0,0 +1 @@ +Alibaba \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/bytedance.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/bytedance.svg new file mode 100644 index 0000000000..6921097dac --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/bytedance.svg @@ -0,0 +1 @@ +ByteDance \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/fal.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/fal.svg new file mode 100644 index 0000000000..d06b8054c0 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/fal.svg @@ -0,0 +1 @@ +Fal \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/google.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/google.svg new file mode 100644 index 0000000000..e8e0f867bd --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/kling.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/kling.svg new file mode 100644 index 0000000000..199e2804d1 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/kling.svg @@ -0,0 +1 @@ +Kling \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/meshy.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/meshy.svg new file mode 100644 index 0000000000..dd6bdd557a --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/meshy.svg @@ -0,0 +1 @@ +Meshy \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/minimax.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/minimax.svg new file mode 100644 index 0000000000..beb7adb86e --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/stability.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/stability.svg new file mode 100644 index 0000000000..4ad0535dff --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/stability.svg @@ -0,0 +1 @@ +Stability \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/tencent.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/tencent.svg new file mode 100644 index 0000000000..42edd6cd94 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/tencent.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/tripo.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/tripo.svg new file mode 100644 index 0000000000..748a22b0af --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/tripo.svg @@ -0,0 +1 @@ +Tripo \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/color/vidu.svg b/frontend/apps/artcraft/app/public/resources/images/services/color/vidu.svg new file mode 100644 index 0000000000..f3d890240d --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/color/vidu.svg @@ -0,0 +1 @@ +Vidu \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/fal.svg b/frontend/apps/artcraft/app/public/resources/images/services/fal.svg new file mode 100644 index 0000000000..c41076f911 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/fal.svg @@ -0,0 +1 @@ +Fal \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/generic.svg b/frontend/apps/artcraft/app/public/resources/images/services/generic.svg new file mode 100644 index 0000000000..1c8166697d --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/generic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/google.svg b/frontend/apps/artcraft/app/public/resources/images/services/google.svg new file mode 100644 index 0000000000..87736bbe09 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/google.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/grok.svg b/frontend/apps/artcraft/app/public/resources/images/services/grok.svg new file mode 100644 index 0000000000..efb1a6183c --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/grok.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/higgsfield.svg b/frontend/apps/artcraft/app/public/resources/images/services/higgsfield.svg new file mode 100644 index 0000000000..de89784862 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/higgsfield.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/hyper3d.svg b/frontend/apps/artcraft/app/public/resources/images/services/hyper3d.svg new file mode 100644 index 0000000000..aa99495e5f --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/hyper3d.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/kling.svg b/frontend/apps/artcraft/app/public/resources/images/services/kling.svg new file mode 100644 index 0000000000..32fb6bed29 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/kling.svg @@ -0,0 +1 @@ +Kling \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/krea.svg b/frontend/apps/artcraft/app/public/resources/images/services/krea.svg new file mode 100644 index 0000000000..d112dc3eab --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/krea.svg @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/meshy.svg b/frontend/apps/artcraft/app/public/resources/images/services/meshy.svg new file mode 100644 index 0000000000..da77f22eb2 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/meshy.svg @@ -0,0 +1 @@ +Meshy \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/midjourney.svg b/frontend/apps/artcraft/app/public/resources/images/services/midjourney.svg new file mode 100644 index 0000000000..e59e3b0709 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/midjourney.svg @@ -0,0 +1 @@ +Midjourney \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/minimax.svg b/frontend/apps/artcraft/app/public/resources/images/services/minimax.svg new file mode 100644 index 0000000000..1d32449ab8 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/openai.svg b/frontend/apps/artcraft/app/public/resources/images/services/openai.svg new file mode 100644 index 0000000000..50d94d6c10 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/openart.svg b/frontend/apps/artcraft/app/public/resources/images/services/openart.svg new file mode 100644 index 0000000000..f47a86e82d --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/openart.svg @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/recraft.svg b/frontend/apps/artcraft/app/public/resources/images/services/recraft.svg new file mode 100644 index 0000000000..b4ea390a72 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/recraft.svg @@ -0,0 +1 @@ +Recraft \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/replicate.svg b/frontend/apps/artcraft/app/public/resources/images/services/replicate.svg new file mode 100644 index 0000000000..48f148af26 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/replicate.svg @@ -0,0 +1 @@ +Replicate \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/suno.svg b/frontend/apps/artcraft/app/public/resources/images/services/suno.svg new file mode 100644 index 0000000000..7aceb6d56b --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/suno.svg @@ -0,0 +1 @@ +Suno diff --git a/frontend/apps/artcraft/app/public/resources/images/services/tencent.svg b/frontend/apps/artcraft/app/public/resources/images/services/tencent.svg new file mode 100644 index 0000000000..724a815028 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/tencent.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/tensorart.svg b/frontend/apps/artcraft/app/public/resources/images/services/tensorart.svg new file mode 100644 index 0000000000..d7e32012d4 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/tensorart.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/tripo.svg b/frontend/apps/artcraft/app/public/resources/images/services/tripo.svg new file mode 100644 index 0000000000..40f4c37190 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/tripo.svg @@ -0,0 +1 @@ +Tripo \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/vidu.svg b/frontend/apps/artcraft/app/public/resources/images/services/vidu.svg new file mode 100644 index 0000000000..0cb11823b4 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/vidu.svg @@ -0,0 +1 @@ +Vidu \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/services/worldlabs.svg b/frontend/apps/artcraft/app/public/resources/images/services/worldlabs.svg new file mode 100644 index 0000000000..795cf7d000 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/images/services/worldlabs.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg b/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg new file mode 100644 index 0000000000..7ad1b1bbf4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg b/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg new file mode 100644 index 0000000000..304473c988 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg b/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg new file mode 100644 index 0000000000..bc3932fca4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/logo/artcraft-icon.png b/frontend/apps/artcraft/app/public/resources/logo/artcraft-icon.png new file mode 100644 index 0000000000..ded60e7ef4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/logo/artcraft-icon.png differ diff --git a/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-black.svg b/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-black.svg new file mode 100644 index 0000000000..e28a3ff4d8 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-black.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg b/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg new file mode 100644 index 0000000000..7906a3eb06 --- /dev/null +++ b/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/apps/artcraft/app/public/resources/memes/doge.jpg b/frontend/apps/artcraft/app/public/resources/memes/doge.jpg new file mode 100644 index 0000000000..aadbba17df Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/memes/doge.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/memes/grimace.jpg b/frontend/apps/artcraft/app/public/resources/memes/grimace.jpg new file mode 100644 index 0000000000..5dba23f510 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/memes/grimace.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/memes/jd_vance.jpg b/frontend/apps/artcraft/app/public/resources/memes/jd_vance.jpg new file mode 100644 index 0000000000..b32376fc27 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/memes/jd_vance.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/memes/lil_jd.jpg b/frontend/apps/artcraft/app/public/resources/memes/lil_jd.jpg new file mode 100644 index 0000000000..01d26978da Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/memes/lil_jd.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/memes/roblox.jpg b/frontend/apps/artcraft/app/public/resources/memes/roblox.jpg new file mode 100644 index 0000000000..595a0234be Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/memes/roblox.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/memes/toothless.jpg b/frontend/apps/artcraft/app/public/resources/memes/toothless.jpg new file mode 100644 index 0000000000..1eb4fa52b9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/memes/toothless.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/objects/crown.png b/frontend/apps/artcraft/app/public/resources/objects/crown.png new file mode 100644 index 0000000000..1af3d75465 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/objects/crown.png differ diff --git a/frontend/apps/artcraft/app/public/resources/objects/moon.png b/frontend/apps/artcraft/app/public/resources/objects/moon.png new file mode 100644 index 0000000000..2a6f3607f9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/objects/moon.png differ diff --git a/frontend/apps/artcraft/app/public/resources/objects/sakura.png b/frontend/apps/artcraft/app/public/resources/objects/sakura.png new file mode 100644 index 0000000000..f7ac5fe8da Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/objects/sakura.png differ diff --git a/frontend/apps/artcraft/app/public/resources/placeholders/audio_placeholder.png b/frontend/apps/artcraft/app/public/resources/placeholders/audio_placeholder.png new file mode 100644 index 0000000000..a5715be612 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/placeholders/audio_placeholder.png differ diff --git a/frontend/apps/artcraft/app/public/resources/placeholders/placeholder.png b/frontend/apps/artcraft/app/public/resources/placeholders/placeholder.png new file mode 100644 index 0000000000..f02db529a9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/placeholders/placeholder.png differ diff --git a/frontend/apps/artcraft/app/public/resources/placeholders/scene_placeholder.png b/frontend/apps/artcraft/app/public/resources/placeholders/scene_placeholder.png new file mode 100644 index 0000000000..1956ea76e9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/placeholders/scene_placeholder.png differ diff --git a/frontend/apps/artcraft/app/public/resources/pose/Lumine Idle cycle.vmd b/frontend/apps/artcraft/app/public/resources/pose/Lumine Idle cycle.vmd new file mode 100644 index 0000000000..31db30dce9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/pose/Lumine Idle cycle.vmd differ diff --git a/frontend/apps/artcraft/app/public/resources/shapes/cube.png b/frontend/apps/artcraft/app/public/resources/shapes/cube.png new file mode 100644 index 0000000000..51cb24114c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/shapes/cube.png differ diff --git a/frontend/apps/artcraft/app/public/resources/shapes/cylinder.png b/frontend/apps/artcraft/app/public/resources/shapes/cylinder.png new file mode 100644 index 0000000000..73cf7eaec4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/shapes/cylinder.png differ diff --git a/frontend/apps/artcraft/app/public/resources/shapes/donut.png b/frontend/apps/artcraft/app/public/resources/shapes/donut.png new file mode 100644 index 0000000000..bb4e5a0fbb Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/shapes/donut.png differ diff --git a/frontend/apps/artcraft/app/public/resources/shapes/pointlight.png b/frontend/apps/artcraft/app/public/resources/shapes/pointlight.png new file mode 100644 index 0000000000..2fd80d5677 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/shapes/pointlight.png differ diff --git a/frontend/apps/artcraft/app/public/resources/shapes/sphere.png b/frontend/apps/artcraft/app/public/resources/shapes/sphere.png new file mode 100644 index 0000000000..b482e9fa30 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/shapes/sphere.png differ diff --git a/frontend/apps/artcraft/app/public/resources/shapes/water.png b/frontend/apps/artcraft/app/public/resources/shapes/water.png new file mode 100644 index 0000000000..ad1251a700 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/shapes/water.png differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/black.jpg b/frontend/apps/artcraft/app/public/resources/skybox/black.jpg new file mode 100644 index 0000000000..01e7d8a48d Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/black.jpg differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/day/nx.webp b/frontend/apps/artcraft/app/public/resources/skybox/day/nx.webp new file mode 100644 index 0000000000..0339ce6915 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/day/nx.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/day/ny.webp b/frontend/apps/artcraft/app/public/resources/skybox/day/ny.webp new file mode 100644 index 0000000000..c7fd8e47da Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/day/ny.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/day/nz.webp b/frontend/apps/artcraft/app/public/resources/skybox/day/nz.webp new file mode 100644 index 0000000000..2f4c856676 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/day/nz.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/day/px.webp b/frontend/apps/artcraft/app/public/resources/skybox/day/px.webp new file mode 100644 index 0000000000..ffb6f051cd Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/day/px.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/day/py.webp b/frontend/apps/artcraft/app/public/resources/skybox/day/py.webp new file mode 100644 index 0000000000..46f7e73ed6 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/day/py.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/day/pz.webp b/frontend/apps/artcraft/app/public/resources/skybox/day/pz.webp new file mode 100644 index 0000000000..78aa941511 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/day/pz.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_0_FrontZ.webp b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_0_FrontZ.webp new file mode 100644 index 0000000000..db9ed476ae Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_0_FrontZ.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_1_Back-Z.webp b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_1_Back-Z.webp new file mode 100644 index 0000000000..10a529c12a Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_1_Back-Z.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_2_LeftX.webp b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_2_LeftX.webp new file mode 100644 index 0000000000..0e2c486a68 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_2_LeftX.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_3_Right-X.webp b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_3_Right-X.webp new file mode 100644 index 0000000000..1631047a86 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_3_Right-X.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_4_UpY.webp b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_4_UpY.webp new file mode 100644 index 0000000000..d2d3b7b28e Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_4_UpY.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_5_Down-Y.webp b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_5_Down-Y.webp new file mode 100644 index 0000000000..3cb86a0ac3 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/gray/Sky_AllSky_Overcast4_Low_Cam_5_Down-Y.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_0_FrontZ.webp b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_0_FrontZ.webp new file mode 100644 index 0000000000..df05a3496b Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_0_FrontZ.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_1_Back-Z.webp b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_1_Back-Z.webp new file mode 100644 index 0000000000..6ba76abdfd Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_1_Back-Z.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_2_LeftX.webp b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_2_LeftX.webp new file mode 100644 index 0000000000..8b07856a80 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_2_LeftX.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_3_Right-X.webp b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_3_Right-X.webp new file mode 100644 index 0000000000..d6f3157017 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_3_Right-X.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_4_UpY.webp b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_4_UpY.webp new file mode 100644 index 0000000000..082800e9a9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_4_UpY.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_5_Down-Y.webp b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_5_Down-Y.webp new file mode 100644 index 0000000000..72a4c18027 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/night/Night_Moon_Burst_Cam_5_Down-Y.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/skybox/single.webp b/frontend/apps/artcraft/app/public/resources/skybox/single.webp new file mode 100644 index 0000000000..e2a5f213c5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/skybox/single.webp differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_101.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_101.wav new file mode 100644 index 0000000000..ecdaa953cb Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_101.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_102.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_102.wav new file mode 100644 index 0000000000..d508557bd7 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_102.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_111.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_111.wav new file mode 100644 index 0000000000..c477f5c4d0 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_111.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_113.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_113.wav new file mode 100644 index 0000000000..5ae208c9d1 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_113.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_135.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_135.wav new file mode 100644 index 0000000000..30b04b46fa Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_135.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_173.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_173.wav new file mode 100644 index 0000000000..70eeab321a Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_173.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_174.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_174.wav new file mode 100644 index 0000000000..2845ef0dee Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_174.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_175.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_175.wav new file mode 100644 index 0000000000..d0f5e053d7 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_175.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/goldensun_214.wav b/frontend/apps/artcraft/app/public/resources/sound/goldensun_214.wav new file mode 100644 index 0000000000..fd58d5521e Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/goldensun_214.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_12.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_12.wav new file mode 100644 index 0000000000..2323f269fa Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_12.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_13.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_13.wav new file mode 100644 index 0000000000..fdd1442c7e Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_13.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_14.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_14.wav new file mode 100644 index 0000000000..c43944278a Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_14.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_15.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_15.wav new file mode 100644 index 0000000000..a51077fe51 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_15.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_18.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_18.wav new file mode 100644 index 0000000000..f6eb93512c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_18.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_51.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_51.wav new file mode 100644 index 0000000000..649699fd36 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_51.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_52.wav b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_52.wav new file mode 100644 index 0000000000..467db39108 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/metroidprime_UI_52.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/oot_dialogue_done.wav b/frontend/apps/artcraft/app/public/resources/sound/oot_dialogue_done.wav new file mode 100644 index 0000000000..6a34234919 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/oot_dialogue_done.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/oot_dialogue_next.wav b/frontend/apps/artcraft/app/public/resources/sound/oot_dialogue_next.wav new file mode 100644 index 0000000000..33fdd81998 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/oot_dialogue_next.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/oot_scrub_crumble.wav b/frontend/apps/artcraft/app/public/resources/sound/oot_scrub_crumble.wav new file mode 100644 index 0000000000..cacadcc22c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/oot_scrub_crumble.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_character_extrapower.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_character_extrapower.wav new file mode 100644 index 0000000000..48eb4e459a Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_character_extrapower.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_click.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_click.wav new file mode 100644 index 0000000000..f954d9c709 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_click.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_correct.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_correct.wav new file mode 100644 index 0000000000..ccb8c51776 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_correct.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_drybones_crumble.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_drybones_crumble.wav new file mode 100644 index 0000000000..7513d1f1ac Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_drybones_crumble.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_spikethrow.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_spikethrow.wav new file mode 100644 index 0000000000..a08dc150a9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_spikethrow.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_flower.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_flower.wav new file mode 100644 index 0000000000..f7b739399d Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_flower.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav new file mode 100644 index 0000000000..ebb3f365d5 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_mario_giantshellkick.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_mario_giantshellkick.wav new file mode 100644 index 0000000000..0a0a24ec0c Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_mario_giantshellkick.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_specialflower.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_specialflower.wav new file mode 100644 index 0000000000..003a20ee06 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_specialflower.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_wrong.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_wrong.wav new file mode 100644 index 0000000000..f7a026c942 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/sound/smrpg_wrong.wav differ diff --git a/frontend/apps/artcraft/app/public/resources/videos/artcraft-3d-demo.mp4 b/frontend/apps/artcraft/app/public/resources/videos/artcraft-3d-demo.mp4 new file mode 100644 index 0000000000..f0b86eb82b Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/videos/artcraft-3d-demo.mp4 differ diff --git a/frontend/apps/artcraft/app/public/resources/videos/artcraft-canvas-demo.mp4 b/frontend/apps/artcraft/app/public/resources/videos/artcraft-canvas-demo.mp4 new file mode 100644 index 0000000000..0e98e936a9 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/videos/artcraft-canvas-demo.mp4 differ diff --git a/frontend/apps/artcraft/app/src/BuildEnvironment.ts b/frontend/apps/artcraft/app/src/BuildEnvironment.ts new file mode 100644 index 0000000000..21b0b25e36 --- /dev/null +++ b/frontend/apps/artcraft/app/src/BuildEnvironment.ts @@ -0,0 +1,61 @@ + +/** + * This is cached whenever vite/remix/whatever launches. + * It's fed from an environment variable. + */ +export enum BuildEnvironmentType { + Dev, + DevProxy, + Staging, + Production, +} + +export class BuildEnvironment { + private static instance: BuildEnvironment; + private readonly environmentType: BuildEnvironmentType; + + public static getInstance(): BuildEnvironment { + if (BuildEnvironment.instance !== undefined) { + return BuildEnvironment.instance; + } + + // https://v2.vitejs.dev/guide/env-and-mode.html + console.log('Reading environment variable: `VITE_ENVIRONMENT_TYPE` to determine build environment.') + console.log(`Environment variable 'VITE_ENVIRONMENT_TYPE' = ${import.meta.env.VITE_ENVIRONMENT_TYPE}`) + + const environmentLabel : string | undefined = import.meta.env.VITE_ENVIRONMENT_TYPE; + + let environmentType = BuildEnvironmentType.Production; + + switch (environmentLabel?.toLocaleLowerCase()) { + case "dev": + case "development": + environmentType = BuildEnvironmentType.Dev; + break; + case "dev-proxy": + case "devproxy": + environmentType = BuildEnvironmentType.DevProxy; + break; + case "stage": + case "staging": + environmentType = BuildEnvironmentType.Staging; + break; + } + + const instance = new BuildEnvironment(environmentType); + BuildEnvironment.instance = instance; + return instance; + } + + public getBuildEnvironmentType(): BuildEnvironmentType { + return this.environmentType; + } + + private constructor(environmentType: BuildEnvironmentType) { + this.environmentType = environmentType; + } +} + +export function GetBuildEnvironment() : BuildEnvironment { + return BuildEnvironment.getInstance(); +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiHelpers.ts b/frontend/apps/artcraft/app/src/Classes/ApiHelpers.ts new file mode 100644 index 0000000000..20ff1ba595 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiHelpers.ts @@ -0,0 +1,17 @@ +import { GetCdnOrigin } from "~/api/GetCdnOrigin"; +import { StorytellerApiHostStore } from "@storyteller/api"; + +export async function get_media_url(mediaId: string) { + //This is for prod when we have the proper info on the url. + const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost(); + const url = `${apiSchemeAndHost}/v1/media_files/file/${mediaId}`; + + const response = await fetch(url); + const json = await JSON.parse(await response.text()); + const bucketPath = json["media_file"]["public_bucket_path"]; + + const media_api_base_url = GetCdnOrigin(); + + const media_url = `${media_api_base_url}${bucketPath}`; + return media_url; +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/ApiManager.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/ApiManager.ts new file mode 100644 index 0000000000..480d2031c9 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/ApiManager.ts @@ -0,0 +1,217 @@ +import { GetBuildEnvironment } from "~/BuildEnvironment"; +import environmentVariables from "../EnvironmentVariables"; +import { Configs } from "~/configs"; +import { FetchProxy as fetch } from "@storyteller/tauri-utils"; +import { StorytellerApiHostStore } from "@storyteller/api"; + +type NonNullableObject = NonNullable; + +export interface ApiResponse { + success: boolean; + errorMessage?: string; + data?: T; + pagination?: P; +} + +export class ApiManager { + ApiTargets: Record = {}; + + constructor() { + const environmentType = GetBuildEnvironment().getBuildEnvironmentType(); + const configs = new Configs(environmentType); + + // TODO(bt,2025-07-06): Not sure that any of this is used anymore. + // If so, try to use Configs, StorytellerApiHostStore, or another configuration source. + + // look at the .env file + this.ApiTargets = { + GoggleApi: environmentVariables.values.GOOGLE_API as string, + FunnelApi: environmentVariables.values.FUNNEL_API as string, + CdnApi: environmentVariables.values.CDN_API as string, + GravatarApi: environmentVariables.values.GRAVATAR_API as string, + uploadApi: environmentVariables.values.UPLOAD_API_VIDEO as string, + }; + } + + protected getApiSchemeAndHost(): string { + return StorytellerApiHostStore.getInstance().getApiSchemeAndHost(); + } + + public async fetch( + endpoint: string, + { + method, + query, + body, + }: { + method: string; + query?: Record; + body?: B; + }, + ): Promise { + const queryInString = + query && + Object.entries(query).reduce( + (allOptions, [key, value]) => { + if (!value) { + return allOptions; + } + allOptions[key] = value.toString(); + return allOptions; + }, + {} as Record, + ); + + const endpointWithQueries = queryInString + ? endpoint + "?" + new URLSearchParams(queryInString) + : endpoint; + + const bodyInString = JSON.stringify(body); + + const response = await fetch(endpointWithQueries, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + credentials: "include", + body: bodyInString, + }); + return response.json(); + } + + public async fetchMultipartFormData( + endpoint: string, + { + method, + body, + }: { + method: string; + body: FormData; + }, + ): Promise { + const response = await fetch(endpoint, { + method, + headers: { + Accept: "application/json", + }, + credentials: "include", + body: body, + }); + return response.json(); + } + + protected get({ + endpoint, + query, + }: { + endpoint: string; + query?: Record; + }): Promise { + return this.fetch(endpoint, { method: "GET", query }); + } + + protected post({ + endpoint, + query, + body, + }: { + endpoint: string; + query?: Record; + body?: B; + }): Promise { + return this.fetch(endpoint, { + method: "POST", + query, + body, + }); + } + + protected delete({ + endpoint, + query, + body, + }: { + endpoint: string; + query?: Record; + body?: B; + }): Promise { + return this.fetch(endpoint, { + method: "DELETE", + query, + body, + }); + } + + protected async postForm({ + endpoint, + formRecord, + uuid, + blob, + blobFileName, + }: { + endpoint: string; + formRecord: Record; + uuid: string; + blob?: Blob | File; + blobFileName?: string; + }): Promise { + const formData = new FormData(); + formData.append("uuid_idempotency_token", uuid); + Object.entries(formRecord).forEach(([key, value]) => { + formData.append(key, value); + }); + if (blob && blobFileName) { + formData.append("file", blob, blobFileName); + } else if (blob) { + formData.append("file", blob); + } + + console.log(`HELLO${formData}`); + return this.fetchMultipartFormData(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + }, + credentials: "include", + body: formData, + }); + } + + protected camelToSnakeCase(str: string) { + return str.replace(/([a-z0])([A-Z])/g, "$1_$2").toLowerCase(); + } + + protected parseQueryValues( + params: Record, + ): Record { + return Object.entries(params).reduce( + (allParams, [key, value]) => { + if (!value) { + return allParams; + } + const snakeKey = this.camelToSnakeCase(key); + if (Array.isArray(value)) { + return { ...allParams, [snakeKey]: value.join(",") }; + } + return { ...allParams, [snakeKey]: value.toString() }; + }, + {} as Record, + ); + } + + protected parseBodyValues( + params: NonNullableObject, + ): B { + return Object.entries(params).reduce((allParams, [key, value]) => { + if (!value) { + return allParams; + } + const snakeKey = this.camelToSnakeCase(key); + if (Array.isArray(value)) { + return { ...allParams, [snakeKey]: value }; + } + return { ...allParams, [snakeKey]: value }; + }, {} as B); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts new file mode 100644 index 0000000000..a089e67b19 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts @@ -0,0 +1,167 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { BetaKey } from "~/models"; +import type { Pagination } from "@storyteller/ui-pagescene"; + +export class BetaKeysApi extends ApiManager { + public CreateBetaKey({ + maybeNote, + maybeReferrerUsername, + numberOfKeys, + uuidIdempotencyToken, + }: { + maybeNote?: string; + maybeReferrerUsername?: string; + numberOfKeys: number; + uuidIdempotencyToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/beta_keys/create`; + + const body = { + maybe_note: maybeNote, + maybe_referrer_username: maybeReferrerUsername, + number_of_keys: numberOfKeys, + uuid_idempotency_token: uuidIdempotencyToken, + }; + + return this.post< + { + maybe_note?: string; + maybe_referrer_username?: string; + number_of_keys: number; + uuid_idempotency_token: string; + }, + { + success?: boolean; + beta_keys?: string[]; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + data: response.beta_keys, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListBetaKeys({ + sortAscending, + pageSize, + pageIndex, + maybeReferrerUsername, + onlyListRemaining, + }: { + sortAscending?: boolean; + pageSize?: number; + pageIndex?: number; + maybeReferrerUsername?: string; + onlyListRemaining?: boolean; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/beta_keys/list`; + + const query = { + sort_ascending: sortAscending, + page_size: pageSize, + page_index: pageIndex, + maybe_referrer_username: maybeReferrerUsername, + only_list_remaining: onlyListRemaining, + }; + + return this.get<{ + success?: boolean; + beta_keys?: BetaKey[]; + pagination?: Pagination; + BadInput?: string; + }>({ endpoint, query }) + .then((response) => ({ + success: response.success ?? false, + data: response.beta_keys, + pagination: response.pagination, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public RedeemBetaKey({ + betaKey, + }: { + betaKey: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/beta_keys/redeem`; + + return this.post< + { + beta_key: string; + }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { beta_key: betaKey } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public UpdateBetaKeyIsDistributed({ + betaKey, + isDistributed, + }: { + betaKey: string; + isDistributed: boolean; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/beta_keys/${betaKey}/distributed`; + + return this.post< + { + is_distributed: boolean; + }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { is_distributed: isDistributed } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public UpdateBetaKeyNote({ + token, + note, + }: { + token: string; + note?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/beta_keys/${token}/note`; + + return this.post< + { + note?: string; + }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { note } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/BillingApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/BillingApi.ts new file mode 100644 index 0000000000..51282a16d6 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/BillingApi.ts @@ -0,0 +1,33 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { Subscription } from "~/models/Billing"; +import { LoyaltyProgram } from "~/enums"; + +export class BillingApi extends ApiManager { + public async ListActiveSubscriptions(): Promise< + ApiResponse<{ + active_subscriptions: Subscription[]; + maybe_loyalty_program?: LoyaltyProgram; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/billing/active_subscriptions`; + return await this.get<{ + success: boolean; + active_subscriptions?: Subscription[]; + maybe_loyalty_program?: LoyaltyProgram; + error_message?: string; + }>({ endpoint: endpoint }) + .then((response) => ({ + success: response.success, + data: { + active_subscriptions: response.active_subscriptions || [], + maybe_loyalty_program: response.maybe_loyalty_program, + }, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/CommentsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/CommentsApi.ts new file mode 100644 index 0000000000..d32f8245fb --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/CommentsApi.ts @@ -0,0 +1,91 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; + +export class CommentsApi extends ApiManager { + public CreateComment({ + commentMarkdown, + entityToken, + entityType, + uuidIdempotencyToken, + }: { + commentMarkdown: string; + entityToken: string; + entityType: string; + uuidIdempotencyToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/comments/new`; + + const body = { + comment_markdown: commentMarkdown, + entity_token: entityToken, + entity_type: entityType, + uuid_idempotency_token: uuidIdempotencyToken, + }; + + return this.post< + { + comment_markdown: string; + entity_token: string; + entity_type: string; + uuid_idempotency_token: string; + }, + { + success?: boolean; + comment_token?: string; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + data: response.comment_token, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public DeleteComment({ + commentToken, + }: { + commentToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/comments/delete/${commentToken}`; + + return this.post< + { as_mod: boolean }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { as_mod: true } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListCommentsByEntity({ + entityType, + entityToken, + }: { + entityType: string; + entityToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/comments/list/${entityType}/${entityToken}`; + + return this.get<{ + success: boolean; + comments: string[]; + }>({ endpoint }) + .then((response) => ({ + success: true, + data: response.comments, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/EngineApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/EngineApi.ts new file mode 100644 index 0000000000..09723a3b13 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/EngineApi.ts @@ -0,0 +1,89 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; + +export class EngineApi extends ApiManager { + public async ConvertTbxToGltf({ + mediaFileToken, + uuidIdempotencyToken, + }: { + mediaFileToken: string; + uuidIdempotencyToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/conversion/enqueue_fbx_to_gltf`; + + const body = { + media_file_token: mediaFileToken, + uuid_idempotency_token: uuidIdempotencyToken, + }; + + return this.post< + { media_file_token: string; uuid_idempotency_token: string }, + { success?: boolean; inference_job_token?: string; BadInput?: string } + >({ + endpoint, + body: body, + }) + .then((response) => { + return { + success: response.success ?? false, + data: response.inference_job_token, + errorMessage: response.BadInput, + }; + }) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async uploadSceneSnapshot({ + screenshot, + sceneMediaToken, + }: { + screenshot: File; // base64 encoded PNG + sceneMediaToken?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/image_studio/scene_snapshot`; + const formData = new FormData(); + formData.append("snapshot", screenshot); // Changed from "screenshot" to "snapshot" to match API spec + if (sceneMediaToken) { + formData.append("scene_media_token", sceneMediaToken); + } + + // for now ... + const uuidIdempotencyToken = crypto.randomUUID(); // Generate a new UUID + formData.append("uuid_idempotency_token", uuidIdempotencyToken); // Added uuid_idempotency_token + + const response = await fetch(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + }, + credentials: "include", + body: formData, + }); + + const postResponse = await response.json(); + + console.log(postResponse); + + let result: { success: boolean; data?: string; errorMessage?: string }; + + if (postResponse.success) { + result = { + success: true, + data: postResponse.snapshot_media_token, + errorMessage: undefined, + }; + } else { + result = { + success: false, + errorMessage: postResponse.BadInput, + }; + } + + return result; + } + +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/FeaturedItemsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/FeaturedItemsApi.ts new file mode 100644 index 0000000000..431ce3bc9d --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/FeaturedItemsApi.ts @@ -0,0 +1,96 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; + +export class FeaturedItemsApi extends ApiManager { + public CreateFeaturedItem({ + entityToken, + entityType, + }: { + entityToken: string; + entityType: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/featured_item/create`; + + const body = { + entity_token: entityToken, + entity_type: entityType, + }; + + return this.post< + { + entity_token: string; + entity_type: string; + }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public DeleteFeaturedItem({ + entityToken, + entityType, + }: { + entityToken: string; + entityType: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/featured_item/delete`; + + const body = { + entity_token: entityToken, + entity_type: entityType, + }; + + return this.delete< + { + entity_token: string; + entity_type: string; + }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public CheckFeaturedItem({ + entityType, + entityToken, + }: { + entityType: string; + entityToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/featured_item/is_featured/${entityType}/${entityToken}`; + + return this.post< + undefined, + { + success?: boolean; + is_featured?: boolean; + BadInput?: string; + } + >({ endpoint }) + .then((response) => ({ + success: response.success ?? false, + data: response.is_featured, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/JobsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/JobsApi.ts new file mode 100644 index 0000000000..a9b4b8da65 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/JobsApi.ts @@ -0,0 +1,74 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { Job, JobState } from "~/models"; + +export class JobsApi extends ApiManager { + public GetJobByToken({ + token, + }: { + token: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/jobs/job/${token}`; + + return this.get<{ + success: boolean; + state: JobState; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.state, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListJobs(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/jobs/batch`; + + return this.get<{ + success: boolean; + job_states: JobState[]; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.job_states, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListRecentJobs(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/jobs/session`; + + return this.get<{ + success: boolean; + jobs: Job[]; + error_reason?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.jobs, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public DeleteJobByToken(jobToken: string): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/jobs/job/${jobToken}`; + + return this.delete< + undefined, + { + success: boolean; + } + >({ endpoint }) + .then((response) => ({ + success: response.success, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts new file mode 100644 index 0000000000..9dde488f64 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts @@ -0,0 +1,399 @@ +import type { + MediaFile, + MediaInfo, + Pagination, + PaginationInfinite, +} from "@storyteller/ui-pagescene"; +import { ApiManager, ApiResponse } from "./ApiManager"; +import { authentication } from "~/signals"; +import type { Property } from "csstype"; +import { Visibility } from "~/enums"; + +import { + FilterEngineCategories, + FilterMediaClasses, + FilterMediaType, +} from "~/enums"; + +interface ListMediaQuery { + sort_ascending?: boolean; + page_size?: number; + cursor?: string; + cursor_is_reversed?: boolean; + filter_media_classes?: FilterMediaClasses[]; + filter_media_type?: FilterMediaType[]; + filter_engine_categories?: FilterEngineCategories[]; +} + +interface ListUserMediaQuery { + sort_ascending?: boolean; + page_size?: number; + page_index?: number; + filter_media_classes?: FilterMediaClasses[]; + filter_media_type?: FilterMediaType[]; + filter_engine_categories?: FilterEngineCategories[]; +} + +interface SearchFeaturedMediaQuery { + search_term: string; + filter_media_classes?: FilterMediaClasses[]; + filter_media_type?: FilterMediaType[]; + filter_engine_categories?: FilterEngineCategories[]; +} + +export class MediaFilesApi extends ApiManager { + + public async ListMediaFilesByTokens({ + mediaTokens, + }: { + mediaTokens: string[]; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/batch`; + + return await this.get<{ + success: boolean; + media_files: MediaFile[]; + error_reason?: string; + }>({ endpoint, query: { tokens: mediaTokens.join(",") } }) + .then((response) => ({ + success: response.success, + data: response.media_files, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public async GetMediaFileByToken({ + mediaFileToken, + }: { + mediaFileToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/file/${mediaFileToken}`; + return await this.get<{ + success: boolean; + media_file: MediaFile; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.media_file, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async ListMediaFiles( + query: ListMediaQuery, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list`; + const queryWithStrings = { + ...query, + filter_media_classes: query.filter_media_classes + ? query.filter_media_classes.join(",") + : undefined, + filter_media_type: query.filter_media_type + ? query.filter_media_type.join(",") + : undefined, + filter_engine_categories: query.filter_engine_categories + ? query.filter_engine_categories.join(",") + : undefined, + }; + return await this.get<{ + success: boolean; + results: MediaInfo[]; + pagination?: PaginationInfinite; + }>({ endpoint, query: queryWithStrings }) + .then((response) => ({ + success: response.success, + data: response.results ?? [], + pagination: response.pagination, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async ListFeaturedMediaFiles( + query: ListMediaQuery, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list_featured`; + const queryWithStrings = { + ...query, + filter_media_classes: query.filter_media_classes + ? query.filter_media_classes.join(",") + : undefined, + filter_media_type: query.filter_media_type + ? query.filter_media_type.join(",") + : undefined, + filter_engine_categories: query.filter_engine_categories + ? query.filter_engine_categories.join(",") + : undefined, + }; + return await this.get<{ + success: boolean; + results: MediaInfo[]; + pagination: PaginationInfinite; + }>({ endpoint, query: queryWithStrings }) + .then((response) => ({ + success: true, + data: response.results, + pagination: response.pagination, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async ListUserMediaFiles( + query: ListUserMediaQuery, + ): Promise> { + const userName = authentication.userInfo.value?.username; + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list/user/${userName}`; + const queryWithStrings = { + ...query, + include_user_uploads: true, + filter_media_classes: query.filter_media_classes + ? query.filter_media_classes.join(",") + : undefined, + filter_media_type: query.filter_media_type + ? query.filter_media_type.join(",") + : undefined, + filter_engine_categories: query.filter_engine_categories + ? query.filter_engine_categories.join(",") + : undefined, + }; + return await this.get<{ + success: boolean; + results: MediaInfo[]; + pagination?: Pagination; + }>({ endpoint, query: queryWithStrings }) + .then((response) => ({ + success: response.success, + data: response.results ?? [], + pagination: response.pagination, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async SearchFeaturedMediaFiles( + query: SearchFeaturedMediaQuery, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/search_featured`; + const queryWithStrings = { + search_term: query.search_term, + filter_media_classes: query.filter_media_classes + ? query.filter_media_classes.join(",") + : undefined, + filter_media_type: query.filter_media_type + ? query.filter_media_type.join(",") + : undefined, + filter_engine_categories: query.filter_engine_categories + ? query.filter_engine_categories.join(",") + : undefined, + }; + return await this.get<{ + success: boolean; + results: MediaInfo[]; + pagination: Pagination; + }>({ endpoint, query: queryWithStrings }) + .then((response) => ({ + success: true, + data: response.results, + pagination: response.pagination, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async SearchUserMediaFiles( + query: SearchFeaturedMediaQuery, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/search_session`; + const queryWithStrings = { + search_term: query.search_term, + filter_media_classes: query.filter_media_classes + ? query.filter_media_classes.join(",") + : undefined, + filter_media_type: query.filter_media_type + ? query.filter_media_type.join(",") + : undefined, + filter_engine_categories: query.filter_engine_categories + ? query.filter_engine_categories.join(",") + : undefined, + }; + return await this.get<{ + success: boolean; + results: MediaInfo[]; + pagination: Pagination; + }>({ endpoint, query: queryWithStrings }) + .then((response) => ({ + success: true, + data: response.results, + pagination: response.pagination, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public async RenameMediaFileByToken({ + mediaToken, + name, + }: { + mediaToken: string; + name: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/rename/${mediaToken}`; + const body = { name }; + + return await this.post< + { name: string }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public async UpdateCoverImage({ + mediaFileToken, + imageToken, + }: { + mediaFileToken: string; + imageToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/cover_image/${mediaFileToken}`; + return await this.post< + { cover_image_media_file_token: string }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { cover_image_media_file_token: imageToken } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async UpdateVisibility({ + mediaFileToken, + visibility, + }: { + mediaFileToken: string; + visibility: Visibility; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/visibility/${mediaFileToken}`; + return await this.post< + { creator_set_visibility: string }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { creator_set_visibility: visibility } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async UploadSpzFile({ + file, + fileName, + uuid, + maybe_title, + maybe_visibility = Visibility.Public, + }: { + file: File; + fileName: string; + uuid: string; + maybe_title?: string; + maybe_visibility?: Visibility; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/spz`; + const options: Record = { + maybe_title, + maybe_visibility: maybe_visibility?.toString(), + }; + return this.Upload({ endpoint, blob: file, fileName, uuid, options }); + } + + private async Upload({ + endpoint, + uuid, + blob, + fileName, + options, + }: { + endpoint: string; + blob: Blob | File; + fileName: string; + uuid: string; + options: Record; + }): Promise> { + const formRecord = Object.entries(options).reduce( + (allOptions, [key, value]) => { + if (value === undefined) { + return allOptions; + } + return { ...allOptions, [key]: value.toString() }; + }, + {} as Record, + ); + + return await this.postForm<{ + success: boolean; + media_file_token?: string; + BadInput?: string; + }>({ endpoint, formRecord, blob, blobFileName: fileName, uuid }) + .then((response) => ({ + success: Boolean(response.success ?? false), + data: response.media_file_token, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaUploadApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaUploadApi.ts new file mode 100644 index 0000000000..623379ac7e --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaUploadApi.ts @@ -0,0 +1,339 @@ +import { EIntermediateFile } from "~/enums/EIntermediateFile"; +import { ApiManager, ApiResponse } from "./ApiManager"; +import { FilterEngineCategories, Visibility } from "~/enums"; + +export class MediaUploadApi extends ApiManager { + private sessionToken: string = undefined; + + private async getSessionTokenForUploadStudioShot() { + const endpoint = `${this.getApiSchemeAndHost()}/v1/session_token`; + return await this.get<{ + maybe_signed_session: string; + }>({ endpoint }) + .then((response) => { + return { + success: true, + data: response["maybe_signed_session"], + }; + }) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async UploadStudioShot({ + maybe_title, + uuid_idempotency_token, + blob, + fileName, + maybe_visibility, + }: { + maybe_title?: string; + uuid_idempotency_token: string; + blob: Blob | null; + fileName: string; + maybe_visibility?: Visibility; + }): Promise> { + // This function is a bit special it requires that + const endpoint = `${this.ApiTargets.uploadApi}/v1/media_files/upload/studio_shot`; + + if (!this.sessionToken) { + console.log("Session Token"); + const response = await this.getSessionTokenForUploadStudioShot(); + if (response.success && response.data) { + this.sessionToken = response.data; + console.log(`Token ${this.sessionToken}`); + } else { + return { + success: false, + errorMessage: "Could not obtain session tokens.", + }; + } + } else { + console.log(`Session Token: ${this.sessionToken}`); + } + + const options: Record = { + maybe_title, + maybe_frame_rate_fps: 60, + maybe_visibility: maybe_visibility?.toString(), + }; + + const formRecord = Object.entries(options).reduce( + (allOptions, [key, value]) => { + if (value === undefined) { + return allOptions; + } + return { ...allOptions, [key]: value.toString() }; + }, + {} as Record, + ); + + const formData = new FormData(); + formData.append("uuid_idempotency_token", uuid_idempotency_token); + Object.entries(formRecord).forEach(([key, value]) => { + formData.append(key, value); + }); + if (blob && fileName) { + formData.append("file", blob, fileName); + } else if (blob) { + formData.append("file", blob); + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + Session: this.sessionToken, + }, + credentials: "include", + body: formData, + }); + + const result = await response.json(); + + if (result.success && result.media_file_token) { + return { success: true, data: result.media_file_token }; + } else { + return { success: false, errorMessage: "Failed to Get Media Token" }; + } + } + + private async Upload({ + endpoint, + uuid, + blob, + fileName, + options, + }: { + endpoint: string; + blob: Blob | File; + fileName: string; + uuid: string; + options: Record; + }): Promise> { + const formRecord = Object.entries(options).reduce( + (allOptions, [key, value]) => { + if (value === undefined) { + return allOptions; + } + return { ...allOptions, [key]: value.toString() }; + }, + {} as Record, + ); + + return await this.postForm<{ + success: boolean; + media_file_token?: string; + BadInput?: string; + }>({ endpoint, formRecord, blob, blobFileName: fileName, uuid }) + .then((response) => ({ + success: Boolean(response.success ?? false), + data: response.media_file_token, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async UploadAudio({ + blob, + fileName, + uuid, + maybe_title, + maybe_visibility = Visibility.Public, + }: { + blob: Blob; + fileName: string; + uuid: string; + maybe_title?: string | undefined; + maybe_visibility?: Visibility | undefined; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/audio`; + const options: Record = { + maybe_title, + maybe_visibility: maybe_visibility?.toString(), + }; + return this.Upload({ endpoint, blob, fileName, uuid, options }); + } + + public async UploadImage({ + blob, + fileName, + uuid, + maybe_title, + maybe_visibility = Visibility.Public, + is_intermediate_system_file = EIntermediateFile.true + }: { + blob: Blob; + fileName: string; + uuid: string; + maybe_title?: string | undefined; + maybe_visibility?: Visibility | undefined; + is_intermediate_system_file?: EIntermediateFile; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/image`; + const options: Record = { + is_intermediate_system_file: EIntermediateFile[is_intermediate_system_file], + maybe_title, + maybe_visibility: maybe_visibility?.toString(), + }; + return this.Upload({ endpoint, blob, fileName, uuid, options }); + } + + public async UploadNewEngineAsset({ + file, + fileName, + uuid, + engine_category, + maybe_animation_type, + maybe_duration_millis, + maybe_title, + maybe_visibility = Visibility.Public, + }: { + file: File; + fileName: string; + uuid: string; + engine_category: FilterEngineCategories; + maybe_animation_type?: string; + maybe_duration_millis?: number; + maybe_title?: string; + maybe_visibility?: Visibility; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/new_engine_asset`; + const options: Record = { + engine_category, + maybe_title, + maybe_visibility: maybe_visibility?.toString(), + maybe_animation_type, + maybe_duration_millis, + }; + return this.Upload({ endpoint, blob: file, fileName, uuid, options }); + } + + public async UploadNewScene({ + blob, + fileName, + uuid, + maybe_title, + maybe_visibility = Visibility.Public, + }: { + blob: Blob; + fileName: string; + uuid: string; + maybe_title?: string; + maybe_visibility?: Visibility; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/new_scene`; + const options: Record = { + maybe_title, + maybe_visibility: maybe_visibility?.toString(), + }; + return this.Upload({ endpoint, blob, fileName, uuid, options }); + } + + public async UploadNewVideo({ + blob, + fileName, + uuid, + maybe_title, + maybe_visibility = Visibility.Public, + maybe_style_name, + maybe_scene_source_media_file_token, + }: { + blob: Blob; + fileName: string; + uuid: string; + maybe_title?: string; + maybe_visibility?: Visibility; + maybe_style_name?: string; + maybe_scene_source_media_file_token?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/new_video`; + const options: Record = { + is_intermediate_system_file: "true", + maybe_title, + maybe_visibility: maybe_visibility?.toString(), + maybe_style_name, + maybe_scene_source_media_file_token, + }; + return this.Upload({ endpoint, blob, fileName, uuid, options }); + } + + public async UploadPmx({ + file, + fileName, + uuid, + engine_category, + maybe_animation_type, + maybe_duration_millis, + maybe_title, + maybe_visibility = Visibility.Public, + }: { + file: File; + fileName: string; + uuid: string; + engine_category?: string; + maybe_animation_type?: string; + maybe_duration_millis?: number; + maybe_title?: string; + maybe_visibility?: Visibility; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/pmx`; + const options: Record = { + is_intermediate_system_file: "true", + engine_category, + maybe_animation_type, + maybe_duration_millis, + maybe_title, + maybe_visibility, + }; + return this.Upload({ endpoint, blob: file, fileName, uuid, options }); + } + + public async UploadSavedScene({ + blob, + fileName, + uuid, + mediaToken, + }: { + blob: Blob; + fileName: string; + uuid: string; + mediaToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/saved_scene/${mediaToken}`; + const options: Record = {}; + return this.Upload({ endpoint, blob, fileName, uuid, options }); + } + + // this is used to send the scene snapshot to the vst upload endpoint this creates a snapshot of the scene where in we can remix from the og. + public async UploadSceneSnapshotMediaFileForm({ + blob, + uuid, + maybe_title, // title of the scene at the time + maybe_scene_source_media_file_token, // original token that started it all + }: { + blob: Blob; + uuid: string; + maybe_title: string; + maybe_scene_source_media_file_token: string | undefined; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/scene_snapshot`; + + const options: Record = { + maybe_title: maybe_title, + maybe_scene_source_media_file_token: maybe_scene_source_media_file_token, + }; + + const fileName = `${maybe_title}-${uuid}`; + return this.Upload({ endpoint, blob, fileName, uuid, options }); // token comes back and send it VST + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/MiscApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/MiscApi.ts new file mode 100644 index 0000000000..cfa347f7c1 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/MiscApi.ts @@ -0,0 +1,26 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { StatusAlert } from "~/models/StatusAlert"; + +export class MiscApi extends ApiManager { + public GetStatusAlertCheck(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/session`; + return this.get<{ + success: boolean; + maybe_alert?: Omit; + refresh_interval_millis: number; + }>({ endpoint: endpoint }) + .then((response) => ({ + success: response.success, + data: { + ...response.maybe_alert, + refresh_interval_millis: response.refresh_interval_millis, + }, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.mesasge, + }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/ModerationApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/ModerationApi.ts new file mode 100644 index 0000000000..d476726b25 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/ModerationApi.ts @@ -0,0 +1,42 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; + +export class ModerationApi extends ApiManager { + public async UpdateFeatureFlagsByToken({ + usernameOrToken, + flags, + }: { + usernameOrToken: string; + flags: string[]; + }): Promise< + ApiResponse<{ + inference_job_token?: string; + inference_job_token_type?: string; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/${usernameOrToken}`; + + const body = { + action: { + AddFlags: { + flags, + }, + }, + }; + + return this.post< + { action: { AddFlags: { flags: string[] } } }, + { success?: boolean; BadInput?: string } + >({ + endpoint, + body: body, + }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts new file mode 100644 index 0000000000..7421c087e6 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts @@ -0,0 +1,51 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import type { Prompts } from "@storyteller/ui-pagescene"; + +export class PromptsApi extends ApiManager { + public GetPromptsByToken({ + token, + }: { + token: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/prompts/${token}`; + + return this.get<{ + success: boolean; + prompt: Prompts; + error_reason?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.prompt, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public BatchGetPrompts({ + tokens, + }: { + tokens: string[]; + }): Promise> { + if (tokens.length === 0) { + return Promise.resolve({ success: true, data: [] }); + } + const query = tokens + .map((t) => `tokens=${encodeURIComponent(t)}`) + .join("&"); + const endpoint = `${this.getApiSchemeAndHost()}/v1/prompt/batch?${query}`; + + return this.get<{ + success: boolean; + prompts: Prompts[]; + }>({ endpoint }) + .then((response) => ({ + success: response.success, + data: response.prompts, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/StudioGen2Api.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/StudioGen2Api.ts new file mode 100644 index 0000000000..9b4eb0cd46 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/StudioGen2Api.ts @@ -0,0 +1,61 @@ + +import { ApiManager, ApiResponse } from "./ApiManager"; +import { Visibility } from "~/enums"; + +interface EnqueueStudioGen2Request { + // Idempotency + uuid_idempotency_token: string; + + // Media token for starting frame + image_file: string; + + // Media token for driver video + video_file: string; + + // Optional visibility override + creator_set_visibility?: string; +} + +export class StudioGen2Api extends ApiManager { + public async EnqueueStudioGen2({ + enqueueVideo, + }: { + enqueueVideo: EnqueueStudioGen2Request; + }): Promise< + ApiResponse<{ + inference_job_token?: string; + inference_job_token_type?: string; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/studio_gen2/enqueue`; + + const body = { + ...enqueueVideo, + ...(enqueueVideo.creator_set_visibility + ? {} + : { creator_set_visibility: Visibility.Public }), + }; + + return await this.post< + EnqueueStudioGen2Request, + { + success?: boolean; + inference_job_token?: string; + //inference_job_token_type?: string; + //BadInput?: string; + } + >({ endpoint, body: body }) + .then((response) => ({ + success: Boolean(response.success ?? false), + data: { + inference_job_token: response.inference_job_token, + //inference_job_token_type: response.inference_job_token_type, + }, + //errorMessage: response.BadInput, + })) + .catch((err) => { + console.log(err.message); + return { success: false, error_reason: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts new file mode 100644 index 0000000000..10a20b7dd6 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts @@ -0,0 +1,64 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { GenerateTtsAudioErrorType } from "@storyteller/ui-pagescene"; +import { Visibility } from "~/enums"; + +export interface GenerateTtsAudioRequest { + creator_set_visibility?: Visibility; + uuid_idempotency_token: string; + tts_model_token: string; + inference_text: string; + is_storyteller_demo?: boolean; +} + +export interface GenerateTtsAudioResponse { + success?: boolean; + inference_job_token?: string; + inference_job_token_type?: string; + BadInput?: string; +} + +export class TtsApi extends ApiManager { + public async GenerateTtsAudio(request: GenerateTtsAudioRequest): Promise< + ApiResponse<{ + inference_job_token?: string; + inference_job_token_type?: string; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/tts/inference`; + + const body = { + ...request, + ...(request.creator_set_visibility + ? {} + : { creator_set_visibility: Visibility.Public }), + }; + + return this.post({ + endpoint, + body: body, + }) + .then((response) => { + if (!response.success) { + return { + success: false, + errorMessage: response.BadInput, + data: {}, + }; + } + return { + success: true, + data: { + inference_job_token: response.inference_job_token, + inference_job_token_type: response.inference_job_token_type, + }, + }; + }) + .catch(() => { + return { + success: false, + errorMessage: GenerateTtsAudioErrorType.UnknownError, + data: {}, + }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts new file mode 100644 index 0000000000..369b2bd50b --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts @@ -0,0 +1,89 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import type { UserBookmarkBatch } from "@storyteller/ui-pagescene"; + +export class UserBookmarksApi extends ApiManager { + public CreateUserBookmark({ + entityToken, + entityType, + }: { + entityToken: string; + entityType: string; + }): Promise< + ApiResponse<{ + new_bookmark_count_for_entity?: number; + user_bookmark_token?: string; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user_bookmarks/create`; + const body = { + entity_token: entityToken, + entity_type: entityType, + }; + + return this.post< + { + entity_token: string; + entity_type: string; + }, + { + success?: boolean; + new_bookmark_count_for_entity?: number; + user_bookmark_token: string; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + data: { + new_bookmark_count_for_entity: response.new_bookmark_count_for_entity, + user_bookmark_token: response.user_bookmark_token, + }, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public DeleteUserBookmark({ + entityToken, + }: { + entityToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user_bookmarks/delete/${entityToken}`; + + return this.delete< + { as_mod: boolean }, + { + success?: boolean; + BadInput?: string; + } + >({ endpoint, body: { as_mod: true } }) + .then((response) => ({ + success: response.success ?? false, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListUserBookmarks(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user_bookmarks/batch`; + + return this.get<{ + success?: boolean; + bookmarks?: UserBookmarkBatch[]; + BadInput?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success ?? false, + data: response.bookmarks, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/UserRatingsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserRatingsApi.ts new file mode 100644 index 0000000000..52a6630794 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserRatingsApi.ts @@ -0,0 +1,92 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; + +export interface UserRating { + entity_token: string; + entity_type: string; + rating_value: string; +} + +export class UserRatingApi extends ApiManager { + public ListUserRatings(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user_rating/batch`; + + return this.get<{ + success?: boolean; + ratings?: UserRating[]; + BadInput?: string; + }>({ endpoint }) + .then((response) => ({ + success: response.success ?? false, + data: response.ratings, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public PostUserRating({ + entityToken, + entityType, + ratingValue, + }: { + entityToken: string; + entityType: string; + ratingValue: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user_rating/rate`; + + const body = { + entity_token: entityToken, + entity_type: entityType, + rating_value: ratingValue, + }; + + return this.post< + { + entity_token: string; + entity_type: string; + rating_value: string; + }, + { + success?: boolean; + new_positive_rating_count_for_entity?: number; + BadInput?: string; + } + >({ endpoint, body }) + .then((response) => ({ + success: response.success ?? false, + data: response.new_positive_rating_count_for_entity, + errorMessage: response.BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListUserRatingByEntity({ + entityType, + entityToken, + }: { + entityType: string; + entityToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user_rating/view/${entityType}/${entityToken}`; + + return this.get<{ + success?: boolean; + maybe_rating_value?: string; + BadInput?: string; + }>({ endpoint }) + .then((response) => { + return { + success: response.success ?? false, + data: response.maybe_rating_value, + errorMessage: response.BadInput, + }; + }) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/UsersApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/UsersApi.ts new file mode 100644 index 0000000000..daa147493b --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/UsersApi.ts @@ -0,0 +1,169 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { UserInfo } from "~/models"; + +interface SignupRequest { + username: string; + email_address: string; + password: string; + password_confirmation: string; + signup_source?: string; +}; + +export class UsersApi extends ApiManager { + public GetSession(): Promise< + ApiResponse<{ + loggedIn: boolean; + user?: UserInfo; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/session`; + return this.get<{ + success: boolean; + logged_in: boolean; + user?: UserInfo; + error_message?: string; + }>({ endpoint: endpoint }) + .then((response) => ({ + success: response.success, + data: { + loggedIn: response.logged_in, + user: response.user, + }, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.mesasge, + }; + }); + } + + public GetUserProfile(username: string): Promise< + ApiResponse<{ + user?: UserInfo; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/user/${username}/profile`; + return this.get<{ + success: boolean; + user?: UserInfo; + error_message?: string; + }>({ endpoint: endpoint }) + .then((response) => ({ + success: response.success, + data: { + user: response.user, + }, + })) + .catch((err) => { + return { + success: false, + error_message: err.message, + }; + }); + } + + public async Login({ + usernameOrEmail, + password, + }: { + usernameOrEmail: string; + password: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/login`; + const body = { + username_or_email: usernameOrEmail, + password: password, + }; + return await this.post< + { username_or_email: string; password: string }, + { + success: boolean; + signed_session?: string; + error_message?: string; + error_type?: string; + } + >({ + endpoint: endpoint, + body: body, + }) + .then((response) => ({ + success: response.success, + data: { signedSession: response.signed_session }, + errorMessage: response.error_message, + })) + .catch((err) => { + return { + success: false, + error_message: err.message, + }; + }); + } + + public Logout(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/logout`; + return this.post({ + endpoint: endpoint, + }) + .then((response) => ({ + success: response.success, + })) + .catch((err) => { + return { + success: false, + errorMessage: err.message, + }; + }); + } + + public async Signup({ + username, + email, + password, + passwordConfirmation, + signupSource, + }: { + username: string; + email: string; + password: string; + passwordConfirmation: string; + signupSource?: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`; + let body: SignupRequest = { + email_address: email, + password, + password_confirmation: passwordConfirmation, + username, + }; + if (!!signupSource) { + body.signup_source = signupSource; + } + return await this.post< + SignupRequest, + { + success: boolean; + signed_session?: string; + error_fields?: Record; + error_message?: string; + error_type?: string; + } + >({ + endpoint: endpoint, + body: body, + }) + .then((response) => { + return { + success: response.success, + data: { signedSession: response.signed_session }, + errorMessage: Object.values(response.error_fields ?? {}).join(","), + }; + }) + .catch((err) => { + return { + success: false, + error_message: err.message, + }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/VideoApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/VideoApi.ts new file mode 100644 index 0000000000..15a2bd0544 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/VideoApi.ts @@ -0,0 +1,70 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { Visibility } from "~/enums"; + +interface GenerateVideoStyleTransferRequest { + creator_set_visibility: string; + disable_lcm: boolean; + enable_lipsync: boolean; + frame_skip: number; + global_ipa_media_token: string; + input_depth_file: string; + input_file: string; + input_normal_file: string; + input_outline_file: string; + negative_prompt: string; + prompt: string; + remove_watermark: boolean; + style: string; + travel_prompt: string; + trim_end_millis: number; + trim_start_millis: number; + use_cinematic: boolean; + use_face_detailer: boolean; + use_strength: number; + use_upscaler: boolean; + uuid_idempotency_token: string; +} + +export class VideoApi extends ApiManager { + public async EnqueueStudio({ + enqueueVideo, + }: { + enqueueVideo: GenerateVideoStyleTransferRequest; + }): Promise< + ApiResponse<{ + inference_job_token?: string; + inference_job_token_type?: string; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/workflows/enqueue_studio`; + + const body = { + ...enqueueVideo, + ...(enqueueVideo.creator_set_visibility + ? {} + : { creator_set_visibility: Visibility.Public }), + }; + + return await this.post< + GenerateVideoStyleTransferRequest, + { + success?: boolean; + inference_job_token?: string; + inference_job_token_type?: string; + BadInput?: string; + } + >({ endpoint, body: body }) + .then((response) => ({ + success: Boolean(response.success ?? false), + data: { + inference_job_token: response.inference_job_token, + inference_job_token_type: response.inference_job_token_type, + }, + errorMessage: response.BadInput, + })) + .catch((err) => { + console.log(err.message); + return { success: false, error_reason: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts new file mode 100644 index 0000000000..9cc192a2b6 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts @@ -0,0 +1,296 @@ +import { ApiManager, ApiResponse } from "./ApiManager"; +import { authentication } from "~/signals"; +import { Weight } from "~/models"; +import type { Pagination, PaginationInfinite } from "@storyteller/ui-pagescene"; +import { + FilterEngineCategories, + FilterMediaClasses, + FilterMediaType, + Visibility, +} from "~/enums"; + +export enum ScopedWeightType { + HIFIGAN_TT2 = "hifigan_tt2", + RVC_V2 = "rvc_v2", + SD_1_5 = "sd_1.5", + SDXL = "sdxl", + SO_VITS_SVC = "so_vits_svc", + TT2 = "tt2", + LORA = "loRA", + VALL_E = "vall_e", + COMFY_UI = "comfy_ui", +} +export enum ScopedWeightCategory { + IMAGE_GENERATION = "image_generation", + TEXT_TO_SPEECH = "text_to_speech", + VOCODER = "vocoder", + VOICE_CONVERSION = "voice_conversion", + WORKFLOW_CONFIG = "workflow_config", +} + +interface WeightsRequest { + username?: string; + pageSize?: number; + weightType?: ScopedWeightType[]; + weightCategory?: ScopedWeightCategory[]; + //TODO: what is scoped?? + scopedWeightType?: ScopedWeightType[]; + scopedWeightCategory?: ScopedWeightCategory[]; +} + +export interface ListWeightsByUserRequest extends WeightsRequest { + pageIndex?: number; + sortAscending?: boolean; +} + +export interface ListWeightsRequest extends WeightsRequest { + cursor?: string; + cursorIsReversed?: boolean; +} + +export interface ListFeaturedWeightsRequest { + sortAscending?: boolean; + pageSize?: number; + cursor?: string; + cursorIsReversed?: boolean; + filterMediaClasses?: FilterMediaClasses[]; + filterMediaType?: FilterMediaType[]; + filterEngineCategories?: FilterEngineCategories[]; +} + +export interface SearchWeightParams { + ietfLanguageSubtag: string; + searchTerm: string; + weightCategory: ScopedWeightCategory; + weightType: ScopedWeightType; +} + +export interface SearchWeightRequest { + ietf_language_subtag: string; + search_term: string; + weight_category: string; + weight_type: string; +} + +interface GetWeightByTokenResponse extends Weight { + success: boolean; +} + +export interface UpdateWeightByTokenParams { + weightToken: string; + coverImageMediaFileToken: string; + descriptionMarkdown: string; + title: string; + visibility: Visibility; +} +export interface UpdateWeightByTokenRequest { + cover_image_media_file_token: string; + description_markdown: string; + title: string; + visibility: Visibility; +} + +export class WeightsApi extends ApiManager { + public ListWeightsByUser({ + username, + ...params + }: ListWeightsByUserRequest): Promise> { + const user = username ?? authentication.userInfo.value?.username; + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/by_user/${user}`; + + const query = this.parseQueryValues(params); + + return this.get<{ + success: boolean; + results: Weight[]; + pagination: Pagination; + }>({ endpoint, query }) + .then((response) => ({ + success: true, + data: response.results, + pagination: response.pagination, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListWeights({ + ...params + }: ListWeightsRequest): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/list`; + + const query = this.parseQueryValues(params); + + return this.get<{ + success: boolean; + results: Weight[]; + pagination: PaginationInfinite; + }>({ endpoint, query }) + .then((response) => ({ + success: true, + data: response.results, + pagination: response.pagination, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListWeightsFeatured({ + ...params + }: ListFeaturedWeightsRequest): Promise< + ApiResponse + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/list_featured`; + + const query = this.parseQueryValues(params); + + return this.get<{ + success: boolean; + results: Weight[]; + pagination: PaginationInfinite; + }>({ endpoint, query }) + .then((response) => ({ + success: true, + data: response.results, + pagination: response.pagination, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public ListWeightsPinned(): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/list_pinned`; + + return this.get<{ + success: boolean; + results: Weight[]; + }>({ endpoint }) + .then((response) => ({ + success: true, + data: response.results, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public SearchWeights( + params: SearchWeightParams, + ): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/search`; + + const body = this.parseBodyValues( + params, + ); + + return this.post< + SearchWeightRequest, + { + success: boolean; + weights: Weight[]; + } + >({ endpoint, body }) + .then((response) => ({ + success: true, + data: response.weights, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public GetWeightByToken({ + weightToken, + }: { + weightToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/weight/${weightToken}`; + + return this.get({ endpoint }) + .then(({ success, ...mediaFile }) => ({ + success: success, + data: mediaFile, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public UpdateWeightByToken({ + weightToken, + ...params + }: UpdateWeightByTokenParams): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/weight/${weightToken}`; + + const body = this.parseBodyValues< + Omit, + UpdateWeightByTokenRequest + >(params); + + return this.post< + UpdateWeightByTokenRequest, + { success?: boolean; BadInput?: string } + >({ endpoint, body }) + .then(({ success, BadInput }) => ({ + success: success ?? false, + errorMessage: BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public DeleteWeightByToken({ + weightToken, + }: { + weightToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/weight/${weightToken}`; + + const body = { + as_mod: true, + set_delete: true, + }; + + return this.delete< + { as_mod: boolean; set_delete: boolean }, + { success?: boolean; BadInput?: string } + >({ endpoint, body }) + .then(({ success, BadInput }) => ({ + success: success ?? false, + errorMessage: BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } + + public UpdateWeightCoverImageByToken({ + weightToken, + coverImageMediaFileToken, + }: { + weightToken: string; + coverImageMediaFileToken: string; + }): Promise> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/weights/weight/${weightToken}/cover_image`; + + const body = { + cover_image_media_file_token: coverImageMediaFileToken, + }; + + return this.post< + { cover_image_media_file_token: string }, + { success?: boolean; BadInput?: string } + >({ endpoint, body }) + .then(({ success, BadInput }) => ({ + success: success ?? false, + errorMessage: BadInput, + })) + .catch((err) => { + return { success: false, errorMessage: err.message }; + }); + } +} diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/index.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/index.ts new file mode 100644 index 0000000000..b48add4b72 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/index.ts @@ -0,0 +1,18 @@ +// https://storyteller-docs.netlify.app/#/ +export * from "./BetaKeysApi"; +export * from "./BillingApi"; +export * from "./CommentsApi"; +export * from "./EngineApi"; +export * from "./FeaturedItemsApi"; +export * from "./JobsApi"; +export * from "./MediaFilesApi"; +export * from "./MediaUploadApi"; +export * from "./MiscApi"; +export * from "./ModerationApi"; +export * from "./PromptsApi"; +export * from "./StudioGen2Api"; +export * from "./TtsApi"; +export * from "./UserBookmarksApi"; +export * from "./UsersApi"; +export * from "./VideoApi"; +export * from "./WeightsApi"; diff --git a/frontend/apps/artcraft/app/src/Classes/EnvironmentVariables.ts b/frontend/apps/artcraft/app/src/Classes/EnvironmentVariables.ts new file mode 100644 index 0000000000..90813c83fa --- /dev/null +++ b/frontend/apps/artcraft/app/src/Classes/EnvironmentVariables.ts @@ -0,0 +1,10 @@ +class EnvironmentVariables { + values: Record = {}; + + initialize(values: Record) { + this.values = values; + } +} + +const environmentVariables = new EnvironmentVariables(); +export default environmentVariables; diff --git a/frontend/apps/artcraft/app/src/Helpers/CanvasHelpers.ts b/frontend/apps/artcraft/app/src/Helpers/CanvasHelpers.ts new file mode 100644 index 0000000000..929ab13416 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Helpers/CanvasHelpers.ts @@ -0,0 +1,29 @@ + +export const normalizeCanvas = (canvas: HTMLCanvasElement, width: number, height: number): OffscreenCanvas => { + const newCanvas = new OffscreenCanvas(width, height); + newCanvas.width = width; + newCanvas.height = height; + + const ctx = newCanvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get offscreen canvas context"); + } + + ctx.imageSmoothingEnabled = true; + ctx.drawImage(canvas, 0, 0, width, height); + return newCanvas; +} + +export const drawAlphaMask = (canvas: OffscreenCanvas, width: number, height: number) => { + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + // Fill the entire screen with black and composite to keep non-transparent pixels + ctx.save(); + ctx.globalCompositeOperation = "source-in"; + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, width, height); + ctx.restore(); +} diff --git a/frontend/apps/artcraft/app/src/Helpers/ImageHelpers.ts b/frontend/apps/artcraft/app/src/Helpers/ImageHelpers.ts new file mode 100644 index 0000000000..585fc55f25 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Helpers/ImageHelpers.ts @@ -0,0 +1,19 @@ +export async function loadImage(img: HTMLImageElement) { + return new Promise((resolve, reject) => { + img.onload = () => resolve(img); + img.onerror = reject; + }); +} + +export async function loadImageFromUrl(url: string) { + const img = new Image(); + img.src = url; + return loadImage(img); +} + +export async function loadImageFromAnonymousOriginUrl(url: string) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = url; + return loadImage(img); +} diff --git a/frontend/apps/artcraft/app/src/Helpers/galleryModalTauriBindings.ts b/frontend/apps/artcraft/app/src/Helpers/galleryModalTauriBindings.ts new file mode 100644 index 0000000000..5cc7a9a0f3 --- /dev/null +++ b/frontend/apps/artcraft/app/src/Helpers/galleryModalTauriBindings.ts @@ -0,0 +1,23 @@ +import { MediaFileDelete } from "@storyteller/tauri-api"; +import { listen } from "@tauri-apps/api/event"; + +export const galleryModalDeleteMedia = (id: string) => MediaFileDelete(id); + +export const galleryModalSubscribeToMediaEvents = (handlers: { + onGenerationComplete: () => void; + onMediaDeleted: (mediaId: string) => void; +}) => { + const unlistenGen = listen("generation-complete-event", () => { + handlers.onGenerationComplete(); + }); + const unlistenDel = listen("media_file_deleted_event", (event) => { + const token: string | undefined = + event?.payload?.data?.media_file_token ?? + event?.payload?.media_file_token; + if (token) handlers.onMediaDeleted(token); + }); + return () => { + unlistenGen.then((fn) => fn()); + unlistenDel.then((fn) => fn()); + }; +}; diff --git a/frontend/apps/artcraft/app/src/api/BucketConfig.ts b/frontend/apps/artcraft/app/src/api/BucketConfig.ts new file mode 100644 index 0000000000..4887f88caf --- /dev/null +++ b/frontend/apps/artcraft/app/src/api/BucketConfig.ts @@ -0,0 +1,53 @@ +import environmentVariables from "~/Classes/EnvironmentVariables"; +import { GetCdnOrigin } from "./GetCdnOrigin"; + +class BucketConfig { + isLocalDev: boolean; + + constructor() { + this.isLocalDev = + document.location.host.includes("localhost") || + document.location.host.includes("jungle.horse") || + document.location.host.startsWith("dev."); + } + + // TODO: Prevent callers with undefined/null paths + // TODO pipeline works make more robust.... + getGcsUrl(bucketRelativePath: string | undefined | null): string { + let path = bucketRelativePath; + if (path !== undefined && path !== null && !path.startsWith("/")) { + path = "/" + path; + } + const media_api_base_url = GetCdnOrigin(); + return `${media_api_base_url}${path}`; + } + + getCdnUrl( + bucketRelativePath: string, + width?: number, + quality?: number, + ): string { + const cndBasePath = environmentVariables.values.CDN_API; + const path = bucketRelativePath?.startsWith("/") + ? bucketRelativePath + : "/" + bucketRelativePath; + let resizeParams = ""; + if (width || quality) { + resizeParams = "cdn-cgi/image/"; + if (width) { + resizeParams += `width=${width},`; + } + if (quality) { + resizeParams += `quality=${quality},`; + } + resizeParams = resizeParams.slice(0, -1); + } + if (resizeParams) { + return `${cndBasePath}/${resizeParams}${path}`; + } else { + return `${cndBasePath}${path}`; + } + } +} + +export { BucketConfig }; diff --git a/frontend/apps/artcraft/app/src/api/GetCdnOrigin.ts b/frontend/apps/artcraft/app/src/api/GetCdnOrigin.ts new file mode 100644 index 0000000000..79e16f250c --- /dev/null +++ b/frontend/apps/artcraft/app/src/api/GetCdnOrigin.ts @@ -0,0 +1,37 @@ +// TODO(bt,2025-05-15): Remove this code + +/** + * Return the CDN origin (scheme + host + optional port, but no path) + * See: https://web.dev/articles/url-parts + * + * TODO(bt,2025-02-10): Make this easier to configure in development + */ +class CdnHostHelper { + private static instance: CdnHostHelper; + private readonly origin: string; + + public static getInstance(): CdnHostHelper { + if (CdnHostHelper.instance !== undefined) { + return CdnHostHelper.instance; + } + + const origin = 'https://cdn-2.fakeyou.com'; + const instance = new CdnHostHelper(origin); + + CdnHostHelper.instance = instance; + return instance; + } + + public getOrigin(): string { + return this.origin; + } + + private constructor(origin: string) { + this.origin = origin; + } +} + +// TODO(bt,2025-02-10): Consolidate all of this logic. +export function GetCdnOrigin() : string { + return CdnHostHelper.getInstance().getOrigin(); +} diff --git a/frontend/apps/artcraft/app/src/api/SyncStorytellerApiConfig.ts b/frontend/apps/artcraft/app/src/api/SyncStorytellerApiConfig.ts new file mode 100644 index 0000000000..04c63ee99e --- /dev/null +++ b/frontend/apps/artcraft/app/src/api/SyncStorytellerApiConfig.ts @@ -0,0 +1,70 @@ +import { GetAppInfo } from "@storyteller/tauri-api"; +import { StorytellerApiHostStore } from "@storyteller/api"; +import { forceGetUserInfoAndSubcriptions } from "~/signals"; + +// Time before we should call Tauri again. +const SYNC_THRESHOLD = 10 * 1000; + +/** + * Keep track of if we can call Tauri again. + */ +class Cache { + private static instance: Cache; + private lastFetchSuccess?: number; + + public static getInstance(): Cache { + if (Cache.instance !== undefined) { + return Cache.instance; + } + const instance = new Cache(); + Cache.instance = instance; + return instance; + } + + public canCall() : boolean { + if (this.lastFetchSuccess === undefined) { + return true; + } + return Date.now() - this.lastFetchSuccess > SYNC_THRESHOLD; + } + + public setCallSuccess() { + this.lastFetchSuccess = Date.now(); + } +} + +/** + * Syncs our view of the Storyteller API configs with Tauri. + * Runs any necessary user session functions if things change. + */ +export const SyncStorytellerApiConfig = async () => { + console.log("SyncStorytellerApiConfig()") + + const cache = Cache.getInstance(); + const oldValue = StorytellerApiHostStore.getInstance().getApiSchemeAndHost(); + + if (!cache.canCall()) { + return; + } + + GetAppInfo().then(async (appInfo) => { + console.log("SyncStorytellerApiConfig() - appInfo", appInfo); + + const schemeAndHost = appInfo.payload.storyteller_host; + if (!schemeAndHost) { + return ; + } + + console.log(`Updating hostname to ${schemeAndHost}`); + StorytellerApiHostStore.getInstance().setApiSchemeAndHost(schemeAndHost); + + cache.setCallSuccess(); + + if (oldValue !== schemeAndHost) { + console.log("SyncStorytellerApiConfig() - force session refresh") + // NB: This is a hack that might prevent the login screen from being shown + // if the user is working in development. + await forceGetUserInfoAndSubcriptions(); + } + }); +} diff --git a/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx b/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx new file mode 100644 index 0000000000..231bf158ba --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx @@ -0,0 +1,228 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; +import { BoxIcon, ImagesIcon, Maximize2Icon } from "lucide-react"; +import { + UploadModal3D, + UploadModalImage, + UploadModalSplat, +} from "@storyteller/ui-upload-modal"; +import { isPromptBoxDropZoneActive } from "@storyteller/ui-promptbox"; +import { FilterEngineCategories } from "../../enums"; + +type ModalType = "3d" | "image" | "splat" | null; + +function getModalTypeForFileName(name: string): ModalType { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + if (ext === "glb") return "3d"; + if (ext === "png" || ext === "jpg" || ext === "jpeg") return "image"; + if (ext === "spz") return "splat"; + return null; +} + +function isAnyModalOpen(): boolean { + return !!document.querySelector("[data-radix-dialog-content]"); +} + +/** + * Whole-app drops stand down while a prompt box owns them. On the create + * pages the prompt box is the only drop target (matching the webapp), so a + * drop anywhere else must do nothing rather than open the upload modal. + */ +function isGlobalDropSuppressed(): boolean { + return isAnyModalOpen() || isPromptBoxDropZoneActive(); +} + +const isTauri = typeof window !== "undefined" && "__TAURI__" in window; + +export function GlobalFileDropHandler() { + const dragCounter = useRef(0); + const [isDragging, setIsDragging] = useState(false); + const [modalType, setModalType] = useState(null); + const [pendingFiles, setPendingFiles] = useState([]); + + const closeModal = () => { + setModalType(null); + setPendingFiles([]); + }; + + useEffect(() => { + if (isTauri) { + const unlisteners: Array<() => void> = []; + + const setup = async () => { + try { + const { getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow"); + const { convertFileSrc } = await import("@tauri-apps/api/core"); + const appWindow = getCurrentWebviewWindow(); + + const unlisten = await appWindow.onDragDropEvent(async (event) => { + const payload = event.payload; + + if (payload.type === "enter") { + if (!isGlobalDropSuppressed()) setIsDragging(true); + } else if (payload.type === "over") { + // The prompt box can mount mid-drag (or the pointer can move + // onto it), so keep re-checking rather than trusting "enter". + if (isGlobalDropSuppressed()) setIsDragging(false); + } else if (payload.type === "drop") { + setIsDragging(false); + if (isGlobalDropSuppressed()) return; + + if (payload.paths.length === 0) return; + + // Find first path with a recognised extension + let modalKind: ModalType = null; + for (const p of payload.paths) { + modalKind = getModalTypeForFileName(p.split(/[/\\]/).pop() ?? ""); + if (modalKind) break; + } + if (!modalKind) return; + + // Keep only paths matching the detected modal type + const matchingPaths = payload.paths.filter((p) => { + const name = p.split(/[/\\]/).pop() ?? ""; + return getModalTypeForFileName(name) === modalKind; + }); + + const skippedCount = payload.paths.length - matchingPaths.length; + if (skippedCount > 0) { + toast( + `${skippedCount} file${skippedCount > 1 ? "s" : ""} skipped — unsupported or mixed types`, + { icon: "⚠️" } + ); + } + + try { + const files = await Promise.all( + matchingPaths.map(async (path) => { + const fileName = path.split(/[/\\]/).pop() ?? "file"; + const assetUrl = convertFileSrc(path); + const response = await fetch(assetUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + return new File([blob], fileName); + }) + ); + await appWindow.setFocus(); + setModalType(modalKind); + setPendingFiles(files); + } catch (err) { + console.error("[DragDrop] file read failed:", err); + } + } else { + setIsDragging(false); + } + }); + + unlisteners.push(unlisten); + } catch (err) { + console.error("[DragDrop] setup failed:", err); + } + }; + + setup(); + return () => { unlisteners.forEach((fn) => fn()); }; + + } else { + // HTML5 path (browser dev mode) + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + if (!e.dataTransfer?.types.includes("Files")) return; + if (isGlobalDropSuppressed()) return; + dragCounter.current++; + setIsDragging(true); + }; + const handleDragLeave = (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("Files")) return; + dragCounter.current--; + if (dragCounter.current <= 0) { dragCounter.current = 0; setIsDragging(false); } + }; + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + }; + const handleDrop = (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("Files")) return; + e.preventDefault(); + setIsDragging(false); + dragCounter.current = 0; + if (isGlobalDropSuppressed()) return; + + const allFiles = Array.from(e.dataTransfer.files); + if (allFiles.length === 0) return; + + // Find first file with a recognised extension + let modalKind: ModalType = null; + for (const f of allFiles) { + modalKind = getModalTypeForFileName(f.name); + if (modalKind) break; + } + if (!modalKind) return; + + // Keep only files matching the detected modal type + const matchingFiles = allFiles.filter( + (f) => getModalTypeForFileName(f.name) === modalKind + ); + + const skippedCount = allFiles.length - matchingFiles.length; + if (skippedCount > 0) { + toast( + `${skippedCount} file${skippedCount > 1 ? "s" : ""} skipped — unsupported or mixed types`, + { icon: "⚠️" } + ); + } + + setModalType(modalKind); + setPendingFiles(matchingFiles); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("dragover", handleDragOver); + window.addEventListener("drop", handleDrop); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("dragover", handleDragOver); + window.removeEventListener("drop", handleDrop); + }; + } + }, []); + + return ( + <> + {isDragging && modalType === null && ( +
+
+ +
Drop to Upload
+
GLB, PNG, JPG, JPEG, SPZ
+
+
+ )} + 0 ? pendingFiles : undefined} + onClose={closeModal} + onSuccess={(_category: FilterEngineCategories) => closeModal()} + title="Upload a 3D Model" + titleIcon={BoxIcon} + /> + 0 ? pendingFiles : undefined} + onClose={closeModal} + onSuccess={() => closeModal()} + title="Upload an Image" + titleIcon={ImagesIcon} + /> + 0 ? pendingFiles : undefined} + onClose={closeModal} + onSuccess={() => {}} + title="Upload a Splat" + titleIcon={BoxIcon} + /> + + ); +} diff --git a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx new file mode 100644 index 0000000000..eb4f3d6420 --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx @@ -0,0 +1,1220 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { animated, useSpring } from "@react-spring/web"; +import { Button, GenerateButton } from "@storyteller/ui-button"; +import { TabSelector } from "@storyteller/ui-tab-selector"; +import { Tooltip } from "@storyteller/ui-tooltip"; +import { Viewer3D } from "@storyteller/ui-viewer-3d"; +import { BoxIcon, ImagesIcon, PlusIcon, UploadIcon, XIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; +import { useImageTo3DStore } from "../../pages/PageImageTo3DObject/ImageTo3DStore"; +import { useImageTo3DWorldStore } from "../../pages/PageImageTo3DWorld/ImageTo3DWorldStore"; +import { MediaUploadApi, downloadFileFromUrl } from "@storyteller/api"; +import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal"; +import { + GenerateMesh, + EnqueueImageTo3dObjectModel, + GenerateSplat, + commandErrorMessage, +} from "@storyteller/tauri-api"; +import { toast } from "react-hot-toast"; +import { v4 as uuidv4 } from "uuid"; +import { useTabStore } from "../../pages/Stores/TabState"; +import { addObject, getActiveEditor } from "@storyteller/ui-pagescene"; +import type { MediaItem } from "@storyteller/ui-pagescene"; +import { AssetType } from "~/enums"; +import { SPLAT_MODELS } from "@storyteller/model-list"; +import { + ClassyModelSelector, + IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST, + IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST, + ModelPage, + useSelectedModel, + useSelectedProviderForModel, +} from "@storyteller/ui-model-selector"; +import { + CostCalculatorButton, + useCostBreakdownModalStore, +} from "@storyteller/ui-pricing-modal"; +import { HelpMenuButton } from "@storyteller/ui-help-menu"; + +type Mode = "image" | "text"; +type Variant = "object" | "world"; + +interface WorldImage { + id: string; + preview: string; + mediaToken: string | null; + name: string; + isUploading: boolean; +} + +const MAX_WORLD_IMAGES = 10; + +interface ImageTo3DExperienceProps { + title: string; + subtitle: string; + variant: Variant; + backgroundImage?: string; +} + +const MODE_TABS = [ + { id: "image", label: "Image to 3D" }, + // { id: "text", label: "Text to 3D" }, +] satisfies { id: Mode; label: string }[]; + +const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +}; + +const generateId = () => + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2, 10); + +const WORLD_MODEL_PAGE = ModelPage.ImageTo3DWorld; +const OBJECT_MODEL_PAGE = ModelPage.ImageTo3DObject; +const DEFAULT_OBJECT_MODEL_ID = EnqueueImageTo3dObjectModel.Hunyuan3d3; + +export const ImageTo3DExperience = ({ + title, + subtitle, + variant, + backgroundImage, +}: ImageTo3DExperienceProps) => { + const [activeMode, setActiveMode] = useState("image"); + const selectedWorldModel = useSelectedModel(WORLD_MODEL_PAGE); + const selectedWorldProvider = useSelectedProviderForModel( + WORLD_MODEL_PAGE, + selectedWorldModel?.id, + ); + const selectedObjectModel = useSelectedModel(OBJECT_MODEL_PAGE); + const selectedObjectModelId = + (selectedObjectModel?.id as EnqueueImageTo3dObjectModel | undefined) ?? + DEFAULT_OBJECT_MODEL_ID; + const worldCredits = useCostBreakdownModalStore( + (s) => s.estimatedCreditsByPage[WORLD_MODEL_PAGE], + ); + const objectCredits = useCostBreakdownModalStore( + (s) => s.estimatedCreditsByPage[OBJECT_MODEL_PAGE], + ); + const [uploadedPreview, setUploadedPreview] = useState(null); + const [uploadedName, setUploadedName] = useState(null); + const [prompt, setPrompt] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + const textareaRef = useRef(null); + const worldFileInputRef = useRef(null); + const worldTextareaRef = useRef(null); + const [worldImages, setWorldImages] = useState([]); + const [worldPrompt, setWorldPrompt] = useState(""); + const [previewImage, setPreviewImage] = useState(null); + + const [selectedResultId, setSelectedResultId] = useState(null); + const promptContentRef = useRef(null); + const [promptHeight, setPromptHeight] = useState(400); + const [vh, setVh] = useState( + typeof window !== "undefined" ? window.innerHeight : 800, + ); + + const objectResults = useImageTo3DStore((s) => s.results); + const objectStartGeneration = useImageTo3DStore((s) => s.startGeneration); + const objectFailGeneration = useImageTo3DStore((s) => s.failGeneration); + const objectReset = useImageTo3DStore((s) => s.reset); + + const worldResults = useImageTo3DWorldStore((s) => s.results); + const worldStartGeneration = useImageTo3DWorldStore((s) => s.startGeneration); + const worldFailGeneration = useImageTo3DWorldStore((s) => s.failGeneration); + const worldReset = useImageTo3DWorldStore((s) => s.reset); + const pendingExternalImage = useImageTo3DWorldStore( + (s) => s.pendingExternalImage, + ); + const clearPendingExternalImage = useImageTo3DWorldStore( + (s) => s.clearPendingExternalImage, + ); + const pendingObjectImage = useImageTo3DStore((s) => s.pendingExternalImage); + const clearPendingObjectImage = useImageTo3DStore( + (s) => s.clearPendingExternalImage, + ); + + const results = variant === "object" ? objectResults : worldResults; + const resetResults = variant === "object" ? objectReset : worldReset; + const [uploadedMediaToken, setUploadedMediaToken] = useState( + null, + ); + const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false); + const [selectedGalleryImages, setSelectedGalleryImages] = useState( + [], + ); + + useEffect(() => { + // Read from store directly to avoid Strict Mode double-execution adding the image twice + if (variant === "world") { + const pending = useImageTo3DWorldStore.getState().pendingExternalImage; + if (pending) { + setWorldImages((prev) => { + if (prev.length >= MAX_WORLD_IMAGES) return prev; + return [ + ...prev, + { + id: generateId(), + preview: pending.url, + mediaToken: pending.mediaToken, + name: "Library Image", + isUploading: false, + }, + ]; + }); + clearPendingExternalImage(); + } + } else if (variant === "object") { + const pending = useImageTo3DStore.getState().pendingExternalImage; + if (pending) { + setUploadedPreview(pending.url); + setUploadedMediaToken(pending.mediaToken); + setUploadedName("Library Image"); + clearPendingObjectImage(); + } + } + }, [ + variant, + pendingExternalImage, + clearPendingExternalImage, + pendingObjectImage, + clearPendingObjectImage, + ]); + + useEffect(() => { + const onResize = () => setVh(window.innerHeight); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + useEffect(() => { + const el = promptContentRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const update = () => setPromptHeight(el.offsetHeight); + update(); + const ro = new ResizeObserver(() => update()); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + } + if (worldTextareaRef.current) { + worldTextareaRef.current.style.height = "auto"; + worldTextareaRef.current.style.height = `${worldTextareaRef.current.scrollHeight}px`; + } + }); + + const handleFiles = async (files?: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file.type.startsWith("image/")) return; + + setUploadedName(file.name); + setUploadedMediaToken(null); + setIsUploading(true); + + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + setUploadedPreview(dataUrl); + }; + reader.readAsDataURL(file); + + try { + const mediaUploadApi = new MediaUploadApi(); + const uuid = uuidv4(); + + const uploadResult = await mediaUploadApi.UploadImage({ + blob: file, + fileName: file.name, + uuid: uuid, + }); + + if (!uploadResult.success || !uploadResult.data) { + throw new Error("Failed to upload image"); + } + + setUploadedMediaToken(uploadResult.data); + } catch (error) { + toast.error("Failed to upload image"); + setUploadedPreview(null); + setUploadedName(null); + } finally { + setIsUploading(false); + } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + handleFiles(event.target.files); + event.target.value = ""; + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragActive(false); + handleFiles(event.dataTransfer?.files); + }; + + const handlePickFromLibrary = () => { + setIsGalleryModalOpen(true); + }; + + const handleWorldFiles = async (files: File[]) => { + const remaining = MAX_WORLD_IMAGES - worldImages.length; + const imageFiles = files + .filter((f) => f.type.startsWith("image/")) + .slice(0, remaining); + + await Promise.all( + imageFiles.map(async (file) => { + const imageId = generateId(); + + const preview = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.readAsDataURL(file); + }); + + setWorldImages((prev) => [ + ...prev, + { + id: imageId, + preview, + mediaToken: null, + name: file.name, + isUploading: true, + }, + ]); + + try { + const mediaUploadApi = new MediaUploadApi(); + const uploadResult = await mediaUploadApi.UploadImage({ + blob: file, + fileName: file.name, + uuid: uuidv4(), + }); + + if (!uploadResult.success || !uploadResult.data) { + throw new Error("Upload failed"); + } + + setWorldImages((prev) => + prev.map((img) => + img.id === imageId + ? { ...img, mediaToken: uploadResult.data!, isUploading: false } + : img, + ), + ); + } catch { + toast.error(`Failed to upload ${file.name}`); + setWorldImages((prev) => prev.filter((img) => img.id !== imageId)); + } + }), + ); + }; + + const removeWorldImage = (imageId: string) => { + setWorldImages((prev) => prev.filter((img) => img.id !== imageId)); + }; + + const handleImageSelect = (id: string) => { + setSelectedGalleryImages((prev) => { + if (prev.includes(id)) return prev.filter((x) => x !== id); + if (variant === "world") { + const max = MAX_WORLD_IMAGES - worldImages.length; + if (prev.length >= max) return prev; + return [...prev, id]; + } + return [id]; + }); + }; + + const handleGallerySelect = async (selectedItems: GalleryItem[]) => { + if (variant === "world") { + const remaining = MAX_WORLD_IMAGES - worldImages.length; + const newImages: WorldImage[] = selectedItems + .slice(0, remaining) + .filter((item) => item.fullImage) + .map((item) => ({ + id: generateId(), + preview: item.fullImage!, + mediaToken: item.id, + name: item.label || "Library Image", + isUploading: false, + })); + setWorldImages((prev) => [...prev, ...newImages]); + setIsGalleryModalOpen(false); + setSelectedGalleryImages([]); + return; + } + + const item = selectedItems[0]; + if (!item || !item.fullImage) { + toast.error("No image selected"); + return; + } + + if (isUploading) return; + + setIsGalleryModalOpen(false); + setSelectedGalleryImages([]); + + setUploadedName(item.label || "Library Image"); + setUploadedPreview(item.fullImage); + setUploadedMediaToken(item.id); + }; + + const handleGenerate = async () => { + if (isGenerating) return; + + if (variant === "world") { + const readyTokens = worldImages + .filter((img) => img.mediaToken && !img.isUploading) + .map((img) => img.mediaToken!); + if (readyTokens.length === 0) return; + } else { + if (isUploading) return; + if (activeMode === "image" && !uploadedMediaToken) return; + if (activeMode === "text" && prompt.trim().length <= 3) return; + } + + setIsGenerating(true); + const subscriberId = generateId(); + + try { + if (variant === "world") { + const readyTokens = worldImages + .filter((img) => img.mediaToken && !img.isUploading) + .map((img) => img.mediaToken!); + const note = + worldPrompt.trim() || + `${worldImages.length} image${worldImages.length !== 1 ? "s" : ""}`; + const firstPreview = worldImages[0]?.preview; + + worldStartGeneration("image", note, firstPreview, subscriberId); + setSelectedResultId(subscriberId); + + window.__storeTaskEnqueueMeta?.({ + prompt: worldPrompt.trim() || undefined, + refImageUrls: worldImages + .map((img) => img.preview) + .filter(Boolean) as string[], + modelType: + (selectedWorldModel as any)?.tauriId || + (SPLAT_MODELS[0] as any)?.tauriId || + String(selectedWorldModel ?? SPLAT_MODELS[0]), + timestamp: Date.now(), + }); + + await GenerateSplat({ + reference_image_media_tokens: readyTokens, + prompt: worldPrompt.trim() || undefined, + model: (selectedWorldModel ?? SPLAT_MODELS[0]).tauriId, + provider: selectedWorldProvider, + frontend_caller: "mini_app", + frontend_subscriber_id: subscriberId, + }); + + } else { + const snapshotPrompt = prompt.trim(); + const snapshotPreview = uploadedPreview || undefined; + const snapshotName = uploadedName; + const note = + activeMode === "text" + ? snapshotPrompt + : snapshotName || "Generated Model"; + + objectStartGeneration( + activeMode, + note, + snapshotPreview, + false, + subscriberId, + ); + setSelectedResultId(subscriberId); + + window.__storeTaskEnqueueMeta?.({ + prompt: snapshotPrompt || undefined, + refImageUrls: snapshotPreview ? [snapshotPreview] : undefined, + modelType: selectedObjectModel?.tauriId || selectedObjectModelId, + timestamp: Date.now(), + }); + + await GenerateMesh({ + prompt: snapshotPrompt || undefined, + reference_image_media_tokens: activeMode === "image" && uploadedMediaToken ? [uploadedMediaToken] : undefined, + model: selectedObjectModel?.tauriId ?? selectedObjectModelId, + frontend_caller: "mini_app", + frontend_subscriber_id: subscriberId, + }); + + + if (activeMode === "text") { + setPrompt(""); + } + } + } catch (error) { + const errorMessage = commandErrorMessage(error, "An unexpected error occurred"); + toast.error(`Failed to generate 3D model: ${errorMessage}`); + if (variant === "world") { + worldFailGeneration(subscriberId); + } else { + objectFailGeneration(subscriberId); + } + } finally { + setIsGenerating(false); + } + }; + + const canGenerate = useMemo(() => { + if (isGenerating) return false; + if (variant === "world") { + return worldImages.some((img) => img.mediaToken && !img.isUploading); + } + if (isUploading) return false; + if (activeMode === "image") { + return Boolean(uploadedMediaToken); + } + if (activeMode === "text") { + return prompt.trim().length > 3; + } + return true; + }, [ + variant, + activeMode, + uploadedMediaToken, + prompt, + isGenerating, + isUploading, + worldImages, + ]); + + const hasResults = results.length > 0; + const showPromptAtBottom = hasResults; + + // Animation logic + const bottomMarginPx = 24; + const bottomOffsetPx = promptHeight + bottomMarginPx; + + const centerTop = vh / 2 - promptHeight / 2 + 80; + const bottomTop = vh - bottomOffsetPx; + + const targetTop = showPromptAtBottom + ? Math.max(0, bottomTop) + : Math.max(0, centerTop); + + const promptAnim = useSpring({ + top: targetTop, + config: { tension: 200, friction: 28, mass: 1.1 }, + }); + + const renderAddImageTile = () => ( + + + + + } + > +
{ + event.preventDefault(); + event.stopPropagation(); + setDragActive(true); + }} + onDragOver={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onDragLeave={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (!event.currentTarget.contains(event.relatedTarget as Node)) { + setDragActive(false); + } + }} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + fileInputRef.current?.click(); + } + }} + > + + {!hasResults && ( + + Add Image + + )} +
+
+ ); + + const renderImageMode = () => ( +
+ {uploadedPreview ? ( +
!isUploading && fileInputRef.current?.click()} + > + Reference + {isUploading && ( +
+
+
+ )} + {!isUploading && ( + + )} +
+ ) : ( + renderAddImageTile() + )} +
+ ); + + const promptInputId = `image-to-3d-${variant}-prompt`; + + const renderTextMode = () => ( +
+